diff --git a/.cargo/config.toml b/.cargo/config.toml index 38df5351..ce9bb7ea 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -27,3 +27,7 @@ linker = "arm-linux-gnueabihf-gcc" [target.arm-unknown-linux-musleabihf] linker = "arm-linux-gnueabihf-gcc" + +[target.x86_64-pc-windows-gnu] +linker = "C:\\msys2\\ucrt64\\bin\\gcc.exe" +ar = "C:\\msys2\\ucrt64\\bin\\ar.exe" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da2b2d82..9d529396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Release id: setup_release - uses: LizardByte/setup-release-action@v2025.612.120948 + uses: LizardByte/actions/actions/release_setup@v2025.715.25226 with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -56,7 +56,7 @@ jobs: # os: ubuntu-latest # container: alpine:latest # shell: sh - cargo_env: "source $HOME/.cargo/env" + # cargo_env: "source $HOME/.cargo/env" - target: aarch64-unknown-linux-gnu # Debian os: ubuntu-24.04-arm shell: bash @@ -105,18 +105,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Cache - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - ./target - key: ${{ matrix.target }}-${{ github.sha }} - restore-keys: | - ${{ matrix.target }}-${{ github.sha }} - ${{ matrix.target }}- - - name: Setup cross compiling (Debian) id: cross_compile if: contains(matrix.os, 'ubuntu') && matrix.container == null @@ -268,7 +256,6 @@ jobs: libayatana-appindicator-dev \ musl-dev \ openssl-dev \ - rustup \ xdotool-dev \ pango-dev \ harfbuzz-dev \ @@ -279,29 +266,29 @@ jobs: gettext-dev echo "::endgroup::" - echo "::group::rustup" - rustup-init -y - echo "::endgroup::" - - - name: Install cargo dependencies + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1.13.0 + with: + target: ${{ matrix.target }} + components: 'clippy' + cache: true + cache-on-failure: false + + # TODO: it may be possible to use cargo-bin in the future to install cargo dependencies, + # but right now it doesn't work without a lock file + # https://github.com/dustinblackman/cargo-run-bin/issues/27 + # cargo install cargo-run-bin + # cargo-bin --install + - name: Install cargo packages run: | - # cargo-bin --install doesn't work without a lock file - # https://github.com/dustinblackman/cargo-run-bin/issues/27 - # cargo install cargo-run-bin - # cargo-bin --install - - cargo install cargo-edit - cargo install cargo-tarpaulin + cargo install \ + cargo-edit \ + cargo-tarpaulin - name: Update Version if: ${{ needs.setup_release.outputs.publish_release == 'true' }} run: cargo set-version ${{ needs.setup_release.outputs.release_version }} - - name: Install toolchain - run: | - ${{ matrix.cargo_env }} - rustup target add ${{ matrix.target }} - - name: Test id: test run: | @@ -370,9 +357,9 @@ jobs: path: artifacts - name: Create/Update GitHub Release - if: false + if: false # TODO: move release to separate job # if: ${{ needs.setup_release.outputs.publish_release == 'true' }} - uses: LizardByte/create-release-action@v2025.612.13419 + uses: LizardByte/actions/actions/release_create@v2025.715.25226 with: allowUpdates: true body: ${{ needs.setup_release.outputs.release_body }} diff --git a/.gitignore b/.gitignore index 4024fee4..71ba4b12 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,9 @@ Cargo.lock # coverage results cobertura.xml tarpaulin-report.json + +# temporary files +build_rs_cov.profraw + +# unit tests +test_data/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d7935fea..46c610c9 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -17,7 +17,7 @@ build: jobs: build: html: - - cargo doc --no-deps --verbose --color always + - cargo doc --no-deps --workspace --verbose --color always - mkdir -p ${READTHEDOCS_OUTPUT}html - cp -r target/doc/. ${READTHEDOCS_OUTPUT}html/ # redirect index.html to html/koko/index.html diff --git a/.rustfmt.toml b/.rustfmt.toml index 9c4e882e..b29faa8b 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -11,4 +11,5 @@ normalize_doc_attributes = true reorder_impl_items = true single_line_if_else_max_width = 100 single_line_let_else_max_width = 100 +style_edition = "2021" wrap_comments = true diff --git a/Cargo.toml b/Cargo.toml index e32fe64b..7e60bcfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,21 @@ -[package] -name = "koko" -description = "Self-hosted media server." -license = "AGPL-3.0-only" -version = "0.0.0" +[workspace] +members = [ + "crates/server", + # "crates/common", + # "crates/clients-*", +] +resolver = "2" + +# Workspace-wide metadata +[workspace.package] authors = ["LizardByte"] edition = "2021" readme = "docs/README.md" documentation = "https://github.com/LizardByte/Koko/blob/master/README.md" homepage = "https://app.lizardbyte.dev" repository = "https://github.com/LizardByte/Koko.git" +license = "" +version = "0.0.0" categories = [ "multimedia", "multimedia::audio", @@ -21,48 +28,11 @@ keywords = [ "self-hosted", ] publish = false # disable publishing to crates.io -build = "build.rs" - -include = [ - "/assets", - "/src", - "/tests", - "/README.md", - "/LICENSE", -] -[lib] -name = "koko" -path = "src/lib.rs" - -[[bin]] -name = "koko" -path = "src/main.rs" - -[dependencies] +# workspace-level dependencies to ensure consistent versions +[workspace.dependencies] # ensure deps are compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses -cargo_metadata = "0.19.1" -chrono = "0.4.39" -fern = { version = "0.7.1", features = ["colored"] } -image = "0.25.5" -log = "0.4.25" -regex = "1.11.1" -rocket = "0.5.1" -rocket_okapi = { version = "0.9.0", features = ["swagger", "rapidoc"] } -schemars = "0.8.1" -serde = "1.0.217" -tao = "0.31.1" -tray-icon = "0.19.2" -webbrowser = "1.0.3" - -[target.'cfg(target_os = "macos")'.dependencies] -objc2-core-foundation = "0.3.0" - -[dev-dependencies] - -[package.metadata.ci] -cargo-run-bin = "1.7.4" - -[package.metadata.bin] -cargo-edit = { version = "0.13.1" } -cargo-tarpaulin = { version = "0.31.5" } +async-std = { version = "1.13.0", features = ["attributes", "tokio1"] } +rstest = "0.25.0" +serial_test = "3.2.0" +tokio = "1.43.0" diff --git a/LICENSE b/LICENSE index 0ad25db4..a0990367 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1 @@ - 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 -. +TBD diff --git a/assets/Koko.png b/assets/Koko.png new file mode 100644 index 00000000..bb422f03 Binary files /dev/null and b/assets/Koko.png differ diff --git a/assets/Koko.svg b/assets/Koko.svg new file mode 100644 index 00000000..eb73ca82 --- /dev/null +++ b/assets/Koko.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + diff --git a/assets/icon.ico b/assets/icon.ico index 79620bf5..d76b73c3 100644 Binary files a/assets/icon.ico and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png index b8a2f2be..07bde486 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/build.rs b/build.rs index 550ad9ee..62944d5c 100644 --- a/build.rs +++ b/build.rs @@ -1,13 +1,25 @@ +use std::env; +use std::fs; +use std::path::Path; + fn main() { - // Copy the images to the output when generating documentation println!("cargo:rerun-if-changed=assets"); - // create the target/doc/assets directory if it doesn't exist - std::fs::create_dir_all("target/doc/assets") - .expect("Failed to create target/doc/assets directory when building documentation."); + // Get the workspace root directory + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR"); + let workspace_root = Path::new(&manifest_dir) + .ancestors() + .nth(2) // Go up two levels from crates/ to reach the workspace root + .expect("Failed to find workspace root"); + + // Create target directory in workspace root + let target_dir = workspace_root.join("target/doc/assets"); + fs::create_dir_all(&target_dir).expect("Failed to create target/doc/assets directory"); - std::fs::copy("assets/icon.ico", "target/doc/assets/icon.ico") - .expect("Failed to copy crate favicon when building documentation."); - std::fs::copy("assets/icon.png", "target/doc/assets/icon.png") - .expect("Failed to copy crate logo when building documentation."); + // Copy assets from workspace root + let assets_dir = workspace_root.join("assets"); + fs::copy(assets_dir.join("icon.ico"), target_dir.join("icon.ico")) + .expect("Failed to copy crate favicon"); + fs::copy(assets_dir.join("icon.png"), target_dir.join("icon.png")) + .expect("Failed to copy crate logo"); } diff --git a/crates/client-android/.keep b/crates/client-android/.keep new file mode 100644 index 00000000..e69de29b diff --git a/crates/client-desktop/.keep b/crates/client-desktop/.keep new file mode 100644 index 00000000..e69de29b diff --git a/crates/client-ios/.keep b/crates/client-ios/.keep new file mode 100644 index 00000000..e69de29b diff --git a/crates/client-xbox/.keep b/crates/client-xbox/.keep new file mode 100644 index 00000000..e69de29b diff --git a/crates/common/.keep b/crates/common/.keep new file mode 100644 index 00000000..e69de29b diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 00000000..dd9c18fd --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,90 @@ +[package] +name = "koko" +description = "Self-hosted media server." +license.workspace = true +version.workspace = true +authors.workspace = true +edition.workspace = true +readme.workspace = true +documentation.workspace = true +homepage.workspace = true +repository.workspace = true +publish.workspace = true +build = "../../build.rs" + +categories = [ + "multimedia", + "multimedia::audio", + "multimedia::images", + "multimedia::video", +] +keywords = [ + "koko", + "media-server", + "self-hosted", +] + +include = [ + "/assets", + "/crates/server/src", + "/tests", + "/README.md", + "/LICENSE", +] + +[lib] +name = "koko" +path = "src/lib.rs" + +[[bin]] +name = "koko" +path = "src/main.rs" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tarpaulin_include)"] } + +[dependencies] +# ensure deps are compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses +base64 = "0.22.1" +bcrypt = "0.17.0" +cargo_metadata = "0.20.0" +chrono = "0.4.39" +config = "0.15.7" +diesel = { version = "2.2.7", features = ["sqlite"] } +diesel_migrations = "2.2.0" +dirs = "6.0.0" +fern = { version = "0.7.1", features = ["colored"] } +image = "0.25.5" +jsonwebtoken = "9.3.1" +libsqlite3-sys = { version = "0.31", features = ["bundled"] } # this is needed for proper linking +log = "0.4.25" +once_cell = "1.20.3" +rand = "0.9.0" +rcgen = "0.13.2" +regex = "1.11.1" +rocket = { version = "0.5.1", features = ["tls"] } +rocket_okapi = { version = "0.9.0", features = ["swagger", "rapidoc"] } +rocket_sync_db_pools = { version = "0.1.0", features = ["diesel_sqlite_pool"] } +schemars = "0.8.1" +serde = "1.0.217" +serde_json = "1.0.138" +tao = "0.31.1" +tray-icon = "0.19.2" +webbrowser = "1.0.3" +# common = { path = "../common" } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-core-foundation = "0.3.0" + +[dev-dependencies] +async-std.workspace = true +rstest.workspace = true +serial_test.workspace = true +tokio.workspace = true + +[package.metadata.ci] +cargo-run-bin = "1.7.4" + +[package.metadata.bin] +cargo-edit = { version = "0.13.1" } +cargo-tarpaulin = { version = "0.31.5" } diff --git a/crates/server/sql/migrations/0_create_users/down.sql b/crates/server/sql/migrations/0_create_users/down.sql new file mode 100644 index 00000000..c99ddcdc --- /dev/null +++ b/crates/server/sql/migrations/0_create_users/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/crates/server/sql/migrations/0_create_users/up.sql b/crates/server/sql/migrations/0_create_users/up.sql new file mode 100644 index 00000000..590d989d --- /dev/null +++ b/crates/server/sql/migrations/0_create_users/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + pin TEXT DEFAULT NULL, + admin BOOLEAN NOT NULL DEFAULT FALSE +); diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs new file mode 100644 index 00000000..66cb89a3 --- /dev/null +++ b/crates/server/src/auth.rs @@ -0,0 +1,173 @@ +#![doc = "Authentication utilities for the application."] + +// lib imports +use base64::{engine::general_purpose, Engine as _}; +use bcrypt::{hash, verify, DEFAULT_COST}; +use diesel::{QueryDsl, RunQueryDsl}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use once_cell::sync::Lazy; +use rand::Rng; +use rocket::http::Status; +use rocket::outcome::Outcome; +use rocket::request::{self, FromRequest, Request}; +use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput}; +use serde::{Deserialize, Serialize}; + +// local imports +use crate::db::DbConn; + +/// Guard for admin routes. +pub struct AdminGuard(Claims); + +impl AdminGuard { + /// Get the claims contained in this guard + pub fn claims(&self) -> &Claims { + &self.0 + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AdminGuard { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + let claims = match request.guard::().await { + Outcome::Success(claims) => claims, + _ => return Outcome::Error((Status::Unauthorized, ())), + }; + + let db = match request.guard::().await { + Outcome::Success(db) => db, + _ => return Outcome::Error((Status::InternalServerError, ())), + }; + + let user_id: i32 = match claims.sub.parse() { + Ok(id) => id, + Err(_) => return Outcome::Error((Status::Unauthorized, ())), + }; + + let is_admin = db + .run(move |conn| { + use crate::db::schema::users::dsl::*; + users.find(user_id).select(admin).first::(conn) + }) + .await + .unwrap_or(false); + + if is_admin { + Outcome::Success(AdminGuard(claims)) + } else { + Outcome::Error((Status::Forbidden, ())) + } + } +} + +#[rocket::async_trait] +impl OpenApiFromRequest<'_> for AdminGuard { + fn from_request_input( + _gen: &mut rocket_okapi::gen::OpenApiGenerator, + _name: String, + _required: bool, + ) -> rocket_okapi::Result { + Ok(RequestHeaderInput::None) + } +} + +/// Claims for the JWT. +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub(crate) sub: String, + exp: usize, +} + +const BEARER: &str = "Bearer "; + +/// Create a JWT token. +pub fn create_token( + user_id: &str, + secret: &str, +) -> String { + let expiration = chrono::Utc::now() + .checked_add_signed(chrono::Duration::seconds(60)) + .expect("valid timestamp") + .timestamp(); + + let claims = Claims { + sub: user_id.to_owned(), + exp: expiration as usize, + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_ref()), + ) + .unwrap() +} + +/// Decode a JWT token. +pub fn decode_token( + token: &str, + secret: &str, +) -> Result { + decode::( + token, + &DecodingKey::from_secret(secret.as_ref()), + &Validation::default(), + ) + .map(|data| data.claims) +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Claims { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + let keys: Vec<_> = request.headers().get("Authorization").collect(); + if keys.len() != 1 { + return Outcome::Error((rocket::http::Status::Unauthorized, ())); + } + + if !keys[0].starts_with(BEARER) { + return Outcome::Error((rocket::http::Status::Unauthorized, ())); + } + + let token = &keys[0][BEARER.len()..]; + let secret = get_jwt_secret(); + + match decode_token(token, secret) { + Ok(claims) => Outcome::Success(claims), + Err(_) => Outcome::Error((rocket::http::Status::Unauthorized, ())), + } + } +} + +impl OpenApiFromRequest<'_> for Claims { + fn from_request_input( + _gen: &mut rocket_okapi::gen::OpenApiGenerator, + _name: String, + _required: bool, + ) -> rocket_okapi::Result { + Ok(RequestHeaderInput::None) + } +} + +static JWT_SECRET: Lazy = Lazy::new(|| { + let random_bytes: [u8; 32] = rand::rng().random(); + general_purpose::STANDARD.encode(random_bytes) +}); + +pub(crate) fn get_jwt_secret() -> &'static str { + &JWT_SECRET +} + +pub(crate) fn hash_password(password: &str) -> String { + hash(password, DEFAULT_COST).unwrap() +} + +pub(crate) fn verify_password( + password: &str, + hash: &str, +) -> bool { + verify(password, hash).unwrap_or(false) +} diff --git a/crates/server/src/certs.rs b/crates/server/src/certs.rs new file mode 100644 index 00000000..2aca7f3c --- /dev/null +++ b/crates/server/src/certs.rs @@ -0,0 +1,31 @@ +#![doc = "Certificate utilities for the application."] + +// standard imports +use std::fs; +use std::path::Path; + +// lib imports +use rcgen::{generate_simple_self_signed, CertifiedKey}; + +/// Ensure that the certificates exist at the given paths. +pub fn ensure_certificates_exist( + cert_path: String, + key_path: String, +) { + if !Path::new(cert_path.as_str()).exists() || !Path::new(key_path.as_str()).exists() { + let subject_alt_names = vec!["localhost".to_string()]; + + let CertifiedKey { cert, key_pair } = + generate_simple_self_signed(subject_alt_names).unwrap(); + + // create directory tree if necessary + let cert_dir = Path::new(&cert_path).parent().unwrap(); + let key_dir = Path::new(&key_path).parent().unwrap(); + fs::create_dir_all(cert_dir).expect("Failed to create certificate directory"); + fs::create_dir_all(key_dir).expect("Failed to create private key directory"); + + // write the certificate and private key to disk + fs::write(cert_path, cert.pem()).expect("Failed to write certificate"); + fs::write(key_path, key_pair.serialize_pem()).expect("Failed to write private key"); + } +} diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs new file mode 100644 index 00000000..0a7e22ac --- /dev/null +++ b/crates/server/src/config.rs @@ -0,0 +1,124 @@ +#![doc = "Configuration module for the application."] + +// lib imports +use config::{Config, ConfigError, Environment, File}; +use dirs::config_local_dir; +use once_cell::sync::Lazy; +use serde::Deserialize; + +// local imports +use crate::globals::GLOBAL_APP_NAME; + +/// General settings. +#[derive(Debug, Deserialize)] +pub struct GeneralSettings { + /// The directory where application data is stored. + #[serde(default)] + pub data_dir: String, +} + +/// Server settings. +#[derive(Debug, Deserialize)] +pub struct ServerSettings { + /// Whether to use HTTPS. + #[serde(default)] + pub use_https: bool, + /// The address to bind to. + #[serde(default)] + pub address: String, + /// The port to bind to. + #[serde(default)] + pub port: u16, + /// Certificate path. + #[serde(default)] + pub cert_path: String, + /// Key path. + #[serde(default)] + pub key_path: String, + /// Use custom certs. + #[serde(default)] + pub use_custom_certs: bool, +} + +/// Application settings. +#[derive(Debug, Deserialize, Default)] +pub struct Settings { + /// General settings. + #[serde(default)] + pub general: GeneralSettings, + /// Server settings. + #[serde(default)] + pub server: ServerSettings, +} + +impl Default for GeneralSettings { + fn default() -> Self { + GeneralSettings { + data_dir: config_local_dir() + .unwrap() + .join(GLOBAL_APP_NAME) + .join("data") + .to_str() + .unwrap() + .into(), + } + } +} + +impl Default for ServerSettings { + fn default() -> Self { + ServerSettings { + use_https: true, + address: "127.0.0.1".into(), + port: 9191, + cert_path: "cert.pem".into(), + key_path: "key.pem".into(), + use_custom_certs: false, + } + } +} + +impl Settings { + /// Create a new instance of `Settings`. + pub fn new() -> Result { + // Start with defaults provided via set_default and then merge in any provided config file or environment variables. + let config = Config::builder() + .set_default("general.data_dir", GeneralSettings::default().data_dir)? + .set_default("server.use_https", ServerSettings::default().use_https)? + .set_default("server.address", ServerSettings::default().address)? + .set_default("server.port", ServerSettings::default().port)? + .set_default("server.cert_path", ServerSettings::default().cert_path)? + .set_default("server.key_path", ServerSettings::default().key_path)? + .set_default( + "server.use_custom_certs", + ServerSettings::default().use_custom_certs, + )? + // Add other configuration sources; values here will override the defaults. + .add_source( + File::with_name( + config_local_dir() + .unwrap() + .join(GLOBAL_APP_NAME) + .join("settings") + .to_str() + .unwrap(), + ) + .required(false), + ) + .add_source(Environment::with_prefix( + GLOBAL_APP_NAME.to_uppercase().as_str(), + )) + .build()?; + + // Deserialize the configuration into our Settings struct. + config.try_deserialize() + } + + /// Load settings from the configuration file. + pub fn load() -> Self { + Self::new().expect("Failed to load settings") + } +} + +/// Global settings for the application. +pub static GLOBAL_SETTINGS: Lazy = Lazy::new(Settings::load); diff --git a/crates/server/src/db/mod.rs b/crates/server/src/db/mod.rs new file mode 100644 index 00000000..705d3eff --- /dev/null +++ b/crates/server/src/db/mod.rs @@ -0,0 +1,57 @@ +#![doc = "Database utilities for the application."] + +pub(crate) mod models; +pub(crate) mod schema; + +// lib imports +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +use rocket::{ + fairing::{Fairing, Info, Kind}, + Build, Rocket, +}; +use rocket_sync_db_pools::{database, diesel}; + +/// Embedded migrations for the SQLite database. +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("sql/migrations"); + +/// Database connection fairing. +#[database("sqlite_db")] +pub struct DbConn(diesel::SqliteConnection); + +/// Fairing to run migrations when the application starts. +pub struct Migrate; + +#[rocket::async_trait] +impl Fairing for Migrate { + fn info(&self) -> Info { + Info { + name: "Database Migrations", + kind: Kind::Ignite, + } + } + + async fn on_ignite( + &self, + rocket: Rocket, + ) -> Result, Rocket> { + if let Some(conn) = DbConn::get_one(&rocket).await { + let _ = conn + .run(|c| { + c.run_pending_migrations(MIGRATIONS) + .expect("Failed to run migrations"); + }) + .await; + } + Ok(rocket) + } +} + +impl rocket_okapi::request::OpenApiFromRequest<'_> for DbConn { + fn from_request_input( + _gen: &mut rocket_okapi::gen::OpenApiGenerator, + _name: String, + _required: bool, + ) -> rocket_okapi::Result { + Ok(rocket_okapi::request::RequestHeaderInput::None) + } +} diff --git a/crates/server/src/db/models.rs b/crates/server/src/db/models.rs new file mode 100644 index 00000000..2495de9c --- /dev/null +++ b/crates/server/src/db/models.rs @@ -0,0 +1,18 @@ +#![doc = "Database models for the application."] + +// lib imports +use diesel::prelude::*; + +// local imports +use crate::db::schema::users; + +#[derive(Queryable, Selectable, Insertable, Debug)] +#[diesel(table_name = users)] +pub struct User { + #[diesel(skip_insertion)] + pub id: i32, + pub username: String, + pub password: String, + pub pin: Option, + pub admin: bool, +} diff --git a/crates/server/src/db/schema.rs b/crates/server/src/db/schema.rs new file mode 100644 index 00000000..e01bf8e9 --- /dev/null +++ b/crates/server/src/db/schema.rs @@ -0,0 +1,16 @@ +#![doc = "Database schema for the application."] + +// lib imports +use diesel::table; + +table! { + users (id) { + id -> Integer, + username -> Text, + password -> Text, + password_salt -> Text, + pin -> Nullable, + pin_salt -> Nullable, + admin -> Bool, + } +} diff --git a/src/dependencies/mod.rs b/crates/server/src/dependencies/mod.rs similarity index 100% rename from src/dependencies/mod.rs rename to crates/server/src/dependencies/mod.rs diff --git a/crates/server/src/globals.rs b/crates/server/src/globals.rs new file mode 100644 index 00000000..0b8ff712 --- /dev/null +++ b/crates/server/src/globals.rs @@ -0,0 +1,75 @@ +#![doc = "Miscellaneous utilities for the application."] + +// lib imports +use once_cell::sync::Lazy; + +// local imports +use crate::config::GLOBAL_SETTINGS; + +// global constants and variables +pub(crate) static GLOBAL_APP_NAME: &str = "Koko"; +pub(crate) static GLOBAL_ICON_ICO_PATH: &str = "assets/icon.ico"; + +/// Environment type for the application +#[derive(Clone, Copy, PartialEq)] +pub enum Environment { + /// Production environment. + Production, + /// Test environment. + Test, +} + +/// Implement the From trait for converting a usize to an Environment. +impl Environment { + /// Convert a usize to an Environment. + pub fn from_usize(value: usize) -> Self { + match value { + 0 => Environment::Production, + 1 => Environment::Test, + _ => Environment::Production, + } + } +} + +/// Atomic variable for the current environment. +pub static CURRENT_ENV: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(Environment::Production as usize); + +/// Paths used by the application. +#[derive(Default)] +pub struct AppPaths { + /// Path to the SQLite database. + pub db_path: String, + /// Path to the log file. + pub log_path: String, +} + +impl AppPaths { + /// Create a new AppPaths instance. + pub fn new() -> Self { + let env = Environment::from_usize(CURRENT_ENV.load(std::sync::atomic::Ordering::Relaxed)); + let base_dir = match env { + Environment::Test => String::from("./test_data"), + Environment::Production => GLOBAL_SETTINGS.general.data_dir.clone(), + }; + + std::fs::create_dir_all(&base_dir).unwrap(); + + AppPaths { + db_path: format!("{}/{}.db", base_dir, GLOBAL_APP_NAME.to_lowercase()), + log_path: format!("{}/{}.log", base_dir, GLOBAL_APP_NAME.to_lowercase()), + } + } +} + +/// Get the server URL based on the global settings. +pub fn get_server_url() -> String { + let schema = if GLOBAL_SETTINGS.server.use_https { "https" } else { "http" }; + format!( + "{}://{}:{}", + schema, GLOBAL_SETTINGS.server.address, GLOBAL_SETTINGS.server.port + ) +} + +/// Global AppPaths instance. +pub static APP_PATHS: Lazy = Lazy::new(AppPaths::new); diff --git a/src/lib.rs b/crates/server/src/lib.rs similarity index 68% rename from src/lib.rs rename to crates/server/src/lib.rs index b39db82a..cf42889d 100644 --- a/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,10 +1,15 @@ #![doc(html_favicon_url = "../assets/icon.ico")] #![doc(html_logo_url = "../assets/icon.png")] -#![doc = include_str!("../docs/README.md")] +#![doc = include_str!("../../../docs/README.md")] #![deny(missing_docs)] // modules +pub mod auth; +pub mod certs; +pub mod config; +pub mod db; pub mod dependencies; +pub mod globals; mod logging; pub mod tray; pub mod web; @@ -12,13 +17,9 @@ pub mod web; // standard imports use std::thread; -// global constants and variables -static GLOBAL_APP_NAME: &str = "Koko"; -static GLOBAL_ICON_ICO_PATH: &str = "assets/icon.ico"; -static GLOBAL_BASE_URL: &str = "http://localhost:8000"; // TODO: get this dynamically - /// Main entry point for the application. /// Initializes logging, the web server, and tray icon. +#[cfg(not(tarpaulin_include))] pub fn main() { logging::init().expect("Failed to initialize logging"); diff --git a/src/logging/mod.rs b/crates/server/src/logging/mod.rs similarity index 95% rename from src/logging/mod.rs rename to crates/server/src/logging/mod.rs index 5d1984ae..f701c08f 100644 --- a/src/logging/mod.rs +++ b/crates/server/src/logging/mod.rs @@ -8,6 +8,9 @@ use fern::colors::{Color, ColoredLevelConfig}; use log::LevelFilter; use regex::Regex; +// local imports +use crate::globals; + #[derive(Clone)] struct Logger { time_format: &'static str, @@ -80,7 +83,7 @@ impl Logger { }); if to_file { - Ok(dispatch.chain(fern::log_file("output.log")?)) + Ok(dispatch.chain(fern::log_file(globals::APP_PATHS.log_path.clone())?)) } else { Ok(dispatch.chain(io::stdout())) } diff --git a/src/main.rs b/crates/server/src/main.rs similarity index 50% rename from src/main.rs rename to crates/server/src/main.rs index 0735cb87..efed0b8f 100644 --- a/src/main.rs +++ b/crates/server/src/main.rs @@ -1,3 +1,4 @@ +#[cfg(not(tarpaulin_include))] fn main() { koko::main(); } diff --git a/src/tray.rs b/crates/server/src/tray.rs similarity index 91% rename from src/tray.rs rename to crates/server/src/tray.rs index 38f9c569..8f69ffa8 100644 --- a/src/tray.rs +++ b/crates/server/src/tray.rs @@ -11,7 +11,7 @@ use tray_icon::{ }; // local imports -use crate::{GLOBAL_APP_NAME, GLOBAL_BASE_URL, GLOBAL_ICON_ICO_PATH}; +use crate::globals; #[derive(Debug)] enum UserEvent { @@ -21,7 +21,7 @@ enum UserEvent { /// Launch the tray icon and event loop. pub fn launch() { - let path = std::path::Path::new(GLOBAL_ICON_ICO_PATH); + let path = std::path::Path::new(globals::GLOBAL_ICON_ICO_PATH); let event_loop = EventLoopBuilder::::with_user_event().build(); @@ -44,11 +44,11 @@ pub fn launch() { let tray_menu = Menu::new(); // top level items - let open_i = MenuItem::new(format!("Open {}", GLOBAL_APP_NAME), true, None); + let open_i = MenuItem::new(format!("Open {}", globals::GLOBAL_APP_NAME), true, None); let about_i = PredefinedMenuItem::about( None, Some(AboutMetadata { - name: Some(GLOBAL_APP_NAME.to_string()), + name: Some(globals::GLOBAL_APP_NAME.to_string()), copyright: Some("© LizardByte".to_string()), version: Some(env!("CARGO_PKG_VERSION").to_string()), ..Default::default() @@ -118,7 +118,7 @@ pub fn launch() { TrayIconBuilder::new() .with_icon(icon) .with_menu(Box::new(tray_menu.clone())) - .with_tooltip(GLOBAL_APP_NAME) + .with_tooltip(globals::GLOBAL_APP_NAME) .build() .unwrap(), ); @@ -149,7 +149,9 @@ pub fn launch() { // TODO: adjust application config first tray_icon.as_mut().unwrap().set_visible(false).unwrap(); } - id if id == open_i.id() => webbrowser::open(GLOBAL_BASE_URL).unwrap(), + id if id == open_i.id() => { + webbrowser::open(globals::get_server_url().as_str()).unwrap() + } id if id == donate_github_i.id() => { webbrowser::open("https://github.com/sponsors/LizardByte").unwrap() } @@ -160,13 +162,15 @@ pub fn launch() { webbrowser::open("https://www.paypal.com/paypalme/ReenigneArcher").unwrap() } id if id == options_settings_i.id() => { - webbrowser::open(&format!("{}/settings", GLOBAL_BASE_URL)).unwrap() + webbrowser::open(&format!("{}/settings", globals::get_server_url())) + .unwrap() } id if id == api_rapidoc_i.id() => { - webbrowser::open(&format!("{}/rapidoc", GLOBAL_BASE_URL)).unwrap() + webbrowser::open(&format!("{}/rapidoc", globals::get_server_url())).unwrap() } id if id == api_swagger_i.id() => { - webbrowser::open(&format!("{}/swagger-ui", GLOBAL_BASE_URL)).unwrap() + webbrowser::open(&format!("{}/swagger-ui", globals::get_server_url())) + .unwrap() } _ => { log::error!("Unknown menu event: {:?}", event); diff --git a/crates/server/src/web/mod.rs b/crates/server/src/web/mod.rs new file mode 100644 index 00000000..d091c87e --- /dev/null +++ b/crates/server/src/web/mod.rs @@ -0,0 +1,93 @@ +#![doc = "Web server utilities for the application."] + +// modules +mod routes; + +// lib imports +use rocket::config::Config; +use rocket::config::TlsConfig; +use rocket::figment::Figment; +use rocket_okapi::settings::UrlObject; +use rocket_okapi::{rapidoc::*, swagger_ui::*}; + +// local imports +use crate::certs; +use crate::config::GLOBAL_SETTINGS; +use crate::db::{DbConn, Migrate}; +use crate::globals; + +/// Build the web server. +pub fn rocket() -> rocket::Rocket { + // the cert path changes depending on if the user wants to use custom certs + let (cert_path, key_path); + if !GLOBAL_SETTINGS.server.use_custom_certs { + cert_path = format!("{}/cert.pem", GLOBAL_SETTINGS.general.data_dir); + key_path = format!("{}/key.pem", GLOBAL_SETTINGS.general.data_dir); + } else { + cert_path = GLOBAL_SETTINGS.server.cert_path.clone(); + key_path = GLOBAL_SETTINGS.server.key_path.clone(); + } + + if GLOBAL_SETTINGS.server.use_https { + certs::ensure_certificates_exist(cert_path.clone(), key_path.clone()); + } + + let figment = Figment::from(Config::default()) + .merge(( + "databases", + rocket::figment::map! { + "sqlite_db" => rocket::figment::map! { + "url" => format!("sqlite://{}", globals::APP_PATHS.db_path), + } + }, + )) + .merge(("address", GLOBAL_SETTINGS.server.address.clone())) + .merge(("port", GLOBAL_SETTINGS.server.port)) + .merge(( + "tls", + if GLOBAL_SETTINGS.server.use_https { + Some(TlsConfig::from_paths(cert_path, key_path)) + } else { + None + }, + )); + + rocket::custom(figment) + .attach(DbConn::fairing()) + .attach(Migrate) + .mount("/", routes::all_routes()) + .mount( + "/swagger-ui/", + make_swagger_ui(&SwaggerUIConfig { + url: "../openapi.json".to_owned(), + ..Default::default() + }), + ) + .mount( + "/rapidoc/", + make_rapidoc(&RapiDocConfig { + general: GeneralConfig { + spec_urls: vec![UrlObject::new( + "General", + "../openapi.json", + )], + ..Default::default() + }, + hide_show: HideShowConfig { + allow_spec_url_load: false, + allow_spec_file_load: false, + ..Default::default() + }, + ..Default::default() + }), + ) +} + +/// Launch the web server. +#[rocket::main] +pub async fn launch() { + rocket() + .launch() + .await + .expect("Failed to launch web server"); +} diff --git a/crates/server/src/web/routes/auth.rs b/crates/server/src/web/routes/auth.rs new file mode 100644 index 00000000..3d23774b --- /dev/null +++ b/crates/server/src/web/routes/auth.rs @@ -0,0 +1,83 @@ +#![doc = "Routes for the web server."] + +// lib imports +use diesel::QueryDsl; +use diesel::RunQueryDsl; +use diesel::{ExpressionMethods, SelectableHelper}; +use rocket::http::Status; +use rocket::serde::{json::Json, Deserialize, Serialize}; +use rocket::{get, post}; +use rocket_okapi::{openapi, JsonSchema}; + +// local imports +use crate::auth::{AdminGuard, Claims}; +use crate::db::models::User; +use crate::db::DbConn; + +#[derive(Deserialize, JsonSchema)] +pub struct LoginForm { + username: String, + password: String, +} + +#[derive(Serialize, JsonSchema)] +pub struct TokenResponse { + token: String, +} + +#[openapi(tag = "Auth")] +#[post("/login", format = "json", data = "")] +pub async fn login( + db: DbConn, + login_form: Json, +) -> Result, Status> { + use crate::db::schema::users::dsl::*; + + let form = login_form.into_inner(); + println!("Attempting login for user: {}", form.username); + + let user = match db + .run(move |conn| { + users + .filter(username.eq(form.username)) + .select(User::as_select()) + .first::(conn) + }) + .await + { + Ok(user) => user, + Err(e) => { + println!("Database error: {}", e); + return Err(Status::Unauthorized); + } + }; + + // debug print user info from db + println!("Found user in db: {:?}", user); + + if !crate::auth::verify_password(&form.password, &user.password) { + println!("Password verification failed"); + return Err(Status::Unauthorized); + } + + let token = crate::auth::create_token(&user.id.to_string(), crate::auth::get_jwt_secret()); + Ok(Json(TokenResponse { token })) +} + +#[openapi(tag = "Auth")] +#[get("/logout")] +pub fn logout() -> &'static str { + "Logout Page" +} + +#[openapi(tag = "Test Auth")] +#[get("/jwt_test")] +pub fn jwt_test(_claims: Claims) -> &'static str { + "Protected Page" +} + +#[openapi(tag = "Test Auth")] +#[get("/admin_test")] +pub fn admin_test(_admin: AdminGuard) -> &'static str { + "Admin only content" +} diff --git a/src/web/routes/common.rs b/crates/server/src/web/routes/common.rs similarity index 70% rename from src/web/routes/common.rs rename to crates/server/src/web/routes/common.rs index 3ac82f34..5efbd6fc 100644 --- a/src/web/routes/common.rs +++ b/crates/server/src/web/routes/common.rs @@ -5,10 +5,10 @@ use rocket::get; use rocket_okapi::openapi; // local imports -use crate::GLOBAL_APP_NAME; +use crate::globals; #[openapi(tag = "Index")] #[get("/")] pub fn index() -> String { - format!("Welcome to {}!", GLOBAL_APP_NAME) + format!("Welcome to {}!", globals::GLOBAL_APP_NAME) } diff --git a/src/web/routes/dependencies.rs b/crates/server/src/web/routes/dependencies.rs similarity index 96% rename from src/web/routes/dependencies.rs rename to crates/server/src/web/routes/dependencies.rs index 1b50c279..a014dbdd 100644 --- a/src/web/routes/dependencies.rs +++ b/crates/server/src/web/routes/dependencies.rs @@ -24,7 +24,7 @@ pub struct PackageResponse { impl From for PackageResponse { fn from(pkg: Package) -> Self { PackageResponse { - name: pkg.name, + name: pkg.name.to_string(), version: pkg.version.to_string(), license: pkg.license, } diff --git a/src/web/routes/mod.rs b/crates/server/src/web/routes/mod.rs similarity index 82% rename from src/web/routes/mod.rs rename to crates/server/src/web/routes/mod.rs index 7d7efb9e..f2afe538 100644 --- a/src/web/routes/mod.rs +++ b/crates/server/src/web/routes/mod.rs @@ -4,6 +4,7 @@ mod auth; mod common; mod dependencies; +mod user; // lib imports use rocket_okapi::openapi_get_routes; // this is a replacement for the rocket::routes macro @@ -13,6 +14,9 @@ pub fn all_routes() -> Vec { common::index, auth::login, auth::logout, + auth::jwt_test, + auth::admin_test, dependencies::get_dependencies, + user::create_user, ] } diff --git a/crates/server/src/web/routes/user.rs b/crates/server/src/web/routes/user.rs new file mode 100644 index 00000000..c0ee13e0 --- /dev/null +++ b/crates/server/src/web/routes/user.rs @@ -0,0 +1,69 @@ +// lib imports +use diesel::{QueryDsl, RunQueryDsl}; +use rocket::http::Status; +use rocket::post; +use rocket::serde::{json::Json, Deserialize}; +use rocket_okapi::openapi; +use rocket_okapi::JsonSchema; + +// local imports +use crate::auth::AdminGuard; +use crate::db::models::User; +use crate::db::DbConn; + +#[derive(Deserialize, JsonSchema)] +pub struct CreateUserForm { + pub username: String, + pub password: String, + pub pin: Option, + pub admin: bool, +} + +#[openapi(tag = "Users")] +#[post("/create_user", format = "json", data = "")] +pub async fn create_user( + db: DbConn, + user_form: Json, + admin_guard: Option, +) -> Result<&'static str, Status> { + use crate::db::schema::users::dsl::*; + let existing = db + .run(|conn| users.count().get_result::(conn)) + .await + .unwrap_or(0); + + // If there are users, require admin privileges + if existing > 0 && admin_guard.is_none() { + return Err(Status::Unauthorized); + } + + let form = user_form.into_inner(); + let hashed_password = crate::auth::hash_password(&form.password); + + let hashed_pin = if let Some(pin_value) = form.pin { + if pin_value.parse::().is_err() { + return Err(Status::BadRequest); + } + if pin_value.len() < 4 || pin_value.len() > 6 { + return Err(Status::BadRequest); + } + Some(crate::auth::hash_password(&pin_value)) + } else { + None + }; + + let user = User { + id: 0, + username: form.username, + password: hashed_password, + pin: hashed_pin, + admin: form.admin, + }; + + // Insert new user + db.run(move |conn| diesel::insert_into(users).values(&user).execute(conn)) + .await + .map_err(|_| Status::InternalServerError)?; + + Ok("User created") +} diff --git a/crates/server/tests/fixtures/mod.rs b/crates/server/tests/fixtures/mod.rs new file mode 100644 index 00000000..cb2c184d --- /dev/null +++ b/crates/server/tests/fixtures/mod.rs @@ -0,0 +1,87 @@ +// standard imports +use std::fs; +use std::path::Path; + +// lib imports +use diesel::sqlite::SqliteConnection; +use diesel::Connection; +use diesel_migrations::MigrationHarness; +use once_cell::sync::Lazy; +use rocket::http::Status; +use rocket::local::asynchronous::Client; +use rstest::fixture; +use serde_json::json; + +// local imports +use koko::db::MIGRATIONS; +use koko::globals::CURRENT_ENV; +use koko::web::rocket; + +// test imports +use crate::test_web::test_request; + +// constants +static DB_PATH: Lazy<&'static Path> = Lazy::new(|| Path::new("./test_data/koko.db")); + +pub struct TestDb { + pub client: Client, +} + +impl Drop for TestDb { + fn drop(&mut self) { + if DB_PATH.exists() { + if let Ok(mut conn) = SqliteConnection::establish(DB_PATH.to_str().unwrap()) { + let _ = conn.revert_all_migrations(MIGRATIONS); + } + + // Sleep to try to all the processes to release the database file + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Delete the database file + match fs::remove_file(DB_PATH.clone()) { + Ok(_) => (), + Err(e) => eprintln!("Warning: Failed to delete test database: {}", e), + } + } + } +} + +#[fixture] +pub async fn db_fixture(#[default(false)] base_user: bool) -> TestDb { + CURRENT_ENV.store(1, std::sync::atomic::Ordering::SeqCst); + + if let Some(parent) = DB_PATH.parent() { + fs::create_dir_all(parent).expect("Failed to create test_data directory"); + } + + // Initialize database with migrations + if let Ok(mut conn) = SqliteConnection::establish(DB_PATH.to_str().unwrap()) { + conn.run_pending_migrations(MIGRATIONS) + .expect("Failed to run migrations"); + } + + let rocket = rocket(); + let client = Client::tracked(rocket) + .await + .expect("Failed to launch web server"); + + if base_user { + let response = test_request( + "post", + "/create_user", + Some(json!({ + "username": "admin", + "password": "password123", + "pin": "1234", + "admin": true, + })), + Status::Ok, + Some(&client), + ) + .await; + + assert_eq!(response.body, "User created"); + } + + TestDb { client } +} diff --git a/crates/server/tests/main.rs b/crates/server/tests/main.rs new file mode 100644 index 00000000..645e640b --- /dev/null +++ b/crates/server/tests/main.rs @@ -0,0 +1,5 @@ +pub mod test_dependencies; +pub mod test_tray; +pub mod test_web; + +pub mod fixtures; diff --git a/tests/dependencies/mod.rs b/crates/server/tests/test_dependencies/mod.rs similarity index 58% rename from tests/dependencies/mod.rs rename to crates/server/tests/test_dependencies/mod.rs index 2513d99e..f3fb27f4 100644 --- a/tests/dependencies/mod.rs +++ b/crates/server/tests/test_dependencies/mod.rs @@ -1,20 +1,14 @@ use koko::dependencies::get_dependencies; -fn is_license_compatible_with_agplv3(license: &str) -> bool { +fn is_license_compatible(license: &str) -> bool { let compatible_licenses = vec![ // compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses // format: https://spdx.github.io/license-list-data/ - "AGPL-3.0-only", - "AGPL-3.0-or-later", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", - "GPL-3.0-only", - "GPL-3.0-or-later", "ISC", - "LGPL-3.0-only", - "LGPL-3.0-or-later", "MIT", "MPL-2.0", "NCSA", @@ -26,15 +20,28 @@ fn is_license_compatible_with_agplv3(license: &str) -> bool { compatible_licenses.iter().any(|&l| license.contains(l)) } +/// Deps that are allowed to have incompatible licenses. +fn dependency_exceptions() -> Vec<&'static str> { + vec![ + "koko", + "dlopen2_derive", // https://github.com/OpenByteDev/dlopen2/issues/20 + "ring", // https://github.com/briansmith/ring/blob/main/LICENSE + ] +} + #[test] fn test_dependencies_licenses() { let dependencies = get_dependencies().unwrap(); for package in dependencies { + if dependency_exceptions().contains(&package.name.as_str()) { + continue; + } + let license = package.license.as_deref().unwrap_or(""); assert!( - is_license_compatible_with_agplv3(license), - "License {} of package {} is not compatible with AGPLv3", + is_license_compatible(license), + "License '{}' of package {} is not compatible", license, package.name ); diff --git a/tests/test_tray.rs b/crates/server/tests/test_tray.rs similarity index 100% rename from tests/test_tray.rs rename to crates/server/tests/test_tray.rs diff --git a/crates/server/tests/test_web/mod.rs b/crates/server/tests/test_web/mod.rs new file mode 100644 index 00000000..2d6601f5 --- /dev/null +++ b/crates/server/tests/test_web/mod.rs @@ -0,0 +1,86 @@ +mod routes; + +use koko::web; +use rocket::http::{ContentType, Header, Status}; +use rocket::local::asynchronous::{Client, LocalResponse}; +use serde_json::Value; + +pub struct TestResponse { + pub status: Status, + pub body: String, + pub headers: Vec>, +} + +pub async fn test_request( + method: &str, + path: &'static str, + json: Option, + expected_status: Status, + client: Option<&Client>, +) -> TestResponse { + let client = match client { + Some(c) => c.to_owned(), + None => { + let rocket = web::rocket(); + &Client::tracked(rocket) + .await + .expect("Failed to launch web server") + } + }; + + let request = match method.to_lowercase().as_str() { + "get" => client.get(path), + "post" => client.post(path), + "put" => client.put(path), + "delete" => client.delete(path), + "patch" => client.patch(path), + _ => panic!("Unsupported HTTP method: {}", method), + }; + + let request = if let Some(json_value) = json { + request + .header(ContentType::JSON) + .body(json_value.to_string()) + } else { + request + }; + + let response = request.dispatch().await; + create_test_response(response, expected_status).await +} + +async fn create_test_response( + response: LocalResponse<'_>, + expected_status: Status, +) -> TestResponse { + assert_eq!(response.status(), expected_status); + + let status = response.status(); + let headers: Vec> = response + .headers() + .iter() + .map(|h| Header::new(h.name().to_string(), h.value().to_string())) + .collect(); + let body = response.into_string().await.unwrap_or_default(); + + TestResponse { + status, + body, + headers, + } +} + +#[rocket::async_test] +async fn test_swagger_ui_route() { + test_request("get", "/swagger-ui/", None, Status::SeeOther, None).await; +} + +#[rocket::async_test] +async fn test_rapidoc_route() { + test_request("get", "/rapidoc/", None, Status::SeeOther, None).await; +} + +#[rocket::async_test] +async fn test_non_existent_route() { + test_request("get", "/non-existent", None, Status::NotFound, None).await; +} diff --git a/crates/server/tests/test_web/routes/auth.rs b/crates/server/tests/test_web/routes/auth.rs new file mode 100644 index 00000000..8aca1bf4 --- /dev/null +++ b/crates/server/tests/test_web/routes/auth.rs @@ -0,0 +1,49 @@ +// lib imports +use rocket::http::Status; +use rstest::rstest; +use serde_json::json; +use serial_test::serial; + +// test imports +use crate::fixtures; +use crate::test_web::test_request; + +#[rstest] +#[serial(db)] +#[tokio::test] +#[case::login_success("admin", "password123", Status::Ok)] +#[case::login_wrong_password("admin", "wrong", Status::Unauthorized)] +#[case::login_non_existent_user("nonexistent", "wrong", Status::Unauthorized)] +async fn test_login( + #[future] + #[from(fixtures::db_fixture)] + #[with(true)] + db_future: fixtures::TestDb, + #[case] username: &str, + #[case] password: &str, + #[case] expected_status: Status, +) { + let db = db_future.await; + + // Test login + let response = test_request( + "post", + "/login", + Some(json!({ + "username": username, + "password": password, + })), + expected_status, + Some(&db.client), + ) + .await; + + if expected_status == Status::Ok { + assert!(response.body.contains("token")); + } +} + +#[rocket::async_test] +async fn test_logout_route() { + test_request("get", "/logout", None, Status::Ok, None).await; +} diff --git a/tests/web/routes/common.rs b/crates/server/tests/test_web/routes/common.rs similarity index 54% rename from tests/web/routes/common.rs rename to crates/server/tests/test_web/routes/common.rs index 47c712f2..10fc6be9 100644 --- a/tests/web/routes/common.rs +++ b/crates/server/tests/test_web/routes/common.rs @@ -1,10 +1,10 @@ -use crate::web::test_route; +use crate::test_web::test_request; use rocket::http::Status; #[rocket::async_test] async fn test_root_route() { - let response = test_route("/", Status::Ok).await; + let response = test_request("get", "/", None, Status::Ok, None).await; assert_eq!(response.body, "Welcome to Koko!"); } diff --git a/tests/web/routes/dependencies.rs b/crates/server/tests/test_web/routes/dependencies.rs similarity index 88% rename from tests/web/routes/dependencies.rs rename to crates/server/tests/test_web/routes/dependencies.rs index 8b384b77..5df86630 100644 --- a/tests/web/routes/dependencies.rs +++ b/crates/server/tests/test_web/routes/dependencies.rs @@ -1,11 +1,11 @@ -use crate::web::test_route; +use crate::test_web::test_request; use rocket::http::Status; use rocket::serde::json::{serde_json, Value}; #[rocket::async_test] async fn test_get_dependencies_route() { - let response = test_route("/dependencies", Status::Ok).await; + let response = test_request("get", "/dependencies", None, Status::Ok, None).await; // ensure response is a json list of dictionaries, and each dictionary has the keys name, version, and license let body = response.body; diff --git a/tests/web/routes/mod.rs b/crates/server/tests/test_web/routes/mod.rs similarity index 80% rename from tests/web/routes/mod.rs rename to crates/server/tests/test_web/routes/mod.rs index 12dd1e86..caed5d83 100644 --- a/tests/web/routes/mod.rs +++ b/crates/server/tests/test_web/routes/mod.rs @@ -1,3 +1,4 @@ mod auth; mod common; mod dependencies; +mod user; diff --git a/crates/server/tests/test_web/routes/user.rs b/crates/server/tests/test_web/routes/user.rs new file mode 100644 index 00000000..ee8fc074 --- /dev/null +++ b/crates/server/tests/test_web/routes/user.rs @@ -0,0 +1,54 @@ +// lib imports +use rocket::http::Status; +use rstest::rstest; +use serde_json::json; +use serial_test::serial; + +// test imports +use crate::fixtures; +use crate::test_web::test_request; + +#[rstest] +#[serial(db)] +#[tokio::test] +async fn test_create_first_user( + #[future] + #[from(fixtures::db_fixture)] + #[with(true)] + db_future: fixtures::TestDb +) { + db_future.await; + + // nothing to do, the fixture handles creating the first user +} + +#[rstest] +#[serial(db)] +#[tokio::test] +#[case::create_user_requires_auth("testuser", "password123", false, Status::Unauthorized)] +async fn test_create_subsequent_user_requires_auth( + #[future] + #[from(fixtures::db_fixture)] + #[with(true)] + db_future: fixtures::TestDb, + #[case] username: &str, + #[case] password: &str, + #[case] admin: bool, + #[case] expected_status: Status, +) { + let db = db_future.await; + + // Try to create second user without auth + test_request( + "post", + "/create_user", + Some(json!({ + "username": username, + "password": password, + "admin": admin + })), + expected_status, + Some(&db.client), + ) + .await; +} diff --git a/docs/README.md b/docs/README.md index e8e53ab2..b9bb6aa5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,46 +5,22 @@ ## ℹ️ About @@ -56,11 +32,40 @@ If you are interested in this project, please leave a star and watch the reposit If you would like to contribute, please reach out on our [discord](https://app.lizardbyte.dev/discord) server. +## ⚙️ Configuration + +Koko uses a YAML configuration file to set up the server. + +The file must be named `settings.yml` and be placed in the following location, depending on your OS. + +| OS | Location | +|---------|------------------------------------------| +| Linux | `$XDG_CONFIG_HOME/Koko` | +| macOS | `$HOME/Library/Application Support/Koko` | +| Windows | `%LOCALAPPDATA%\Koko` | + +Only the non default values need to be set in the configuration file. +An example with all the default values is shown below. + +```yml +--- +general: + data_dir: 'data' + +server: + use_https: true + address: '127.0.0.1' + port: 9191 + cert_path: 'cert.pem' + key_path: 'key.pem' + use_custom_certs: false +``` + ## 📝 TODO This list is not all-inclusive, and just meant to be a very high level for the initial design. - [ ] Branding - - [ ] Koko logo + - [x] Koko logo - [ ] Koko banner - [ ] Tray icons for different states/activity - [ ] Publishing (enabling readme badges as required) @@ -74,15 +79,16 @@ This list is not all-inclusive, and just meant to be a very high level for the i - [x] Unit Testing - [ ] doc tests - [x] Coverage -- [ ] Settings/Config +- [x] Settings/Config - [ ] Notification System - [ ] System Notifications - [ ] Discord - [ ] Webhooks -- [ ] Database +- [x] Database - [ ] Backend - - [ ] Authentication + - [x] Authentication - [ ] API + - [x] Certs/SSL - [ ] Media Scanner - [ ] Media Player - [x] Legal/Licensing info on dependencies diff --git a/rust-toolchain.toml b/rust-toolchain.toml index cf6d0f55..b8889a3b 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.86.0" +channel = "1.87.0" diff --git a/src/web/mod.rs b/src/web/mod.rs deleted file mode 100644 index db637a2f..00000000 --- a/src/web/mod.rs +++ /dev/null @@ -1,48 +0,0 @@ -#![doc = "Web server utilities for the application."] - -// modules -mod routes; - -// lib imports -use rocket_okapi::settings::UrlObject; -use rocket_okapi::{rapidoc::*, swagger_ui::*}; - -/// Build the web server. -pub fn rocket() -> rocket::Rocket { - rocket::build() - .mount("/", routes::all_routes()) - .mount( - "/swagger-ui/", - make_swagger_ui(&SwaggerUIConfig { - url: "../openapi.json".to_owned(), - ..Default::default() - }), - ) - .mount( - "/rapidoc/", - make_rapidoc(&RapiDocConfig { - general: GeneralConfig { - spec_urls: vec![UrlObject::new( - "General", - "../openapi.json", - )], - ..Default::default() - }, - hide_show: HideShowConfig { - allow_spec_url_load: false, - allow_spec_file_load: false, - ..Default::default() - }, - ..Default::default() - }), - ) -} - -/// Launch the web server. -#[rocket::main] -pub async fn launch() { - rocket() - .launch() - .await - .expect("Failed to launch web server"); -} diff --git a/src/web/routes/auth.rs b/src/web/routes/auth.rs deleted file mode 100644 index 0d788714..00000000 --- a/src/web/routes/auth.rs +++ /dev/null @@ -1,17 +0,0 @@ -#![doc = "Routes for the web server."] - -// lib imports -use rocket::get; -use rocket_okapi::openapi; - -#[openapi(tag = "Auth")] -#[get("/login")] -pub fn login() -> &'static str { - "Login Page" -} - -#[openapi(tag = "Auth")] -#[get("/logout")] -pub fn logout() -> &'static str { - "Logout Page" -} diff --git a/tests/main.rs b/tests/main.rs deleted file mode 100644 index 89fb08b1..00000000 --- a/tests/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod dependencies; -pub mod test_tray; -pub mod web; diff --git a/tests/web/mod.rs b/tests/web/mod.rs deleted file mode 100644 index 8a614f88..00000000 --- a/tests/web/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -mod routes; - -use koko::web; -use rocket::http::{Header, Status}; -use rocket::local::asynchronous::Client; - -pub struct TestResponse { - pub status: Status, - pub body: String, - pub headers: Vec>, -} - -pub async fn test_route( - path: &'static str, - expected_status: Status, -) -> TestResponse { - let rocket = web::rocket(); - let client = Client::tracked(rocket) - .await - .expect("Failed to launch web server"); - - let response = client.get(path).dispatch().await; - - assert_eq!(response.status(), expected_status); - - let status = response.status(); - - // Extract headers before moving `response` - let headers: Vec> = response - .headers() - .iter() - .map(|h| Header::new(h.name().to_string(), h.value().to_string())) // Convert to owned Header - .collect(); - - let body = response.into_string().await.unwrap_or_default(); // Move response - - TestResponse { - status, - body, - headers, - } -} - -#[rocket::async_test] -async fn test_swagger_ui_route() { - test_route("/swagger-ui/", Status::SeeOther).await; -} - -#[rocket::async_test] -async fn test_rapidoc_route() { - test_route("/rapidoc/", Status::SeeOther).await; -} - -#[rocket::async_test] -async fn test_non_existent_route() { - test_route("/non-existent", Status::NotFound).await; -} diff --git a/tests/web/routes/auth.rs b/tests/web/routes/auth.rs deleted file mode 100644 index 7eaa0a9a..00000000 --- a/tests/web/routes/auth.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::web::test_route; - -use rocket::http::Status; - -#[rocket::async_test] -async fn test_login_route() { - test_route("/login", Status::Ok).await; -} - -#[rocket::async_test] -async fn test_logout_route() { - test_route("/logout", Status::Ok).await; -}