From b6fe2fba37fbc17cca28ba0827c413f3c240c788 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 22 Mar 2025 22:17:47 -0400 Subject: [PATCH] add: postgres support chore: restructure --- Cargo.lock | 803 +++++++++++++++++- Cargo.toml | 35 +- README.md | 4 - crates/app/Cargo.toml | 26 + crates/app/LICENSE | 1 + crates/app/src/assets.rs | 48 ++ crates/app/src/avif.rs | 82 ++ crates/app/src/macros.rs | 54 ++ crates/app/src/main.rs | 68 ++ {src => crates/app/src}/public/css/style.css | 357 ++++++++ .../app/src}/public/html/auth/base.html | 2 +- .../app/src}/public/html/auth/login.html | 4 +- crates/app/src/public/html/auth/register.html | 62 ++ crates/app/src/public/html/macros.html | 45 + .../app/src/public/html/misc}/index.html | 19 +- crates/app/src/public/html/root.html | 73 ++ .../app/src/public/images/default-avatar.svg | 3 + .../app/src/public/images/default-banner.svg | 3 + crates/app/src/public/js/atto.js | 618 ++++++++++++++ crates/app/src/public/js/loader.js | 205 +++++ {src => crates/app/src}/routes/api/mod.rs | 0 crates/app/src/routes/api/v1/auth/images.rs | 102 +++ crates/app/src/routes/api/v1/auth/mod.rs | 127 +++ crates/app/src/routes/api/v1/mod.rs | 28 + {src => crates/app/src}/routes/assets.rs | 15 +- crates/app/src/routes/mod.rs | 25 + crates/app/src/routes/pages/auth.rs | 39 + crates/app/src/routes/pages/misc.rs | 15 + crates/app/src/routes/pages/mod.rs | 13 + crates/tetratto_core/Cargo.toml | 28 + crates/tetratto_core/LICENSE | 1 + {src => crates/tetratto_core/src}/config.rs | 72 +- crates/tetratto_core/src/database/auth.rs | 196 +++++ .../src/database/drivers/common.rs | 1 + .../tetratto_core/src/database/drivers/mod.rs | 7 + .../src/database/drivers/postgres.rs | 95 +++ .../src/database/drivers/sql/create_users.sql | 9 + .../src/database/drivers/sqlite.rs | 46 + crates/tetratto_core/src/database/mod.rs | 10 + crates/tetratto_core/src/lib.rs | 5 + .../tetratto_core/src/model/auth.rs | 55 +- crates/tetratto_core/src/model/mod.rs | 56 ++ example/.gitignore | 12 +- example/html/.gitkeep | 0 example/tetratto.toml | 1 - justfile | 7 + src/data/assets.rs | 14 - src/data/manager.rs | 187 ---- src/data/mod.rs | 5 - src/macros.rs | 40 - src/main.rs | 44 - src/public/html/auth/register.html | 33 - src/public/html/root.html | 17 - src/public/js/atto.js | 31 - src/routes/api/v1/auth.rs | 41 - src/routes/api/v1/mod.rs | 32 - src/routes/mod.rs | 69 -- tetratto.toml | 16 + 58 files changed, 3403 insertions(+), 603 deletions(-) create mode 100644 crates/app/Cargo.toml create mode 120000 crates/app/LICENSE create mode 100644 crates/app/src/assets.rs create mode 100644 crates/app/src/avif.rs create mode 100644 crates/app/src/macros.rs create mode 100644 crates/app/src/main.rs rename {src => crates/app/src}/public/css/style.css (65%) rename {src => crates/app/src}/public/html/auth/base.html (86%) rename {src => crates/app/src}/public/html/auth/login.html (88%) create mode 100644 crates/app/src/public/html/auth/register.html create mode 100644 crates/app/src/public/html/macros.html rename {example/html => crates/app/src/public/html/misc}/index.html (52%) create mode 100644 crates/app/src/public/html/root.html create mode 100644 crates/app/src/public/images/default-avatar.svg create mode 100644 crates/app/src/public/images/default-banner.svg create mode 100644 crates/app/src/public/js/atto.js create mode 100644 crates/app/src/public/js/loader.js rename {src => crates/app/src}/routes/api/mod.rs (100%) create mode 100644 crates/app/src/routes/api/v1/auth/images.rs create mode 100644 crates/app/src/routes/api/v1/auth/mod.rs create mode 100644 crates/app/src/routes/api/v1/mod.rs rename {src => crates/app/src}/routes/assets.rs (50%) create mode 100644 crates/app/src/routes/mod.rs create mode 100644 crates/app/src/routes/pages/auth.rs create mode 100644 crates/app/src/routes/pages/misc.rs create mode 100644 crates/app/src/routes/pages/mod.rs create mode 100644 crates/tetratto_core/Cargo.toml create mode 120000 crates/tetratto_core/LICENSE rename {src => crates/tetratto_core/src}/config.rs (66%) create mode 100644 crates/tetratto_core/src/database/auth.rs create mode 100644 crates/tetratto_core/src/database/drivers/common.rs create mode 100644 crates/tetratto_core/src/database/drivers/mod.rs create mode 100644 crates/tetratto_core/src/database/drivers/postgres.rs create mode 100644 crates/tetratto_core/src/database/drivers/sql/create_users.sql create mode 100644 crates/tetratto_core/src/database/drivers/sqlite.rs create mode 100644 crates/tetratto_core/src/database/mod.rs create mode 100644 crates/tetratto_core/src/lib.rs rename src/data/model.rs => crates/tetratto_core/src/model/auth.rs (51%) create mode 100644 crates/tetratto_core/src/model/mod.rs create mode 100644 example/html/.gitkeep create mode 100644 justfile delete mode 100644 src/data/assets.rs delete mode 100644 src/data/manager.rs delete mode 100644 src/data/mod.rs delete mode 100644 src/macros.rs delete mode 100644 src/main.rs delete mode 100644 src/public/html/auth/register.html delete mode 100644 src/public/html/root.html delete mode 100644 src/public/js/atto.js delete mode 100644 src/routes/api/v1/auth.rs delete mode 100644 src/routes/api/v1/mod.rs delete mode 100644 src/routes/mod.rs create mode 100644 tetratto.toml diff --git a/Cargo.lock b/Cargo.lock index f740a0a..f306508 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" + [[package]] name = "ammonia" version = "4.0.0" @@ -104,12 +110,75 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "av1-grain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" +dependencies = [ + "arrayvec", +] + [[package]] name = "axum" version = "0.8.1" @@ -175,11 +244,13 @@ dependencies = [ "axum-core", "bytes", "cookie", + "fastrand", "futures-util", "http", "http-body", "http-body-util", "mime", + "multer", "pin-project-lite", "serde", "tower", @@ -219,6 +290,28 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bb8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d8b8e1a22743d9241575c6ba822cf9c8fef34771c86ab7e477a4fbfd254e5" +dependencies = [ + "futures-util", + "parking_lot", + "tokio", +] + +[[package]] +name = "bb8-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e570e6557cd0f88d28d32afa76644873271a70dc22656df565b2021c4036aa9c" +dependencies = [ + "bb8", + "tokio", + "tokio-postgres", +] + [[package]] name = "bincode" version = "1.3.3" @@ -243,6 +336,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + [[package]] name = "bitflags" version = "1.3.2" @@ -255,6 +354,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + [[package]] name = "block-buffer" version = "0.10.4" @@ -299,12 +404,36 @@ dependencies = [ "serde", ] +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + [[package]] name = "bumpalo" version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +[[package]] +name = "bytemuck" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.10.1" @@ -326,9 +455,21 @@ version = "1.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" dependencies = [ + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -412,6 +553,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.3" @@ -497,6 +644,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + [[package]] name = "crypto-common" version = "0.1.6" @@ -565,6 +718,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -578,6 +732,21 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "entities" version = "1.0.1" @@ -600,6 +769,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -622,6 +812,21 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "flate2" version = "1.1.0" @@ -670,6 +875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -678,6 +884,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -697,9 +914,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -735,6 +955,16 @@ dependencies = [ "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.31.1" @@ -765,6 +995,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "half" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db2ff139bba50379da6aa0766b52fdcb62cb5b263009b09ed58ba604e14bbd1" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -795,6 +1035,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.27.0" @@ -1089,6 +1338,45 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" + [[package]] name = "indexmap" version = "2.8.0" @@ -1099,18 +1387,53 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + [[package]] name = "js-sys" version = "0.3.77" @@ -1127,12 +1450,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + [[package]] name = "libc" version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +[[package]] +name = "libfuzzer-sys" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.11" @@ -1183,6 +1522,15 @@ version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "mac" version = "0.1.1" @@ -1224,6 +1572,26 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1246,6 +1614,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.5" @@ -1253,6 +1627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1266,12 +1641,45 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1298,6 +1706,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1307,6 +1726,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1391,6 +1821,12 @@ dependencies = [ "regex", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathbufd" version = "0.1.4" @@ -1520,6 +1956,48 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ff0abab4a9b844b93ef7b81f1efc0a366062aaef2cd702c76256b5dc075c54" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.9.0", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1560,6 +2038,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.32.0" @@ -1663,6 +2175,76 @@ dependencies = [ "getrandom 0.3.2", ] +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.5", + "rand_chacha 0.3.1", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2413fd96bd0ea5cdeeb37eaf446a22e6ed7b981d792828721e74ded1980a45c6" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.10" @@ -1716,6 +2298,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rgb" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" + [[package]] name = "rusqlite" version = "0.34.0" @@ -1723,7 +2311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" dependencies = [ "bitflags 2.9.0", - "fallible-iterator", + "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -1871,12 +2459,36 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "siphasher" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "slug" version = "0.1.6" @@ -1903,6 +2515,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1934,12 +2552,29 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.100" @@ -1991,6 +2626,25 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tendril" version = "0.4.3" @@ -2040,6 +2694,28 @@ version = "0.1.0" dependencies = [ "axum", "axum-extra", + "image", + "mime_guess", + "pathbufd", + "rainbeam-shared", + "serde", + "serde_json", + "tera", + "tetratto_core", + "tokio", + "toml", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tetratto_core" +version = "0.1.0" +dependencies = [ + "axum", + "axum-extra", + "bb8-postgres", "pathbufd", "rainbeam-shared", "rusqlite", @@ -2047,6 +2723,7 @@ dependencies = [ "serde_json", "tera", "tokio", + "tokio-postgres", "toml", "tower-http", "tracing", @@ -2103,6 +2780,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.40" @@ -2169,6 +2857,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "socket2", "tokio-macros", @@ -2186,6 +2875,32 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-postgres" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.0", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-util" version = "0.7.14" @@ -2422,6 +3137,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2437,6 +3158,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + [[package]] name = "unicode_categories" version = "0.1.1" @@ -2487,6 +3214,17 @@ dependencies = [ "getrandom 0.3.2", ] +[[package]] +name = "v_frame" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2499,6 +3237,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + [[package]] name = "version_check" version = "0.9.5" @@ -2530,6 +3274,12 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -2588,6 +3338,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + +[[package]] +name = "whoami" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2847,3 +3624,27 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 2d3a620..d87eb91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,18 @@ -[package] -name = "tetratto" -version = "0.1.0" -edition = "2024" +[workspace] +resolver = "3" +members = ["crates/app", "crates/tetratto_core"] +package.authors = ["trisuaso"] +package.repository = "https://github.com/trisuaso/tetratto" +package.license = "AGPL-3.0-or-later" -[dependencies] -pathbufd = "0.1.4" -rusqlite = "0.34.0" -serde = { version = "1.0.219", features = ["derive"] } -tera = "1.20.0" -toml = "0.8.20" -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tower-http = { version = "0.6.2", features = ["trace", "fs"] } -axum = { version = "0.8.1", features = ["macros"] } -tokio = { version = "1.44.0", features = ["macros", "rt-multi-thread"] } -rainbeam-shared = "1.0.1" -serde_json = "1.0.140" -axum-extra = { version = "0.10.0", features = ["cookie"] } +[profile.dev] +incremental = true + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +# panic = "abort" +panic = "unwind" +strip = true +incremental = true diff --git a/README.md b/README.md index 19a6cf5..ee966b4 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,11 @@ This is the year of the personal website. Tetratto (`4 * 10^-18`) is a _super_ simple **dynamic** site server which takes in a conglomeration of HTML files (which are actually Jinja templates) and static files like CSS and JS, then serves them! -You _might_ by wondering: "why dynamic and not just generate a static site then?" Well the answer is simple! I needed something to manage my server remotely through my browser, and most things were just overly complicated for this simple feat. - ## Features - Templated HTML files (`html/` directory) - Markdown posts (`posts/` directory, served with `html/post.html` template) - Super simple SQLite database for authentication (and other stuff) -- Web terminal for managing your server - - Must be enabled in `tetratto.toml` in project root ## Usage diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml new file mode 100644 index 0000000..f11633f --- /dev/null +++ b/crates/app/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tetratto" +version = "0.1.0" +edition = "2024" + +[features] +postgres = ["tetratto_core/postgres"] +sqlite = ["tetratto_core/sqlite"] +default = ["sqlite"] + +[dependencies] +pathbufd = "0.1.4" +serde = { version = "1.0.219", features = ["derive"] } +tera = "1.20.0" +toml = "0.8.20" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +tower-http = { version = "0.6.2", features = ["trace", "fs"] } +axum = { version = "0.8.1", features = ["macros"] } +tokio = { version = "1.44.0", features = ["macros", "rt-multi-thread"] } +rainbeam-shared = "1.0.1" +serde_json = "1.0.140" +axum-extra = { version = "0.10.0", features = ["cookie", "multipart"] } +tetratto_core = { path = "../tetratto_core", default-features = false } +image = "0.25.5" +mime_guess = "2.0.5" diff --git a/crates/app/LICENSE b/crates/app/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/app/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs new file mode 100644 index 0000000..dabdfb1 --- /dev/null +++ b/crates/app/src/assets.rs @@ -0,0 +1,48 @@ +use pathbufd::PathBufD; +use tera::Context; +use tetratto_core::{config::Config, model::auth::User}; + +use crate::write_template; + +// images +pub const DEFAULT_AVATAR: &str = include_str!("./public/images/default-avatar.svg"); +pub const DEFAULT_BANNER: &str = include_str!("./public/images/default-banner.svg"); + +// css +pub const STYLE_CSS: &str = include_str!("./public/css/style.css"); + +// js +pub const ATTO_JS: &str = include_str!("./public/js/atto.js"); +pub const LOADER_JS: &str = include_str!("./public/js/loader.js"); + +// html +pub const ROOT: &str = include_str!("./public/html/root.html"); +pub const MACROS: &str = include_str!("./public/html/macros.html"); + +pub const MISC_INDEX: &str = include_str!("./public/html/misc/index.html"); + +pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.html"); +pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.html"); +pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html"); + +// ... + +/// Set up public directories. +pub(crate) fn write_assets(html_path: &PathBufD) { + write_template!(html_path->"root.html"(crate::assets::ROOT)); + write_template!(html_path->"macros.html"(crate::assets::MACROS)); + + write_template!(html_path->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc"); + + write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth"); + write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN)); + write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER)); +} + +/// Create the initial template context. +pub(crate) fn initial_context(config: &Config, user: &Option) -> Context { + let mut ctx = Context::new(); + ctx.insert("config", &config); + ctx.insert("user", &user); + ctx +} diff --git a/crates/app/src/avif.rs b/crates/app/src/avif.rs new file mode 100644 index 0000000..4e82223 --- /dev/null +++ b/crates/app/src/avif.rs @@ -0,0 +1,82 @@ +use axum::{ + body::Bytes, + extract::{FromRequest, Request}, + http::{StatusCode, header::CONTENT_TYPE}, +}; +use axum_extra::extract::Multipart; +use std::{fs::File, io::BufWriter}; + +/// An image extractor accepting: +/// * `multipart/form-data` +/// * `image/png` +/// * `image/jpeg` +/// * `image/avif` +/// * `image/webp` +pub struct Image(pub Bytes); + +impl FromRequest for Image +where + Bytes: FromRequest, + S: Send + Sync, +{ + type Rejection = StatusCode; + + async fn from_request(req: Request, state: &S) -> Result { + let Some(content_type) = req.headers().get(CONTENT_TYPE) else { + return Err(StatusCode::BAD_REQUEST); + }; + + let body = if content_type + .to_str() + .unwrap() + .starts_with("multipart/form-data") + { + let mut multipart = Multipart::from_request(req, state) + .await + .map_err(|_| StatusCode::BAD_REQUEST)?; + + let Ok(Some(field)) = multipart.next_field().await else { + return Err(StatusCode::BAD_REQUEST); + }; + + field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)? + } else if (content_type == "image/avif") + | (content_type == "image/jpeg") + | (content_type == "image/png") + | (content_type == "image/webp") + { + Bytes::from_request(req, state) + .await + .map_err(|_| StatusCode::BAD_REQUEST)? + } else { + return Err(StatusCode::BAD_REQUEST); + }; + + Ok(Self(body)) + } +} + +/// Create an AVIF buffer given an input of `bytes` +pub fn save_avif_buffer(path: &str, bytes: Vec) -> std::io::Result<()> { + let pre_img_buffer = match image::load_from_memory(&bytes) { + Ok(i) => i, + Err(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Image failed", + )); + } + }; + + let file = File::create(path)?; + let mut writer = BufWriter::new(file); + + if let Err(_) = pre_img_buffer.write_to(&mut writer, image::ImageFormat::Avif) { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Image conversion failed", + )); + }; + + Ok(()) +} diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs new file mode 100644 index 0000000..64bb21b --- /dev/null +++ b/crates/app/src/macros.rs @@ -0,0 +1,54 @@ +#[macro_export] +macro_rules! write_template { + ($html_path:ident->$path:literal($as:expr)) => { + std::fs::write($html_path.join($path), $as).unwrap(); + }; + + ($html_path:ident->$path:literal($as:expr) -d $dir_path:literal) => { + let dir = $html_path.join($dir_path); + if !std::fs::exists(&dir).unwrap() { + std::fs::create_dir(dir).unwrap(); + } + + std::fs::write($html_path.join($path), $as).unwrap(); + }; +} + +#[macro_export] +macro_rules! create_dir_if_not_exists { + ($dir_path:expr) => { + if !std::fs::exists(&$dir_path).unwrap() { + std::fs::create_dir($dir_path).unwrap(); + } + }; +} + +#[macro_export] +macro_rules! get_user_from_token { + (($jar:ident, $db:expr) ) => {{ + if let Some(token) = $jar.get("__Secure-atto-token") { + match $db + .get_user_by_token(&rainbeam_shared::hash::hash( + token.to_string().replace("__Secure-atto-token=", ""), + )) + .await + { + Ok(ua) => Some(ua), + Err(_) => None, + } + } else { + None + } + }}; + + ($jar:ident, $db:ident) => {{ + if let Some(token) = $jar.get("__Secure-Atto-Token") { + match $db.get_user_by_token(token) { + Ok(ua) => ua, + Err(_) => return axum::response::Html(crate::data::assets::REDIRECT_TO_AUTH), + } + } else { + None + } + }}; +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs new file mode 100644 index 0000000..ad74e5c --- /dev/null +++ b/crates/app/src/main.rs @@ -0,0 +1,68 @@ +mod assets; +mod avif; +mod macros; +mod routes; + +use assets::write_assets; +pub use tetratto_core::*; + +use axum::{Extension, Router}; +use pathbufd::PathBufD; +use tera::Tera; +use tower_http::trace::{self, TraceLayer}; +use tracing::{Level, info}; + +use std::sync::Arc; +use tokio::sync::RwLock; + +pub(crate) type State = Arc>; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_target(false) + .compact() + .init(); + + let config = config::Config::get_config(); + + // ... + create_dir_if_not_exists!(&config.dirs.media); + let images_path = + PathBufD::current().extend(&[config.dirs.media.clone(), "images".to_string()]); + create_dir_if_not_exists!(&images_path); + create_dir_if_not_exists!( + &PathBufD::current().extend(&[config.dirs.media.clone(), "avatars".to_string()]) + ); + create_dir_if_not_exists!( + &PathBufD::current().extend(&[config.dirs.media.clone(), "banners".to_string()]) + ); + + write_template!(images_path->"default-avatar.svg"(assets::DEFAULT_AVATAR)); + write_template!(images_path->"default-banner.svg"(assets::DEFAULT_BANNER)); + + // create templates + let html_path = PathBufD::current().join(&config.dirs.templates); + write_assets(&html_path); + + // ... + let app = Router::new() + .merge(routes::routes(&config)) + .layer(Extension(Arc::new(RwLock::new(( + DataManager::new(config.clone()).await.unwrap(), + Tera::new(&format!("{html_path}/**/*")).unwrap(), + ))))) + .layer( + TraceLayer::new_for_http() + .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) + .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), + ); + + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) + .await + .unwrap(); + + info!("🐐 tetratto."); + info!("listening on http://0.0.0.0:{}", config.port); + axum::serve(listener, app).await.unwrap(); +} diff --git a/src/public/css/style.css b/crates/app/src/public/css/style.css similarity index 65% rename from src/public/css/style.css rename to crates/app/src/public/css/style.css index b08cfa6..ce7b3d3 100644 --- a/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -132,6 +132,11 @@ footer { } /* typo */ +.icon { + color: inherit; + stroke: currentColor; +} + hr { border-top: 1px var(--color-super-lowered); } @@ -566,6 +571,358 @@ nav .button:not(.title):not(.active):hover { } } +/* dialog */ +dialog { + padding: 0; + position: fixed; + bottom: 0; + top: 0; + display: flex; + background: var(--color-surface); + border: solid 1px var(--color-super-lowered) !important; + border-radius: var(--radius); + max-width: 100%; + border-style: none; + display: none; + margin: auto; + color: var(--color-text); + animation: popin ease-in-out 1 0.1s forwards running; +} + +dialog .inner { + padding: 1rem; + width: 25rem; + max-width: 100%; +} + +dialog .inner hr:not(.flipped):last-of-type { + /* options separator */ + margin-top: 2rem; +} + +dialog .inner hr.flipped:last-of-type { + margin-bottom: 2rem; +} + +dialog[open] { + display: block; +} + +/* dropdown */ +.dropdown { + position: relative; +} + +.dropdown .inner { + --horizontal-padding: 1.25rem; + display: none; + position: absolute; + background: var(--color-raised); + z-index: 2; + border-radius: var(--radius); + top: calc(100% + 5px); + right: 0; + width: max-content; + min-width: 10rem; + max-width: 100dvw; + max-height: 80dvh; + overflow: auto; + padding: 0.5rem 0; + box-shadow: 0 0 8px 2px var(--color-shadow); +} + +.dropdown .inner.top { + top: unset; + bottom: calc(100% + 5px); +} + +.dropdown .inner.left { + left: 0; + right: unset; +} + +.dropdown .inner.open { + display: flex; + flex-direction: column; +} + +.dropdown .inner .title { + padding: 0.25rem var(--horizontal-padding); + font-size: 13px; + opacity: 50%; + color: var(--color-text-raised); + text-align: left; +} + +.dropdown .inner b.title { + font-weight: 600; +} + +.dropdown .inner .title:not(:first-of-type) { + padding-top: 0.5rem; +} + +.dropdown .inner a, +.dropdown .inner button { + width: 100%; + padding: 0.25rem var(--horizontal-padding); + /* transition: + background 0.1s, + transform 0.15s; */ + text-decoration: none; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + color: var(--color-text-raised); + box-shadow: none !important; + background: transparent; + border-radius: 0 !important; + font-size: 13px; + min-height: 30px !important; + height: 30px !important; + font-weight: 500 !important; + position: relative; + opacity: 100% !important; + + & svg { + width: 16px; + height: 16px; + aspect-ratio: 1 / 1; + } +} + +.dropdown .inner a:hover, +.dropdown .inner button:hover { + background-color: var(--color-lowered); +} + +.dropdown .inner a:focus, +.dropdown .inner button:focus { + outline: none; +} + +.dropdown:not(nav *):has(.inner.open) button:not(.inner button) { + color: var(--color-text) !important; + background: var(--color-lowered) !important; +} + +.dropdown:not(nav *):has(.inner.open) button.primary:not(.inner button) { + color: var(--color-text-primary) !important; + background: var(--color-primary-lowered) !important; +} + +.dropdown button .icon { + transition: transform 0.15s; +} + +.dropdown:has(.inner.open) .dropdown-arrow { + transform: rotateZ(180deg); +} + +/* toasts */ +#toast_zone { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + position: fixed; + bottom: 0.5rem; + right: 0.5rem; + z-index: 6880; + width: calc(100% - 1rem); + pointer-events: none; +} + +.toast { + box-shadow: 0 0 8px var(--color-shadow); + width: max-content; + border-radius: var(--radius); + padding: 0.75rem 1rem; + animation: popin ease-in-out 1 0.15s running; + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.toast.success { + background: rgb(41, 81, 56); + color: rgb(134, 239, 172); +} + +.toast.error { + background: rgb(81, 41, 41); + color: rgb(239, 134, 134); +} + +.toast .timer { + font-family: monospace; + display: flex; + align-items: center; + justify-content: center; + min-width: max-content; +} + +@keyframes popin { + from { + opacity: 0%; + transform: scale(0); + } + + to { + opacity: 100%; + transform: scale(1); + } +} + +@keyframes fadeout { + from { + opacity: 100%; + transform: scale(1); + } + + to { + opacity: 0%; + transform: scale(0); + } +} + +/* tag */ +.tag { + font-size: 0.825rem; + font-family: monospace; + opacity: 75%; + color: inherit; +} + +/* hook:long */ +.hook\:long\.hidden_text { + position: relative; + cursor: pointer; +} + +.hook\:long\.hidden_text::before { + content: ""; + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; + background: linear-gradient(transparent 50%, var(--color-raised)); +} + +.hook\:long\.hidden_text\+lowered::before { + background: linear-gradient(transparent 50%, var(--color-lowered)); +} + +.hook\:long\.hidden_text::after { + position: absolute; + content: "Show full content"; + border-radius: calc(var(--radius) * 4); + padding: 0.25rem 0.75rem; + background: var(--color-primary); + font-weight: 600; + bottom: 20px; + opacity: 0%; + left: calc(50% - (180px / 2)); + width: 156px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + transform: scale(0); + transition: + transform 0.15s, + opacity 0.25s; + box-shadow: 0 8px 16px var(--color-shadow); + color: var(--color-text-primary); +} + +.hook\:long\.hidden_text:hover::after { + transform: scale(1); + opacity: 100%; +} + +@media screen and (max-width: 900px) { + .hook\:long\.hidden_text::after { + transform: scale(1); + opacity: 100%; + } +} + +/* turbo */ +.turbo-progress-bar { + background: var(--color-primary); +} + +/* details */ +details summary { + display: flex; + align-items: center; + gap: 0.25rem; + transition: background 0.15s; + cursor: pointer; + width: max-content; + padding: 0.25rem 0.75rem; + border-radius: var(--radius); + background: var(--color-lowered); +} + +details summary:hover { + background: var(--color-super-lowered); +} + +details summary::-webkit-details-marker { + display: none; +} + +details[open] summary { + background: hsla(var(--color-primary-hsl), 25%); + margin-bottom: 0.25rem; +} + +details .card { + background: var(--color-super-raised); +} + +details.accordion { + --background: var(--color-surface); + width: 100%; +} + +details.accordion summary { + background: var(--background); + border: solid 1px var(--color-super-lowered); + border-radius: var(--radius); + padding: 0.75rem 1rem; + margin: 0; + width: 100%; + user-select: none; +} + +details.accordion summary .icon { + transition: transform 0.15s; +} + +details.accordion[open] summary .icon { + transform: rotateZ(180deg); +} + +details.accordion[open] summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +details.accordion .inner { + background: var(--background); + padding: 0.75rem 1rem; + border-radius: var(--radius); + border-top-left-radius: 0; + border-top-right-radius: 0; + border: solid 1px var(--color-super-lowered); + border-top: none; +} + /* utility */ .flex { display: flex; diff --git a/src/public/html/auth/base.html b/crates/app/src/public/html/auth/base.html similarity index 86% rename from src/public/html/auth/base.html rename to crates/app/src/public/html/auth/base.html index 28f39b0..40ebd28 100644 --- a/src/public/html/auth/base.html +++ b/crates/app/src/public/html/auth/base.html @@ -1,4 +1,4 @@ -{% extends "_atto/root.html" %} {% block body %} +{% extends "root.html" %} {% block body %}

{% block title %}{% endblock %}

diff --git a/src/public/html/auth/login.html b/crates/app/src/public/html/auth/login.html similarity index 88% rename from src/public/html/auth/login.html rename to crates/app/src/public/html/auth/login.html index 278a7c6..f35c90a 100644 --- a/src/public/html/auth/login.html +++ b/crates/app/src/public/html/auth/login.html @@ -1,4 +1,4 @@ -{% extends "_atto/auth/base.html" %} {% block head %} +{% extends "auth/base.html" %} {% block head %} 🐐 Login {% endblock %} {% block title %}Login{% endblock %} {% block content %}
@@ -28,6 +28,6 @@
{% endblock %} {% block footer %} Or, registerOr, register {% endblock %} diff --git a/crates/app/src/public/html/auth/register.html b/crates/app/src/public/html/auth/register.html new file mode 100644 index 0000000..e6c1607 --- /dev/null +++ b/crates/app/src/public/html/auth/register.html @@ -0,0 +1,62 @@ +{% extends "auth/base.html" %} {% block head %} +🐐 Register +{% endblock %} {% block title %}Register{% endblock %} {% block content %} +
+
+ + +
+ +
+ + +
+ + +
+ + +{% endblock %} {% block footer %} +Or, login +{% endblock %} diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html new file mode 100644 index 0000000..834fd66 --- /dev/null +++ b/crates/app/src/public/html/macros.html @@ -0,0 +1,45 @@ +{% macro nav(selected="", show_lhs=true) -%} + +{%- endmacro %} {% macro avatar(username, size="24px") -%} +@{{ username }} +{%- endmacro %} diff --git a/example/html/index.html b/crates/app/src/public/html/misc/index.html similarity index 52% rename from example/html/index.html rename to crates/app/src/public/html/misc/index.html index bd48e48..dd53adf 100644 --- a/example/html/index.html +++ b/crates/app/src/public/html/misc/index.html @@ -1,20 +1,5 @@ -{% extends "_atto/root.html" %} {% block body %} - +{% import "macros.html" as macros %} {% extends "root.html" %} {% block body %} +{{ macros::nav(selected="home") }}

Hello, world!

diff --git a/crates/app/src/public/html/root.html b/crates/app/src/public/html/root.html new file mode 100644 index 0000000..4c675cd --- /dev/null +++ b/crates/app/src/public/html/root.html @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + {% block head %}{% endblock %} + + + +
+ + {% block body %}{% endblock %} + + + + diff --git a/crates/app/src/public/images/default-avatar.svg b/crates/app/src/public/images/default-avatar.svg new file mode 100644 index 0000000..c53bc6f --- /dev/null +++ b/crates/app/src/public/images/default-avatar.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/app/src/public/images/default-banner.svg b/crates/app/src/public/images/default-banner.svg new file mode 100644 index 0000000..c53bc6f --- /dev/null +++ b/crates/app/src/public/images/default-banner.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js new file mode 100644 index 0000000..1a89521 --- /dev/null +++ b/crates/app/src/public/js/atto.js @@ -0,0 +1,618 @@ +console.log("🐐 tetratto - https://github.com/trisuaso/tetratto"); + +// theme preference +function media_theme_pref() { + document.documentElement.removeAttribute("class"); + + if ( + window.matchMedia("(prefers-color-scheme: dark)").matches && + !window.localStorage.getItem("tetratto:theme") + ) { + document.documentElement.classList.add("dark"); + // window.localStorage.setItem("theme", "dark"); + } else if ( + window.matchMedia("(prefers-color-scheme: light)").matches && + !window.localStorage.getItem("tetratto:theme") + ) { + document.documentElement.classList.remove("dark"); + // window.localStorage.setItem("theme", "light"); + } else if (window.localStorage.getItem("tetratto:theme")) { + /* restore theme */ + const current = window.localStorage.getItem("tetratto:theme"); + document.documentElement.className = current; + } +} + +function set_theme(theme) { + window.localStorage.setItem("tetratto:theme", theme); + document.documentElement.className = theme; +} + +media_theme_pref(); + +// atto ns +(() => { + const self = reg_ns("atto"); + + // env + self.DEBOUNCE = []; + self.OBSERVERS = []; + + // ... + self.define("try_use", (_, ns_name, callback) => { + // attempt to get existing namespace + if (globalThis._app_base.ns_store[`$${ns_name}`]) { + return callback(globalThis._app_base.ns_store[`$${ns_name}`]); + } + + // otherwise, call normal use + use(ns_name, callback); + }); + + self.define("debounce", ({ $ }, name) => { + return new Promise((resolve, reject) => { + if ($.DEBOUNCE.includes(name)) { + return reject(); + } + + $.DEBOUNCE.push(name); + + setTimeout(() => { + delete $.DEBOUNCE[$.DEBOUNCE.indexOf(name)]; + }, 1000); + + return resolve(); + }); + }); + + self.define("rel_date", (_, date) => { + // stolen and slightly modified because js dates suck + const diff = (new Date().getTime() - date.getTime()) / 1000; + const day_diff = Math.floor(diff / 86400); + + if (Number.isNaN(day_diff) || day_diff < 0 || day_diff >= 31) { + return; + } + + return ( + (day_diff === 0 && + ((diff < 60 && "just now") || + (diff < 120 && "1 minute ago") || + // biome-ignore lint/style/useTemplate: ok + (diff < 3600 && Math.floor(diff / 60) + " minutes ago") || + (diff < 7200 && "1 hour ago") || + (diff < 86400 && + // biome-ignore lint/style/useTemplate: ok + Math.floor(diff / 3600) + " hours ago"))) || + (day_diff === 1 && "Yesterday") || + // biome-ignore lint/style/useTemplate: ok + (day_diff < 7 && day_diff + " days ago") || + // biome-ignore lint/style/useTemplate: ok + (day_diff < 31 && Math.ceil(day_diff / 7) + " weeks ago") + ); + }); + + self.define("clean_date_codes", ({ $ }) => { + for (const element of Array.from(document.querySelectorAll(".date"))) { + if (element.getAttribute("data-unix")) { + // this allows us to run the function twice on the same page + // without errors from already rendered dates + element.innerText = element.getAttribute("data-unix"); + } + + element.setAttribute("data-unix", element.innerText); + const then = new Date(Number.parseInt(element.innerText)); + + if (Number.isNaN(element.innerText)) { + continue; + } + + element.setAttribute("title", then.toLocaleString()); + + let pretty = $.rel_date(then); + + if (screen.width < 900 && pretty !== undefined) { + // shorten dates even more for mobile + pretty = pretty + .replaceAll(" minutes ago", "m") + .replaceAll(" minute ago", "m") + .replaceAll(" hours ago", "h") + .replaceAll(" hour ago", "h") + .replaceAll(" days ago", "d") + .replaceAll(" day ago", "d") + .replaceAll(" weeks ago", "w") + .replaceAll(" week ago", "w") + .replaceAll(" months ago", "m") + .replaceAll(" month ago", "m") + .replaceAll(" years ago", "y") + .replaceAll(" year ago", "y"); + } + + element.innerText = + pretty === undefined ? then.toLocaleDateString() : pretty; + + element.style.display = "inline-block"; + } + }); + + self.define("copy_text", ({ $ }, text) => { + navigator.clipboard.writeText(text); + $.toast("success", "Copied!"); + }); + + self.define("smooth_remove", (_, element, ms) => { + // run animation + element.style.animation = `fadeout ease-in-out 1 ${ms}ms forwards running`; + + // remove + setTimeout(() => { + element.remove(); + }, ms); + }); + + self.define("disconnect_observers", ({ $ }) => { + for (const observer of $.OBSERVERS) { + observer.disconnect(); + } + + $.OBSERVERS = []; + }); + + self.define("offload_work_to_client_when_in_view", (_, entry_callback) => { + // instead of spending the time on the server loading everything before + // returning the page, we can instead of just create an IntersectionObserver + // and send individual requests as we see the element it's needed for + const seen = []; + return new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const element = entry.target; + if (!entry.isIntersecting || seen.includes(element)) { + continue; + } + + seen.push(element); + entry_callback(element); + } + }, + { + root: document.body, + rootMargin: "0px", + threshold: 1.0, + }, + ); + }); + + self.define("toggle_flex", (_, element) => { + if (element.style.display === "none") { + element.style.display = "flex"; + } else { + element.style.display = "none"; + } + }); + + // hooks + self.define("hooks::scroll", (_, scroll_element, track_element) => { + const goals = [150, 250, 500, 1000]; + + track_element.setAttribute("data-scroll", "0"); + scroll_element.addEventListener("scroll", (e) => { + track_element.setAttribute("data-scroll", scroll_element.scrollTop); + + for (const goal of goals) { + const name = `data-scroll-${goal}`; + if (scroll_element.scrollTop >= goal) { + track_element.setAttribute(name, "true"); + } else { + track_element.removeAttribute(name); + } + } + }); + }); + + self.define("hooks::dropdown.close", (_) => { + for (const dropdown of Array.from( + document.querySelectorAll(".inner.open"), + )) { + dropdown.classList.remove("open"); + } + }); + + self.define("hooks::dropdown", ({ $ }, event) => { + event.stopImmediatePropagation(); + let target = event.target; + + while (!target.matches(".dropdown")) { + target = target.parentElement; + } + + // close all others + $["hooks::dropdown.close"](); + + // open + setTimeout(() => { + for (const dropdown of Array.from( + target.querySelectorAll(".inner"), + )) { + // check y + const box = target.getBoundingClientRect(); + + let parent = dropdown.parentElement; + + while (!parent.matches("html, .window")) { + parent = parent.parentElement; + } + + let parent_height = parent.getBoundingClientRect().y; + + if (parent.nodeName === "HTML") { + parent_height = window.screen.height; + } + + const scroll = window.scrollY; + const height = parent_height; + const y = box.y + scroll; + + if (y > height - scroll - 300) { + dropdown.classList.add("top"); + } else { + dropdown.classList.remove("top"); + } + + // open + dropdown.classList.add("open"); + + if (dropdown.classList.contains("open")) { + dropdown.removeAttribute("aria-hidden"); + } else { + dropdown.setAttribute("aria-hidden", "true"); + } + } + }, 5); + }); + + self.define("hooks::dropdown.init", (_, bind_to) => { + for (const dropdown of Array.from( + document.querySelectorAll(".inner"), + )) { + dropdown.setAttribute("aria-hidden", "true"); + } + + bind_to.addEventListener("click", (event) => { + if ( + event.target.matches(".dropdown") || + event.target.matches("[exclude=dropdown]") + ) { + return; + } + + for (const dropdown of Array.from( + document.querySelectorAll(".inner.open"), + )) { + dropdown.classList.remove("open"); + } + }); + }); + + self.define("hooks::character_counter", (_, event) => { + let target = event.target; + + while (!target.matches("textarea, input")) { + target = target.parentElement; + } + + const counter = document.getElementById(`${target.id}:counter`); + counter.innerText = `${target.value.length}/${target.getAttribute("maxlength")}`; + }); + + self.define("hooks::character_counter.init", (_, event) => { + for (const element of Array.from( + document.querySelectorAll("[hook=counter]") || [], + )) { + const counter = document.getElementById(`${element.id}:counter`); + counter.innerText = `0/${element.getAttribute("maxlength")}`; + element.addEventListener("keyup", (e) => + app["hooks::character_counter"](e), + ); + } + }); + + self.define("hooks::long", (_, element, full_text) => { + element.classList.remove("hook:long.hidden_text"); + element.innerHTML = full_text; + }); + + self.define("hooks::long_text.init", (_, event) => { + for (const element of Array.from( + document.querySelectorAll("[hook=long]") || [], + )) { + const is_long = element.innerText.length >= 64 * 16; + + if (!is_long) { + continue; + } + + element.classList.add("hook:long.hidden_text"); + + if (element.getAttribute("hook-arg") === "lowered") { + element.classList.add("hook:long.hidden_text+lowered"); + } + + const html = element.innerHTML; + const short = html.slice(0, 64 * 16); + element.innerHTML = `${short}...`; + + // event + const listener = () => { + app["hooks::long"](element, html); + element.removeEventListener("click", listener); + }; + + element.addEventListener("click", listener); + } + }); + + self.define("hooks::alt", (_) => { + for (const element of Array.from( + document.querySelectorAll("img") || [], + )) { + if (element.getAttribute("alt") && !element.getAttribute("title")) { + element.setAttribute("title", element.getAttribute("alt")); + } + } + }); + + self.define( + "hooks::attach_to_partial", + ({ $ }, partial, full, attach, wrapper, page, run_on_load) => { + return new Promise((resolve, reject) => { + async function load_partial() { + const url = `${partial}${partial.includes("?") ? "&" : "?"}page=${page}`; + history.replaceState( + history.state, + "", + url.replace(partial, full), + ); + + fetch(url) + .then(async (res) => { + const text = await res.text(); + + if ( + text.length < 100 || + text.includes('data-marker="no-results"') + ) { + // pretty much blank content, no more pages + wrapper.removeEventListener("scroll", event); + + return resolve(); + } + + attach.innerHTML += text; + + $.clean_date_codes(); + $.link_filter(); + $["hooks::alt"](); + }) + .catch(() => { + // done scrolling, no more pages (http error) + wrapper.removeEventListener("scroll", event); + + resolve(); + }); + } + + const event = async () => { + if ( + wrapper.scrollTop + wrapper.offsetHeight + 100 > + attach.offsetHeight + ) { + self.debounce("app::partials") + .then(async () => { + if (document.getElementById("initial_loader")) { + console.log("partial blocked"); + return; + } + + // biome-ignore lint/style/noParameterAssign: no it isn't + page += 1; + await load_partial(); + await $["hooks::partial_embeds"](); + }) + .catch(() => { + console.log("partial stuck"); + }); + } + }; + + wrapper.addEventListener("scroll", event); + }); + }, + ); + + self.define("hooks::partial_embeds", (_) => { + for (const paragraph of Array.from( + document.querySelectorAll("span[class] p"), + )) { + const groups = /(\/\+r\/)([\w]+)/.exec(paragraph.innerText); + + if (groups === null) { + continue; + } + + // add embed + paragraph.innerText = paragraph.innerText.replace(groups[0], ""); + paragraph.parentElement.innerHTML += ``; + } + }); + + self.define("hooks::check_reactions", async ({ $ }) => { + const observer = $.offload_work_to_client_when_in_view( + async (element) => { + const reaction = await ( + await fetch( + `/api/v1/reactions/${element.getAttribute("hook-arg:id")}`, + ) + ).json(); + + if (reaction.success) { + element.classList.add("green"); + element.querySelector("svg").classList.add("filled"); + } + }, + ); + + for (const element of Array.from( + document.querySelectorAll("[hook=check_reaction]") || [], + )) { + observer.observe(element); + } + + $.OBSERVERS.push(observer); + }); + + self.define("hooks::tabs:switch", (_, tab) => { + // tab + for (const element of Array.from( + document.querySelectorAll("[data-tab]"), + )) { + element.classList.add("hidden"); + } + + document + .querySelector(`[data-tab="${tab}"]`) + .classList.remove("hidden"); + + // button + if (document.querySelector(`[data-tab-button="${tab}"]`)) { + for (const element of Array.from( + document.querySelectorAll("[data-tab-button]"), + )) { + element.classList.remove("active"); + } + + document + .querySelector(`[data-tab-button="${tab}"]`) + .classList.add("active"); + } + }); + + self.define("hooks::tabs:check", ({ $ }, hash) => { + if (!hash || !hash.startsWith("#/")) { + return; + } + + $["hooks::tabs:switch"](hash.replace("#/", "")); + }); + + self.define("hooks::tabs", ({ $ }) => { + $["hooks::tabs:check"](window.location.hash); // initial check + window.addEventListener("hashchange", (event) => + $["hooks::tabs:check"](new URL(event.newURL).hash), + ); + }); + + // web api replacements + self.define("prompt", (_, msg) => { + const dialog = document.getElementById("web_api_prompt"); + document.getElementById("web_api_prompt:msg").innerText = msg; + + return new Promise((resolve, _) => { + globalThis.web_api_prompt_submit = (value) => { + dialog.close(); + return resolve(value); + }; + + dialog.showModal(); + }); + }); + + self.define("prompt_long", (_, msg) => { + const dialog = document.getElementById("web_api_prompt_long"); + document.getElementById("web_api_prompt_long:msg").innerText = msg; + + return new Promise((resolve, _) => { + globalThis.web_api_prompt_long_submit = (value) => { + dialog.close(); + return resolve(value); + }; + + dialog.showModal(); + }); + }); + + self.define("confirm", (_, msg) => { + const dialog = document.getElementById("web_api_confirm"); + document.getElementById("web_api_confirm:msg").innerText = msg; + + return new Promise((resolve, _) => { + globalThis.web_api_confirm_submit = (value) => { + dialog.close(); + return resolve(value); + }; + + dialog.showModal(); + }); + }); + + // toast + self.define("toast", ({ $ }, type, content, time_until_remove = 5) => { + const element = document.createElement("div"); + element.id = "toast"; + element.classList.add(type); + element.classList.add("toast"); + element.innerHTML = `${content + .replaceAll("<", "<") + .replaceAll(">", ">")}`; + + document.getElementById("toast_zone").prepend(element); + + const timer = document.createElement("span"); + element.appendChild(timer); + + timer.innerText = time_until_remove; + timer.classList.add("timer"); + + // start timer + setTimeout(() => { + clearInterval(count_interval); + $.smooth_remove(element, 500); + }, time_until_remove * 1000); + + const count_interval = setInterval(() => { + // biome-ignore lint/style/noParameterAssign: no it isn't + time_until_remove -= 1; + timer.innerText = time_until_remove; + }, 1000); + }); + + // link filter + self.define("link_filter", (_) => { + for (const anchor of Array.from(document.querySelectorAll("a"))) { + if (anchor.href.length === 0) { + continue; + } + + const url = new URL(anchor.href); + if ( + anchor.href.startsWith("/") || + anchor.href.startsWith("javascript:") || + url.origin === window.location.origin + ) { + continue; + } + + anchor.addEventListener("click", (e) => { + e.preventDefault(); + document.getElementById("link_filter_url").innerText = + anchor.href; + document.getElementById("link_filter_continue").href = + anchor.href; + document.getElementById("link_filter").showModal(); + }); + } + }); +})(); diff --git a/crates/app/src/public/js/loader.js b/crates/app/src/public/js/loader.js new file mode 100644 index 0000000..c1ef34c --- /dev/null +++ b/crates/app/src/public/js/loader.js @@ -0,0 +1,205 @@ +//! https://github.com/trisuaso/tetratto +globalThis.ns_config = globalThis.ns_config || { + root: "/static/js/", + version: 0, + verbose: true, +}; + +globalThis._app_base = globalThis._app_base || { ns_store: {}, classes: {} }; + +function regns_log(level, ...args) { + if (globalThis.ns_config.verbose) { + console[level](...args); + } else { + return; + } +} + +/// Query an existing namespace +globalThis.ns = (ns) => { + regns_log("info", "namespace query:", ns); + + // get namespace from app base + const res = globalThis._app_base.ns_store[`$${ns}`]; + + if (!res) { + return console.error( + "namespace does not exist, please use one of the following:", + Object.keys(globalThis._app_base.ns_store), + ); + } + + return res; +}; + +/// Register a new namespace +globalThis.reg_ns = (ns, deps) => { + if (typeof ns !== "string") { + return console.error("type check failed on namespace:", ns); + } + + if (!ns) { + return console.error("cannot register invalid namespace!"); + } + + if (globalThis._app_base.ns_store[`$${ns}`]) { + regns_log("warn", "overwriting existing namespace:", ns); + } + + // register new blank namespace + globalThis._app_base.ns_store[`$${ns}`] = { + _ident: ns, + _deps: deps || [], + /// Pull dependencies (other namespaces) as listed in the given `deps` argument + _get_deps: () => { + const self = globalThis._app_base.ns_store[`$${ns}`]; + const deps = {}; + + for (const dep of self._deps) { + const res = globalThis.ns(dep); + + if (!res) { + regns_log("warn", "failed to pull dependency:", dep); + continue; + } + + deps[dep] = res; + } + + deps.$ = self; // give access to self through $ + return deps; + }, + /// Store the real versions of functions + _fn_store: {}, + /// Call a function in a namespace and load namespace dependencies + define: (name, func, types) => { + const self = globalThis.ns(ns); + self._fn_store[name] = func; // store real function + self[name] = function (...args) { + regns_log("info", "namespace call:", ns, name); + + // js doesn't provide type checking, we do + if (types) { + for (const i in args) { + // biome-ignore lint: this is incorrect, you do not need a string literal to use typeof + if (types[i] && typeof args[i] !== types[i]) { + return console.error( + "argument does not pass type check:", + i, + args[i], + ); + } + } + } + + // ... + // we MUST return here, otherwise nothing will work in workers + return self._fn_store[name](self._get_deps(), ...args); // call with deps and arguments + }; + }, + }; + + regns_log("log", "registered namespace:", ns); + return globalThis._app_base.ns_store[`$${ns}`]; +}; + +/// Call a namespace function quickly +globalThis.trigger = (id, args) => { + // get namespace + const s = id.split("::"); + const [namespace, func] = [s[0], s.slice(1, s.length).join("::")]; + const self = ns(namespace); + + if (!self) { + return console.error("namespace does not exist:", namespace); + } + + if (!self[func]) { + return console.error("namespace function does not exist:", id); + } + + return self[func](...(args || [])); +}; + +/// Import a namespace from path (relative to ns_config.root) +globalThis.use = (id, callback) => { + let file = id; + + if (id.includes(".h.")) { + const split = id.split(".h."); + file = split[1]; + } + + // check if namespace already exists + const res = globalThis._app_base.ns_store[`$${file}`]; + + if (res) { + return callback(res); + } + + // create script to load + const script = document.createElement("script"); + script.src = `${globalThis.ns_config.root}${id}.js?v=${globalThis.ns_config.version}`; + script.id = `${globalThis.ns_config.version}-${file}.js`; + document.head.appendChild(script); + + script.setAttribute("data-turbo-permanent", "true"); + script.setAttribute("data-registered", new Date().toISOString()); + script.setAttribute("data-version", globalThis.ns_config.version); + + // run callback once the script loads + script.addEventListener("load", () => { + const res = globalThis._app_base.ns_store[`$${file}`]; + + if (!res) { + return console.error("imported namespace failed to register:", id); + } + + callback(res); + }); +}; + +// classes + +/// Import a class from path (relative to ns_config.root/classes) +globalThis.require = (id, callback) => { + let file = id; + + if (id.includes(".h.")) { + const split = id.split(".h."); + file = split[1]; + } + + // check if class already exists + const res = globalThis._app_base.classes[file]; + + if (res) { + return callback(res); + } + + // create script to load + const script = document.createElement("script"); + script.src = `${globalThis.ns_config.root}classes/${id}.js?v=${globalThis.ns_config.version}`; + script.id = `${globalThis.ns_config.version}-${file}.class.js`; + document.head.appendChild(script); + + script.setAttribute("data-turbo-permanent", "true"); + script.setAttribute("data-registered", new Date().toISOString()); + script.setAttribute("data-version", globalThis.ns_config.version); + + // run callback once the script loads + script.addEventListener("load", () => { + const res = globalThis._app_base.classes[file]; + + if (!res) { + return console.error("imported class failed to register:", id); + } + + callback(res); + }); +}; + +globalThis.define = (class_name, class_) => { + globalThis._app_base.classes[class_name] = class_; + regns_log("log", "registered class:", class_name); +}; diff --git a/src/routes/api/mod.rs b/crates/app/src/routes/api/mod.rs similarity index 100% rename from src/routes/api/mod.rs rename to crates/app/src/routes/api/mod.rs diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs new file mode 100644 index 0000000..e092881 --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -0,0 +1,102 @@ +use axum::{Extension, body::Body, extract::Path, response::IntoResponse}; +use pathbufd::PathBufD; +use std::{ + fs::{File, exists}, + io::Read, +}; + +use crate::State; + +pub fn read_image(path: PathBufD) -> Vec { + let mut bytes = Vec::new(); + + for byte in File::open(path).unwrap().bytes() { + bytes.push(byte.unwrap()) + } + + bytes +} + +/// Get a profile's avatar image +/// `/api/v1/auth/profile/{id}/avatar` +pub async fn avatar_request( + Path(username): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let user = match data.get_user_by_username(&username).await { + Ok(ua) => ua, + Err(_) => { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-avatar.svg", + ]))), + ); + } + }; + + let path = + PathBufD::current().extend(&["avatars", &data.0.dirs.media, &format!("{}.avif", &user.id)]); + + if !exists(&path).unwrap() { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-avatar.svg", + ]))), + ); + } + + ( + [("Content-Type", "image/avif")], + Body::from(read_image(path)), + ) +} + +/// Get a profile's banner image +/// `/api/v1/auth/profile/{id}/banner` +pub async fn banner_request( + Path(username): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let user = match data.get_user_by_username(&username).await { + Ok(ua) => ua, + Err(_) => { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ); + } + }; + + let path = + PathBufD::current().extend(&["avatars", &data.0.dirs.media, &format!("{}.avif", &user.id)]); + + if !exists(&path).unwrap() { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ); + } + + ( + [("Content-Type", "image/avif")], + Body::from(read_image(path)), + ) +} diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs new file mode 100644 index 0000000..7fee2a1 --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -0,0 +1,127 @@ +pub mod images; + +use super::AuthProps; +use crate::{ + State, get_user_from_token, + model::{ApiReturn, Error, auth::User}, +}; +use axum::{ + Extension, Json, + http::{HeaderMap, HeaderValue}, + response::IntoResponse, +}; +use axum_extra::extract::CookieJar; + +/// `/api/v1/auth/register` +pub async fn register_request( + headers: HeaderMap, + jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = get_user_from_token!((jar, data) ); + + if user.is_some() { + return ( + None, + Json(ApiReturn { + ok: false, + message: Error::AlreadyAuthenticated.to_string(), + payload: (), + }), + ); + } + + // get real ip + let real_ip = headers + .get(data.0.security.real_ip_header.to_owned()) + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .to_string(); + + // ... + let mut user = User::new(props.username, props.password); + let (initial_token, t) = User::create_token(&real_ip); + user.tokens.push(t); + + // return + match data.create_user(user).await { + Ok(_) => ( + Some([( + "Set-Cookie", + format!( + "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}", + initial_token, + 60 * 60 * 24 * 365 + ), + )]), + Json(ApiReturn { + ok: true, + message: "User created".to_string(), + payload: (), + }), + ), + Err(e) => (None, Json(e.into())), + } +} + +/// `/api/v1/auth/login` +pub async fn login_request( + headers: HeaderMap, + jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = get_user_from_token!((jar, data) ); + + if user.is_some() { + return (None, Json(Error::AlreadyAuthenticated.into())); + } + + // get real ip + let real_ip = headers + .get(data.0.security.real_ip_header.to_owned()) + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .to_string(); + + // verify password + let user = match data.get_user_by_username(&props.username).await { + Ok(ua) => ua, + Err(_) => return (None, Json(Error::IncorrectPassword.into())), + }; + + if !user.check_password(props.password) { + return (None, Json(Error::IncorrectPassword.into())); + } + + // update tokens + let mut new_tokens = user.tokens.clone(); + let (unhashed_token_id, token) = User::create_token(&real_ip); + new_tokens.push(token); + + if let Err(e) = data.update_user_tokens(user.id, new_tokens).await { + return (None, Json(e.into())); + } + + // ... + ( + Some([( + "Set-Cookie", + format!( + "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}", + unhashed_token_id, + 60 * 60 * 24 * 365 + ), + )]), + Json(ApiReturn { + ok: true, + message: unhashed_token_id, + payload: (), + }), + ) +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs new file mode 100644 index 0000000..c0d0906 --- /dev/null +++ b/crates/app/src/routes/api/v1/mod.rs @@ -0,0 +1,28 @@ +pub mod auth; +use axum::{ + Router, + routing::{get, post}, +}; +use serde::Deserialize; + +pub fn routes() -> Router { + Router::new() + // global + .route("/auth/register", post(auth::register_request)) + .route("/auth/login", post(auth::login_request)) + // profile + .route( + "/auth/profile/{id}/avatar", + get(auth::images::avatar_request), + ) + .route( + "/auth/profile/{id}/banner", + get(auth::images::banner_request), + ) +} + +#[derive(Deserialize)] +pub struct AuthProps { + pub username: String, + pub password: String, +} diff --git a/src/routes/assets.rs b/crates/app/src/routes/assets.rs similarity index 50% rename from src/routes/assets.rs rename to crates/app/src/routes/assets.rs index b6b7788..a56bfe9 100644 --- a/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -2,16 +2,21 @@ use axum::response::IntoResponse; /// `/css/style.css` pub async fn style_css_request() -> impl IntoResponse { - ( - [("Content-Type", "text/css")], - crate::data::assets::STYLE_CSS, - ) + ([("Content-Type", "text/css")], crate::assets::STYLE_CSS) } /// `/js/atto.js` pub async fn atto_js_request() -> impl IntoResponse { ( [("Content-Type", "text/javascript")], - crate::data::assets::ATTO_JS, + crate::assets::ATTO_JS, + ) +} + +/// `/js/atto.js` +pub async fn loader_js_request() -> impl IntoResponse { + ( + [("Content-Type", "text/javascript")], + crate::assets::LOADER_JS, ) } diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs new file mode 100644 index 0000000..d6b24a9 --- /dev/null +++ b/crates/app/src/routes/mod.rs @@ -0,0 +1,25 @@ +pub mod api; +pub mod assets; +pub mod pages; + +use crate::config::Config; +use axum::{ + Router, + routing::{get, get_service}, +}; + +pub fn routes(config: &Config) -> Router { + Router::new() + // assets + .route("/css/style.css", get(assets::style_css_request)) + .route("/js/atto.js", get(assets::atto_js_request)) + .route("/js/loader.js", get(assets::loader_js_request)) + .nest_service( + "/static", + get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), + ) + // api + .nest("/api/v1", api::v1::routes()) + // pages + .merge(pages::routes()) +} diff --git a/crates/app/src/routes/pages/auth.rs b/crates/app/src/routes/pages/auth.rs new file mode 100644 index 0000000..9831c3c --- /dev/null +++ b/crates/app/src/routes/pages/auth.rs @@ -0,0 +1,39 @@ +use crate::{State, assets::initial_context, get_user_from_token}; +use axum::{ + Extension, + response::{Html, IntoResponse, Redirect}, +}; +use axum_extra::extract::CookieJar; + +/// `/auth/login` +pub async fn login_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!((jar, data.0) ); + + if user.is_some() { + return Err(Redirect::to("/")); + } + + let mut context = initial_context(&data.0.0, &user); + Ok(Html( + data.1.render("auth/login.html", &mut context).unwrap(), + )) +} + +/// `/auth/register` +pub async fn register_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!((jar, data.0) ); + + if user.is_some() { + return Err(Redirect::to("/")); + } + + let mut context = initial_context(&data.0.0, &user); + Ok(Html( + data.1.render("auth/register.html", &mut context).unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs new file mode 100644 index 0000000..eab6bcd --- /dev/null +++ b/crates/app/src/routes/pages/misc.rs @@ -0,0 +1,15 @@ +use crate::{State, assets::initial_context, get_user_from_token}; +use axum::{ + Extension, + response::{Html, IntoResponse}, +}; +use axum_extra::extract::CookieJar; + +/// `/` +pub async fn index_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!((jar, data.0) ); + + let mut context = initial_context(&data.0.0, &user); + Html(data.1.render("misc/index.html", &mut context).unwrap()) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs new file mode 100644 index 0000000..2b2a30e --- /dev/null +++ b/crates/app/src/routes/pages/mod.rs @@ -0,0 +1,13 @@ +pub mod auth; +pub mod misc; + +use axum::{Router, routing::get}; + +pub fn routes() -> Router { + Router::new() + // misc + .route("/", get(misc::index_request)) + // auth + .route("/auth/register", get(auth::register_request)) + .route("/auth/login", get(auth::login_request)) +} diff --git a/crates/tetratto_core/Cargo.toml b/crates/tetratto_core/Cargo.toml new file mode 100644 index 0000000..3062a99 --- /dev/null +++ b/crates/tetratto_core/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "tetratto_core" +version = "0.1.0" +edition = "2024" + +[features] +postgres = ["dep:tokio-postgres", "dep:bb8-postgres"] +sqlite = ["dep:rusqlite"] +default = ["sqlite"] + +[dependencies] +pathbufd = "0.1.4" +serde = { version = "1.0.219", features = ["derive"] } +tera = "1.20.0" +toml = "0.8.20" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +tower-http = { version = "0.6.2", features = ["trace", "fs"] } +axum = { version = "0.8.1", features = ["macros"] } +tokio = { version = "1.44.0", features = ["macros", "rt-multi-thread"] } +rainbeam-shared = "1.0.1" +serde_json = "1.0.140" +axum-extra = { version = "0.10.0", features = ["cookie"] } + +rusqlite = { version = "0.34.0", optional = true } + +tokio-postgres = { version = "0.7.13", optional = true } +bb8-postgres = { version = "0.9.0", optional = true } diff --git a/crates/tetratto_core/LICENSE b/crates/tetratto_core/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/tetratto_core/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/src/config.rs b/crates/tetratto_core/src/config.rs similarity index 66% rename from src/config.rs rename to crates/tetratto_core/src/config.rs index 32d0d66..26d2201 100644 --- a/src/config.rs +++ b/crates/tetratto_core/src/config.rs @@ -12,6 +12,9 @@ pub struct SecurityConfig { /// If registrations are enabled. #[serde(default = "default_security_admin_user")] pub admin_user: String, + /// If registrations are enabled. + #[serde(default = "default_real_ip_header")] + pub real_ip_header: String, } fn default_security_registration_enabled() -> bool { @@ -22,11 +25,16 @@ fn default_security_admin_user() -> String { "admin".to_string() } +fn default_real_ip_header() -> String { + "CF-Connecting-IP".to_string() +} + impl Default for SecurityConfig { fn default() -> Self { Self { registration_enabled: default_security_registration_enabled(), admin_user: default_security_admin_user(), + real_ip_header: default_real_ip_header(), } } } @@ -40,6 +48,9 @@ pub struct DirsConfig { /// Static files directory. #[serde(default = "default_dir_assets")] pub assets: String, + /// Media (user avatars/banners) files directory. + #[serde(default = "default_dir_media")] + pub media: String, } fn default_dir_templates() -> String { @@ -50,11 +61,42 @@ fn default_dir_assets() -> String { "public".to_string() } +fn default_dir_media() -> String { + "media".to_string() +} + impl Default for DirsConfig { fn default() -> Self { Self { templates: default_dir_templates(), assets: default_dir_assets(), + media: default_dir_media(), + } + } +} + +/// Database configuration. +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct DatabaseConfig { + pub name: String, + #[cfg(feature = "postgres")] + pub url: String, + #[cfg(feature = "postgres")] + pub user: String, + #[cfg(feature = "postgres")] + pub password: String, +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + name: "atto.db".to_string(), + #[cfg(feature = "postgres")] + url: "localhost:5432".to_string(), + #[cfg(feature = "postgres")] + user: "postgres".to_string(), + #[cfg(feature = "postgres")] + password: "postgres".to_string(), } } } @@ -62,33 +104,41 @@ impl Default for DirsConfig { /// Configuration file #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Config { - /// The name of the app for templates. + /// The name of the app. #[serde(default = "default_name")] pub name: String, + /// The description of the app. + #[serde(default = "default_description")] + pub description: String, + /// The theme color of the app. + #[serde(default = "default_color")] + pub color: String, /// The port to serve the server on. #[serde(default = "default_port")] pub port: u16, - /// The name of the file to store the SQLite database in. - #[serde(default = "default_database")] - pub database: String, /// Database security. #[serde(default = "default_security")] pub security: SecurityConfig, /// The locations where different files should be matched. #[serde(default = "default_dirs")] pub dirs: DirsConfig, + #[serde(default = "default_database")] + pub database: DatabaseConfig, } fn default_name() -> String { "Tetratto".to_string() } -fn default_port() -> u16 { - 4118 +fn default_description() -> String { + "🐐 tetratto!".to_string() } -fn default_database() -> String { - "atto.db".to_string() +fn default_color() -> String { + "#c9b1bc".to_string() +} +fn default_port() -> u16 { + 4118 } fn default_security() -> SecurityConfig { @@ -99,10 +149,16 @@ fn default_dirs() -> DirsConfig { DirsConfig::default() } +fn default_database() -> DatabaseConfig { + DatabaseConfig::default() +} + impl Default for Config { fn default() -> Self { Self { name: default_name(), + description: default_description(), + color: default_color(), port: default_port(), database: default_database(), security: default_security(), diff --git a/crates/tetratto_core/src/database/auth.rs b/crates/tetratto_core/src/database/auth.rs new file mode 100644 index 0000000..a21af56 --- /dev/null +++ b/crates/tetratto_core/src/database/auth.rs @@ -0,0 +1,196 @@ +use super::*; +use crate::model::{Error, Result}; +use crate::{execute, get, query_row}; + +use rainbeam_shared::hash::hash_salted; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`User`] from an SQL row. + pub(crate) fn get_user_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> User { + User { + id: get!(x->0(u64)) as usize, + created: get!(x->1(u64)) as usize as usize, + username: get!(x->2(String)), + password: get!(x->3(String)), + salt: get!(x->4(String)), + settings: serde_json::from_str(&get!(x->5(String)).to_string()).unwrap(), + tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(), + } + } + + /// Get a user given just their `id`. + /// + /// # Arguments + /// * `id` - the ID of the user + pub async fn get_user_by_id(&self, id: &str) -> Result { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!(&conn, "SELECT * FROM users WHERE id = $1", &[&id], |x| { + Ok(Self::get_user_from_row(x)) + }); + + if res.is_err() { + return Err(Error::UserNotFound); + } + + Ok(res.unwrap()) + } + + /// Get a user given just their `username`. + /// + /// # Arguments + /// * `username` - the username of the user + pub async fn get_user_by_username(&self, username: &str) -> Result { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM users WHERE username = $1", + &[&username], + |x| Ok(Self::get_user_from_row(x)) + ); + + if res.is_err() { + return Err(Error::UserNotFound); + } + + Ok(res.unwrap()) + } + + /// Get a user given just their auth token. + /// + /// # Arguments + /// * `token` - the token of the user + pub async fn get_user_by_token(&self, token: &str) -> Result { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM users WHERE tokens LIKE $1", + &[&format!("%\"{token}\"%")], + |x| Ok(Self::get_user_from_row(x)) + ); + + if res.is_err() { + return Err(Error::UserNotFound); + } + + Ok(res.unwrap()) + } + + /// Create a new user in the database. + /// + /// # Arguments + /// * `data` - a mock [`User`] object to insert + pub async fn create_user(&self, data: User) -> Result<()> { + if self.0.security.registration_enabled == false { + return Err(Error::RegistrationDisabled); + } + + // check values + if data.username.len() < 2 { + return Err(Error::DataTooShort("username".to_string())); + } else if data.username.len() > 32 { + return Err(Error::DataTooLong("username".to_string())); + } + + if data.password.len() < 6 { + return Err(Error::DataTooShort("password".to_string())); + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7)", + &[ + &data.id.to_string(), + &data.created.to_string(), + &data.username, + &data.password, + &data.salt, + &serde_json::to_string(&data.settings).unwrap(), + &serde_json::to_string(&data.tokens).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } + + /// Create a new user in the database. + /// + /// # Arguments + /// * `id` - the ID of the user + /// * `password` - the current password of the user + /// * `force` - if we should delete even if the given password is incorrect + pub async fn delete_user(&self, id: &str, password: &str, force: bool) -> Result<()> { + let user = self.get_user_by_id(id).await?; + + if (hash_salted(password.to_string(), user.salt) != user.password) && !force { + return Err(Error::IncorrectPassword); + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM users WHERE id = $1", &[&id]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } + + /// Update the tokens of the the specified account (by `id`). + /// + /// # Arguments + /// * `id` - the ID of the user + /// * `tokens` - the **new** tokens vector for the user + pub async fn update_user_tokens(&self, id: usize, tokens: Vec) -> Result<()> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET tokens = $1 WHERE id = $2", + &[&serde_json::to_string(&tokens).unwrap(), &id.to_string()] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } +} diff --git a/crates/tetratto_core/src/database/drivers/common.rs b/crates/tetratto_core/src/database/drivers/common.rs new file mode 100644 index 0000000..bdd1240 --- /dev/null +++ b/crates/tetratto_core/src/database/drivers/common.rs @@ -0,0 +1 @@ +pub const CREATE_TABLE_USERS: &str = include_str!("./sql/create_users.sql"); diff --git a/crates/tetratto_core/src/database/drivers/mod.rs b/crates/tetratto_core/src/database/drivers/mod.rs new file mode 100644 index 0000000..e12ff69 --- /dev/null +++ b/crates/tetratto_core/src/database/drivers/mod.rs @@ -0,0 +1,7 @@ +pub mod common; + +#[cfg(feature = "sqlite")] +pub mod sqlite; + +#[cfg(feature = "postgres")] +pub mod postgres; diff --git a/crates/tetratto_core/src/database/drivers/postgres.rs b/crates/tetratto_core/src/database/drivers/postgres.rs new file mode 100644 index 0000000..0123360 --- /dev/null +++ b/crates/tetratto_core/src/database/drivers/postgres.rs @@ -0,0 +1,95 @@ +use crate::config::Config; +use bb8_postgres::{ + PostgresConnectionManager, + bb8::{Pool, PooledConnection}, +}; +use tokio_postgres::{Config as PgConfig, NoTls, Row, types::ToSql}; + +pub type Result = std::result::Result; +pub type Connection<'a> = PooledConnection<'a, PostgresConnectionManager>; + +#[derive(Clone)] +pub struct DataManager(pub Config, pub Pool>); + +impl DataManager { + /// Obtain a connection to the staging database. + pub(crate) async fn connect(&self) -> Result { + Ok(self.1.get().await.unwrap()) + } + + /// Create a new [`DataManager`] (and init database). + pub async fn new(config: Config) -> Result { + let manager = PostgresConnectionManager::new( + { + let mut c = PgConfig::new(); + c.user(&config.database.user); + c.password(&config.database.password); + c.dbname(&config.database.name); + c + }, + NoTls, + ); + let pool = Pool::builder().max_size(15).build(manager).await.unwrap(); + + let this = Self(config.clone(), pool); + let c = this.clone(); + let conn = c.connect().await?; + + conn.execute(super::common::CREATE_TABLE_USERS, &[]) + .await + .unwrap(); + + Ok(this) + } +} + +#[cfg(feature = "postgres")] +#[macro_export] +macro_rules! get { + ($row:ident->$idx:literal($t:tt)) => { + $row.get::>($idx).unwrap() + }; +} + +pub async fn query_row_helper( + conn: &Connection<'_>, + sql: &str, + params: &[&(dyn ToSql + Sync)], + f: F, +) -> Result +where + F: FnOnce(&Row) -> Result, +{ + let query = conn.prepare(sql).await.unwrap(); + let res = conn.query_one(&query, params).await; + + if let Ok(row) = res { + Ok(f(&row).unwrap()) + } else { + Err(res.unwrap_err()) + } +} + +#[macro_export] +macro_rules! query_row { + ($conn:expr, $sql:expr, $params:expr, $f:expr) => { + crate::database::query_row_helper($conn, $sql, $params, $f).await + }; +} + +pub async fn execute_helper( + conn: &Connection<'_>, + sql: &str, + params: &[&(dyn ToSql + Sync)], +) -> Result<()> { + let query = conn.prepare(sql).await.unwrap(); + conn.execute(&query, params).await?; + Ok(()) +} + +#[macro_export] +macro_rules! execute { + ($conn:expr, $sql:expr, $params:expr) => { + crate::database::execute_helper($conn, $sql, $params).await + }; +} diff --git a/crates/tetratto_core/src/database/drivers/sql/create_users.sql b/crates/tetratto_core/src/database/drivers/sql/create_users.sql new file mode 100644 index 0000000..869c9f6 --- /dev/null +++ b/crates/tetratto_core/src/database/drivers/sql/create_users.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER NOT NULL PRIMARY KEY, + created INTEGER NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + salt TEXT NOT NULL, + settings TEXT NOT NULL, + tokens TEXT NOT NULL +) diff --git a/crates/tetratto_core/src/database/drivers/sqlite.rs b/crates/tetratto_core/src/database/drivers/sqlite.rs new file mode 100644 index 0000000..70c4385 --- /dev/null +++ b/crates/tetratto_core/src/database/drivers/sqlite.rs @@ -0,0 +1,46 @@ +use crate::config::Config; +use rusqlite::{Connection, Result}; + +#[derive(Clone)] +pub struct DataManager(pub Config); + +impl DataManager { + /// Obtain a connection to the staging database. + pub(crate) async fn connect(&self) -> Result { + Ok(Connection::open(&self.0.database.name)?) + } + + /// Create a new [`DataManager`] (and init database). + pub async fn new(config: Config) -> Result { + let this = Self(config.clone()); + let conn = this.connect().await?; + + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.execute(super::common::CREATE_TABLE_USERS, ()).unwrap(); + + Ok(this) + } +} + +#[cfg(feature = "sqlite")] +#[macro_export] +macro_rules! get { + ($row:ident->$idx:literal($t:tt)) => { + $row.get::($idx).unwrap() + }; +} + +#[macro_export] +macro_rules! query_row { + ($conn:expr, $sql:expr, $params:expr, $f:expr) => {{ + let mut query = $conn.prepare($sql).unwrap(); + query.query_row($params, $f) + }}; +} + +#[macro_export] +macro_rules! execute { + ($conn:expr, $sql:expr, $params:expr) => { + $conn.prepare($sql).unwrap().execute($params) + }; +} diff --git a/crates/tetratto_core/src/database/mod.rs b/crates/tetratto_core/src/database/mod.rs new file mode 100644 index 0000000..c1f2804 --- /dev/null +++ b/crates/tetratto_core/src/database/mod.rs @@ -0,0 +1,10 @@ +mod auth; +mod drivers; + +use super::model::auth::{Token, User}; + +#[cfg(feature = "sqlite")] +pub use drivers::sqlite::*; + +#[cfg(feature = "postgres")] +pub use drivers::postgres::*; diff --git a/crates/tetratto_core/src/lib.rs b/crates/tetratto_core/src/lib.rs new file mode 100644 index 0000000..b3a148a --- /dev/null +++ b/crates/tetratto_core/src/lib.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod database; +pub mod model; + +pub use database::DataManager; diff --git a/src/data/model.rs b/crates/tetratto_core/src/model/auth.rs similarity index 51% rename from src/data/model.rs rename to crates/tetratto_core/src/model/auth.rs index 8fbdc8f..9fcef2d 100644 --- a/src/data/model.rs +++ b/crates/tetratto_core/src/model/auth.rs @@ -5,36 +5,10 @@ use rainbeam_shared::{ }; use serde::{Deserialize, Serialize}; -#[derive(Debug)] -pub enum Error { - DatabaseConnection(String), - UserNotFound, - RegistrationDisabled, - DatabaseError, - IncorrectPassword, - AlreadyAuthenticated, - Unknown, -} +/// `(ip, token, creation timestamp)` +pub type Token = (String, String, usize); -impl ToString for Error { - fn to_string(&self) -> String { - match self { - Error::DatabaseConnection(msg) => msg.to_owned(), - Error::UserNotFound => "Unable to find user with given parameters".to_string(), - Error::RegistrationDisabled => "Registration is disabled".to_string(), - Error::IncorrectPassword => "The given password is invalid".to_string(), - Error::AlreadyAuthenticated => "Already authenticated".to_string(), - _ => format!("An unknown error as occurred ({:?})", self), - } - } -} - -pub type Result = std::result::Result; - -/// `(ip, token)` -pub type Token = (String, String); - -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct User { pub id: usize, pub created: usize, @@ -70,7 +44,28 @@ impl User { password, salt, settings: UserSettings::default(), - tokens: vec![(String::new(), AlmostSnowflake::new(1234567890).to_string())], + tokens: Vec::new(), } } + + /// Create a new token + /// + /// # Returns + /// `(unhashed id, token)` + pub fn create_token(ip: &str) -> (String, Token) { + let unhashed = rainbeam_shared::hash::uuid(); + ( + unhashed.clone(), + ( + ip.to_string(), + rainbeam_shared::hash::hash(unhashed), + unix_epoch_timestamp() as usize, + ), + ) + } + + /// Check if the given password is correct for the user. + pub fn check_password(&self, against: String) -> bool { + self.password == hash_salted(against, self.salt.clone()) + } } diff --git a/crates/tetratto_core/src/model/mod.rs b/crates/tetratto_core/src/model/mod.rs new file mode 100644 index 0000000..44d2392 --- /dev/null +++ b/crates/tetratto_core/src/model/mod.rs @@ -0,0 +1,56 @@ +pub mod auth; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct ApiReturn +where + T: Serialize, +{ + pub ok: bool, + pub message: String, + pub payload: T, +} + +#[derive(Debug)] +pub enum Error { + DatabaseConnection(String), + UserNotFound, + RegistrationDisabled, + DatabaseError(String), + IncorrectPassword, + AlreadyAuthenticated, + DataTooLong(String), + DataTooShort(String), + Unknown, +} + +impl ToString for Error { + fn to_string(&self) -> String { + match self { + Error::DatabaseConnection(msg) => msg.to_owned(), + Error::DatabaseError(msg) => format!("Database error: {msg}"), + Error::UserNotFound => "Unable to find user with given parameters".to_string(), + Error::RegistrationDisabled => "Registration is disabled".to_string(), + Error::IncorrectPassword => "The given password is invalid".to_string(), + Error::AlreadyAuthenticated => "Already authenticated".to_string(), + Error::DataTooLong(name) => format!("Given {name} is too long!"), + Error::DataTooShort(name) => format!("Given {name} is too short!"), + _ => format!("An unknown error as occurred: ({:?})", self), + } + } +} + +impl Into> for Error +where + T: Default + Serialize, +{ + fn into(self) -> ApiReturn { + ApiReturn { + ok: false, + message: self.to_string(), + payload: T::default(), + } + } +} + +pub type Result = std::result::Result; diff --git a/example/.gitignore b/example/.gitignore index a788228..db9b0fd 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,3 +1,9 @@ -atto.db -html/_atto/ -public/_atto/ +atto.db* + +html/* +!html/.gitkeep + +public/* +!public/.gitkeep + +media/* diff --git a/example/html/.gitkeep b/example/html/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/example/tetratto.toml b/example/tetratto.toml index 3165272..c4f4304 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -3,7 +3,6 @@ port = 4118 [security] registration_enabled = true -admin_user = "admin" [dirs] templates = "html" diff --git a/justfile b/justfile new file mode 100644 index 0000000..023dd4e --- /dev/null +++ b/justfile @@ -0,0 +1,7 @@ +clean-deps: + cargo upgrade -i + cargo machete + +fix: + cargo fix --allow-dirty + cargo clippy --fix --allow-dirty diff --git a/src/data/assets.rs b/src/data/assets.rs deleted file mode 100644 index 3613d3f..0000000 --- a/src/data/assets.rs +++ /dev/null @@ -1,14 +0,0 @@ -// css -pub const STYLE_CSS: &str = include_str!("../public/css/style.css"); - -// js -pub const ATTO_JS: &str = include_str!("../public/js/atto.js"); - -// html -pub const ROOT: &str = include_str!("../public/html/root.html"); -pub const REDIRECT_TO_AUTH: &str = - ""; - -pub const AUTH_BASE: &str = include_str!("../public/html/auth/base.html"); -pub const LOGIN: &str = include_str!("../public/html/auth/login.html"); -pub const REGISTER: &str = include_str!("../public/html/auth/register.html"); diff --git a/src/data/manager.rs b/src/data/manager.rs deleted file mode 100644 index b45fb26..0000000 --- a/src/data/manager.rs +++ /dev/null @@ -1,187 +0,0 @@ -use super::model::{Error, Result, User}; -use crate::config::Config; -use crate::write_template; - -use pathbufd::PathBufD as PathBuf; -use rainbeam_shared::hash::hash_salted; -use rusqlite::{Connection, Result as SqlResult, Row}; -use std::fs::{create_dir, exists}; -use tera::{Context, Tera}; - -pub struct DataManager(pub(crate) Config, pub Tera); - -impl DataManager { - /// Obtain a connection to the staging database. - pub(crate) fn connect(name: &str) -> SqlResult { - Ok(Connection::open(name)?) - } - - /// Create a new [`DataManager`] (and init database). - pub async fn new(config: Config) -> SqlResult { - let conn = Self::connect(&config.database)?; - - conn.pragma_update(None, "journal_mode", "WAL").unwrap(); - - conn.execute( - "CREATE TABLE IF NOT EXISTS users ( - id INTEGER NOT NULL PRIMARY KEY, - created INTEGER NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - salt TEXT NOT NULL, - settings TEXT NOT NULL, - tokens TEXT NOT NULL - )", - (), - ) - .unwrap(); - - // create system templates - let html_path = PathBuf::current().join(&config.dirs.templates); - let atto_dir = html_path.join("_atto"); - - if !exists(&atto_dir).unwrap() { - create_dir(&atto_dir).unwrap(); - } - - write_template!(atto_dir->"root.html"(super::assets::ROOT)); - - write_template!(atto_dir->"auth/base.html"(super::assets::AUTH_BASE) -d "auth"); - write_template!(atto_dir->"auth/login.html"(super::assets::LOGIN)); - write_template!(atto_dir->"auth/register.html"(super::assets::REGISTER)); - - // return - Ok(Self( - config.clone(), - Tera::new(&format!("{html_path}/**/*")).unwrap(), - )) - } - - /// Create the initial template context. - pub(crate) fn initial_context(&self) -> Context { - let mut ctx = Context::new(); - ctx.insert("name", &self.0.name); - ctx - } - - // users - - /// Get a [`User`] from an SQL row. - pub(crate) fn get_user_from_row(x: &Row<'_>) -> User { - User { - id: x.get(0).unwrap(), - created: x.get(1).unwrap(), - username: x.get(2).unwrap(), - password: x.get(3).unwrap(), - salt: x.get(4).unwrap(), - settings: serde_json::from_str(&x.get::(5).unwrap().to_string()) - .unwrap(), - tokens: serde_json::from_str(&x.get::(6).unwrap().to_string()).unwrap(), - } - } - - /// Get a user given just their `id`. - /// - /// # Arguments - /// * `id` - the ID of the user - pub async fn get_user_by_id(&self, id: &str) -> Result { - let conn = match Self::connect(&self.0.name) { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let mut query = conn.prepare("SELECT * FROM users WHERE id = ?").unwrap(); - let res = query.query_row([id], |x| Ok(Self::get_user_from_row(x))); - - if res.is_err() { - return Err(Error::UserNotFound); - } - - Ok(res.unwrap()) - } - - /// Get a user given just their auth token. - /// - /// # Arguments - /// * `token` - the token of the user - pub async fn get_user_by_token(&self, token: &str) -> Result { - let conn = match Self::connect(&self.0.name) { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let mut query = conn - .prepare("SELECT * FROM users WHERE tokens LIKE ?") - .unwrap(); - let res = query.query_row([format!("%,\"{token}\"%")], |x| { - Ok(Self::get_user_from_row(x)) - }); - - if res.is_err() { - return Err(Error::UserNotFound); - } - - Ok(res.unwrap()) - } - - /// Create a new user in the database. - /// - /// # Arguments - /// * `data` - a mock [`User`] object to insert - pub async fn create_user(&self, data: User) -> Result<()> { - if self.0.security.registration_enabled == false { - return Err(Error::RegistrationDisabled); - } - - let conn = match Self::connect(&self.0.name) { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = conn.execute( - "INSERT INTO users VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - data.id, - data.created, - data.username, - data.password, - data.salt, - serde_json::to_string(&data.settings).unwrap(), - serde_json::to_string(&data.tokens).unwrap(), - ), - ); - - if res.is_err() { - return Err(Error::DatabaseError); - } - - Ok(()) - } - - /// Create a new user in the database. - /// - /// # Arguments - /// * `id` - the ID of the user - /// * `password` - the current password of the user - /// * `force` - if we should delete even if the given password is incorrect - pub async fn delete_user(&self, id: &str, password: &str, force: bool) -> Result<()> { - let user = self.get_user_by_id(id).await?; - - if (hash_salted(password.to_string(), user.salt) != user.password) && !force { - return Err(Error::IncorrectPassword); - } - - let conn = match Self::connect(&self.0.name) { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = conn.execute("DELETE FROM users WHERE id = ?", [id]); - - if res.is_err() { - return Err(Error::DatabaseError); - } - - Ok(()) - } -} diff --git a/src/data/mod.rs b/src/data/mod.rs deleted file mode 100644 index e3e1a47..0000000 --- a/src/data/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod assets; -pub mod manager; -pub mod model; - -pub use manager::DataManager; diff --git a/src/macros.rs b/src/macros.rs deleted file mode 100644 index 69bae99..0000000 --- a/src/macros.rs +++ /dev/null @@ -1,40 +0,0 @@ -#[macro_export] -macro_rules! write_template { - ($atto_dir:ident->$path:literal($as:expr)) => { - std::fs::write($atto_dir.join($path), $as).unwrap(); - }; - - ($atto_dir:ident->$path:literal($as:expr) -d $dir_path:literal) => { - let dir = $atto_dir.join($dir_path); - if !std::fs::exists(&dir).unwrap() { - std::fs::create_dir(dir).unwrap(); - } - - std::fs::write($atto_dir.join($path), $as).unwrap(); - }; -} - -#[macro_export] -macro_rules! get_user_from_token { - (($jar:ident, $db:ident) ) => {{ - if let Some(token) = $jar.get("__Secure-Atto-Token") { - match $db.get_user_by_token(&token.to_string()).await { - Ok(ua) => Some(ua), - Err(_) => None, - } - } else { - None - } - }}; - - ($jar:ident, $db:ident) => {{ - if let Some(token) = $jar.get("__Secure-Atto-Token") { - match $db.get_user_by_token(token) { - Ok(ua) => ua, - Err(_) => return axum::response::Html(crate::data::assets::REDIRECT_TO_AUTH), - } - } else { - None - } - }}; -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 3891545..0000000 --- a/src/main.rs +++ /dev/null @@ -1,44 +0,0 @@ -mod config; -mod data; -mod macros; -mod routes; - -use data::DataManager; - -use axum::{Extension, Router}; -use tower_http::trace::{self, TraceLayer}; -use tracing::{Level, info}; - -use std::sync::Arc; -use tokio::sync::RwLock; - -pub(crate) type State = Arc>; - -#[tokio::main] -async fn main() { - tracing_subscriber::fmt() - .with_target(false) - .compact() - .init(); - - let config = config::Config::get_config(); - - let app = Router::new() - .merge(routes::routes()) - .layer(Extension(Arc::new(RwLock::new( - DataManager::new(config.clone()).await.unwrap(), - )))) - .layer( - TraceLayer::new_for_http() - .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) - .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), - ); - - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) - .await - .unwrap(); - - info!("🐐 tetratto."); - info!("listening on http://0.0.0.0:{}", config.port); - axum::serve(listener, app).await.unwrap(); -} diff --git a/src/public/html/auth/register.html b/src/public/html/auth/register.html deleted file mode 100644 index 71f0d08..0000000 --- a/src/public/html/auth/register.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "_atto/auth/base.html" %} {% block head %} -🐐 Register -{% endblock %} {% block title %}Register{% endblock %} {% block content %} -
-
- - -
- -
- - -
- - -
-{% endblock %} {% block footer %} -Or, login -{% endblock %} diff --git a/src/public/html/root.html b/src/public/html/root.html deleted file mode 100644 index 04c055e..0000000 --- a/src/public/html/root.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - {% block head %}{% endblock %} - - - - {% block body %}{% endblock %} - - diff --git a/src/public/js/atto.js b/src/public/js/atto.js deleted file mode 100644 index fd4ae4a..0000000 --- a/src/public/js/atto.js +++ /dev/null @@ -1,31 +0,0 @@ -console.log("🐐 tetratto - https://github.com/trisuaso/tetratto"); - -// theme preference -function media_theme_pref() { - document.documentElement.removeAttribute("class"); - - if ( - window.matchMedia("(prefers-color-scheme: dark)").matches && - !window.localStorage.getItem("tetratto:theme") - ) { - document.documentElement.classList.add("dark"); - // window.localStorage.setItem("theme", "dark"); - } else if ( - window.matchMedia("(prefers-color-scheme: light)").matches && - !window.localStorage.getItem("tetratto:theme") - ) { - document.documentElement.classList.remove("dark"); - // window.localStorage.setItem("theme", "light"); - } else if (window.localStorage.getItem("tetratto:theme")) { - /* restore theme */ - const current = window.localStorage.getItem("tetratto:theme"); - document.documentElement.className = current; - } -} - -function set_theme(theme) { - window.localStorage.setItem("tetratto:theme", theme); - document.documentElement.className = theme; -} - -media_theme_pref(); diff --git a/src/routes/api/v1/auth.rs b/src/routes/api/v1/auth.rs deleted file mode 100644 index 9256a3a..0000000 --- a/src/routes/api/v1/auth.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::{ApiReturn, AuthProps}; -use crate::{ - State, - data::model::{Error, User}, - get_user_from_token, -}; -use axum::{Extension, Json, response::IntoResponse}; -use axum_extra::extract::CookieJar; - -pub async fn register_request( - jar: CookieJar, - Extension(data): Extension, - Json(props): Json, -) -> impl IntoResponse { - let data = data.read().await; - let user = get_user_from_token!((jar, data) ); - - if user.is_some() { - return Json(ApiReturn { - ok: false, - message: Error::AlreadyAuthenticated.to_string(), - payload: (), - }); - } - - match data - .create_user(User::new(props.username, props.password)) - .await - { - Ok(_) => Json(ApiReturn { - ok: true, - message: "User created".to_string(), - payload: (), - }), - Err(_) => Json(ApiReturn { - ok: false, - message: Error::Unknown.to_string(), - payload: (), - }), - } -} diff --git a/src/routes/api/v1/mod.rs b/src/routes/api/v1/mod.rs deleted file mode 100644 index 1b3f2f8..0000000 --- a/src/routes/api/v1/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -pub mod auth; -use axum::{Router, routing::post}; -use serde::{Deserialize, Serialize}; - -pub fn routes() -> Router { - Router::new().route("/auth/register", post(auth::register_request)) -} - -#[derive(Serialize, Deserialize)] -pub struct ApiReturn -where - T: Serialize, -{ - pub ok: bool, - pub message: String, - pub payload: T, -} - -impl ApiReturn -where - T: Serialize, -{ - pub fn to_json(&self) -> String { - serde_json::to_string(&self).unwrap() - } -} - -#[derive(Deserialize)] -pub struct AuthProps { - pub username: String, - pub password: String, -} diff --git a/src/routes/mod.rs b/src/routes/mod.rs deleted file mode 100644 index 5dc5573..0000000 --- a/src/routes/mod.rs +++ /dev/null @@ -1,69 +0,0 @@ -pub mod api; -pub mod assets; - -use crate::{State, get_user_from_token}; -use axum::{ - Extension, Router, - response::{Html, IntoResponse, Redirect}, - routing::get, -}; -use axum_extra::extract::CookieJar; - -/// `/` -pub async fn index_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { - let data = data.read().await; - let user = get_user_from_token!((jar, data) ); - - let mut context = data.initial_context(); - Html(data.1.render("index.html", &mut context).unwrap()) -} - -/// `/_atto/login` -pub async fn login_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { - let data = data.read().await; - let user = get_user_from_token!((jar, data) ); - - if user.is_some() { - return Err(Redirect::to("/")); - } - - let mut context = data.initial_context(); - Ok(Html( - data.1 - .render("_atto/auth/login.html", &mut context) - .unwrap(), - )) -} - -/// `/_atto/register` -pub async fn register_request( - jar: CookieJar, - Extension(data): Extension, -) -> impl IntoResponse { - let data = data.read().await; - let user = get_user_from_token!((jar, data) ); - - if user.is_some() { - return Err(Redirect::to("/")); - } - - let mut context = data.initial_context(); - Ok(Html( - data.1 - .render("_atto/auth/register.html", &mut context) - .unwrap(), - )) -} - -pub fn routes() -> Router { - Router::new() - // assets - .route("/css/style.css", get(assets::style_css_request)) - .route("/js/atto.js", get(assets::atto_js_request)) - // api - .nest("/api/v1", api::v1::routes()) - // pages - .route("/", get(index_request)) - .route("/_atto/login", get(login_request)) - .route("/_atto/register", get(register_request)) -} diff --git a/tetratto.toml b/tetratto.toml new file mode 100644 index 0000000..0aa07cd --- /dev/null +++ b/tetratto.toml @@ -0,0 +1,16 @@ +name = "Tetratto" +description = "🐐 tetratto!" +color = "#c9b1bc" +port = 4118 + +[security] +registration_enabled = true +admin_user = "admin" +real_ip_header = "CF-Connecting-IP" + +[dirs] +templates = "html" +assets = "public" + +[database] +name = "atto.db"