diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..809497c --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,142 @@ +name: .NET Core Build and Release + +on: + push: + workflow_dispatch: + +jobs: + build_and_test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "8.0.x" + + - name: Setup JQ + run: sudo apt-get install -y jq + + - name: Cache FFmpeg + id: cache-ffmpeg + uses: actions/cache@v2 + with: + path: ffmpeg/ + key: ffmpeg-${{ runner.os }} + + - name: Download FFmpeg + if: steps.cache-ffmpeg.outputs.cache-hit != 'true' + run: | + mkdir -p ffmpeg + wget -O ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz + tar xvf ffmpeg.tar.xz -C ffmpeg --strip-components=1 + + - name: Add FFmpeg into path + run: | + echo "$(pwd)/ffmpeg" >> $GITHUB_PATH + + - name: Check FFmpeg Version + run: ffmpeg -version + + - name: Restore dependencies and cache + uses: actions/cache@v2 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.fsproj', '**/*.vbproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: "18" + + - name: Install LESS + run: npm install -g less + + - name: Compile LESS files + run: | + lessc SwipetorApp/wwwroot/public/styles/public.less SwipetorApp/wwwroot/public/styles/public.css + lessc SwipetorApp/wwwroot/admin/styles/_main.less SwipetorApp/wwwroot/admin/styles/_main.css + rm -f SwipetorApp/wwwroot/public/styles/*.less + rm -f SwipetorApp/wwwroot/admin/styles/*.less + + - name: Restore, Build and Test + run: | + dotnet nuget add source https://nuget.pkg.github.com/atas/index.json -n github -u ${{github.repository_owner}} -p ${{secrets.PKGS_READ_TOKEN}} --store-password-in-clear-text + dotnet restore + dotnet build --no-restore + dotnet test --no-build --verbosity normal --filter FullyQualifiedName~Tests + + - name: Publish Application + run: dotnet publish --configuration Release --output ./publish + + - name: Archive Published App + run: tar -czf swpapp.tar.gz -C ./publish . + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: swpapp + path: swpapp.tar.gz + + release: + runs-on: ubuntu-latest + permissions: + contents: write + needs: build_and_test + if: github.ref == 'refs/heads/master' + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: swpapp + + - name: Set next version + run: | + next_version=$(git for-each-ref --sort=-creatordate --format '%(refname:short)' refs/tags/v* | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 | awk -F '.' '{print $1 "." $2 "." $3+1}') + next_version=${next_version#v} + echo "next_version=$next_version" + echo "next_version=$next_version" >> $GITHUB_ENV + + - name: Commit msg + id: commit_msg + run: | + COMMIT_MSG=$(git log -1 --pretty=%B) + echo "commit_msg=$commit_msg" >> $GITHUB_ENV + + - name: Create GitHub Release + run: | + mv swpapp.tar.gz swpapp-$next_version.tar.gz + gh release create "v$next_version" swpapp-$next_version.tar.gz\ + --title "Release v$next_version" \ + --notes "$commit_msg" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up SSH + run: | + mkdir -p ~/.ssh/ + ls -lha ~/.ssh + echo "${{ secrets.SSH_PRIV_ARES_UF }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -p 1989 ${{ secrets.PROD_HOST }} >> ~/.ssh/known_hosts + + - name: Trigger pull-deploy + run: | + ssh -p1989 ata@${{ secrets.PROD_HOST }} "/opt/swipetor/bin/pull-deploy/run.sh" + +# - name: Commit bumped version +# run: | +# git config user.name "GitHub Actions" +# git config user.email "" +# git tag -a "v$next_version" -m "Release v$next_version" +# git push origin "v$next_version" +# diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f08f50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.DS_Store + +.idea + +bin +obj + +SwipetorApp/appsettings.Development.json +SwipetorApp/App_Data/version.txt +SwipetorApp/App_Data/firebase-admin.json + +SwipetorApp/wwwroot/public/build/* +SwipetorApp/wwwroot/**/*.css.map +SwipetorApp/wwwroot/**/*.css + +script.sql +SwipetorApp/script.sql +publish diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..edb4988 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified +it, and giving a relevant date. + +b) The work must carry prominent notices stating that it is +released under this License and any conditions added under section +7. This requirement modifies the requirement in section 4 to +"keep intact all notices". + +c) You must license the entire work, as a whole, under this +License to anyone who comes into possession of a copy. This +License will therefore apply, along with any applicable section 7 +additional terms, to the whole of the work, and all its parts, +regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not +invalidate such permission if you have separately received it. + +d) If the work has interactive user interfaces, each must display +Appropriate Legal Notices; however, if the Program has interactive +interfaces that do not display Appropriate Legal Notices, your +work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + +a) Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by the +Corresponding Source fixed on a durable physical medium +customarily used for software interchange. + +b) Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by a +written offer, valid for at least three years and valid for as +long as you offer spare parts or customer support for that product +model, to give anyone who possesses the object code either (1) a +copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical +medium customarily used for software interchange, for a price no +more than your reasonable cost of physically performing this +conveying of source, or (2) access to copy the +Corresponding Source from a network server at no charge. + +c) Convey individual copies of the object code with a copy of the +written offer to provide the Corresponding Source. This +alternative is allowed only occasionally and noncommercially, and +only if you received the object code with such an offer, in accord +with subsection 6b. + +d) Convey the object code by offering access from a designated +place (gratis or for a charge), and offer equivalent access to the +Corresponding Source in the same way through the same place at no +further charge. You need not require recipients to copy the +Corresponding Source along with the object code. If the place to +copy the object code is a network server, the Corresponding Source +may be on a different server (operated by you or a third party) +that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the +Corresponding Source, you remain obligated to ensure that it is +available for as long as needed to satisfy these requirements. + +e) Convey the object code using peer-to-peer transmission, provided +you inform other peers where the object code and Corresponding +Source of the work are being offered to the general public at no +charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the +terms of sections 15 and 16 of this License; or + +b) Requiring preservation of specified reasonable legal notices or +author attributions in that material or in the Appropriate Legal +Notices displayed by works containing it; or + +c) Prohibiting misrepresentation of the origin of that material, or +requiring that modified versions of such material be marked in +reasonable ways as different from the original version; or + +d) Limiting the use for publicity purposes of names of licensors or +authors of the material; or + +e) Declining to grant rights under trademark law for use of some +trade names, trademarks, or service marks; or + +f) Requiring indemnification of licensors and authors of that +material by anyone who conveys the material (or modified versions of +it) with contractual assumptions of liability to the recipient, for +any liability that these contractual assumptions directly impose on +those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + +Copyright (C) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c94a426 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +ARGS = $(filter-out $@,$(MAKECMDGOALS)) + +dbupdate: + dotnet ef database update -p SwipetorApp + +addmigration: + dotnet ef migrations add $(M) -p SwipetorApp + +remove-last-migration: + dotnet ef migrations remove -p SwipetorApp + +migration-script: + dotnet ef migrations script --idempotent --output "script.sql" -p SwipetorApp $(FROM) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f386e1b --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# swipetor-server + +Server-side application for open-source Swipetor videos swiping web app. + +Find more information on the UI side of the project at https://github.com/atas/swipetor-ui + +Visit https://www.swipetor.com for the demo. + +## Dual Licensing + +- **AGPL-3.0 License**: This is the default and preferred open-source license for the project. For more details, see the [AGPL-3.0 License](LICENSE). +- Different licenes: please contact at https://swipetor.com diff --git a/Swipetor.sln b/Swipetor.sln new file mode 100644 index 0000000..e0887a6 --- /dev/null +++ b/Swipetor.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SwipetorApp", "SwipetorApp\SwipetorApp.csproj", "{2CAE9D31-213A-4C0D-BC07-480BCCCFB7BC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SwipetorAppTest", "SwipetorAppTest\SwipetorAppTest.csproj", "{95B9CDDE-1ECF-403D-90DC-324D80C8C4C4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2CAE9D31-213A-4C0D-BC07-480BCCCFB7BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CAE9D31-213A-4C0D-BC07-480BCCCFB7BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CAE9D31-213A-4C0D-BC07-480BCCCFB7BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CAE9D31-213A-4C0D-BC07-480BCCCFB7BC}.Release|Any CPU.Build.0 = Release|Any CPU + {95B9CDDE-1ECF-403D-90DC-324D80C8C4C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {95B9CDDE-1ECF-403D-90DC-324D80C8C4C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {95B9CDDE-1ECF-403D-90DC-324D80C8C4C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {95B9CDDE-1ECF-403D-90DC-324D80C8C4C4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Swipetor.sln.DotSettings b/Swipetor.sln.DotSettings new file mode 100644 index 0000000..b52c5eb --- /dev/null +++ b/Swipetor.sln.DotSettings @@ -0,0 +1,30 @@ + + BER + EUR + GBP + SMTP + UI + USD + VM + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True \ No newline at end of file diff --git a/Swipetor.sln.DotSettings.user b/Swipetor.sln.DotSettings.user new file mode 100644 index 0000000..d368648 --- /dev/null +++ b/Swipetor.sln.DotSettings.user @@ -0,0 +1,54 @@ + + True + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + <AssemblyExplorer> + <Assembly Path="\Users\ata\.nuget\packages\automapper\9.0.0\lib\netstandard2.0\AutoMapper.dll" /> + <Assembly Path="C:\Users\atasa\.nuget\packages\htmlsanitizer.netcore3.1\1.0.0\lib\netcoreapp3.1\HtmlSanitizer.dll" /> + <Assembly Path="C:\Users\atasa\.nuget\packages\humanizer.core\2.8.26\lib\netstandard2.0\Humanizer.dll" /> + <Assembly Path="C:\Users\atasa\.nuget\packages\serilog.extensions.logging\2.0.2\lib\netstandard2.0\Serilog.Extensions.Logging.dll" /> + <Assembly Path="C:\Users\atasa\.nuget\packages\sixlabors.imagesharp\2.0.0\lib\netcoreapp3.1\SixLabors.ImageSharp.dll" /> + <Assembly Path="C:\Users\atasa\.nuget\packages\awssdk.s3\3.7.9.19\lib\netcoreapp3.1\AWSSDK.S3.dll" /> + <Assembly Path="C:\Users\atasa\.nuget\packages\awssdk.core\3.7.12.3\lib\netcoreapp3.1\AWSSDK.Core.dll" /> + <Assembly Path="C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.8\System.ComponentModel.Annotations.dll" /> + <Assembly Path="/usr/local/share/dotnet/packs/Microsoft.NETCore.App.Ref/6.0.8/ref/net6.0/System.IO.Pipes.dll" /> + <Assembly Path="/usr/local/share/dotnet/shared/Microsoft.NETCore.App/6.0.11/System.Net.Sockets.dll" /> + <Assembly Path="/Users/ata/.nuget/packages/ffmpegcore/4.8.0/lib/netstandard2.0/FFMpegCore.dll" /> + <Assembly Path="/Users/ata/.nuget/packages/toolbelt.entityframeworkcore.indexattribute.attribute/5.0.0/lib/netstandard2.0/Toolbelt.EntityFrameworkCore.IndexAttribute.Attribute.dll" /> + <Assembly Path="/Users/ata/.nuget/packages/anglesharp/1.0.4/lib/net7.0/AngleSharp.dll" /> + <Assembly Path="/Users/ata/.nuget/packages/webappshared/1.0.17/lib/net7.0/WebAppShared.dll" /> + <Assembly Path="/Users/ata/.nuget/packages/webappshared/1.0.33/lib/net8.0/WebAppShared.dll" /> +</AssemblyExplorer> + <SessionState ContinuousTestingMode="0" Name="ParseYoutubeVideoId #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Or> + <TestAncestor> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Utils.PasswordUtilsTest</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Utils.KeyGeneratorTest</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Utils.StringUtilsTest</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Types.SetOnceTests.TestSetOnce</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Types.SetOnceTests.TestSetOnceWithoutObjectInitializer</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Types.SetOnceTests</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Extensions.DateExtsTests</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Extensions.TimeExtsTests</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Extensions.ObjectExtensionTests</TestId> + <TestId>xUnit::4D76BEBA-7EF4-4239-8AF9-1328679250E1::net7.0::SwipetorLibsTest.Security.SanitizeTests</TestId> + <TestId>xUnit::95B9CDDE-1ECF-403D-90DC-324D80C8C4C4::net7.0::SwipetorAppTest.Libs.Posts.PostsQuerierTests</TestId> + </TestAncestor> + <ProjectFile>95B9CDDE-1ECF-403D-90DC-324D80C8C4C4/d:System/d:Extensions/f:VideoExtensionTest.cs</ProjectFile> + <Project Location="/Users/ata/projects/ufapp/SwipetorAppTest" Presentation="&lt;SwipetorAppTest&gt;" /> + </Or> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="GenerateClipsAndMerge_Successful" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::95B9CDDE-1ECF-403D-90DC-324D80C8C4C4::net8.0::SwipetorAppTest.VideoServices.VideoClipGeneratorTest</TestId> + <TestId>xUnit::95B9CDDE-1ECF-403D-90DC-324D80C8C4C4::net8.0::SwipetorAppTest.System.Extensions.VideoExtensionsTests</TestId> + <TestId>xUnit::95B9CDDE-1ECF-403D-90DC-324D80C8C4C4::net8.0::SwipetorAppTest.Utils.ListExtsTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="ParseYoutubeVideoId" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::95B9CDDE-1ECF-403D-90DC-324D80C8C4C4::net8.0::SwipetorAppTest.Utils.ListExtsTests</TestId> + </TestAncestor> +</SessionState> \ No newline at end of file diff --git a/SwipetorApp/AppDefaults.cs b/SwipetorApp/AppDefaults.cs new file mode 100644 index 0000000..6e13120 --- /dev/null +++ b/SwipetorApp/AppDefaults.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Logging; +using WebAppShared.Types; + +namespace SwipetorApp; + +public static class AppDefaults +{ + public static readonly SetOnce LoggerFactory = new(); +} \ No newline at end of file diff --git a/SwipetorApp/App_Data/index.html b/SwipetorApp/App_Data/index.html new file mode 100644 index 0000000..e69de29 diff --git a/SwipetorApp/Areas/Admin/HomeController.cs b/SwipetorApp/Areas/Admin/HomeController.cs new file mode 100644 index 0000000..5668d78 --- /dev/null +++ b/SwipetorApp/Areas/Admin/HomeController.cs @@ -0,0 +1,28 @@ +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using SwipetorApp.Areas.Admin.ViewModels; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.System.UserRoleAuth; + +namespace SwipetorApp.Areas.Admin; + +[Area(AreaNames.Admin)] +[UserRoleAuth(UserRole.Admin)] +public class HomeController(IDbProvider dbProvider) : Controller +{ + // [Route("")] + public IActionResult Index() + { + using var db = dbProvider.Create(); + var model = new AdminHomeViewModel + { + TotalUsers = db.Users.Count(), + TotalPosts = db.Posts.Count() + }; + + + return View(model); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/HubsController.cs b/SwipetorApp/Areas/Admin/HubsController.cs new file mode 100644 index 0000000..51d0c43 --- /dev/null +++ b/SwipetorApp/Areas/Admin/HubsController.cs @@ -0,0 +1,171 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Areas.Admin.ViewModels; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.PhotoServices; +using SwipetorApp.System.UserRoleAuth; +using WebAppShared.DI; +using WebAppShared.Photos; + +namespace SwipetorApp.Areas.Admin; + +[Area(AreaNames.Admin)] +[UserRoleAuth(UserRole.Admin)] +public class HubsController( + PhotoDeleterSvc photoDeleterSvc, + IFactory photoSaverFactory, + IDbProvider dbProvider) + : Controller +{ + public IActionResult Index() + { + using var db = dbProvider.Create(); + + var model = new HubsHomeViewModel + { + Hubs = db.Hubs.Include(c => c.Photo).OrderBy(c => c.Name).ToList() + }; + + return View(model); + } + + public IActionResult Add() + { + return View("AddEdit", new HubsAddEditViewModel()); + } + + [HttpPost] + public async Task Add(HubsAddEditViewModel model) + { + if (!ModelState.IsValid) return Add(); + + var hub = new Hub + { + Name = model.Name.Trim() + }; + + await using var db = dbProvider.Create(); + + db.Hubs.Add(hub); + await db.SaveChangesAsync(); + + if (model.HubIcon != null && CommonPhotoUtils.IsExtensionPhotoFile(model.HubIcon.FileName)) + { + var hubIcon = model.HubIcon; + using var uploader = photoSaverFactory.GetInstance() + .SetSource(hubIcon.OpenReadStream()) + .SetExtensionByFileName(hubIcon.FileName); + + var saved = await uploader.Save(); + + hub.Photo = saved; + await db.SaveChangesAsync(); + } + + return RedirectToAction("Index"); + } + + public IActionResult Edit(int hubId) + { + Hub hub; + using (var db = dbProvider.Create()) + { + hub = db.Hubs.Include(l => l.Photo).Single(c => c.Id == hubId); + } + + return View("AddEdit", new HubsAddEditViewModel + { + Id = hub.Id, + Name = hub.Name, + Photo = hub.Photo + }); + } + + [HttpPost] + public async Task Edit(HubsAddEditViewModel model) + { + if (!ModelState.IsValid) return View("AddEdit", model); + + await using var db = dbProvider.Create(); + + var hub = db.Hubs.Include(c => c.Photo).Single(f => f.Id == model.Id); + + hub.Name = model.Name.Trim(); + + await db.SaveChangesAsync(); + + if (model.HubIcon != null && CommonPhotoUtils.IsExtensionPhotoFile(model.HubIcon.FileName)) + { + var hubIcon = model.HubIcon; + + using var uploader = photoSaverFactory.GetInstance() + .SetSource(hubIcon.OpenReadStream()) + .SetExtensionByFileName(hubIcon.FileName); + + // Delete existing photo if exists + if (hub.PhotoId != null) uploader.DeletePreviousClause = p => p.Id == hub.PhotoId; + + var saved = await uploader.Save(); + hub.Photo = saved; + await db.SaveChangesAsync(); + } + + return RedirectToAction("Index"); + } + + [HttpGet] + public IActionResult Delete(int hubId) + { + using var db = dbProvider.Create(); + + var hub = db.Hubs.Single(c => c.Id == hubId); + + var hubList = db.Hubs + .Where(c => c.Id != hubId) + .Select(c => new Tuple(c.Id, c.Name)).ToList(); + hubList.Insert(0, new Tuple(null, "Select a hub")); + + return View(new HubsDeleteViewModel + { + Hub = hub, + HubList = hubList, + HubIdToDelete = hubId + }); + } + + [HttpPost] + public async Task Delete(HubsDeleteViewModel model) + { + if (!ModelState.IsValid) return RedirectToAction("Delete", new { hubId = model.HubIdToDelete }); + + var hubIdToDelete = model.HubIdToDelete ?? 0; + var hubIdToMovePosts = model.HubIdToMovePosts ?? 0; + + await using var db = dbProvider.Create(); + + var hub = db.Hubs.Single(c => c.Id == model.HubIdToDelete); + + // Move all posts of the hub being deleted + await db.PostHubs.Where(pc => pc.HubId == hubIdToDelete && + pc.Post.PostHubs.All(c => c.HubId != hubIdToMovePosts)) + .UpdateFromQueryAsync(pc => + new + { + HubId = hubIdToMovePosts + }); + + // Delete hub's photo if exists + if (hub.PhotoId != null) await photoDeleterSvc.Delete(hub.PhotoId.Value); + + db.Hubs.Remove(hub); + await db.SaveChangesAsync(); + + return RedirectToAction("Index"); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Pages/_ViewImports.cshtml b/SwipetorApp/Areas/Admin/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..d35116e --- /dev/null +++ b/SwipetorApp/Areas/Admin/Pages/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using SwipetorApp.Models.ViewModels +@using SwipetorApp.Models.DbEntities +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Mvc.RazorPages diff --git a/SwipetorApp/Areas/Admin/Pages/_ViewStart.cshtml b/SwipetorApp/Areas/Admin/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..8cdbb0b --- /dev/null +++ b/SwipetorApp/Areas/Admin/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_AdminLayout"; +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/PostsController.cs b/SwipetorApp/Areas/Admin/PostsController.cs new file mode 100644 index 0000000..1dc237d --- /dev/null +++ b/SwipetorApp/Areas/Admin/PostsController.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using SwipetorApp.Areas.Admin.ViewModels; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.SqlQueries; +using SwipetorApp.System.UserRoleAuth; + +namespace SwipetorApp.Areas.Admin; + +[Area(AreaNames.Admin)] +[UserRoleAuth(UserRole.Admin)] +public class PostsController(IMapper mapper, IDbProvider dbProvider) : Controller +{ + public IActionResult Index(bool isRemoved, int? hubId) + { + using var db = dbProvider.Create(); + + var q = db.Posts.AsQueryable(); + q = q.Where(p => p.IsRemoved == isRemoved); + if (hubId != null) q = q.Where(p => p.PostHubs.Any(pc => pc.HubId == hubId)); + + q = q.Take(1000); + + var posts = q.SelectForUser(null); + + var hubs = db.Hubs.SelectForUser().ToList(); + + var postsDto = mapper.Map>(posts); + var hubsDto = mapper.Map>(hubs); + + return View(new PostsIndexViewModel + { + Posts = postsDto, + Hubs = hubsDto, + IsRemoved = isRemoved, + HubId = hubId + }); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/UsersController.cs b/SwipetorApp/Areas/Admin/UsersController.cs new file mode 100644 index 0000000..43223d6 --- /dev/null +++ b/SwipetorApp/Areas/Admin/UsersController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.System.UserRoleAuth; + +namespace SwipetorApp.Areas.Admin; + +[Area(AreaNames.Admin)] +[UserRoleAuth(UserRole.Admin)] +public class UsersController : Controller +{ + public IActionResult Index() + { + return View(); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/ViewModels/AdminHomeViewModel.cs b/SwipetorApp/Areas/Admin/ViewModels/AdminHomeViewModel.cs new file mode 100644 index 0000000..25f9d3c --- /dev/null +++ b/SwipetorApp/Areas/Admin/ViewModels/AdminHomeViewModel.cs @@ -0,0 +1,7 @@ +namespace SwipetorApp.Areas.Admin.ViewModels; + +public class AdminHomeViewModel +{ + public int TotalUsers { get; set; } + public int TotalPosts { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/ViewModels/HubsAddEditViewModel.cs b/SwipetorApp/Areas/Admin/ViewModels/HubsAddEditViewModel.cs new file mode 100644 index 0000000..ec6825a --- /dev/null +++ b/SwipetorApp/Areas/Admin/ViewModels/HubsAddEditViewModel.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; +using SwipetorApp.Models.DbEntities; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Admin.ViewModels; + +public class HubsAddEditViewModel +{ + public int? Id { get; set; } + + [Required] + [MaxLength(32)] + [MinLength(3)] + public string Name { get; set; } + + [MaxFileSize] + public IFormFile HubIcon { get; set; } + + public Photo Photo { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/ViewModels/HubsDeleteViewModel.cs b/SwipetorApp/Areas/Admin/ViewModels/HubsDeleteViewModel.cs new file mode 100644 index 0000000..7d4b607 --- /dev/null +++ b/SwipetorApp/Areas/Admin/ViewModels/HubsDeleteViewModel.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using SwipetorApp.Models.DbEntities; + +namespace SwipetorApp.Areas.Admin.ViewModels; + +public class HubsDeleteViewModel +{ + public Hub Hub { get; set; } + public List> HubList { get; set; } + + [Required] + public int? HubIdToDelete { get; set; } + + [Required] + public int? HubIdToMovePosts { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/ViewModels/HubsHomeViewModel.cs b/SwipetorApp/Areas/Admin/ViewModels/HubsHomeViewModel.cs new file mode 100644 index 0000000..eb127be --- /dev/null +++ b/SwipetorApp/Areas/Admin/ViewModels/HubsHomeViewModel.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using SwipetorApp.Models.DbEntities; + +namespace SwipetorApp.Areas.Admin.ViewModels; + +public class HubsHomeViewModel +{ + public List Hubs { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/ViewModels/PostsIndexViewModel.cs b/SwipetorApp/Areas/Admin/ViewModels/PostsIndexViewModel.cs new file mode 100644 index 0000000..0e1ce0e --- /dev/null +++ b/SwipetorApp/Areas/Admin/ViewModels/PostsIndexViewModel.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using SwipetorApp.Models.DTOs; + +namespace SwipetorApp.Areas.Admin.ViewModels; + +public class PostsIndexViewModel +{ + public List Posts { get; set; } + public List Hubs { get; set; } + public bool IsRemoved { get; set; } + public int? HubId { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Home/Index.cshtml b/SwipetorApp/Areas/Admin/Views/Home/Index.cshtml new file mode 100644 index 0000000..447f101 --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Home/Index.cshtml @@ -0,0 +1,27 @@ +@model SwipetorApp.Areas.Admin.ViewModels.AdminHomeViewModel +@{ + ViewBag.Title = "Admin Dashboard "; +} + +@section scripts +{ + + +} + +
+

