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;
-}