+ Admin Dashboard +

+
+ +
+

Total users: @Model.TotalUsers

+

Total posts: @Model.TotalPosts

+
+ +@*

Admin Dashboard

*@ + +@* *@ \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Home/NewMembersChart.cshtml b/SwipetorApp/Areas/Admin/Views/Home/NewMembersChart.cshtml new file mode 100644 index 0000000..c4d6e8e --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Home/NewMembersChart.cshtml @@ -0,0 +1,30 @@ + + + +
\ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Home/NewTopics.cshtml b/SwipetorApp/Areas/Admin/Views/Home/NewTopics.cshtml new file mode 100644 index 0000000..c4d6e8e --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Home/NewTopics.cshtml @@ -0,0 +1,30 @@ + + + +
\ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Hubs/AddEdit.cshtml b/SwipetorApp/Areas/Admin/Views/Hubs/AddEdit.cshtml new file mode 100644 index 0000000..c083463 --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Hubs/AddEdit.cshtml @@ -0,0 +1,54 @@ +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using SwipetorApp.Models.ViewModels +@model SwipetorApp.Areas.Admin.ViewModels.HubsAddEditViewModel + +@{ + ViewBag.Title = Model.Id == null ? "Add Hub" : $"Edit Hub {Model.Name ?? ""}"; + // var photoPartialViewModel = new PhotoPartialViewModel + // { + // Photo = Model?.Photos.FirstOrDefault(), + // Size = 64 + // }; +} + +
+

@ViewBag.Title

+
+ +
+ + @Html.ValidationSummary() + +
+ + + +
+ +

+ + Hub Icon + +

+
There are lovely icons on www.iconfinder.com, filter by free.
+ +
+ + + + @if (Model.Id != null) + { + + } + + +
+ +
\ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Hubs/Delete.cshtml b/SwipetorApp/Areas/Admin/Views/Hubs/Delete.cshtml new file mode 100644 index 0000000..a81d3b4 --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Hubs/Delete.cshtml @@ -0,0 +1,32 @@ +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model SwipetorApp.Areas.Admin.ViewModels.HubsDeleteViewModel + +@{ + ViewBag.Title = $"Delete Hub {Model.Hub.Name}"; +} + +
+

@ViewBag.Title

+
+ +
+ + @Html.ValidationSummary() + +
+ +

Please select a hub to move existing posts into:

+ +

+ @Html.DropDownListFor(m => m.HubIdToMovePosts, new SelectList(Model.HubList, "Item1", "Item2")) +

+ + + + + @Html.HiddenFor(m => m.HubIdToDelete) +
+ +
\ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Hubs/Index.cshtml b/SwipetorApp/Areas/Admin/Views/Hubs/Index.cshtml new file mode 100644 index 0000000..d2f02a4 --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Hubs/Index.cshtml @@ -0,0 +1,36 @@ +@model SwipetorApp.Areas.Admin.ViewModels.HubsHomeViewModel +@{ + ViewBag.Title = "Hubs"; +} + +
+ + +

Hubs Management

+
+ +
+ + + + + + + + + + + + @foreach (var hub in Model.Hubs) + { + + } + + +
HubActions
+ +
\ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Hubs/IndexHubRowPartial.cshtml b/SwipetorApp/Areas/Admin/Views/Hubs/IndexHubRowPartial.cshtml new file mode 100644 index 0000000..264b181 --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Hubs/IndexHubRowPartial.cshtml @@ -0,0 +1,24 @@ +@using SwipetorApp.Models.ViewModels +@model SwipetorApp.Models.DbEntities.Hub + + + + + + + @Model.Name + + + @* *@ + @* arrow_drop_up *@ + @* *@ + @* *@ + @* *@ + @* arrow_drop_down *@ + @* *@ + + + edit + + + \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Posts/Index.cshtml b/SwipetorApp/Areas/Admin/Views/Posts/Index.cshtml new file mode 100644 index 0000000..df8080a --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Posts/Index.cshtml @@ -0,0 +1,33 @@ +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using SwipetorApp.Models.ViewModels +@model SwipetorApp.Areas.Admin.ViewModels.PostsIndexViewModel +@{ + ViewBag.Title = "Posts"; +} + +
+

Posts list

+
+ +
+ +
+ @foreach (var p in Model.Posts) + { +
+ + + +
+ + @p.Title + +
+
+ @string.Join(", ", p.Hubs.Select(c => c.Name)) +
+
+ } +
+ +
\ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Shared/_AdminLayout.cshtml b/SwipetorApp/Areas/Admin/Views/Shared/_AdminLayout.cshtml new file mode 100644 index 0000000..fc837c0 --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Shared/_AdminLayout.cshtml @@ -0,0 +1,110 @@ +@using Microsoft.AspNetCore.Mvc.TagHelpers +@{ + Layout = null; +} + + + + + @ViewBag.Title - Swipetor + + + + + + + + + @RenderSection("header", false) + + + + + +
+ + +
+ @RenderBody() + + +
+
+ + + + +@RenderSection("scripts", false) + + \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/Users/Index.cshtml b/SwipetorApp/Areas/Admin/Views/Users/Index.cshtml new file mode 100644 index 0000000..2a9148d --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/Users/Index.cshtml @@ -0,0 +1,16 @@ +@{ + ViewBag.Title = "Admin Dashboard "; +} + +
+

+ User Management +

+
+ +
+
+ +@*

Admin Dashboard

*@ + +@* *@ \ No newline at end of file diff --git a/SwipetorApp/Areas/Admin/Views/_ViewImports.cshtml b/SwipetorApp/Areas/Admin/Views/_ViewImports.cshtml new file mode 100644 index 0000000..d35116e --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using SwipetorApp.Models.ViewModels +@using SwipetorApp.Models.DbEntities +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Mvc.RazorPages diff --git a/SwipetorApp/Areas/Admin/Views/_ViewStart.cshtml b/SwipetorApp/Areas/Admin/Views/_ViewStart.cshtml new file mode 100644 index 0000000..8cdbb0b --- /dev/null +++ b/SwipetorApp/Areas/Admin/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_AdminLayout"; +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/AuthApi.cs b/SwipetorApp/Areas/Api/AuthApi.cs new file mode 100644 index 0000000..5b229d7 --- /dev/null +++ b/SwipetorApp/Areas/Api/AuthApi.cs @@ -0,0 +1,190 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Users; +using WebAppShared.Contexts; +using WebAppShared.Exceptions; +using WebAppShared.Utils; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/auth")] +public class AuthApi( + IDbProvider dbProvider, + UserCx userCx, + UsernameCheckerSvc usernameCheckerSvc, + AuthSvc authSvc, + IConnectionCx connectionCx, + AuthProtectionSvc authProtectionSvc, + LoginCodeSvc loginCodeSvc) + : Controller +{ + [HttpPost("email-login-code")] + public async Task EmailLoginCode(AuthEmailLoginCodeModel model) + { + if (!ModelState.IsValid) throw new HttpJsonError("Please check and provide all input."); + + await loginCodeSvc.ThrowIfNotAllowedToProceed(model.Email); + + await using var db = dbProvider.Create(); + + var user = db.Users.SingleOrDefault(u => u.Email == model.Email); + + var loginRequest = new LoginRequest + { + Id = Guid.NewGuid(), + UserId = user?.Id, + Email = model.Email, + EmailCode = StringUtils.RandomString(new Random().Next(6, 8)).ToUpperInvariant() + }; + + db.LoginRequests.Add(loginRequest); + await db.SaveChangesAsync(); + + await loginCodeSvc.EmailLoginCode(loginRequest); + + return Json(new + { + loginRequestId = loginRequest.Id + }); + } + + [HttpPost("submit-login-code")] + public async Task SubmitLoginCode(AuthEnterLoginCodeReqModel model) + { + if (!ModelState.IsValid) + throw new HttpJsonError("Please check and provide all input."); + + authProtectionSvc.ThrowIfManyLoginAttemptsFromThisIpAddress(); + authProtectionSvc.ThrowIfLoginRequestIdAttemptedBefore(model.LoginRequestId); + + model.LoginCode = model.LoginCode.Trim().ToUpperInvariant(); + + // Add Login Try into DB + await using var db = dbProvider.Create(); + var loginAttempt = new LoginAttempt + { + Id = Guid.NewGuid(), + LoginRequestId = model.LoginRequestId, + TriedEmailCode = model.LoginCode + }; + db.LoginAttempts.Add(loginAttempt); + await db.SaveChangesAsync(); + // End Login Try + + var loginRequest = db.LoginRequests.FirstOrDefault(l => + l.Id == model.LoginRequestId && l.CreatedAt > DateTime.UtcNow.AddMinutes(-30)); + + if (loginRequest == null) throw new HttpJsonError("Invalid login request. [INEXT]"); + + authProtectionSvc.ThrowIfManyLoginAttemptsToUserId(loginRequest.UserId); + + if (loginRequest.IsUsed) throw new HttpJsonError("Already used login code."); + + if (loginRequest.BrowserAgent != connectionCx.BrowserAgent) + throw new HttpJsonError("Invalid login request. [BRWAG]"); + if (loginRequest.EmailCode != model.LoginCode) throw new HttpJsonError("Invalid login code. [INCD]"); + + loginRequest.IsUsed = true; + await db.SaveChangesAsync(); + + var user = authSvc.GetOrCreateUser(loginRequest.Email); + await authSvc.LoginWith(user); + + return Json(new + { + hasUsername = !string.IsNullOrWhiteSpace(user.Username) + }); + } + + [HttpPost("set-username")] + public async Task SetUsername([FromBody] AuthSetUsernameRequestModel model) + { + var currentUser = userCx.Value; + var username = model.Username?.Trim(); + + if (!string.IsNullOrEmpty(currentUser.Username)) return Ok(); + + usernameCheckerSvc.CheckAndThrowIfInvalid(username); + + await using var db = dbProvider.Create(); + + var user = db.Users.Single(u => u.Id == currentUser.Id); + + if (!string.IsNullOrEmpty(user.Username)) return Ok(); + + user.Username = username; + await db.SaveChangesAsync(); + + return Ok(); + } + + [HttpPost("check-username")] + public IActionResult CheckUsername(AuthCheckUsernameReqModel model) + { + var username = model.Username.Trim(); + + try + { + usernameCheckerSvc.CheckAndThrowIfInvalid(username); + } + catch (HttpJsonError e) + { + return Json(new { available = false, error = e.Title }); + } + + return Json(new { available = true }); + } + + [HttpGet("suggest-usernames")] + public IActionResult SuggestUsernames() + { + using var db = dbProvider.Create(); + + // Fetch 10 random words + var randomWords = db.EnglishWords + .OrderBy(w => Guid.NewGuid()) + .Take(10) + .Select(w => w.Word) + .ToList(); + + // Create the array with combined words + var combinedWords = new string[randomWords.Count / 2]; + for (var i = 0; i < randomWords.Count / 2; i++) + { + var firstWord = TruncateWord(randomWords[i], 5); + var secondWord = TruncateWord(randomWords[randomWords.Count - 1 - i], 5); + var random = new Random(); + combinedWords[i] = firstWord + secondWord + random.Next(10, 100); + } + + return Json(combinedWords); + } + + private string TruncateWord(string word, int maxLength) + { + word = word.Length <= maxLength ? word : word.Substring(0, maxLength); + return StringUtils.FirstLetterToUpper(word); + } + + [Authorize] + [Route("logout/{userId:int}")] + public async Task Logout(int userId) + { + if (userId != userCx.ValueOrNull?.Id) return Redirect("~/"); + + await HttpContext.SignOutAsync(); + + return Redirect("~/"); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/CommentsApi.cs b/SwipetorApp/Areas/Api/CommentsApi.cs new file mode 100644 index 0000000..2ead894 --- /dev/null +++ b/SwipetorApp/Areas/Api/CommentsApi.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Linq; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Notifs; +using SwipetorApp.Services.RateLimiter; +using SwipetorApp.Services.RateLimiter.Rules; +using SwipetorApp.Services.SqlQueries; +using WebAppShared.Exceptions; +using WebAppShared.Security; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/comments")] +public class CommentsApi(IDbProvider dbProvider, IMapper mapper, NotifSvc notifSvc, UserIdCx userIdCx) + : Controller +{ + [Route("/api/posts/{postId:int}/comments")] + public IActionResult Index(int postId) + { + using var db = dbProvider.Create(); + var comments = db.Comments.Where(c => c.PostId == postId).OrderBy(c => c.LikeCount).ThenBy(c => c.CreatedAt) + .SelectForUser(userIdCx.ValueOrNull).ToList(); + + var commentDtos = mapper.Map>(comments); + + return Json(commentDtos); + } + + [HttpPost] + [Authorize] + [RateLimitFilter] + public IActionResult Post([FromBody] CommentReqModel model) + { + if (!ModelState.IsValid) throw new HttpJsonError("Comment should be at least 3 characters."); + + var userId = userIdCx.Value; + + using var db = dbProvider.Create(); + var post = db.Posts.Single(p => p.Id == model.PostId); + + var comment = new Comment + { + Txt = Sanitize.EditorText(model.Txt), + UserId = userId, + PostId = model.PostId + }; + + db.Users.Where(u => u.Id == userId) + .UpdateFromQuery(u => new { CommentCount = u.CommentCount + 1 }); + + db.Comments.Add(comment); + db.SaveChanges(); + + db.Posts.Where(p => p.Id == model.PostId).UpdateFromQuery(p => new + { + CommentsCount = p.CommentsCount + 1 + }); + + notifSvc.NewComment(userId, comment.Id, post.Id, post.UserId); + + // TODO Notify mentioned users + // var mentionedUserIds = new MentionExtractor(model.Txt).ExtractUserIds(); + // _notifSvc.NewMentionInComment(_current.User.Id, comment.Id, model.PostId, mentionedUserIds); + db.SaveChanges(); + + var commentQM = db.Comments.Where(c => c.Id == comment.Id).SelectForUser(userId).ToList().Single(); + + return Json(mapper.Map(commentQM)); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/EmailApi.cs b/SwipetorApp/Areas/Api/EmailApi.cs new file mode 100644 index 0000000..565e576 --- /dev/null +++ b/SwipetorApp/Areas/Api/EmailApi.cs @@ -0,0 +1,67 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Emailing; +using SwipetorApp.System.UserRoleAuth; +using WebAppShared.Metrics; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/email")] +public class EmailApi(EmailerProvider emailerProvider, IDbProvider dbProvider, IMetricsSvc metricsSvc) + : Controller +{ + [UserRoleAuth(UserRole.HostMaster)] + [HttpGet("test")] + public async Task Test(string to) + { + using var emailer = emailerProvider.GetGenericEmailer(); + await emailer.Send(to, "this is a test email", ""); + + return Ok(); + } + + [HttpGet("unsubscribe/pms")] + public IActionResult UnsubscribePms(string email, string secret) + { + email = email.Trim(); + + using var db = dbProvider.Create(); + + var user = db.Users.Where(u => u.Email == email && u.Secret == secret).FirstOrDefault(); + + if (user == null) return Redirect($"/?msgCode={SayMsgKey.UnsubscribeLinkWrong}"); + + user.PmEmailIntervalHours = null; + db.SaveChanges(); + + metricsSvc.Collect("PmEmailUnsubscribed", 1); + + return Redirect($"/?msgCode={SayMsgKey.UnsubscribePmSuccessful}"); + } + + [HttpGet("unsubscribe/notifs")] + public IActionResult UnsubscribeNotifs(string email, string secret) + { + email = email.Trim(); + + using var db = dbProvider.Create(); + + var user = db.Users.Where(u => u.Email == email && u.Secret == secret).FirstOrDefault(); + + if (user == null) return Redirect($"/?msgCode={SayMsgKey.UnsubscribeLinkWrong}"); + + user.NotifEmailIntervalHours = null; + db.SaveChanges(); + + metricsSvc.Collect("NotifEmailUnsubscribed", 1); + + return Redirect($"/?msgCode={SayMsgKey.UnsubscribeNotifSuccessful}"); + } + +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/HubsApi.cs b/SwipetorApp/Areas/Api/HubsApi.cs new file mode 100644 index 0000000..eff24bb --- /dev/null +++ b/SwipetorApp/Areas/Api/HubsApi.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using MoreLinq; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.SqlQueries; +using Z.EntityFramework.Plus; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("/api/hubs")] +public class HubsApi(IDbProvider dbProvider, IMapper mapper, HubSvc hubSvc) : Controller +{ + public JsonResult Index() + { + using var db = dbProvider.Create(); + + var hubPostCounts = db.Hubs.Select(c => new + { + HubId = c.Id, + PostCount = c.PostHubs.Count + }).FromCache(new MemoryCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(1) + }).ToDictionary(k => k.HubId, v => v.PostCount); + + var hubs = db.Hubs.SelectForUser().ToList(); + + var hubDtos = mapper.Map>(hubs); + var hubsById = hubDtos.ToDictionary(f => f.Id); + + hubsById.ForEach(c => c.Value.PostCount = hubPostCounts.TryGetValue(c.Key, out var count) ? count : 0); + + return Json(new + { + hubsById + }); + } + + [Route("{id}")] + public JsonResult Get(int id) + { + if (id <= 0) return Json(hubSvc.GetById(id)); + + using var db = dbProvider.Create(); + + var hub = db.Hubs.Where(f => f.Id == id).SelectForUser().Single(); + + return Json(mapper.Map(hub)); + } + + [HttpGet("search")] + public IActionResult Search(string name) + { + using var db = dbProvider.Create(); + var hubs = db.Hubs.Where(c => EF.Functions.ILike(c.Name, $"%{name.Trim()}%")).OrderBy(h => h.Name) + .Take(10).ToList(); + + var hubDtos = mapper.Map>(hubs); + return Json(hubDtos); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/LocationsApi.cs b/SwipetorApp/Areas/Api/LocationsApi.cs new file mode 100644 index 0000000..b2cbbb9 --- /dev/null +++ b/SwipetorApp/Areas/Api/LocationsApi.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Contexts; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/locations")] +public class LocationsApi(IMapper mapper, IDbProvider dbProvider) : Controller +{ + [HttpGet("")] + public IActionResult Search(string q, LocationType type) + { + q = string.Join(" & ", q.Trim().Split(" ").Where(e => !string.IsNullOrEmpty(e)).Select(e => $"{e}:*")); + + using var db = dbProvider.Create(); + var results = db.Locations.Where(p => p.SearchVector.Matches(EF.Functions.ToTsQuery(q)) && p.Type == type) + .OrderBy(l => l.NameAscii) + .Take(10).ToList(); + + return Json(mapper.Map>(results)); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/MediasApi.cs b/SwipetorApp/Areas/Api/MediasApi.cs new file mode 100644 index 0000000..6de8b29 --- /dev/null +++ b/SwipetorApp/Areas/Api/MediasApi.cs @@ -0,0 +1,240 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Models.Extensions; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Medias; +using SwipetorApp.Services.Permissions; +using SwipetorApp.Services.PhotoServices; +using SwipetorApp.Services.RateLimiter; +using SwipetorApp.Services.RateLimiter.Rules; +using SwipetorApp.Services.VideoDownload; +using SwipetorApp.Services.VideoServices; +using SwipetorApp.Services.WebPush; +using SwipetorApp.System; +using WebAppShared.DI; +using WebAppShared.Disk; +using WebAppShared.Exceptions; +using WebAppShared.Uploaders; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/medias")] +[Authorize] +public class MediasApi( + UserCx userCx, + PhotoDeleterSvc photoDeleterSvc, + ILogger logger, + IFactory videoMediaSaverSvc, + IFileDeleter fileDeleter, + VideoDownloaderSvc videoDownloaderSvc, + IStorageBucket bucket, + MediaSvc mediaSvc, + IDbProvider dbProvider, + WebPushSvc webPushSvc) + : Controller +{ + /// + /// Add a photo to an existing post + /// + /// + /// + /// + /// + /// + /// TODO - Enable this endpoint + [HttpPost("photos")] + [RateLimitFilter] + [MinRole(UserRole.Creator)] + public async Task UploadPhoto(int postId, [FromForm] DraftPostAddPhotoViewModel model, bool isInstant = false) + { + throw new HttpJsonError("Photos in posts are currently disabled, will be back soon."); + + /*var photoUrl = model.PhotoUrl?.Trim(); + + await using var db = dbProvider.Create(); + + var post = db.Posts.Include(p => p.User).Where(p => p.Id == postId).Single(); + new PostPerms().CanEdit(post, userCx.Value); + + if (!string.IsNullOrEmpty(photoUrl)) + await photoMediaSvc.AddPhotoByUrl(postId, photoUrl); + else if (model.File != null) + await photoMediaSvc.UploadPhotoIntoPost(postId, model.File, isInstant); + else + throw new HttpJsonError("Either upload a photo or provide a photo URL."); + + post = db.Posts + .Include(p => p.User) + .Include(p => p.Medias) + .Where(p => p.Id == postId).Single(); + + return Json(mapper.Map(post));*/ + } + + [HttpPost("videos")] + [RequestSizeLimit(500 * 1024 * 1024)] //500mb limit + [RateLimitFilter] + [MinRole(UserRole.Creator)] + public async Task UploadVideo(int postId, [FromForm] DraftPostAddVideoViewModel model, bool isInstant = false) + { + logger.LogInformation( + "Uploading video FileName: {FN}, Name: {Name}, Length: {Length}", model.File.FileName, + model.File.Name, model.File.Length); + + await using (var db = dbProvider.Create()) + { + var post = db.Posts.Include(p => p.User).Where(p => p.Id == postId).Single(); + new PostPerms().CanEdit(post, userCx.Value); + } + + var vidFilePath = Path.GetTempFileName(); + + // Read the stream until the end. + await using (var vidFile = + new FileStream(vidFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, 4096)) + { + await model.File.CopyToAsync(vidFile); + } + + using var videoFlow = videoMediaSaverSvc.GetInstance(); + await videoFlow.Save(postId, vidFilePath, isInstant); + + return Ok(); + } + + [HttpPost("videos/from-url")] + [RateLimitFilter] + [MinRole(UserRole.Creator)] + public async Task ImportFromUrl(DraftPostAddVideoFromUrlViewModel model) + { + logger.LogInformation( + "Importing video from url: {YU} into post {PostId}", model.Url, model.PostId); + + await using (var db = dbProvider.Create()) + { + var post = db.Posts.Include(p => p.User).Where(p => p.Id == model.PostId).Single(); + new PostPerms().CanEdit(post, userCx.Value); + } + + using var tempPath = new ScopedTempPath(); + var localFilePath = await videoDownloaderSvc.Download(model.Url, tempPath); + + using var videoFlow = videoMediaSaverSvc.GetInstance(); + videoFlow.VideoReferenceUrl = model.Url; + await videoFlow.Save(model.PostId, localFilePath); + + return Ok(); + } + + /// + /// Delete media + /// + /// + /// + /// + [HttpDelete("{mediaId}")] + [MinRole(UserRole.Creator)] + public async Task Delete(int mediaId) + { + await using var db = dbProvider.Create(); + var media = db.PostMedias + .Include(m => m.Post) + .Include(m => m.Video).ThenInclude(video => video.Sprites) + .Single(m => m.Id == mediaId); + + new PostPerms().CanDelete(media.Post, userCx.Value); + + logger.LogInformation("Deleting media {MediaId}", mediaId); + + db.PostMedias.Remove(media); + await db.SaveChangesAsync(); + + if (media.Type == PostMediaType.Photo && media.PhotoId != null && + db.Photos.Count(p => p.Id == media.PhotoId) == 1) + await photoDeleterSvc.Delete(media.PhotoId.Value); + + // Only delete the video file if no other media is referencing it. + if (media.Type == PostMediaType.Video && media.VideoId != null && + db.PostMedias.Count(m => m.VideoId == media.VideoId) <= 1) + { + foreach (var f in media.Video.GetFileNames()) await fileDeleter.Delete(bucket.Videos, f); + + foreach (var sprite in media.Video.Sprites) await fileDeleter.Delete(bucket.Sprites, sprite.Id + ".webp"); + } + + return Ok(); + } + + [HttpPost("{mediaId}/duplicate")] + [MinRole(UserRole.Creator)] + public IActionResult Duplicate(int mediaId) + { + mediaSvc.Duplicate(mediaId); + return Ok(); + } + + [HttpGet("{mediaId}/move")] + [MinRole(UserRole.Creator)] + public IActionResult Move(int mediaId, string direction) + { + using var db = dbProvider.Create(); + var media = db.PostMedias + .Include(m => m.Post).ThenInclude(p => p.Medias) + .Single(m => m.Id == mediaId); + + new PostPerms().CanEdit(media.Post, userCx.Value); + + var orderedMedias = media.Post.Medias.OrderBy(m => m.Ordering); + + var swapWith = direction == "up" + ? orderedMedias.LastOrDefault(m => m.Ordering < media.Ordering && m.Id != media.Id) + : orderedMedias.FirstOrDefault(m => m.Ordering > media.Ordering && m.Id != media.Id); + + if (swapWith == null) return Ok(); + + (media.Ordering, swapWith.Ordering) = (swapWith.Ordering, media.Ordering); + db.SaveChanges(); + + return Ok(); + } + + [HttpPost("{mediaId:int}/notif-reveal")] + [Authorize] + public async Task NotifReveal(int mediaId, [FromBody] MediaNotifRevealVM model) + { + var db = dbProvider.Create(); + var pushDevice = webPushSvc.SaveGetPushDevice(model.Token); + var media = db.PostMedias.Include(m => m.Post).Single(m => m.Id == mediaId); + + if (pushDevice == null || media == null) return NotFound(); + + var payload = new WebPushPayload + { + UserId = userCx.Value.Id, + Title = $"Reveal Exclusive media in post {media.Post.Title}", + Body = "Click to reveal the media", + Url = $"/p/{media.Post.Id}", + PushDevice = pushDevice, + Tag = WebPushTag.RevealMediaByNotif, + Icon = "/public/images/hub/hub-dot-256.png", + Data = new Dictionary(){ { "mediaId", mediaId.ToString() } } + }; + + var payloads = new List { payload }; + await webPushSvc.PushToDevices(payloads); + + return Ok(); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/AuthCheckUsernameReqModel.cs b/SwipetorApp/Areas/Api/Models/AuthCheckUsernameReqModel.cs new file mode 100644 index 0000000..e314916 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/AuthCheckUsernameReqModel.cs @@ -0,0 +1,6 @@ +namespace SwipetorApp.Areas.Api.Models; + +public class AuthCheckUsernameReqModel +{ + public string Username { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/AuthEmailLoginCodeModel.cs b/SwipetorApp/Areas/Api/Models/AuthEmailLoginCodeModel.cs new file mode 100644 index 0000000..fef1fb4 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/AuthEmailLoginCodeModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Api.Models; + +public class AuthEmailLoginCodeModel +{ + [Required] + [EmailAddress] + public string Email { get; set; } + + [ValidateRecaptcha] + public string RecaptchaValue { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/AuthEnterLoginCodeReqModel.cs b/SwipetorApp/Areas/Api/Models/AuthEnterLoginCodeReqModel.cs new file mode 100644 index 0000000..8642e52 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/AuthEnterLoginCodeReqModel.cs @@ -0,0 +1,18 @@ +using System; +using System.ComponentModel.DataAnnotations; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Api.Models; + +public class AuthEnterLoginCodeReqModel +{ + [Required] + public Guid LoginRequestId { get; set; } + + [Required] + [MinLength(5)] + public string LoginCode { get; set; } + + [ValidateRecaptcha] + public string RecaptchaValue { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/AuthSetUsernameRequestModel.cs b/SwipetorApp/Areas/Api/Models/AuthSetUsernameRequestModel.cs new file mode 100644 index 0000000..eaf13c6 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/AuthSetUsernameRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class AuthSetUsernameRequestModel +{ + [Required] + [MinLength(3)] + [MaxLength(18)] + public string Username { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/CommentReqModel.cs b/SwipetorApp/Areas/Api/Models/CommentReqModel.cs new file mode 100644 index 0000000..a9f5909 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/CommentReqModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class CommentReqModel +{ + [Required] + [MinLength(3)] + public string Txt { get; set; } + + [Required] + public int PostId { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/CreateDraftPostViewModel.cs b/SwipetorApp/Areas/Api/Models/CreateDraftPostViewModel.cs new file mode 100644 index 0000000..91e686c --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/CreateDraftPostViewModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class CreateDraftPostViewModel +{ + [MinLength(3)] + [Required] + public string Title { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/DraftPostAddPhotoViewModel.cs b/SwipetorApp/Areas/Api/Models/DraftPostAddPhotoViewModel.cs new file mode 100644 index 0000000..7eed739 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/DraftPostAddPhotoViewModel.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Api.Models; + +public class DraftPostAddPhotoViewModel +{ + [CanBeNull] + public string PhotoUrl { get; set; } + + [MaxFileSize] + [CanBeNull] + public IFormFile File { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/DraftPostAddVideoFromUrlViewModel.cs b/SwipetorApp/Areas/Api/Models/DraftPostAddVideoFromUrlViewModel.cs new file mode 100644 index 0000000..bd2304b --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/DraftPostAddVideoFromUrlViewModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class DraftPostAddVideoFromUrlViewModel +{ + [Required] + public int PostId { get; set; } + + [Required] + [MinLength(5)] + public string Url { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/DraftPostAddVideoViewModel.cs b/SwipetorApp/Areas/Api/Models/DraftPostAddVideoViewModel.cs new file mode 100644 index 0000000..2626a83 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/DraftPostAddVideoViewModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace SwipetorApp.Areas.Api.Models; + +public class DraftPostAddVideoViewModel +{ + [Required] + public IFormFile File { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/MediaNotifRevealVM.cs b/SwipetorApp/Areas/Api/Models/MediaNotifRevealVM.cs new file mode 100644 index 0000000..40142bc --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/MediaNotifRevealVM.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class MediaNotifRevealVM +{ + [Required] + public string Token { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/MyApiCustomDomainReqModel.cs b/SwipetorApp/Areas/Api/Models/MyApiCustomDomainReqModel.cs new file mode 100644 index 0000000..0082261 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/MyApiCustomDomainReqModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Api.Models; + +public class MyApiCustomDomainReqModel +{ + [MaxLength(64)] + [WebDomain] + public string DomainName { get; set; } + + [MaxLength(128)] + public string RecaptchaKey { get; set; } + + [MaxLength(128)] + public string RecaptchaSecret { get; set; } +} diff --git a/SwipetorApp/Areas/Api/Models/MyApiProfileDescReqModel.cs b/SwipetorApp/Areas/Api/Models/MyApiProfileDescReqModel.cs new file mode 100644 index 0000000..6ba3359 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/MyApiProfileDescReqModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Api.Models; + +public class MyApiProfileDescReqModel +{ + [MaxLength(200)] + public string Description { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/PmInitReqModel.cs b/SwipetorApp/Areas/Api/Models/PmInitReqModel.cs new file mode 100644 index 0000000..f9f17df --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/PmInitReqModel.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class PmInitReqModel +{ + [Required] + [MinLength(1, ErrorMessage = "UserIds must contain at least 1 element.")] + [MaxLength(3, ErrorMessage = "UserIds can contain a maximum of 3 elements.")] + public List UserIds { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/PmInviteRequestModel.cs b/SwipetorApp/Areas/Api/Models/PmInviteRequestModel.cs new file mode 100644 index 0000000..973a924 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/PmInviteRequestModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class PmInviteRequestModel +{ + [Required] public int UserId { get; set; } + + [Required] + [MinLength(10)] + [MaxLength(120)] + public string Intro { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/PmSendMsgRequestModel.cs b/SwipetorApp/Areas/Api/Models/PmSendMsgRequestModel.cs new file mode 100644 index 0000000..7d09f33 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/PmSendMsgRequestModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class PmSendMsgRequestModel +{ + [Required] public long ThreadId { get; set; } + + [Required] public string Txt { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/PostSaveModel.cs b/SwipetorApp/Areas/Api/Models/PostSaveModel.cs new file mode 100644 index 0000000..232c5ad --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/PostSaveModel.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class PostSaveModel +{ + public IList Items { get; set; } + public bool IsPublished { get; set; } + + public int[] HubIds { get; set; } + + public int? PosterUserId { get; set; } + + [UsedImplicitly] + public class PostMediaItemModel + { + public int Id { get; set; } + public string Description { get; set; } + public bool IsFollowersOnly { get; set; } + public int? SubPlanId { get; set; } + public List> ClipTimes { get; set; } + } +} diff --git a/SwipetorApp/Areas/Api/Models/PostsApiTitleUpdateReqModel.cs b/SwipetorApp/Areas/Api/Models/PostsApiTitleUpdateReqModel.cs new file mode 100644 index 0000000..8dded18 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/PostsApiTitleUpdateReqModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class PostsApiTitleUpdateReqModel +{ + [Required] + [MinLength(3)] + [MaxLength(128)] + public string Title { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/PostsGetRequestModel.cs b/SwipetorApp/Areas/Api/Models/PostsGetRequestModel.cs new file mode 100644 index 0000000..957de4d --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/PostsGetRequestModel.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class PostsGetRequestModel +{ + public int? FirstPostId { get; set; } + + public int? UserId { get; set; } + + [CanBeNull] + public string HubIds { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/RegisterPushDeviceRequestModel.cs b/SwipetorApp/Areas/Api/Models/RegisterPushDeviceRequestModel.cs new file mode 100644 index 0000000..8180926 --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/RegisterPushDeviceRequestModel.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class RegisterPushDeviceRequestModel +{ + [Required] public string Token { get; set; } +} diff --git a/SwipetorApp/Areas/Api/Models/SubPlansUpdateReqModel.cs b/SwipetorApp/Areas/Api/Models/SubPlansUpdateReqModel.cs new file mode 100644 index 0000000..6b2093d --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/SubPlansUpdateReqModel.cs @@ -0,0 +1,19 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace SwipetorApp.Areas.Api.Models; + +public class SubPlansUpdateReqModel +{ + [Required, MinLength(2)] + public string Name { get; set; } + + [Required, MinLength(10)] + public string Description { get; set; } + + [Required] + public decimal? Price { get; set; } + + [Required] + public string Currency { get; set; } +} diff --git a/SwipetorApp/Areas/Api/Models/UserApiBecomeCreatorReqModel.cs b/SwipetorApp/Areas/Api/Models/UserApiBecomeCreatorReqModel.cs new file mode 100644 index 0000000..cfa9f2f --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/UserApiBecomeCreatorReqModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Api.Models; + +public class UserApiBecomeCreatorReqModel +{ + [Required] + [MinLength(50)] + public string Txt { get; set; } + + [ValidateRecaptcha] + public string RecaptchaValue { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/Models/UsersApiGetUsersResp.cs b/SwipetorApp/Areas/Api/Models/UsersApiGetUsersResp.cs new file mode 100644 index 0000000..c3860bb --- /dev/null +++ b/SwipetorApp/Areas/Api/Models/UsersApiGetUsersResp.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.DTOs; + +namespace SwipetorApp.Areas.Api.Models; + +public class UsersApiGetUsersResp +{ + public PublicUserDto User { get; set; } + public List Posts { get; set; } + public bool CanMsg { get; set; } + public long? PmThreadId { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/MyApi.cs b/SwipetorApp/Areas/Api/MyApi.cs new file mode 100644 index 0000000..2fa5270 --- /dev/null +++ b/SwipetorApp/Areas/Api/MyApi.cs @@ -0,0 +1,181 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.PhotoServices; +using SwipetorApp.Services.Users; +using SwipetorApp.System; +using WebAppShared.DI; +using WebAppShared.Exceptions; +using WebAppShared.WebSys; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/my")] +[Authorize] +public class MyApi( + IFactory photoSaverFactory, + IMapper mapper, + UserCx userCx, + IDbProvider dbProvider, + PhotoDeleterSvc photoDeleterSvc) + : Controller +{ + [AllowAnonymous] + public IActionResult Index() + { + var userId = userCx.ValueOrNull?.Id; + + using var db = dbProvider.Create(); + var user = db.Users.Include(u => u.Photo).Where(u => u.Id == userId) + .SingleOrDefault(); + var userDto = mapper.Map(user); + + return Json(new + { + user = userDto + }); + } + + [AllowAnonymous] + [Route("ping")] + public IActionResult Ping() + { + if (!userCx.IsLoggedIn) return Ok(); + + using var db = dbProvider.Create(); + + var unreadNotifCount = db.Notifs.Where(n => n.ReceiverUserId == userCx.Value.Id && n.IsViewed == false) + .Count(); + + var unreadPmCount = db.PmThreadUsers.Where(tu => + tu.UserId == userCx.Value.Id && tu.UnreadMsgCount > 0 && + tu.Thread.LastMsgAt > tu.User.LastPmCheckAt) + .Count(); + + var user = db.Users.Include(u => u.Photo).Where(u => u.Id == userCx.Value.Id).SingleOrDefault(); + + var userDto = mapper.Map(user); + + return Json(new + { + unreadNotifCount, + unreadPmCount, + uiVersion = DeployInfo.GetUiVersion(), + appVersion = DeployInfo.GetAppVersion(), + user = userDto + }); + } + + [HttpPost] + [Route("photos")] + public async Task Photos([MaxFileSize] IFormFile file) + { + using var photoSaver = photoSaverFactory.GetInstance() + .SetSource(file.OpenReadStream()) + .SetExtensionByFileName(file.FileName) + .SetMaxWidthHeight(1280); + + var photoUploadingTask = photoSaver.Save(); + + await using var db = dbProvider.Create(); + var user = db.Users.Single(u => u.Id == userCx.Value.Id); + + // Delete if photo exists + if (user.PhotoId != null) await photoDeleterSvc.Delete(user.PhotoId.Value); + + var photo = await photoUploadingTask; + + user.PhotoId = photo.Id; + await db.SaveChangesAsync(); + + return Ok(); + } + + [HttpPut("profile")] + [Authorize] + public IActionResult Profile(MyApiProfileDescReqModel model) + { + if (!ModelState.IsValid) throw new HttpJsonError("Data is invalid."); + + using var db = dbProvider.Create(); + var user = db.Users.Single(u => u.Id == userCx.Value.Id); + user.Description = !string.IsNullOrWhiteSpace(model.Description) ? model.Description.Trim() : null; + db.SaveChanges(); + + return Ok(); + } + + [HttpGet("custom-domain")] + [Authorize] + [MinRole(UserRole.Creator)] + public IActionResult CustomDomain() + { + using var db = dbProvider.Create(); + + var customDomain = db.CustomDomains.Where(cd => cd.UserId == userCx.Value.Id).SingleOrDefault(); + var dto = mapper.Map(customDomain); + return Json(dto); + } + + [HttpPut("custom-domain")] + [Authorize] + [MinRole(UserRole.Creator)] + public IActionResult CustomDomain(MyApiCustomDomainReqModel model) + { + if (!ModelState.IsValid) throw new HttpJsonError("Data is invalid."); + + using var db = dbProvider.Create(); + + if (db.CustomDomains.Any(u => u.DomainName == model.DomainName && u.UserId != userCx.Value.Id)) + throw new HttpJsonError("Custom domain is already taken."); + + var cd = db.CustomDomains.SingleOrDefault(c => c.UserId == userCx.Value.Id); + + if (cd == null) + { + cd = new CustomDomain() + { + UserId = userCx.Value.Id + }; + db.CustomDomains.Add(cd); + } + + cd.DomainName = model.DomainName; + cd.RecaptchaKey = model.RecaptchaKey; + cd.RecaptchaSecret = model.RecaptchaSecret; + + db.SaveChanges(); + + return Ok(); + } + + [HttpDelete] + [Route("photos/{photoId}")] + public IActionResult Photos(Guid photoId) + { + using var db = dbProvider.Create(); + var photo = db.Users.Include(user => user.Photo).Single(u => u.Id == userCx.Value.Id).Photo; + + if (photo == null) throw new HttpStatusCodeException(HttpStatusCode.NotFound); + + db.Photos.Remove(photo); + db.SaveChanges(); + + return Ok(); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/NotifsApi.cs b/SwipetorApp/Areas/Api/NotifsApi.cs new file mode 100644 index 0000000..469d79e --- /dev/null +++ b/SwipetorApp/Areas/Api/NotifsApi.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.SqlQueries; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/notifs")] +[Authorize] +public class NotifsApi(UserCx userCx, IMapper mapper, IDbProvider dbProvider) : Controller +{ + public IActionResult Index() + { + using var db = dbProvider.Create(); + + // Set all notifications as viewed as we render them to the user. + db.Notifs.Where(n => n.ReceiverUserId == userCx.Value.Id && n.IsViewed == false) + .UpdateFromQuery(n => new + { + IsViewed = true + }); + + db.Users.Where(u => u.Id == userCx.Value.Id).UpdateFromQuery(n => new + { + LastNotifCheckAt = DateTime.UtcNow + }); + + var notifs = db.Notifs.Where(n => n.ReceiverUserId == userCx.Value.Id) + .OrderByDescending(n => n.CreatedAt).Take(20).SelectForUser() + .ToList(); + + var notifDtos = mapper.Map>(notifs); + + return Json(new + { + notifs = notifDtos, + LastNotifCheckAt = mapper.Map(userCx.Value.LastNotifCheckAt) + }); + } + + [HttpPost] + [Route("{notifId}/read")] + public IActionResult MarkRead(int notifId) + { + using var db = dbProvider.Create(); + db.Notifs.Where(n => n.Id == notifId && n.ReceiverUserId == userCx.Value.Id).UpdateFromQuery(n => new + { + IsRead = true + }); + db.SaveChanges(); + + return Ok(); + } + + [HttpDelete] + [Route("{notifId}/read")] + public IActionResult MarkUnread(int notifId) + { + using var db = dbProvider.Create(); + db.Notifs.Where(n => n.Id == notifId && n.ReceiverUserId == userCx.Value.Id).UpdateFromQuery(n => new + { + IsRead = false + }); + db.SaveChanges(); + + return Ok(); + } + + [HttpPost("register-push-device")] + public IActionResult RegisterPushDevice([FromBody] RegisterPushDeviceRequestModel model) + { + if (string.IsNullOrEmpty(model.Token)) return BadRequest("Push token was empty."); + + using var db = dbProvider.Create(); + + if (db.PushDevices.Any(p => p.Token == model.Token && p.UserId == userCx.Value.Id)) + return Ok(); + + db.PushDevices.Add(new PushDevice + { + UserId = userCx.Value.Id, + Token = model.Token, + LastUsedAt = DateTime.UtcNow + }); + db.SaveChanges(); + return Ok(); + } + + // [HttpGet("test")] + // public async Task Test() + // { + // var devices = _cx.PushDevices.ToList(); + // await _pushNotifSvc.PushToDevices(devices, new PushNotifPayload + // { + // Title = "Hello msg", + // Body = "This works!", + // Url = "/notifications", + // Tag = PushNotifTag.NewNotifications, + // Icon = "/public/images/forum/forum-dot-256.png" + // }); + // return Ok(); + // } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/PhotosApi.cs b/SwipetorApp/Areas/Api/PhotosApi.cs new file mode 100644 index 0000000..9c6c526 --- /dev/null +++ b/SwipetorApp/Areas/Api/PhotosApi.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Config; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.PhotoServices; +using WebAppShared.Exceptions; +using WebAppShared.Photos; +using WebAppShared.Uploaders; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/photos")] +public class PhotosApi( + IOptions storageConfig, + UploadedPhotoResizerSvc resizerSvc, + IStorageBucket bucket, + IDbProvider dbProvider) + : Controller +{ + [Route("{photoId:guid}/sizes/{size:int}")] + public async Task Index(Guid photoId, int size) + { + CommonPhotoUtils.AssertPhotoSizeValid(size); + + await using var db = dbProvider.Create(); + var photo = db.Photos.SingleOrDefault(p => p.Id == photoId); + + if (photo == null) throw new HttpStatusCodeException(HttpStatusCode.NotFound); + + if (!photo.Sizes.Contains(size)) await resizerSvc.Resize(photo.Id, size); + + return Redirect($"https://{storageConfig.Value.MediaHost}/{bucket.Photos}/{photo.GetFilename(size)}"); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/PmApi.cs b/SwipetorApp/Areas/Api/PmApi.cs new file mode 100644 index 0000000..d6943ee --- /dev/null +++ b/SwipetorApp/Areas/Api/PmApi.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AngleSharp.Text; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Management.Network.Fluent.PCFilter.Definition; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Pm; +using SwipetorApp.Services.SqlQueries; +using SwipetorApp.Services.SqlQueries.Models; +using WebAppShared.Exceptions; +using WebAppShared.Metrics; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/pm")] +[Authorize] +public class PmApi( + IMapper mapper, + IDbProvider dbProvider, + PmThreadSvc pmThreadSvc, + PmMsgSenderSvc pmMsgSender, + UserIdCx userIdCx, + IMetricsSvc metricsSvc) + : Controller +{ + /// + /// Get threads of current user + /// + /// + public IActionResult Index() + { + // Update users last PM check time non-blocking + Task.Run(() => + { + using var db = dbProvider.Create(); + db.Users.Where(u => u.Id == userIdCx).UpdateFromQuery(u => new + { + LastPmCheckAt = DateTime.UtcNow + }); + }); + + using var db = dbProvider.Create(); + var threads = db.PmThreads.Where(t => t.ThreadUsers.Any(tu => tu.UserId == userIdCx)) + .OrderByDescending(t => t.LastMsgAt).Take(20).ToList(); + + // db.TenantUsers.Where(tu => tu.Role >= UserRole.TenantEditor).Select(tu => tu.User) + // .Include(u => u.Photo).Include(u => u.TenantUsers).ToList(); + + var threadDtos = mapper.Map>(threads); + + return Json(new + { + threads = threadDtos + }); + } + + [HttpPost("init")] + public async Task Init(PmInitReqModel model) + { + if (!ModelState.IsValid) throw new HttpJsonError("Min 1, Max 3 users can be added"); + + await using var db = dbProvider.Create(); + var thread = pmThreadSvc.GetOrCreateThread(model.UserIds, userIdCx); + + return Json(mapper.Map(thread.Id)); + } + + /// + /// Get threads of a user + /// TODO Convert to single user threads + /// + /// + /// + [HttpGet("threads")] + public IActionResult Threads(string userIds) + { + using var db = dbProvider.Create(); + var userIdArr = userIds.Trim().Split(",").Select(int.Parse).ToList(); + userIdArr.Add(userIdCx); + userIdArr = userIdArr.Distinct().ToList(); + + var pmThreads = db.PmThreads.Where(pt => + pt.ThreadUsers.All(ptu => userIdArr.Contains(ptu.UserId)) && + pt.ThreadUsers.Count == userIdArr.Count) + .SelectForUser() + .ToList(); + + return Json(mapper.Map>(pmThreads)); + } + + /// + /// Get PM thread + /// + /// + /// + [HttpGet("{threadId}")] + public IActionResult Thread(long threadId) + { + using var db = dbProvider.Create(); + var pmThreads = db.PmThreads.Where(pt => pt.Id == threadId) + .SelectForUser() + .ToList().SingleOrDefault(); + + return Json(mapper.Map(pmThreads)); + } + + /// + /// Get messages of a thread + /// + /// + /// + /// + [HttpGet("{threadId}/msgs")] + public IActionResult Msgs(bool markedRead, int threadId) + { + using var db = dbProvider.Create(); + + var msgs = db.PmMsgs + .Where(m => m.ThreadId == threadId && m.Thread.ThreadUsers.Any(tu => tu.UserId == userIdCx.Value)) + .OrderByDescending(m => m.CreatedAt).Take(20) + .ToList(); + + var lastMsgId = msgs.LastOrDefault()?.Id; + + if (markedRead) + db.PmThreadUsers.Where(ptu => ptu.ThreadId == threadId && ptu.UserId == userIdCx) + .UpdateFromQuery(p => new + { + FirstUnreadMsgId = (long?)null, + LastReadMsgId = lastMsgId, + UnreadMsgCount = 0, + EmailSentAt = (DateTime?)null + }); + + var msgDtos = mapper.Map>(msgs); + + return Json(msgDtos); + } + + /// + /// Send a message to a thread + /// + /// + /// + /// + [HttpPost("{threadId}/msgs")] + public async Task SendMsg(PmSendMsgRequestModel model) + { + await using var db = dbProvider.Create(); + var thread = db.PmThreads.Include(t => t.ThreadUsers).Single(t => t.Id == model.ThreadId); + if (!thread.ThreadUsers.Any(tu => tu.UserId == userIdCx)) + throw new HttpStatusCodeException(HttpStatusCode.Forbidden, "Can't send msg here"); + + var msg = await pmMsgSender.Send(model.ThreadId, model.Txt); + + var msgDto = mapper.Map(msg); + + metricsSvc.CollectNamed(AppEvent.PmMsg); + + return Json(msgDto); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/PostsApi.cs b/SwipetorApp/Areas/Api/PostsApi.cs new file mode 100644 index 0000000..364829c --- /dev/null +++ b/SwipetorApp/Areas/Api/PostsApi.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using MoreLinq; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Permissions; +using SwipetorApp.Services.Posts; +using SwipetorApp.Services.SqlQueries; +using SwipetorApp.Services.VideoServices; +using SwipetorApp.System; +using WebAppShared.DI; +using WebAppShared.Exceptions; +using WebAppShared.Metrics; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/posts")] +public class PostsApi( + UserCx userCx, + IDbProvider dbProvider, + IMapper mapper, + PostSvc postSvc, + ILogger logger, + IMetricsSvc metricsSvc, + IFactory videoMediaUpdaterFactory) + : Controller +{ + [HttpGet("/api/posts")] + public IActionResult Get([FromQuery] PostsGetRequestModel model) + { + var hubsArr = model.HubIds?.Split(',').Select(int.Parse).ToList() ?? []; + + using var querier = new PostsQuerier(userCx.ValueOrNull); + querier.FirstPostId = model.FirstPostId; + querier.UserId = model.UserId; + querier.HubIds = hubsArr; + + using var db = dbProvider.Create(); + var posts = querier.Run(db); + + var res = new + { + posts = mapper.Map>(posts) + }; + + return Json(res); + } + + /// + /// Get single post + /// + /// + /// + [HttpGet("{postId}")] + public IActionResult GetByPostId(int postId) + { + using var db = dbProvider.Create(); + var postsQuery = db.Posts.Where(p => p.Id == postId); + var post = postsQuery.SelectForUser(userCx.ValueOrNull?.Id).Take(1).ToList().FirstOrDefault(); + + if (post == null) return NotFound(); + + var postDto = mapper.Map(post); + + return Json(postDto); + } + + [HttpPut("{postId}")] + [Authorize] + [MinRole(UserRole.Creator)] + public async Task Update(int postId, PostSaveModel model) + { + await using var db = dbProvider.Create(); + var post = db.Posts + .Include(p => p.PostHubs).ThenInclude(c => c.Hub) + .Include(p => p.Medias).ThenInclude(postMedia => postMedia.Video) + .Single(p => p.Id == postId); + + new PostPerms().AssertCanEdit(post, userCx.Value); + + var firstPostId = post.Medias.MinBy(m => m.Ordering)?.Id; + if (model.Items.SingleOrDefault(i => i.Id == firstPostId)?.IsFollowersOnly == true) + throw new HttpJsonError("First post item cannot be paid"); + + if (model.Items.Any(i => i.SubPlanId != null) && model.Items.Any(i => i.IsFollowersOnly)) + throw new HttpJsonError("A post media cannot be both followers only and paid"); + + post.PostHubs.ForEach(c => c.Hub.PostCount++); + post.IsPublished = model.IsPublished; + + if (model.PosterUserId != null) post.UserId = model.PosterUserId.Value; + + foreach (var item in model.Items) + { + using var mediaUpdater = videoMediaUpdaterFactory.GetInstance(); + await mediaUpdater.Update(item); + } + + if (model.HubIds is { Length: > 0 }) + postSvc.UpdateHubs(post.Id, model.HubIds.ToList(), + post.PostHubs.Select(c => c.HubId).ToList()); + + metricsSvc.Collect("PostUpdated", 1); + metricsSvc.Collect(model.IsPublished ? "PostPublished" : "PostSaved", 1); + + await db.SaveChangesAsync(); + + // Start notif batch if this is the first time the post is published + if (model.IsPublished) + { + postSvc.StartNotifBatchIfNotExists(post.Id); + } + + return Ok(); + } + + [HttpDelete("{postId}")] + [Authorize] + [MinRole(UserRole.Creator)] + public IActionResult Delete(int postId) + { + using var db = dbProvider.Create(); + var post = db.Posts.Single(t => t.Id == postId); + + new PostPerms().AssertCanDelete(post, userCx.Value); + + post.IsRemoved = true; + db.SaveChanges(); + + metricsSvc.Collect("PostRemoved", 1); + + return Ok(); + } + + [Authorize] + [HttpGet("my")] + public IActionResult MyPosts(bool isPublished = true) + { + using var db = dbProvider.Create(); + + var postsQuery = db.Posts + .Where(p => p.IsPublished == isPublished && p.UserId == userCx.Value.Id && !p.IsRemoved) + .OrderByDescending(p => p.ModifiedAt) + .ThenBy(p => p.ModifiedAt); + var post = postsQuery.ToList(); + + var postDto = mapper.Map>(post); + return Json(postDto); + } + + /// + /// Favourite posts + /// + /// + [HttpGet("favs")] + [Authorize] + public IActionResult Favs() + { + using var db = dbProvider.Create(); + var cu = userCx.Value; + + var posts = db.Posts + .Where(p => p.Favs.Any(fp => fp.UserId == cu.Id)) + .OrderByDescending(p => p.Favs.FirstOrDefault(fp => fp.UserId == cu.Id).CreatedAt) + .SelectForUser(cu.Id) + .ToList(); + + var dtos = mapper.Map>(posts); + + return Json(dtos); + } + + [HttpPost("{postId:int}/fav")] + [Authorize] + public IActionResult Save(int postId) + { + using var db = dbProvider.Create(); + var cu = userCx.Value; + + var alreadySaved = db.FavPosts.Any(sp => sp.UserId == cu.Id && sp.PostId == postId); + + if (alreadySaved) return Ok(); + + db.FavPosts.Add(new FavPost + { + UserId = cu.Id, + PostId = postId + }); + + db.SaveChanges(); + + return Ok(); + } + + [HttpDelete("{postId:int}/fav")] + [Authorize] + public IActionResult Unsave(int postId) + { + using var db = dbProvider.Create(); + var cu = userCx.Value; + + db.FavPosts.Where(sp => sp.UserId == cu.Id && sp.PostId == postId).DeleteFromQuery(); + + return Ok(); + } + + [HttpGet("my-drafts")] + [Authorize] + public IActionResult MyDrafts() + { + if (!userCx.IsLoggedIn) return Json("[]"); + using var db = dbProvider.Create(); + + var postsQuery = db.Posts.Where(p => + !p.IsPublished && p.UserId == userCx.Value.Id && !p.IsRemoved) + .OrderByDescending(p => p.ModifiedAt); + var post = postsQuery.ToList(); + + var postDto = mapper.Map>(post); + return Json(postDto); + } + + [HttpGet("all")] + [Authorize] + public IActionResult All(bool? isPublished) + { + using var db = dbProvider.Create(); + + var postsQuery = db.Posts.Where(p => !p.IsRemoved && p.UserId == userCx.Value.Id); + + if (isPublished != null) + postsQuery = postsQuery.Where(p => p.IsPublished == isPublished); + + var posts = postsQuery.OrderByDescending(p => p.ModifiedAt) + .ThenBy(p => p.ModifiedAt) + .ToList(); + + var postDto = mapper.Map>(posts); + return Json(postDto); + } + + + /// + /// Create a new Draft post + /// + /// + /// + [HttpPost("my-drafts")] + [Authorize] + [MinRole(UserRole.Creator)] + public IActionResult CreateDraft(CreateDraftPostViewModel model) + { + var post = new Post + { + Title = model.Title, + UserId = userCx.Value.Id, + IsPublished = false, + ModifiedAt = DateTime.UtcNow + }; + + using (var db = dbProvider.Create()) + { + db.Posts.Add(post); + db.SaveChanges(); + } + + metricsSvc.Collect("DraftPostCreated", 1); + + return Json(mapper.Map(post)); + } + + [HttpPut("{postId}/title")] + [Authorize] + [MinRole(UserRole.Creator)] + public IActionResult Title(int postId, PostsApiTitleUpdateReqModel model) + { + using var db = dbProvider.Create(); + var post = db.Posts.Single(t => t.Id == postId); + + new PostPerms().AssertCanEdit(post, userCx.Value); + + post.Title = model.Title; + db.SaveChanges(); + return Ok(); + } + + + [HttpDelete("{postId}/publish")] + [Authorize] + [MinRole(UserRole.Creator)] + public IActionResult Unpublish(int postId) + { + using var db = dbProvider.Create(); + var post = db.Posts + .Include(p => p.PostHubs).ThenInclude(c => c.Hub) + .Single(p => p.Id == postId); + + new PostPerms().AssertCanEdit(post, userCx.Value); + + post.PostHubs.ForEach(c => c.Hub.PostCount--); + post.IsPublished = false; + + db.SaveChanges(); + + return Ok(); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/SearchApi.cs b/SwipetorApp/Areas/Api/SearchApi.cs new file mode 100644 index 0000000..794de19 --- /dev/null +++ b/SwipetorApp/Areas/Api/SearchApi.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Contexts; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/search")] +[Authorize] +public class SearchApi(IMapper mapper, IDbProvider dbProvider) : Controller +{ + // TODO Do caching here + [Route("mention/at/{keyword}")] + public IActionResult AtMention(string keyword) + { + if (keyword.Length < 3) return BadRequest(); + + var pattern = $"%{keyword.Trim()}%"; + + using var db = dbProvider.Create(); + var matches = db.Users.Where(u => EF.Functions.ILike(u.Username, pattern)).Take(10).ToList(); + + var userDtos = mapper.Map>(matches); + + return Json(new + { + users = userDtos + }); + } + + // TODO Do caching here + [Route("mention/hash/{keyword}")] + public IActionResult HashMention(string keyword) + { + if (keyword.Length < 3) return BadRequest(); + + var pattern = $"%{keyword.Trim()}%"; + + using var db = dbProvider.Create(); + var matches = db.Hubs.Where(u => EF.Functions.ILike(u.Name, pattern)).Take(10).ToList(); + + var hubDtos = mapper.Map>(matches); + + return Json(new + { + labels = hubDtos + }); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/SubPlansApi.cs b/SwipetorApp/Areas/Api/SubPlansApi.cs new file mode 100644 index 0000000..bcb3b0a --- /dev/null +++ b/SwipetorApp/Areas/Api/SubPlansApi.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Contexts; + +namespace SwipetorApp.Areas.Api; + +[Authorize] +[ApiController] +[Area(AreaNames.Api)] +[Route("api/sub-plans")] +public class SubPlanApi(IMapper mapper, IDbProvider dbProvider, UserIdCx userIdCx) : Controller +{ + [HttpGet("{id:int}")] + public IActionResult GetById(int id) + { + using var db = dbProvider.Create(); + var plan = db.SubPlans.Where(sp => sp.OwnerUserId == userIdCx.Value) + .Include(p => p.OwnerUser) + .SingleOrDefault(p => p.Id == id); + + if (plan == null) return NotFound(); + + var planDto = mapper.Map(plan); + return Json(planDto); + } + + [HttpGet] + public IActionResult GetAll() + { + using var db = dbProvider.Create(); + var plans = db.SubPlans + .Where(sp => sp.OwnerUserId == userIdCx.Value) + .ToList(); + + var planDtos = mapper.Map>(plans); + return Json(planDtos); + } + + [HttpPost] + public IActionResult Create([FromBody] SubPlansUpdateReqModel model) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + using var db = dbProvider.Create(); + var plan = db.SubPlans.FirstOrDefault(p => p.OwnerUserId == userIdCx.Value); + + if (plan == null) + { + plan = new SubPlan + { + OwnerUserId = userIdCx.Value + }; + db.SubPlans.Add(plan); + } + + plan.Name = model.Name; + plan.Description = model.Description; + plan.Price = model.Price; + plan.Currency = model.Currency; + + db.SaveChanges(); + + return Ok(); + } + + [HttpDelete("{id:int}")] + public IActionResult Delete(int id) + { + using var db = dbProvider.Create(); + var plan = db.SubPlans.SingleOrDefault(p => p.Id == id); + if (plan == null) return NotFound(); + + db.SubPlans.Remove(plan); + db.SaveChanges(); + + return NoContent(); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/Api/UsersApi.cs b/SwipetorApp/Areas/Api/UsersApi.cs new file mode 100644 index 0000000..8186ad5 --- /dev/null +++ b/SwipetorApp/Areas/Api/UsersApi.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SwipetorApp.Areas.Api.Models; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.DTOs; +using SwipetorApp.Models.EmailViewModels; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Config; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Emailing; +using SwipetorApp.Services.Pm; +using SwipetorApp.Services.SqlQueries; +using SwipetorApp.Services.Users; +using SwipetorApp.System; +using WebAppShared.Exceptions; +using WebAppShared.Extensions; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.Api; + +[ApiController] +[Area(AreaNames.Api)] +[Route("api/users")] +public class UsersApi(IMapper mapper, IDbProvider dbProvider, PmThreadSvc pmThreadSvc, IOptions siteConfig, EmailerProvider emailerProvider, UserCx userCx) + : Controller +{ + [HttpGet("{userId:int}")] + public IActionResult GetById(int userId, bool includePosts = false) + { + var myId = userCx.ValueOrNull?.Id; + using var db = dbProvider.Create(); + var result = db.Users.Where(u => u.Id == userId).Include(u => u.Photo) + .Select(u => new + { + user = u, + userFollows = db.UserFollows.Any(uf => uf.FollowerUserId == myId && uf.FollowedUserId == u.Id) + }) + .SingleOrDefault(); + + if (result == null) return NotFound(); + + var resp = new UsersApiGetUsersResp + { + User = mapper.Map(result.user) + }; + + if (resp.User != null) + { + resp.User.UserFollows = result.userFollows; + + if (includePosts) + { + var posts = db.Posts.Where(p => p.UserId == userId && p.IsPublished && !p.IsRemoved) + .OrderByDescending(p => p.CreatedAt) + .SelectForUser(myId).ToList(); + resp.Posts = mapper.Map>(posts); + } + + resp.PmThreadId = pmThreadSvc.GetThreadIfExists([resp.User.Id])?.Id; + resp.CanMsg = resp.PmThreadId != null || result.userFollows || pmThreadSvc.HasCommentedMeLastWeek(resp.User.Id); + } + + return Json(resp); + } + + [HttpPost("{followedUserId:int}/follow")] + [Authorize] + public IActionResult Follow(int followedUserId) + { + var myId = userCx.Value.Id; + using var db = dbProvider.Create(); + var isFollowing = + db.UserFollows.Any(uf => uf.FollowerUserId == myId && uf.FollowedUserId == followedUserId); + + if (isFollowing) return Ok(); + + var userFollow = new UserFollow + { + FollowerUserId = myId, + FollowedUserId = followedUserId + }; + + db.UserFollows.Add(userFollow); + db.SaveChanges(); + return Ok(); + } + + [HttpDelete("{followedUserId:int}/follow")] + [Authorize] + public IActionResult DeleteFollow(int followedUserId) + { + var myId = userCx.Value.Id; + using var db = dbProvider.Create(); + db.UserFollows.Where(uf => uf.FollowerUserId == myId && uf.FollowedUserId == followedUserId) + .DeleteFromQuery(); + return Ok(); + } + + [Authorize] + [Route("search")] + [MinRole(UserRole.Default)] + public IActionResult Search([Required] [MinLength(3)] string username, UserRole? minRole = null) + { + using var db = dbProvider.Create(); + var query = db.Users.Where(u => EF.Functions.ILike(u.Username, $"%{username.Trim()}%")); + + var users = query.Include(u => u.Photo).Take(10).ToList(); + var userDtos = mapper.Map>(users); + return Json(userDtos); + } + + [Authorize] + [Route("become-creator")] + public async Task BecomeCreator(UserApiBecomeCreatorReqModel model) + { + var me = userCx.Value; + if (!ModelState.IsValid) throw new HttpJsonError("Invalid model"); + + var txt = $"User: {me.Username} #{me.Id}

----------

{model.Txt}

"; + + var genericEmailVM = new GenericEmailVM + { + Text = txt + }; + + using var emailer = emailerProvider.GetGenericEmailer(); + await emailer.Send(siteConfig.Value.Email, "Become Creator Request", genericEmailVM); + + return Ok(); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/HostMaster/BatchController.cs b/SwipetorApp/Areas/HostMaster/BatchController.cs new file mode 100644 index 0000000..ccfb69e --- /dev/null +++ b/SwipetorApp/Areas/HostMaster/BatchController.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Models.Enums; +using SwipetorApp.Models.Extensions; +using SwipetorApp.Services.Contexts; +using WebAppShared.DI; +using WebAppShared.SharedLogic.Sitemaps; +using WebAppShared.Types; + +namespace SwipetorApp.Areas.HostMaster; + +[Area(AreaNames.HostMaster)] +public class BatchController( + IFactory sitemapGeneratorFactory, + IHostnameCx hostnameCx, + IDbProvider dbProvider) + : Controller +{ + public IActionResult Index() + { + return Content("Index Works"); + } + + public IActionResult HelloAta() + { + return Content("World Works"); + } + + public async Task GenerateSitemaps() + { + await using var db = dbProvider.Create(); + using var sitemapGen = sitemapGeneratorFactory.GetInstance(); + + sitemapGen.ClearOldSitemaps(); + + var allUsers = db.Users.Where(u => u.Posts.Any(p => p.IsPublished)).Select(u => new + { + user = u, + lastPostDate = u.Posts.Max(p => (DateTime?)p.CreatedAt) + }).ToList(); + + foreach (var r in allUsers) + await sitemapGen.AddItem(new SitemapItem + { + Loc = $"https://{hostnameCx.SiteHostname}/u/{r.user.Id}/{r.user.Username?.ToLowerInvariant()}", + Priority = 0.5M, + ChangeFreq = SitemapItemChangeFreq.Weekly, + LastMod = r.lastPostDate + }); + + var allPosts = db.Posts.Include(p => p.User) + .Include(p => p.Medias).ThenInclude(m => m.Video) + .Where(p => p.IsPublished).ToList(); + + foreach (var p in allPosts) + await sitemapGen.AddItem(new SitemapItem + { + Loc = $"https://{hostnameCx.SiteHostname}{p.GetRelativeUrl()}", + Priority = 0.5M, + ChangeFreq = SitemapItemChangeFreq.Monthly, + LastMod = p.ModifiedAt + }); + + await sitemapGen.WriteSitemapFooter(); + + await sitemapGen.GenerateSitemapIndex(); + + return Content("Sitemap is generated."); + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/HostMaster/ImportController.cs b/SwipetorApp/Areas/HostMaster/ImportController.cs new file mode 100644 index 0000000..ea7097b --- /dev/null +++ b/SwipetorApp/Areas/HostMaster/ImportController.cs @@ -0,0 +1,216 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CsvHelper; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SwipetorApp.Models.DbEntities; +using SwipetorApp.Models.Enums; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Contexts; +using SwipetorApp.System.UserRoleAuth; + +namespace SwipetorApp.Areas.HostMaster; + +[Area(AreaNames.Admin)] +[UserRoleAuth(UserRole.HostMaster)] +public class ImportController(IDbProvider dbProvider, ILogger logger) : Controller +{ + public IActionResult Locations() + { + var countriesWithProvinces = new List + { "US", "CA", "AU", "CN", "MX", "MY", "ES", "IN", "SA", "PK", "BR", "TH", "RU" }; + + // countryToCountinent Iso2 both key and value + var countryToContinent = new Dictionary(); + using (var reader = new StreamReader("../_dev/_files/country-continents.csv")) + using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture)) + { + while (csv.Read()) countryToContinent.Add(csv.GetField(3).Trim(), csv.GetField(0).Trim()); + } + + var countryIso2ToContinentLocation = new Dictionary(); + + using var db = dbProvider.Create(); + + // Add continents to Locations table + foreach (var cc in countryToContinent) + { + var continent = db.Locations.SingleOrDefault(l => l.Iso2 == cc.Value && l.Type == LocationType.Continent); + + if (continent == null) + { + continent = new Location + { + Iso2 = cc.Value, + Type = LocationType.Continent + }; + db.Locations.Add(continent); + logger.LogInformation("Added country {Continent}", continent.Iso2); + db.SaveChanges(); + } + + countryIso2ToContinentLocation.Add(cc.Key, continent); + } + + db.SaveChanges(); + logger.LogInformation("Saving continents"); + + List cities; + + using (var reader = new StreamReader("../_dev/_files/simplemaps-worldcities-basic.csv")) + using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture)) + { + cities = csv.GetRecords().ToList().Where(c => c.capital != "minor").ToList(); + } + + foreach (var city in cities) + { + var country = db.Locations.Include(location => location.Parent) + .SingleOrDefault(l => l.Iso2 == city.iso2 && l.Type == LocationType.Country); + + // If country is not in the database + if (country == null) + { + country = new Location + { + Iso2 = city.iso2, + Iso3 = city.iso3, + Name = city.country, + NameAscii = city.country, + FullName = city.country, + ParentId = countryIso2ToContinentLocation.ContainsKey(city.iso2) + ? countryIso2ToContinentLocation[city.iso2]?.Id + : null, + Type = LocationType.Country + }; + db.Locations.Add(country); + logger.LogInformation("Adding country {Country}", country.Name); + db.SaveChanges(); + } + + var cityParent = country; + + // Handle province if the country is in countriesWithprovinces list + if (countriesWithProvinces.Contains(city.iso2.ToUpper())) + { + var province = db.Locations.Include(location => location.Parent).SingleOrDefault(l => + l.Name == city.admin_name && l.Type == LocationType.Province && l.ParentId == country.Id); + + if (province == null) + { + province = new Location + { + Name = city.admin_name, + NameAscii = city.admin_name, + FullName = $"{city.admin_name}, {country.Name}", + ParentId = country.Id, + Type = LocationType.Province + }; + + db.Locations.Add(province); + logger.LogInformation("Adding province {Province}", province.Name); + db.SaveChanges(); + } + + cityParent = province; + } + // Province ends + + var cityFromDb = db.Locations.FirstOrDefault(l => + l.NameAscii == city.city_ascii && l.Type == LocationType.City && l.ParentId == cityParent.Id); + + if (cityFromDb == null) + { + cityFromDb = new Location + { + Capital = city.capital, + Name = city.city, + NameAscii = city.city_ascii, + FullName = $"{city.city}, {cityParent.Name}", + Lat = double.Parse(city.lat), + Lng = double.Parse(city.lng), + Population = !string.IsNullOrEmpty(city.population) ? (int)double.Parse(city.population) : 0, + Parent = cityParent, + SimplemapsId = city.id, + Type = LocationType.City + }; + + if (cityParent.Parent != null && !string.IsNullOrEmpty(cityParent.Parent.NameAscii)) + cityFromDb.FullName += $", {cityParent.Parent.Name}"; + + db.Locations.Add(cityFromDb); + + logger.LogInformation("Adding city {City}", cityFromDb.Name); + } + } + + db.SaveChanges(); + + return Ok(); + } + + public async Task InsertEnglishWords() + { + const int batchSize = 1000; + var filePath = Path.Combine("App_Data", "english_words_alpha.txt"); + + await using var db = dbProvider.Create(); + + if (db.EnglishWords.Count() > 0) return Ok("English words already imported."); + + using var reader = new StreamReader(filePath); + var words = new List(); + string line; + + while ((line = await reader.ReadLineAsync()) != null) + { + words.Add(new EnglishWord { Word = line }); + + if (words.Count >= batchSize) + { + await db.EnglishWords.AddRangeAsync(words); + await db.SaveChangesAsync(); + words.Clear(); // Clear the list for the next batch + } + } + + // Insert any remaining words + if (words.Count > 0) + { + await db.EnglishWords.AddRangeAsync(words); + await db.SaveChangesAsync(); + } + + return Ok("Words imported successfully."); + } + + [UsedImplicitly] + [SuppressMessage("ReSharper", "InconsistentNaming")] + private class SimplemapsWorldCity + { + public string city { get; } + public string city_ascii { get; } + public string lat { get; } + + public string lng { get; } + + // Country name + public string country { get; } + + // Country iso2 + public string iso2 { get; } + + // Country iso3 + public string iso3 { get; } + public string admin_name { get; } + public string capital { get; } + public string population { get; } + public string id { get; } + } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/HostMaster/Models/ScraperSavePostReqModel.cs b/SwipetorApp/Areas/HostMaster/Models/ScraperSavePostReqModel.cs new file mode 100644 index 0000000..3bfaaff --- /dev/null +++ b/SwipetorApp/Areas/HostMaster/Models/ScraperSavePostReqModel.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; + +namespace SwipetorApp.Areas.HostMaster.Models; + +public class ScraperSavePostReqModel +{ + [FromForm] + public IFormFile Video { get; set; } + + [Required] + public string Title { get; set; } + + [Required] + public string Captions { get; set; } + + public string Stats { get; set; } + + public string Info { get; set; } + + public string Article { get; set; } + + public string UrlSlug { get; set; } + + [Required] + public string ReferenceDomain { get; set; } + + [Required] + public string ReferenceId { get; set; } + + [CanBeNull] + public string Comments { get; set; } + + [CanBeNull] + public string HubIds { get; set; } + + public List HubIdsList => string.IsNullOrWhiteSpace(HubIds) ? new List() : JsonConvert.DeserializeObject>(HubIds); + public List CommentsList => string.IsNullOrWhiteSpace(Comments) ? new List() : JsonConvert.DeserializeObject>(Comments); +} + +[UsedImplicitly] +public class ScraperSavePostCommentReqModel +{ + public int LikeCount { get; set; } + public string Original { get; set; } + public string Rephrased { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Areas/HostMaster/Models/ScraperSaveUserReqModel.cs b/SwipetorApp/Areas/HostMaster/Models/ScraperSaveUserReqModel.cs new file mode 100644 index 0000000..f5ecf9a --- /dev/null +++ b/SwipetorApp/Areas/HostMaster/Models/ScraperSaveUserReqModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using WebAppShared.WebSys.MvcAttributes; + +namespace SwipetorApp.Areas.HostMaster.Models; + +public class ScraperSaveUserReqModel +{ + [Required, MinLength(3)] + public string Username { get; set; } + + [FromForm, MaxFileSize(2), CanBeNull] + public IFormFile Photo { get; set; } + + [Required, MaxLength(200)] + public string RobotSource { get; set; } +} diff --git a/SwipetorApp/Areas/HostMaster/Views/_ViewImports.cshtml b/SwipetorApp/Areas/HostMaster/Views/_ViewImports.cshtml new file mode 100644 index 0000000..9db9486 --- /dev/null +++ b/SwipetorApp/Areas/HostMaster/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/SwipetorApp/Areas/HostMaster/Views/_ViewStart.cshtml b/SwipetorApp/Areas/HostMaster/Views/_ViewStart.cshtml new file mode 100644 index 0000000..eca52d5 --- /dev/null +++ b/SwipetorApp/Areas/HostMaster/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = null; +} \ No newline at end of file diff --git a/SwipetorApp/Controllers/ErrorsController.cs b/SwipetorApp/Controllers/ErrorsController.cs new file mode 100644 index 0000000..19e7408 --- /dev/null +++ b/SwipetorApp/Controllers/ErrorsController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace SwipetorApp.Controllers; + +public class ErrorsController : Controller +{ + public IActionResult PageNotFound() + { + return View(); + } +} \ No newline at end of file diff --git a/SwipetorApp/Controllers/FallbackController.cs b/SwipetorApp/Controllers/FallbackController.cs new file mode 100644 index 0000000..5496060 --- /dev/null +++ b/SwipetorApp/Controllers/FallbackController.cs @@ -0,0 +1,28 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Models.Enums; +using SwipetorApp.Models.ViewModels; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Users; +using WebAppShared.Photos; + +namespace SwipetorApp.Controllers; + +public class FallbackController : Controller +{ + public IActionResult Index() + { + var requestPath = Request.Path.ToString(); + + // Check if this is an API request and return 404 for those + if (requestPath.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) + return NotFound(); + + // If no influencer is found or if the URL does not match any expected patterns, return 404 + Response.StatusCode = 404; + return View("~/Views/Home/Appshell.cshtml", new AppshellViewModel()); + } +} diff --git a/SwipetorApp/Controllers/HomeController.cs b/SwipetorApp/Controllers/HomeController.cs new file mode 100644 index 0000000..643dd8b --- /dev/null +++ b/SwipetorApp/Controllers/HomeController.cs @@ -0,0 +1,194 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SwipetorApp.Models.EmailViewModels; +using SwipetorApp.Models.Enums; +using SwipetorApp.Models.Extensions; +using SwipetorApp.Models.ViewModels; +using SwipetorApp.Services.Auth; +using SwipetorApp.Services.Config; +using SwipetorApp.Services.Contexts; +using SwipetorApp.Services.Emailing; +using SwipetorApp.System.MvcFilters; +using WebAppShared.Contexts; +using WebAppShared.Photos; +using WebAppShared.Types; +using WebAppShared.Utils; + +namespace SwipetorApp.Controllers; + +public class HomeController( + IOptions siteConfig, + UserCx userCx, + IDbProvider dbProvider, + IHostnameCx hostnameCx, + EmailerProvider emailerProvider, + PhotoUrlBuilderSvc photoUrlBuilderSvc) + : Controller +{ + [Route("/")] + public IActionResult Index(int? hubId) + { + if (hubId != null) return ViewHub(hubId.Value); + + var url = "https://" + hostnameCx.RequestedHostname; + + string title = $"{siteConfig.Value.Name} - {siteConfig.Value.Slogan}"; + string desc = siteConfig.Value.Description; + string img = $"{url}/public/swipetor/logo-underlined-blackbg-256x256.png"; + + return View("Appshell", new AppshellViewModel + { + Title = title, + Description = desc, + Image = img, + OpenGraphType = OpenGraphType.Website, + Url = url + }); + } + + public IActionResult ViewHub(int hubId) + { + using var db = dbProvider.Create(); + var hub = db.Hubs.SingleOrDefault(c => c.Id == hubId); + + if (hub == null) return Redirect("/"); + + return View("Appshell", new AppshellViewModel + { + Title = $"{hub.Name} posts - {siteConfig.Value.Name}", + Description = + $"{hub.Name} hub lists related videos and posts. {siteConfig.Value.Name} - {siteConfig.Value.Slogan}", + Image = "/public/swipetor/logo-underlined-blackbg-256x256.png", + OpenGraphType = OpenGraphType.Website, + Url = $"https://{siteConfig.Value.Hostname}/?hubId={hubId}" + }); + } + + [Route("/login")] + [Route("/login/code")] + public IActionResult PublicBoardRoutes() + { + return View("Appshell", new AppshellViewModel()); + } + + [Route("/pm")] + [Route("/pm/new")] + [Route("/pm/{threadId:int}")] + [Route("/my")] + [Route("/my/first-login")] + [Route("/post-builder")] + [Route("/post-builder/{postId:int}")] + [Route("/notifs")] + [Route("/become-creator")] + [Route("/sub-plans")] + public IActionResult LoggedOnlyRoutes() + { + if (!userCx.IsLoggedIn) return Redirect($"/login?redir={Request.Path}{Request.QueryString}"); + + return View("Appshell", new AppshellViewModel()); + } + + /*[Route("/p/{postId:int}")] + [Route("/p/{postId:int}/{slug}")] + [ProcessLinkRef] + public IActionResult ViewPost(int postId, string slug, int? hubId = null) + { + using var db = dbProvider.Create(); + var post = db.Posts.Include(p => p.User).Include(p => p.Influencer) + .Include(p => p.PostHubs).Include(post => post.Medias).ThenInclude(postMedia => postMedia.PreviewPhoto) + .SingleOrDefault(p => p.Id == postId); + + if (post == null) return RedirectPermanent("/"); + + // Only admins and posters can see a deleted post. + if (post.IsRemoved) return RedirectPermanent("/"); + + if (string.IsNullOrWhiteSpace(slug) || slug.Trim() != post.GetSlug()) + return RedirectPermanent(post.GetRelativeUrl()); + + var desc = !string.IsNullOrWhiteSpace(post.Title) ? post.Title : post.Medias.FirstOrDefault()?.Description; + desc = desc.Shorten(157); + + return View("Appshell", new AppshellViewModel + { + Title = $"{post.Title} by @{post.Influencer?.Name ?? post.User?.Username}", + Description = desc.Trim(), + Image = photoUrlBuilderSvc.GetFullUrl(post.Medias.MinBy(m => m.Ordering)?.PreviewPhoto), + OpenGraphType = OpenGraphType.VideoOther, + Url = $"https://{siteConfig.Value.Hostname}{post.GetRelativeUrl()}" + }); + }*/ + + [Route("/u/{userId:int}/{userName?}")] + public IActionResult UserPage(int userId, string userName) + { + using var db = dbProvider.Create(); + var user = db.Users.Include(u => u.Photo).SingleOrDefault(u => u.Id == userId); + + if (user == null) return RedirectPermanent("/"); + + var desc = user.Description ?? siteConfig.Value.UserProfileDesc.Replace("{username}", user.Username); + var title = siteConfig.Value.UserProfileTitle.Replace("{username}", user.Username); + + return View("Appshell", new AppshellViewModel + { + Title = title, + Description = desc.Trim(), + Image = user.Photo != null ? photoUrlBuilderSvc.GetFullUrl(user.Photo) : "/public/images/nophoto/nophoto-600.png", + OpenGraphType = OpenGraphType.Profile, + Url = $"https://{siteConfig.Value.Hostname}/u/{userId}/{user.Username}" + }); + } + + [HttpGet(@"/sw.js")] + public IActionResult SwJs() + { + return File("public/build/sw.js", "text/javascript"); + } + + [HttpGet(@"/manifest.json")] + public IActionResult ManifestJson() + { + Response.ContentType = "application/json"; + return View("ManifestJson"); + } + + [HttpGet(@"/robots.txt")] + public IActionResult RobotsTxt() + { + Response.ContentType = "text/plain"; + return View("RobotsTxt"); + } + + [HttpGet("/upgrade-creator")] + [Authorize] + public IActionResult CreatorUpgrade(string code) + { + if (string.IsNullOrWhiteSpace(code)) return Redirect("/"); + + var user = userCx.Value; + + if (user.Role >= UserRole.Creator) return Redirect("/"); + + using var db = dbProvider.Create(); + user = db.Users.Single(u => u.Id == user.Id); + user.Role = UserRole.Creator; + db.SaveChanges(); + + //send email notifying atasasmaz@gmail.com + var txt = $"

User: {user.Username} #{user.Id}

Code: {code}

"; + var genericEmailVM = new GenericEmailVM + { + Text = txt, + }; + + var emailer = emailerProvider.GetGenericEmailer(); + emailer.Send(siteConfig.Value.Email, "User Upgraded to Creator", genericEmailVM); + + return Redirect("/?msgCode=" + SayMsgKey.UserUpgradedCreator); + } +} diff --git a/SwipetorApp/Controllers/PagesController.cs b/SwipetorApp/Controllers/PagesController.cs new file mode 100644 index 0000000..b0f88c3 --- /dev/null +++ b/SwipetorApp/Controllers/PagesController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; + +namespace SwipetorApp.Controllers; + +public class PagesController : Controller +{ + public IActionResult Terms() + { + return View(); + } + + public IActionResult Privacy() + { + return View(); + } +} \ No newline at end of file diff --git a/SwipetorApp/Controllers/PostsController.cs b/SwipetorApp/Controllers/PostsController.cs new file mode 100644 index 0000000..e5b43d7 --- /dev/null +++ b/SwipetorApp/Controllers/PostsController.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SwipetorApp.Models.Enums; +using SwipetorApp.Models.Extensions; +using SwipetorApp.Models.ViewModels; +using SwipetorApp.Services.Contexts; +using WebAppShared.Photos; +using WebAppShared.Types; +using WebAppShared.Utils; + +namespace SwipetorApp.Controllers; + +public class PostsController(PhotoUrlBuilderSvc photoUrlBuilderSvc, IHostnameCx hostnameCx, IDbProvider dbProvider) : Controller +{ + [Route("/p/{postId:int}")] + [Route("/p/{postId:int}/{slug}")] + public IActionResult Post(string infSlug, int postId, string postSlug) + { + using var db = dbProvider.Create(); + var post = db.Posts.Include(p => p.User).Include(p => p.Medias).ThenInclude(m => m.Video) + .Include(p => p.PostHubs).Include(post => post.Medias).ThenInclude(postMedia => postMedia.PreviewPhoto) + .SingleOrDefault(p => p.Id == postId); + + if (post == null) return RedirectPermanent("/"); + + // Only admins and posters can see a deleted post. + if (post.IsRemoved) return RedirectPermanent("/"); + + if (HttpContext.Request.Path.ToString() != post.GetRelativeUrl()) + return RedirectPermanent(post.GetRelativeUrl()); + + var video = post.Medias.FirstOrDefault()?.Video; + var desc = video?.Captions; + + if (string.IsNullOrWhiteSpace(desc)) + { + desc = string.IsNullOrWhiteSpace(post.Title) ? post.Medias.FirstOrDefault()?.Description : post.Title; + } + desc = desc.Shorten(157); + + return View("~/Views/Home/Appshell.cshtml", new AppshellViewModel + { + Title = $"{post.Title} by @{post.User?.Username}", + Description = desc.Trim(), + Image = photoUrlBuilderSvc.GetFullUrl(post.Medias.MinBy(m => m.Ordering)?.PreviewPhoto), + OpenGraphType = OpenGraphType.VideoOther, + Url = $"https://{hostnameCx.SiteHostname}{post.GetRelativeUrl()}" + }); + } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/CommentDto.cs b/SwipetorApp/Models/DTOs/CommentDto.cs new file mode 100644 index 0000000..674fa79 --- /dev/null +++ b/SwipetorApp/Models/DTOs/CommentDto.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class CommentDto +{ + public int Id { get; set; } + + public string Txt { get; set; } + + public int UserId { get; set; } + public virtual UserDto User { get; set; } + + public int PostId { get; set; } + + public long CreatedAt { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/CustomDomainDto.cs b/SwipetorApp/Models/DTOs/CustomDomainDto.cs new file mode 100644 index 0000000..bd891df --- /dev/null +++ b/SwipetorApp/Models/DTOs/CustomDomainDto.cs @@ -0,0 +1,9 @@ +namespace SwipetorApp.Models.DTOs; + +public class CustomDomainDto +{ + public string DomainName { get; set; } + public int UserId { get; set; } + public string RecaptchaKey { get; set; } + public string RecaptchaSecret { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/HubDto.cs b/SwipetorApp/Models/DTOs/HubDto.cs new file mode 100644 index 0000000..6ad6cc0 --- /dev/null +++ b/SwipetorApp/Models/DTOs/HubDto.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; + +namespace SwipetorApp.Models.DTOs; + +public class HubDto +{ + public int Id { get; set; } + public string Name { get; set; } + + public long LastPostAt { get; set; } + + public int Ordering { get; set; } + public int PostCount { get; set; } + + [CanBeNull] + public PhotoDto Photo { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/LocationDto.cs b/SwipetorApp/Models/DTOs/LocationDto.cs new file mode 100644 index 0000000..81e31c2 --- /dev/null +++ b/SwipetorApp/Models/DTOs/LocationDto.cs @@ -0,0 +1,26 @@ +using SwipetorApp.Models.Enums; + +namespace SwipetorApp.Models.DTOs; + +public class LocationDto +{ + public int Id { get; set; } + + public string Name { get; set; } + + public string NameAscii { get; set; } + + public string FullName { get; set; } + + public double Lat { get; set; } + + public double Lng { get; set; } + + public string Iso2 { get; set; } + + public string Iso3 { get; set; } + + public LocationType Type { get; set; } + + public int? ParentId { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/NotifDto.cs b/SwipetorApp/Models/DTOs/NotifDto.cs new file mode 100644 index 0000000..336c276 --- /dev/null +++ b/SwipetorApp/Models/DTOs/NotifDto.cs @@ -0,0 +1,33 @@ +using JetBrains.Annotations; +using SwipetorApp.Models.Enums; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class NotifDto +{ + public int Id { get; set; } + + public int ReceiverUserId { get; set; } + + public virtual UserDto ReceiverUser { get; set; } + + public int RelatedPostId { get; set; } + [CanBeNull] public virtual PostDto RelatedPost { get; set; } + + public int? RelatedCommentId { get; set; } + [CanBeNull] public virtual CommentDto RelatedComment { get; set; } + + public int? SenderUserId { get; set; } + [CanBeNull] public virtual UserDto SenderUser { get; set; } + + public PhotoDto SenderUserPhoto { get; set; } + + public NotifType Type { get; set; } + + public string Data { get; set; } + + public bool IsRead { get; set; } + + public long CreatedAt { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/PhotoDto.cs b/SwipetorApp/Models/DTOs/PhotoDto.cs new file mode 100644 index 0000000..a81712b --- /dev/null +++ b/SwipetorApp/Models/DTOs/PhotoDto.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using SwipetorApp.Services.Config.UIConfigs; +using WebAppShared.Photos; + +namespace SwipetorApp.Models.DTOs; + +public class PhotoDto : ISharedPhoto +{ + public long CreatedAt { get; set; } + + [OutputConfigToUI] + public Guid Id { get; set; } + + [OutputConfigToUI] + public int Height { get; set; } + + [OutputConfigToUI] + public int Width { get; set; } + + [OutputConfigToUI] + public string Ext { get; set; } + + [OutputConfigToUI] + public List Sizes { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/PmMsgDto.cs b/SwipetorApp/Models/DTOs/PmMsgDto.cs new file mode 100644 index 0000000..1b858e7 --- /dev/null +++ b/SwipetorApp/Models/DTOs/PmMsgDto.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class PmMsgDto +{ + public long Id { get; set; } + + public long ThreadId { get; set; } + + public int UserId { get; set; } + + public string Txt { get; set; } + + public long CreatedAt { get; set; } +} diff --git a/SwipetorApp/Models/DTOs/PmThreadDto.cs b/SwipetorApp/Models/DTOs/PmThreadDto.cs new file mode 100644 index 0000000..308b280 --- /dev/null +++ b/SwipetorApp/Models/DTOs/PmThreadDto.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class PmThreadDto +{ + public long Id { get; set; } + + public int UserCount { get; set; } + + public bool IsGroupChat { get; set; } + + public int LastMsgId { get; set; } + public virtual PmMsgDto LastMsg { get; set; } + + public long LastMsgAt { get; set; } + + public long ExpirationDate { get; set; } + + public long CreatedAt { get; set; } + + public int UnreadMsgCount { get; set; } + + public virtual List ThreadUsers { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/PmThreadUserDto.cs b/SwipetorApp/Models/DTOs/PmThreadUserDto.cs new file mode 100644 index 0000000..56b5878 --- /dev/null +++ b/SwipetorApp/Models/DTOs/PmThreadUserDto.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class PmThreadUserDto +{ + public int UserId { get; set; } + public UserDto User { get; set; } + + public long ThreadId { get; set; } + + public int LastReadMsgId { get; set; } + + public int UnreadMsgCount { get; set; } + + public long LastMsgAt { get; set; } + + public long CreatedAt { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/PostDto.cs b/SwipetorApp/Models/DTOs/PostDto.cs new file mode 100644 index 0000000..76446a5 --- /dev/null +++ b/SwipetorApp/Models/DTOs/PostDto.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using SwipetorApp.Models.DbEntities; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class PostDto +{ + public int Id { get; set; } + + public string Title { get; set; } + + public int UserId { get; set; } + public PublicUserDto User { get; set; } + public int CommentsCount { get; set; } + + protected int FavCount { get; set; } + + public bool IsPublished { get; set; } + + public bool IsRemoved { get; set; } + + public long CreatedAt { get; set; } + + public bool UserFav { get; set; } + + public List Hubs { get; set; } + + public List Medias { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/PostMediaDto.cs b/SwipetorApp/Models/DTOs/PostMediaDto.cs new file mode 100644 index 0000000..af75aa3 --- /dev/null +++ b/SwipetorApp/Models/DTOs/PostMediaDto.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using SwipetorApp.Models.Enums; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class PostMediaDto +{ + public int Id { get; set; } + + public int PostId { get; set; } + + public PhotoDto Photo { get; set; } + + public VideoDto Video { get; set; } + + public VideoDto ClippedVideo { get; set; } + + public Guid? PreviewPhotoId { get; set; } + public virtual PhotoDto PreviewPhoto { get; set; } + + public List> ClipTimes { get; set; } + + public bool IsFollowersOnly { get; set; } + + public int? SubPlanId { get; set; } + public SubPlanDto SubPlan { get; set; } + + public PostMediaType Type { get; set; } + + public string Description { get; set; } + + public string Article { get; set; } + + public bool IsInstant { get; set; } + + public int Ordering { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/PublicUserDto.cs b/SwipetorApp/Models/DTOs/PublicUserDto.cs new file mode 100644 index 0000000..52eff5c --- /dev/null +++ b/SwipetorApp/Models/DTOs/PublicUserDto.cs @@ -0,0 +1,23 @@ +using System; +using JetBrains.Annotations; +using SwipetorApp.Services.Auth; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class PublicUserDto +{ + public int Id { get; set; } + + public string Username { get; set; } + + public Guid? PhotoId { get; set; } + public virtual PhotoDto Photo { get; set; } + + public string Description { get; set; } + + /// + /// If this user is followed by the current user + /// + public bool UserFollows { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/SpriteDto.cs b/SwipetorApp/Models/DTOs/SpriteDto.cs new file mode 100644 index 0000000..57b68bf --- /dev/null +++ b/SwipetorApp/Models/DTOs/SpriteDto.cs @@ -0,0 +1,26 @@ +using System; + +namespace SwipetorApp.Models.DTOs; + +public class SpriteDto +{ + public Guid Id { get; set; } + + public int VideoId { get; set; } + + /// + /// Start time in seconds + /// + public double StartTime { get; set; } + + /// + /// Interval in seconds + /// + public double Interval { get; set; } + + public int ThumbnailCount { get; set; } + + public int ThumbnailWidth { get; set; } + + public int ThumbnailHeight { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/SubDto.cs b/SwipetorApp/Models/DTOs/SubDto.cs new file mode 100644 index 0000000..ed0179d --- /dev/null +++ b/SwipetorApp/Models/DTOs/SubDto.cs @@ -0,0 +1,20 @@ +using System; +using SwipetorApp.Models.DbEntities; + +namespace SwipetorApp.Models.DTOs; + +public class SubDto +{ + public int Id { get; set; } + + public int UserId { get; set; } + + public long StartedAt { get; set; } + + public long? EndedAt { get; set; } + + public bool IsActive { get; set; } + + public int PlanId { get; set; } + public SubPlanDto Plan { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/SubPlanDto.cs b/SwipetorApp/Models/DTOs/SubPlanDto.cs new file mode 100644 index 0000000..2cfbf99 --- /dev/null +++ b/SwipetorApp/Models/DTOs/SubPlanDto.cs @@ -0,0 +1,12 @@ +using WebAppShared.SharedLogic.Fx; + +namespace SwipetorApp.Models.DTOs; + +public class SubPlanDto +{ + public int Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public CPrice CPrice { get; set; } + public int OwnerUserId { get; set; } +} diff --git a/SwipetorApp/Models/DTOs/UserDto.cs b/SwipetorApp/Models/DTOs/UserDto.cs new file mode 100644 index 0000000..7465d02 --- /dev/null +++ b/SwipetorApp/Models/DTOs/UserDto.cs @@ -0,0 +1,30 @@ +using System; +using JetBrains.Annotations; +using SwipetorApp.Services.Auth; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class UserDto +{ + public int Id { get; set; } + + public string Username { get; set; } + + public int CommentCount { get; set; } + + public long LastPmCheckAt { get; set; } + + public int UnreadNotifCount { get; set; } + + public Guid? PhotoId { get; set; } + public virtual PhotoDto Photo { get; set; } + + public string Description { get; set; } + + public string CustomDomain { get; set; } + + public long PremiumUntil { get; set; } + + public UserRole Role { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DTOs/VideoDto.cs b/SwipetorApp/Models/DTOs/VideoDto.cs new file mode 100644 index 0000000..4a4986e --- /dev/null +++ b/SwipetorApp/Models/DTOs/VideoDto.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using SwipetorApp.Models.DbEntities; +using WebAppShared.Videos; + +namespace SwipetorApp.Models.DTOs; + +[UsedImplicitly] +public class VideoDto +{ + public Guid Id { get; set; } = Guid.Empty; + + public string Ext { get; set; } + + public long SizeInBytes { get; set; } + + public int Width { get; set; } + + public int Height { get; set; } + + public double Duration { get; set; } + + public string Captions { get; set; } + + public virtual List Sprites { get; set; } + + public List Formats { get; set; } + + public long CreatedAt { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DbEntities/AuditLog.cs b/SwipetorApp/Models/DbEntities/AuditLog.cs new file mode 100644 index 0000000..b416737 --- /dev/null +++ b/SwipetorApp/Models/DbEntities/AuditLog.cs @@ -0,0 +1,43 @@ +using System; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using SwipetorApp.Models.Enums; +using Toolbelt.ComponentModel.DataAnnotations.Schema.V5; +using WebAppShared.Types; + +namespace SwipetorApp.Models.DbEntities; + +[UsedImplicitly] +public class AuditLog : IDbEntity +{ + public Guid Id { get; set; } + + [IndexColumn] + public DateTime CreatedAt { get; set; } + + [IndexColumn, MaxLength(128)] + public string EntityName { get; set; } + + [MaxLength(64)] + [IndexColumn] + public string EntityId { get; set; } + + /// + /// The user who performed the action + /// + public int UserId { get; set; } + public virtual User User { get; set; } + + [IndexColumn] + public AuditAction Action { get; set; } + + [MaxLength(4096)] + public string Log { get; set; } + + [IndexColumn] + [MaxLength(64)] + public string CreatedIp { get; set; } + + [MaxLength(2048)] + public string BrowserAgent { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DbEntities/Comment.cs b/SwipetorApp/Models/DbEntities/Comment.cs new file mode 100644 index 0000000..c347a6b --- /dev/null +++ b/SwipetorApp/Models/DbEntities/Comment.cs @@ -0,0 +1,37 @@ +using System; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using Toolbelt.ComponentModel.DataAnnotations.Schema.V5; +using WebAppShared.Types; + +namespace SwipetorApp.Models.DbEntities; + +[UsedImplicitly] +public class Comment : IDbEntity +{ + public int Id { get; set; } + + [Required] + [MaxLength(65536)] + public string Txt { get; set; } + + public int PostId { get; set; } + public virtual Post Post { get; set; } + + public int UserId { get; set; } + public virtual User User { get; set; } + + [IndexColumn] + public int LikeCount { get; set; } + + [Required] + [MaxLength(64)] + public string CreatedIp { get; set; } + + public DateTime CreatedAt { get; set; } + + [MaxLength(64)] + public string ModifiedIp { get; set; } + + public DateTime? ModifiedAt { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DbEntities/CustomDomain.cs b/SwipetorApp/Models/DbEntities/CustomDomain.cs new file mode 100644 index 0000000..f1c6f57 --- /dev/null +++ b/SwipetorApp/Models/DbEntities/CustomDomain.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using Toolbelt.ComponentModel.DataAnnotations.Schema.V5; + +namespace SwipetorApp.Models.DbEntities; + +[UsedImplicitly] +public class CustomDomain +{ + public int Id { get; set; } + + [IndexColumn] + [MaxLength(64)] + [CanBeNull] + public string DomainName { get; set; } + + public int UserId { get; set; } + public virtual User User { get; set; } + + [MaxLength(128)] + public string RecaptchaKey { get; set; } + + [MaxLength(128)] + public string RecaptchaSecret { get; set; } +} \ No newline at end of file diff --git a/SwipetorApp/Models/DbEntities/DbCx.cs b/SwipetorApp/Models/DbEntities/DbCx.cs new file mode 100644 index 0000000..b5b07ed --- /dev/null +++ b/SwipetorApp/Models/DbEntities/DbCx.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using SwipetorApp.Models.Enums; +using Toolbelt.ComponentModel.DataAnnotations; +using WebAppShared.Contexts; +using WebAppShared.SharedLogic.Fx; +using WebAppShared.Videos; +using WebAppShared.WebSys; + +namespace SwipetorApp.Models.DbEntities; + +public class DbCx : DbContext +{ + private static readonly ILoggerFactory DevLoggerFactory + = LoggerFactory.Create(builder => + { + builder.ClearProviders().AddFile("/var/log/swipetor/sql-{Date}.log", isJson: true); + }); + + private readonly IConnectionCx _connCx; + + // public DbCx() + // { + // _connCx = null; + // } + + public DbCx(DbContextOptions options) : base(options) + { + _connCx = null; + } + + public DbCx([CanBeNull] IConnectionCx connCx, DbContextOptions options) : + base(options) + { + _connCx = connCx; + } + + public DbSet AuditLogs { get; set; } + + public DbSet EnglishWords { get; set; } + public DbSet Comments { get; set; } + + public DbSet CustomDomains { get; set; } + public DbSet Hubs { get; set; } + public DbSet KeyValues { get; set; } + public DbSet LoginRequests { get; set; } + public DbSet LoginAttempts { get; set; } + public DbSet Locations { get; set; } + public DbSet Notifs { get; set; } + public DbSet Photos { get; set; } + public DbSet PmMsgs { get; set; } + public DbSet PmPermissions { get; set; } + public DbSet PmThreads { get; set; } + public DbSet PmThreadUsers { get; set; } + public DbSet Posts { get; set; } + public DbSet PostHubs { get; set; } + public DbSet PostMedias { get; set; } + + public DbSet PostNotifBatches { get; set; } + + public DbSet PostViews { get; set; } + public DbSet PushDevices { get; set; } + public DbSet FavPosts { get; set; } + public DbSet RemoteVideoInfos { get; set; } + public DbSet Sprites { get; set; } + public DbSet Subscriptions { get; set; } + public DbSet SubPlans { get; set; } + public DbSet