commit 771494671be1c50141cd695397d42ecc977c21f5 Author: Mateusz Gruszczyński Date: Fri Jul 17 15:29:08 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72c09d7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +target +rustpad.db +rustpad.db-shm +rustpad.db-wal +.env +.env.docker +*.log +README.md +Dockerfile* +docker-compose*.yml diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..a2886c0 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,9 @@ +IMAGE_TAG=0.3.0 +RUSTPAD_PORT=3000 +APP_HOST=0.0.0.0 +APP_PORT=3000 +DATABASE_URL=sqlite:///data/rustpad.db?mode=rwc +DATABASE_MAX_CONNECTIONS=8 +STATIC_DIR=/app/static +ASSET_VERSION=0.5.1 +RUST_LOG=rustpad=info,tower_http=info diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c5bb4ae --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Serwer deweloperski +APP_HOST=0.0.0.0 +APP_PORT=3000 + +# SQLite +DATABASE_URL=sqlite://rustpad.db?mode=rwc +DATABASE_MAX_CONNECTIONS=8 + +# Assety i logowanie +STATIC_DIR=static +ASSET_VERSION=0.0.1 +RUST_LOG=rustpad=debug,tower_http=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86ee631 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +rustpad.db +rustpad.db-shm +rustpad.db-wal +.env +.env.docker +*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2da6791 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2525 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[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.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustpad" +version = "0.6.0" +dependencies = [ + "argon2", + "axum", + "chrono", + "dotenvy", + "futures-util", + "rand_core 0.6.4", + "serde", + "serde_json", + "slug", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[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.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..16be754 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rustpad" +version = "0.6.0" +edition = "2024" +rust-version = "1.85" +description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite" +license = "MIT" + +[dependencies] +argon2 = "0.5" +axum = { version = "0.8", features = ["ws"] } +chrono = { version = "0.4", features = ["serde"] } +dotenvy = "0.15" +futures-util = "0.3" +rand_core = { version = "0.6", features = ["getrandom"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +slug = "0.1" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "migrate"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] } +tower-http = { version = "0.6", features = ["fs", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..733b953 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM rust:1.85-bookworm AS builder +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY migrations ./migrations +COPY src ./src +COPY static ./static + +RUN cargo build --release + +FROM debian:bookworm-slim AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --create-home rustpad \ + && mkdir -p /app/static /data \ + && chown -R rustpad:rustpad /app /data + +WORKDIR /app +COPY --from=builder /app/target/release/rustpad /usr/local/bin/rustpad +COPY --from=builder /app/static ./static + +USER rustpad +ENV APP_HOST=0.0.0.0 \ + APP_PORT=3000 \ + DATABASE_URL=sqlite:///data/rustpad.db?mode=rwc \ + DATABASE_MAX_CONNECTIONS=8 \ + STATIC_DIR=/app/static \ + RUST_LOG=rustpad=info,tower_http=info + +EXPOSE 3000 +VOLUME ["/data"] +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl --fail --silent http://127.0.0.1:3000/health || exit 1 + +ENTRYPOINT ["rustpad"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9d2cf4 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# RustPad 0.6.0 + +Współdzielony edytor Markdown z dwoma niezależnymi trybami: + +- szybka notatka pod `/p/`, +- workspace z wieloma notatkami pod `/w/`. + +Oba tryby obsługują opcjonalne hasła, edycję na żywo, SQLite, historię zmian, podgląd Markdown i stan widoku zapisany w URL. + +## Uruchomienie lokalne + +```bash +cp .env.example .env +cargo run +``` + +Otwórz `http://127.0.0.1:3000`. + +## Docker + +```bash +cp .env.docker.example .env +docker compose up --build +``` + +Baza znajduje się w wolumenie `rustpad_data`. + +## Adresy + +```text +/p/notatka?view=split&mode=markdown +/w/workspace +/w/workspace/n/notatka?view=split&mode=markdown +``` + +## Frontend + +- `home.js` — tworzenie szybkiej notatki i workspace, +- `pad.js` — samodzielna notatka, +- `workspace.js` — lista i tworzenie notatek w workspace, +- `note.js` — notatka należąca do workspace, +- `url-state.js` — stan widoku w URL, +- `clipboard.js` — kopiowanie pełnego adresu, +- `socket.js` — połączenia WebSocket, +- `markdown.js` — renderowanie Markdown, +- `editor-format.js` — formatowanie tekstu, +- `api.js` — komunikacja HTTP, +- `session.js` — hasła workspace w `sessionStorage`. + +## Dane istniejące + +Tabele `pads` i `revisions` pozostają aktywne. Workspace są dodatkiem, nie zamiennikiem zwykłych notatek. Przed aktualizacją wykonaj kopię `rustpad.db`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2b46d93 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + rustpad: + build: + context: . + dockerfile: Dockerfile + image: rustpad:${IMAGE_TAG:-local} + restart: unless-stopped + environment: + APP_HOST: ${APP_HOST:-0.0.0.0} + APP_PORT: ${APP_PORT:-3000} + DATABASE_URL: ${DATABASE_URL:-sqlite:///data/rustpad.db?mode=rwc} + DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8} + STATIC_DIR: ${STATIC_DIR:-/app/static} + ASSET_VERSION: ${ASSET_VERSION:-0.0.1} + RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=info} + ports: + - "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}" + volumes: + - rustpad_data:/data + +volumes: + rustpad_data: diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..ec67696 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS pads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + password_hash TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pad_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_revisions_pad_id ON revisions(pad_id, id DESC); diff --git a/migrations/0002_workspaces.sql b/migrations/0002_workspaces.sql new file mode 100644 index 0000000..2f2f98f --- /dev/null +++ b/migrations/0002_workspaces.sql @@ -0,0 +1,43 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + password_hash TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + UNIQUE(workspace_id, slug) +); + +CREATE TABLE IF NOT EXISTS note_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_notes_workspace ON notes(workspace_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_note_revisions_note ON note_revisions(note_id, id DESC); + +-- Zachowanie danych z wersji 0.4.x: stara notatka staje się workspace z jedną notatką. +INSERT OR IGNORE INTO workspaces (id, slug, title, password_hash, created_at, updated_at) +SELECT id, slug, title, password_hash, created_at, updated_at FROM pads; + +INSERT OR IGNORE INTO notes (id, workspace_id, slug, title, content, created_at, updated_at) +SELECT id, id, 'notatka', title, content, created_at, updated_at FROM pads; + +INSERT OR IGNORE INTO note_revisions (id, note_id, content, created_at) +SELECT id, pad_id, content, created_at FROM revisions; diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..44f48c7 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,500 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use slug::slugify; + +use crate::{ + db, + state::{NoteUpdate, SharedState}, +}; + +const MAX_NAME_LENGTH: usize = 80; +const MIN_PASSWORD_LENGTH: usize = 8; +const MAX_PASSWORD_LENGTH: usize = 128; +const MIN_WORKSPACE_SLUG_LENGTH: usize = 6; + +#[derive(Debug, Deserialize)] +pub struct CreateWorkspaceRequest { + name: String, + #[serde(default)] + password: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateWorkspaceResponse { + slug: String, + url: String, +} + +#[derive(Debug, Deserialize)] +pub struct PasswordRequest { + #[serde(default)] + password: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateNoteRequest { + name: String, + #[serde(default)] + password: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RestoreRequest { + #[serde(default)] + password: Option, + revision_id: i64, +} + +#[derive(Debug, Serialize)] +pub struct WorkspaceInfo { + slug: String, + title: String, + protected: bool, + created_at: String, + updated_at: String, +} + +#[derive(Debug, Serialize)] +pub struct WorkspaceOpenResponse { + workspace: WorkspaceInfo, + notes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct NoteListItem { + slug: String, + title: String, + created_at: String, + updated_at: String, + url: String, +} + +#[derive(Debug, Serialize)] +pub struct NoteInfo { + workspace_slug: String, + workspace_title: String, + slug: String, + title: String, + protected: bool, + created_at: String, + updated_at: String, +} + +pub async fn create_workspace( + State(state): State, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let title = validate_name(&payload.name, "Nazwa workspace")?; + let password = validate_password(payload.password.as_deref())?; + let slug = unique_workspace_slug(&state, title).await?; + + db::create_workspace(&state.db, &slug, title, password).await?; + + Ok(( + StatusCode::CREATED, + Json(CreateWorkspaceResponse { + url: format!("/w/{slug}"), + slug, + }), + )) +} + +pub async fn workspace_info( + State(state): State, + Path(workspace_slug): Path, +) -> Result, ApiError> { + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + Ok(Json(workspace_info_from(&workspace))) +} + +pub async fn open_workspace( + State(state): State, + Path(workspace_slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?; + let notes = db::list_notes(&state.db, workspace.id) + .await? + .into_iter() + .map(|note| NoteListItem { + url: format!("/w/{}/n/{}", workspace.slug, note.slug), + slug: note.slug, + title: note.title, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + }) + .collect(); + + Ok(Json(WorkspaceOpenResponse { + workspace: workspace_info_from(&workspace), + notes, + })) +} + +pub async fn create_note( + State(state): State, + Path(workspace_slug): Path, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?; + let title = validate_name(&payload.name, "Nazwa notatki")?; + let base = slugify(title); + if base.is_empty() { + return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu")); + } + + let slug = unique_note_slug(&state, workspace.id, &base).await?; + let note = db::create_note(&state.db, workspace.id, &slug, title).await?; + Ok(( + StatusCode::CREATED, + Json(NoteListItem { + url: format!("/w/{workspace_slug}/n/{slug}"), + slug: note.slug, + title: note.title, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + }), + )) +} + +pub async fn note_info( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, +) -> Result, ApiError> { + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + let note = db::find_note(&state.db, workspace.id, ¬e_slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + + Ok(Json(NoteInfo { + workspace_slug: workspace.slug, + workspace_title: workspace.title, + slug: note.slug, + title: note.title, + protected: workspace.password_hash.is_some(), + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + })) +} + +pub async fn history( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result>, ApiError> { + let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let _ = workspace; + Ok(Json(db::list_revisions(&state.db, note.id).await?)) +} + +pub async fn restore( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let content: Option = sqlx::query_scalar( + "SELECT content FROM note_revisions WHERE id = ? AND note_id = ?", + ) + .bind(payload.revision_id) + .bind(note.id) + .fetch_optional(&state.db) + .await?; + let content = content.ok_or_else(ApiError::not_found_revision)?; + let (revision_id, updated_at) = + db::save_revision(&state.db, note.id, workspace.id, &content).await?; + let update = NoteUpdate { + content, + revision_id, + updated_at, + }; + let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update); + Ok(Json(serde_json::json!({"ok": true}))) +} + +pub async fn authorized_workspace( + state: &SharedState, + slug: &str, + password: Option<&str>, +) -> Result { + let workspace = db::find_workspace(&state.db, slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + if !db::verify_workspace_password(&workspace, password) { + return Err(ApiError::unauthorized()); + } + Ok(workspace) +} + +async fn authorized_note( + state: &SharedState, + workspace_slug: &str, + note_slug: &str, + password: Option<&str>, +) -> Result<(db::Workspace, db::Note), ApiError> { + let workspace = authorized_workspace(state, workspace_slug, password).await?; + let note = db::find_note(&state.db, workspace.id, note_slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok((workspace, note)) +} + +fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo { + WorkspaceInfo { + slug: workspace.slug.clone(), + title: workspace.title.clone(), + protected: workspace.password_hash.is_some(), + created_at: db::normalize_timestamp(&workspace.created_at), + updated_at: db::normalize_timestamp(&workspace.updated_at), + } +} + +fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> { + let value = value.trim(); + if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH { + return Err(ApiError::bad_request(&format!( + "{field} musi mieć od 1 do {MAX_NAME_LENGTH} znaków" + ))); + } + Ok(value) +} + +fn validate_password(password: Option<&str>) -> Result, ApiError> { + let Some(password) = password.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let length = password.chars().count(); + if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) { + return Err(ApiError::bad_request( + "Hasło musi mieć od 8 do 128 znaków", + )); + } + Ok(Some(password)) +} + +async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result { + let base = slugify(title); + if base.is_empty() { + return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu")); + } + + let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH + || db::find_workspace(&state.db, &base).await?.is_some(); + if !needs_suffix { + return Ok(base); + } + + for _ in 0..8 { + let candidate = format!("{base}-{}", db::random_suffix(8)); + if db::find_workspace(&state.db, &candidate).await?.is_none() { + return Ok(candidate); + } + } + Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu")) +} + +async fn unique_note_slug( + state: &SharedState, + workspace_id: i64, + base: &str, +) -> Result { + if db::find_note(&state.db, workspace_id, base).await?.is_none() { + return Ok(base.to_owned()); + } + for _ in 0..8 { + let candidate = format!("{base}-{}", db::random_suffix(6)); + if db::find_note(&state.db, workspace_id, &candidate) + .await? + .is_none() + { + return Ok(candidate); + } + } + Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu")) +} + + +#[derive(Debug, Deserialize)] +pub struct CreatePadRequest { + name: String, + #[serde(default)] + password: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreatePadResponse { + slug: String, + url: String, +} + +#[derive(Debug, Serialize)] +pub struct PadInfo { + slug: String, + title: String, + protected: bool, + created_at: String, + updated_at: String, +} + +pub async fn create_pad( + State(state): State, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let title = validate_name(&payload.name, "Nazwa notatki")?; + let password = validate_password(payload.password.as_deref())?; + let base = slugify(title); + if base.is_empty() { + return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu")); + } + let slug = unique_pad_slug(&state, &base).await?; + db::create_pad(&state.db, &slug, title, password).await?; + Ok(( + StatusCode::CREATED, + Json(CreatePadResponse { + url: format!("/p/{slug}"), + slug, + }), + )) +} + +pub async fn pad_info( + State(state): State, + Path(slug): Path, +) -> Result, ApiError> { + let pad = db::find_pad(&state.db, &slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok(Json(PadInfo { + slug: pad.slug, + title: pad.title, + protected: pad.password_hash.is_some(), + created_at: db::normalize_timestamp(&pad.created_at), + updated_at: db::normalize_timestamp(&pad.updated_at), + })) +} + +pub async fn pad_history( + State(state): State, + Path(slug): Path, + Json(payload): Json, +) -> Result>, ApiError> { + let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; + Ok(Json(db::list_pad_revisions(&state.db, pad.id).await?)) +} + +pub async fn pad_restore( + State(state): State, + Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; + let content: Option = sqlx::query_scalar( + "SELECT content FROM revisions WHERE id = ? AND pad_id = ?", + ) + .bind(payload.revision_id) + .bind(pad.id) + .fetch_optional(&state.db) + .await?; + let content = content.ok_or_else(ApiError::not_found_revision)?; + let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content).await?; + let update = NoteUpdate { + content, + revision_id, + updated_at, + }; + let _ = state.pad_channel(&slug).await.send(update); + Ok(Json(serde_json::json!({"ok": true}))) +} + +async fn authorized_pad( + state: &SharedState, + slug: &str, + password: Option<&str>, +) -> Result { + let pad = db::find_pad(&state.db, slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + if !db::verify_pad_password(&pad, password) { + return Err(ApiError::unauthorized()); + } + Ok(pad) +} + +async fn unique_pad_slug(state: &SharedState, base: &str) -> Result { + if db::find_pad(&state.db, base).await?.is_none() { + return Ok(base.to_owned()); + } + for _ in 0..8 { + let candidate = format!("{base}-{}", db::random_suffix(6)); + if db::find_pad(&state.db, &candidate).await?.is_none() { + return Ok(candidate); + } + } + Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu")) +} + +pub struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn bad_request(message: &str) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + fn unauthorized() -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: "Nieprawidłowe hasło".into(), + } + } + fn not_found_workspace() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Nie znaleziono workspace".into(), + } + } + fn not_found_note() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Nie znaleziono notatki".into(), + } + } + fn not_found_revision() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Nie znaleziono wersji".into(), + } + } + fn internal(message: &str) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } +} + +impl From for ApiError { + fn from(error: sqlx::Error) -> Self { + tracing::error!(%error, "database error"); + Self::internal("Błąd bazy danych") + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(serde_json::json!({"error": self.message}))).into_response() + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..17f526e --- /dev/null +++ b/src/app.rs @@ -0,0 +1,213 @@ +use axum::{ + extract::{Path, State}, + http::{header, HeaderValue, StatusCode}, + response::{Html, IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use tower_http::{services::ServeDir, trace::TraceLayer}; + +use crate::{api, db, state::SharedState, websocket}; + +pub fn router(state: SharedState, static_dir: &str) -> Router { + Router::new() + .route("/", get(home)) + .route("/p/{slug}", get(pad)) + .route("/w/{workspace_slug}", get(workspace)) + .route("/w/{workspace_slug}/n/{note_slug}", get(note)) + .route("/health", get(health)) + .route("/api/pads", post(api::create_pad)) + .route("/api/pads/{slug}", get(api::pad_info)) + .route("/api/pads/{slug}/history", post(api::pad_history)) + .route("/api/pads/{slug}/restore", post(api::pad_restore)) + .route("/api/workspaces", post(api::create_workspace)) + .route("/api/workspaces/{workspace_slug}", get(api::workspace_info)) + .route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace)) + .route("/api/workspaces/{workspace_slug}/notes", post(api::create_note)) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}", + get(api::note_info), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/history", + post(api::history), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/restore", + post(api::restore), + ) + .route("/ws/p/{slug}", get(websocket::upgrade_pad)) + .route( + "/ws/{workspace_slug}/{note_slug}", + get(websocket::upgrade), + ) + .nest_service("/assets", ServeDir::new(static_dir)) + .fallback(not_found) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +async fn health() -> &'static str { + "ok" +} + +async fn home(State(state): State) -> Response { + versioned_html(include_str!("../static/home.html"), &state.asset_version) +} + +async fn pad( + State(state): State, + Path(slug): Path, +) -> Response { + match db::find_pad(&state.db, &slug).await { + Ok(Some(_)) => versioned_html(include_str!("../static/pad.html"), &state.asset_version), + Ok(None) => error_response( + StatusCode::NOT_FOUND, + "404", + "Nie znaleziono notatki", + "Ta notatka nie istnieje albo została usunięta.", + "/", + "Strona główna", + &state.asset_version, + ), + Err(error) => { + tracing::error!(%error, %slug, "failed to load standalone pad"); + internal_error(&state.asset_version) + } + } +} + +async fn workspace( + State(state): State, + Path(workspace_slug): Path, +) -> Response { + match db::find_workspace(&state.db, &workspace_slug).await { + Ok(Some(_)) => versioned_html( + include_str!("../static/workspace.html"), + &state.asset_version, + ), + Ok(None) => error_response( + StatusCode::NOT_FOUND, + "404", + "Nie znaleziono workspace", + "Ten workspace nie istnieje albo został usunięty.", + "/", + "Strona główna", + &state.asset_version, + ), + Err(error) => { + tracing::error!(%error, %workspace_slug, "failed to load workspace page"); + internal_error(&state.asset_version) + } + } +} + +async fn note( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, +) -> Response { + let workspace = match db::find_workspace(&state.db, &workspace_slug).await { + Ok(Some(workspace)) => workspace, + Ok(None) => { + return error_response( + StatusCode::NOT_FOUND, + "404", + "Nie znaleziono workspace", + "Workspace tej notatki nie istnieje albo został usunięty.", + "/", + "Strona główna", + &state.asset_version, + ); + } + Err(error) => { + tracing::error!(%error, %workspace_slug, "failed to load note workspace"); + return internal_error(&state.asset_version); + } + }; + + match db::find_note(&state.db, workspace.id, ¬e_slug).await { + Ok(Some(_)) => versioned_html(include_str!("../static/note.html"), &state.asset_version), + Ok(None) => error_response( + StatusCode::NOT_FOUND, + "404", + "Nie znaleziono notatki", + "Ta notatka nie istnieje albo została usunięta.", + &format!("/w/{workspace_slug}"), + "Wróć do workspace", + &state.asset_version, + ), + Err(error) => { + tracing::error!(%error, %workspace_slug, %note_slug, "failed to load note page"); + internal_error(&state.asset_version) + } + } +} + +async fn not_found(State(state): State) -> Response { + error_response( + StatusCode::NOT_FOUND, + "404", + "Nie znaleziono strony", + "Sprawdź adres albo wróć na stronę główną.", + "/", + "Strona główna", + &state.asset_version, + ) +} + +fn internal_error(asset_version: &str) -> Response { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "500", + "Błąd serwera", + "Nie udało się wczytać strony. Spróbuj ponownie za chwilę.", + "/", + "Strona główna", + asset_version, + ) +} + +fn error_response( + status: StatusCode, + code: &str, + title: &str, + message: &str, + primary_url: &str, + primary_label: &str, + asset_version: &str, +) -> Response { + let html = include_str!("../static/error.html") + .replace("__ASSET_VERSION__", &escape_html(asset_version)) + .replace("__ERROR_CODE__", &escape_html(code)) + .replace("__ERROR_TITLE__", &escape_html(title)) + .replace("__ERROR_MESSAGE__", &escape_html(message)) + .replace("__PRIMARY_URL__", &escape_html(primary_url)) + .replace("__PRIMARY_LABEL__", &escape_html(primary_label)); + + let mut response = (status, Html(html)).into_response(); + no_store(&mut response); + response +} + +fn versioned_html(template: &str, asset_version: &str) -> Response { + let html = template.replace("__ASSET_VERSION__", asset_version); + let mut response = Html(html).into_response(); + no_store(&mut response); + response +} + +fn no_store(response: &mut Response) { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-store, must-revalidate"), + ); +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..954ebc8 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,35 @@ +use std::{env, net::IpAddr}; + +#[derive(Debug, Clone)] +pub struct Config { + pub host: IpAddr, + pub port: u16, + pub database_url: String, + pub database_max_connections: u32, + pub static_dir: String, + pub asset_version: String, +} + +impl Config { + pub fn from_env() -> Result> { + let host = env_var("APP_HOST", "127.0.0.1").parse()?; + let port = env_var("APP_PORT", "3000").parse()?; + let database_max_connections = env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?; + + Ok(Self { + host, + port, + database_url: env_var("DATABASE_URL", "sqlite://rustpad.db?mode=rwc"), + database_max_connections, + static_dir: env_var("STATIC_DIR", "static"), + asset_version: env::var("ASSET_VERSION") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_owned()), + }) + } +} + +fn env_var(name: &str, default: &str) -> String { + env::var(name).unwrap_or_else(|_| default.to_owned()) +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..f2416f3 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,289 @@ +use argon2::{ + password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier, +}; +use chrono::{DateTime, Utc}; +use rand_core::{OsRng, RngCore}; +use serde::Serialize; +use sqlx::{FromRow, SqlitePool}; + +#[derive(Debug, Clone, FromRow)] +pub struct Workspace { + pub id: i64, + pub slug: String, + pub title: String, + pub password_hash: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct Note { + pub id: i64, + #[serde(skip_serializing)] + pub workspace_id: i64, + pub slug: String, + pub title: String, + #[serde(skip_serializing)] + pub content: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize, FromRow)] +pub struct Revision { + pub id: i64, + pub content: String, + pub created_at: String, +} + +pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Workspace>( + "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?", + ) + .bind(slug) + .fetch_optional(pool) + .await +} + +pub async fn create_workspace( + pool: &SqlitePool, + slug: &str, + title: &str, + password: Option<&str>, +) -> Result { + let password_hash = password.filter(|value| !value.is_empty()).map(hash_password); + let result = sqlx::query( + "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)", + ) + .bind(slug) + .bind(title) + .bind(password_hash) + .execute(pool) + .await?; + + sqlx::query_as::<_, Workspace>( + "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE id = ?", + ) + .bind(result.last_insert_rowid()) + .fetch_one(pool) + .await +} + +pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool { + match (&workspace.password_hash, password.filter(|value| !value.is_empty())) { + (None, _) => true, + (Some(hash), Some(password)) => PasswordHash::new(hash) + .ok() + .and_then(|parsed| { + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .ok() + }) + .is_some(), + (Some(_), None) => false, + } +} + +pub async fn list_notes(pool: &SqlitePool, workspace_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Note>( + "SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC", + ) + .bind(workspace_id) + .fetch_all(pool) + .await +} + +pub async fn find_note( + pool: &SqlitePool, + workspace_id: i64, + slug: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Note>( + "SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? AND slug = ?", + ) + .bind(workspace_id) + .bind(slug) + .fetch_optional(pool) + .await +} + +pub async fn create_note( + pool: &SqlitePool, + workspace_id: i64, + slug: &str, + title: &str, +) -> Result { + let result = sqlx::query( + "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)", + ) + .bind(workspace_id) + .bind(slug) + .bind(title) + .execute(pool) + .await?; + + sqlx::query_as::<_, Note>( + "SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE id = ?", + ) + .bind(result.last_insert_rowid()) + .fetch_one(pool) + .await +} + +pub async fn save_revision( + pool: &SqlitePool, + note_id: i64, + workspace_id: i64, + content: &str, +) -> Result<(i64, String), sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query("UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + .bind(content) + .bind(note_id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?") + .bind(workspace_id) + .execute(&mut *tx) + .await?; + let result = sqlx::query("INSERT INTO note_revisions (note_id, content) VALUES (?, ?)") + .bind(note_id) + .bind(content) + .execute(&mut *tx) + .await?; + let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?") + .bind(note_id) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok((result.last_insert_rowid(), updated_at)) +} + +pub async fn list_revisions(pool: &SqlitePool, note_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Revision>( + "SELECT id, content, created_at FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100", + ) + .bind(note_id) + .fetch_all(pool) + .await +} + +pub fn random_suffix(length: usize) -> String { + const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; + let mut bytes = vec![0_u8; length]; + let mut rng = OsRng; + rng.fill_bytes(&mut bytes); + bytes + .into_iter() + .map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char) + .collect() +} + +fn hash_password(password: &str) -> String { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .expect("Argon2 hashing should succeed") + .to_string() +} + +pub fn normalize_timestamp(value: &str) -> String { + DateTime::parse_from_rfc3339(value) + .map(|dt| dt.with_timezone(&Utc).to_rfc3339()) + .unwrap_or_else(|_| value.replace(' ', "T") + "Z") +} + +#[derive(Debug, Clone, FromRow)] +pub struct Pad { + pub id: i64, + pub slug: String, + pub title: String, + pub content: String, + pub password_hash: Option, + pub created_at: String, + pub updated_at: String, +} + +pub async fn find_pad(pool: &SqlitePool, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Pad>( + "SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE slug = ?", + ) + .bind(slug) + .fetch_optional(pool) + .await +} + +pub async fn create_pad( + pool: &SqlitePool, + slug: &str, + title: &str, + password: Option<&str>, +) -> Result { + let password_hash = password.filter(|value| !value.is_empty()).map(hash_password); + let result = sqlx::query( + "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)", + ) + .bind(slug) + .bind(title) + .bind(password_hash) + .execute(pool) + .await?; + + sqlx::query_as::<_, Pad>( + "SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE id = ?", + ) + .bind(result.last_insert_rowid()) + .fetch_one(pool) + .await +} + +pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { + match (&pad.password_hash, password.filter(|value| !value.is_empty())) { + (None, _) => true, + (Some(hash), Some(password)) => PasswordHash::new(hash) + .ok() + .and_then(|parsed| { + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .ok() + }) + .is_some(), + (Some(_), None) => false, + } +} + +pub async fn save_pad_revision( + pool: &SqlitePool, + pad_id: i64, + content: &str, +) -> Result<(i64, String), sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query("UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + .bind(content) + .bind(pad_id) + .execute(&mut *tx) + .await?; + let result = sqlx::query("INSERT INTO revisions (pad_id, content) VALUES (?, ?)") + .bind(pad_id) + .bind(content) + .execute(&mut *tx) + .await?; + let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?") + .bind(pad_id) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok((result.last_insert_rowid(), updated_at)) +} + +pub async fn list_pad_revisions( + pool: &SqlitePool, + pad_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Revision>( + "SELECT id, content, created_at FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100", + ) + .bind(pad_id) + .fetch_all(pool) + .await +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..aa355dd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,67 @@ +mod api; +mod app; +mod config; +mod db; +mod state; +mod websocket; + +use std::{net::SocketAddr, sync::Arc}; + +use config::Config; +use sqlx::sqlite::SqlitePoolOptions; +use state::AppState; +use tokio::net::TcpListener; +use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenvy::dotenv().ok(); + init_tracing(); + + let config = Config::from_env()?; + let db = SqlitePoolOptions::new() + .max_connections(config.database_max_connections) + .connect(&config.database_url) + .await?; + sqlx::migrate!().run(&db).await?; + + let state = Arc::new(AppState::new(db, config.asset_version.clone())); + let app = app::router(state, &config.static_dir); + let address = SocketAddr::new(config.host, config.port); + let listener = TcpListener::bind(address).await?; + + info!(%address, asset_version = %config.asset_version, "RustPad is running"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +fn init_tracing() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "rustpad=debug,tower_http=info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { () = ctrl_c => {}, () = terminate => {} } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..7b94869 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,52 @@ +use std::{collections::HashMap, sync::Arc}; + +use sqlx::SqlitePool; +use tokio::sync::{broadcast, RwLock}; + +const CHANNEL_CAPACITY: usize = 256; + +#[derive(Debug, Clone)] +pub struct NoteUpdate { + pub content: String, + pub revision_id: i64, + pub updated_at: String, +} + +#[derive(Debug)] +pub struct AppState { + pub db: SqlitePool, + pub asset_version: String, + channels: RwLock>>, +} + +impl AppState { + pub fn new(db: SqlitePool, asset_version: String) -> Self { + Self { + db, + asset_version, + channels: RwLock::new(HashMap::new()), + } + } + + async fn channel_for_key(&self, key: String) -> broadcast::Sender { + if let Some(sender) = self.channels.read().await.get(&key) { + return sender.clone(); + } + + let mut channels = self.channels.write().await; + channels + .entry(key) + .or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0) + .clone() + } + + pub async fn note_channel(&self, workspace_slug: &str, note_slug: &str) -> broadcast::Sender { + self.channel_for_key(format!("workspace:{workspace_slug}/{note_slug}")).await + } + + pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender { + self.channel_for_key(format!("pad:{slug}")).await + } +} + +pub type SharedState = Arc; diff --git a/src/websocket.rs b/src/websocket.rs new file mode 100644 index 0000000..3f60634 --- /dev/null +++ b/src/websocket.rs @@ -0,0 +1,292 @@ +use axum::{ + extract::{ + ws::{Message, WebSocket}, + Path, State, WebSocketUpgrade, + }, + response::Response, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +use crate::{ + db, + state::{NoteUpdate, SharedState}, +}; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientMessage { + Authenticate { password: Option }, + Update { content: String }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ServerMessage { + Authenticated { + workspace_title: String, + note_title: String, + content: String, + }, + Document { + content: String, + revision_id: i64, + updated_at: String, + }, + Error { message: String }, +} + +pub async fn upgrade( + ws: WebSocketUpgrade, + Path((workspace_slug, note_slug)): Path<(String, String)>, + State(state): State, +) -> Response { + ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug)) +} + +async fn handle_socket( + mut socket: WebSocket, + state: SharedState, + workspace_slug: String, + note_slug: String, +) { + let Some(workspace) = db::find_workspace(&state.db, &workspace_slug) + .await + .ok() + .flatten() + else { + let _ = send_error(&mut socket, "Nie znaleziono workspace").await; + return; + }; + let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug) + .await + .ok() + .flatten() + else { + let _ = send_error(&mut socket, "Nie znaleziono notatki").await; + return; + }; + + let password = match socket.recv().await { + Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { + Ok(ClientMessage::Authenticate { password }) => password, + _ => { + let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await; + return; + } + }, + _ => return, + }; + + if !db::verify_workspace_password(&workspace, password.as_deref()) { + let _ = send_error(&mut socket, "Nieprawidłowe hasło").await; + return; + } + + if send( + &mut socket, + &ServerMessage::Authenticated { + workspace_title: workspace.title.clone(), + note_title: note.title.clone(), + content: note.content.clone(), + }, + ) + .await + .is_err() + { + return; + } + + let channel = state.note_channel(&workspace_slug, ¬e_slug).await; + let mut updates = channel.subscribe(); + let (mut sender, mut receiver) = socket.split(); + + loop { + tokio::select! { + incoming = receiver.next() => { + match incoming { + Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { + Ok(ClientMessage::Update { content }) => { + if content.len() > 2_000_000 { + let _ = send_split(&mut sender, &ServerMessage::Error { message: "Dokument jest zbyt duży".into() }).await; + continue; + } + match db::save_revision(&state.db, note.id, workspace.id, &content).await { + Ok((revision_id, updated_at)) => { + let _ = channel.send(NoteUpdate { content, revision_id, updated_at }); + } + Err(error) => warn!(%error, "failed to save revision"), + } + } + Ok(ClientMessage::Authenticate { .. }) => {} + Err(error) => warn!(%error, "invalid WebSocket message"), + }, + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} + Some(Err(error)) => { + debug!(%error, "WebSocket receive error"); + break; + } + } + } + update = updates.recv() => match update { + Ok(update) => { + if send_split(&mut sender, &ServerMessage::Document { + content: update.content, + revision_id: update.revision_id, + updated_at: update.updated_at, + }).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + if let Ok(Some(current)) = db::find_note(&state.db, workspace.id, ¬e_slug).await { + if send_split(&mut sender, &ServerMessage::Document { + content: current.content, + revision_id: 0, + updated_at: current.updated_at, + }).await.is_err() { + break; + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } +} + +async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> { + send(socket, &ServerMessage::Error { message: message.into() }).await +} + +async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> { + let payload = serde_json::to_string(message).expect("serializing server message cannot fail"); + socket.send(Message::Text(payload.into())).await +} + +async fn send_split( + sender: &mut futures_util::stream::SplitSink, + message: &ServerMessage, +) -> Result<(), axum::Error> { + let payload = serde_json::to_string(message).expect("serializing server message cannot fail"); + sender.send(Message::Text(payload.into())).await +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PadServerMessage { + Authenticated { title: String, content: String }, + Document { content: String, revision_id: i64, updated_at: String }, + Error { message: String }, +} + +pub async fn upgrade_pad( + ws: WebSocketUpgrade, + Path(slug): Path, + State(state): State, +) -> Response { + ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug)) +} + +async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) { + let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else { + let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Nie znaleziono notatki".into() }).await; + return; + }; + + let password = match socket.recv().await { + Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { + Ok(ClientMessage::Authenticate { password }) => password, + _ => { + let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Wymagane uwierzytelnienie".into() }).await; + return; + } + }, + _ => return, + }; + + if !db::verify_pad_password(&pad, password.as_deref()) { + let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Nieprawidłowe hasło".into() }).await; + return; + } + + if send_pad(&mut socket, &PadServerMessage::Authenticated { + title: pad.title.clone(), + content: pad.content.clone(), + }).await.is_err() { + return; + } + + let channel = state.pad_channel(&slug).await; + let mut updates = channel.subscribe(); + let (mut sender, mut receiver) = socket.split(); + + loop { + tokio::select! { + incoming = receiver.next() => { + match incoming { + Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { + Ok(ClientMessage::Update { content }) => { + if content.len() > 2_000_000 { + let _ = send_pad_split(&mut sender, &PadServerMessage::Error { message: "Dokument jest zbyt duży".into() }).await; + continue; + } + match db::save_pad_revision(&state.db, pad.id, &content).await { + Ok((revision_id, updated_at)) => { + let _ = channel.send(NoteUpdate { content, revision_id, updated_at }); + } + Err(error) => warn!(%error, "failed to save pad revision"), + } + } + Ok(ClientMessage::Authenticate { .. }) => {} + Err(error) => warn!(%error, "invalid pad WebSocket message"), + }, + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} + Some(Err(error)) => { + debug!(%error, "pad WebSocket receive error"); + break; + } + } + } + update = updates.recv() => match update { + Ok(update) => { + if send_pad_split(&mut sender, &PadServerMessage::Document { + content: update.content, + revision_id: update.revision_id, + updated_at: update.updated_at, + }).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + if let Ok(Some(current)) = db::find_pad(&state.db, &slug).await { + if send_pad_split(&mut sender, &PadServerMessage::Document { + content: current.content, + revision_id: 0, + updated_at: current.updated_at, + }).await.is_err() { + break; + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } +} + +async fn send_pad(socket: &mut WebSocket, message: &PadServerMessage) -> Result<(), axum::Error> { + let payload = serde_json::to_string(message).expect("serializing pad server message cannot fail"); + socket.send(Message::Text(payload.into())).await +} + +async fn send_pad_split( + sender: &mut futures_util::stream::SplitSink, + message: &PadServerMessage, +) -> Result<(), axum::Error> { + let payload = serde_json::to_string(message).expect("serializing pad server message cannot fail"); + sender.send(Message::Text(payload.into())).await +} diff --git a/static/error.html b/static/error.html new file mode 100644 index 0000000..af9a516 --- /dev/null +++ b/static/error.html @@ -0,0 +1,24 @@ + + + + + + + + __ERROR_TITLE__ · RustPad + + + +
+
+

__ERROR_CODE__

+

__ERROR_TITLE__

+

__ERROR_MESSAGE__

+
+ __PRIMARY_LABEL__ + +
+
+
+ + diff --git a/static/home.html b/static/home.html new file mode 100644 index 0000000..e79136c --- /dev/null +++ b/static/home.html @@ -0,0 +1,63 @@ + + + + + + + RustPad + + + + + +
+
+

Nowa przestrzeń

+

Utwórz szybką notatkę albo workspace z wieloma notatkami.

+
+ +
+
+
+

Notatka

+

Jeden dokument pod własnym linkiem.

+
+
+
+ + +
/p/notatki-ze-spotkania0/80
+
+
+
opcjonalne, min. 8 znaków
+
+
+ + +
+
+ +
+
+

Workspace

+

Przestrzeń z listą wielu notatek.

+
+
+
+ + +
/w/moj-projekt0/80
+ Krótka lub zajęta nazwa otrzyma losowy sufiks. +
+
+
opcjonalne, min. 8 znaków
+
+
+ + +
+
+
+
+ + diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..c0950f8 --- /dev/null +++ b/static/js/api.js @@ -0,0 +1,19 @@ +export async function api(path, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12000); + try { + const response = await fetch(path, { + ...options, + headers: { "content-type": "application/json", ...(options.headers || {}) }, + signal: controller.signal, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `Błąd ${response.status}`); + return data; + } catch (error) { + if (error.name === "AbortError") throw new Error("Przekroczono czas odpowiedzi serwera"); + throw error; + } finally { + clearTimeout(timeout); + } +} diff --git a/static/js/clipboard.js b/static/js/clipboard.js new file mode 100644 index 0000000..88916a9 --- /dev/null +++ b/static/js/clipboard.js @@ -0,0 +1,16 @@ +export async function copyText(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + const input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + const copied = document.execCommand("copy"); + input.remove(); + if (!copied) throw new Error("Nie udało się skopiować linku"); +} diff --git a/static/js/editor-format.js b/static/js/editor-format.js new file mode 100644 index 0000000..c545a33 --- /dev/null +++ b/static/js/editor-format.js @@ -0,0 +1,23 @@ +export function applyFormat(editor, format) { + const wrap = (before, after = before, placeholder = "tekst") => { + const start = editor.selectionStart, end = editor.selectionEnd; + const selected = editor.value.slice(start, end) || placeholder; + editor.setRangeText(before + selected + after, start, end, "select"); + }; + const prefix = (value) => { + const start = editor.selectionStart, end = editor.selectionEnd; + const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1; + const selected = editor.value.slice(lineStart, end); + editor.setRangeText(selected.split("\n").map((line, index) => typeof value === "function" ? value(index) + line : value + line).join("\n"), lineStart, end, "select"); + }; + if (format === "bold") wrap("**"); + if (format === "italic") wrap("*"); + if (format === "strike") wrap("~~"); + if (format === "heading") prefix("## "); + if (format === "bullet") prefix("- "); + if (format === "number") prefix((index) => `${index + 1}. `); + if (format === "quote") prefix("> "); + if (format === "link") wrap("[", "](https://)", "opis linku"); + editor.focus(); + editor.dispatchEvent(new Event("input", { bubbles: true })); +} diff --git a/static/js/home.js b/static/js/home.js new file mode 100644 index 0000000..3750d0e --- /dev/null +++ b/static/js/home.js @@ -0,0 +1,76 @@ +import { api } from "./api.js?v=0.6.0"; + +function slugify(value, fallback) { + return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback; +} + +function bindPreview(inputId, previewId, countId, prefix, fallback) { + const input = document.querySelector(inputId); + const preview = document.querySelector(previewId); + const count = document.querySelector(countId); + const update = () => { + count.textContent = `${input.value.length}/80`; + preview.textContent = `${prefix}${slugify(input.value, fallback)}`; + }; + input.addEventListener("input", update); + update(); +} + +function setBusy(button, busy, idleText, busyText) { + button.disabled = busy; + button.textContent = busy ? busyText : idleText; +} + +bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "notatka"); +bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace"); + +document.querySelectorAll(".password-toggle").forEach((button) => { + button.addEventListener("click", () => { + const input = document.getElementById(button.dataset.target); + const show = input.type === "password"; + input.type = show ? "text" : "password"; + button.textContent = show ? "Ukryj" : "Pokaż"; + }); +}); + +document.querySelector("#pad-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const name = document.querySelector("#pad-name"); + const password = document.querySelector("#pad-password"); + const button = document.querySelector("#pad-button"); + const error = document.querySelector("#pad-error"); + error.textContent = ""; + setBusy(button, true, "Utwórz notatkę", "Tworzenie…"); + try { + const payload = { name: name.value.trim() }; + if (password.value) payload.password = password.value; + const result = await api("/api/pads", { method: "POST", body: JSON.stringify(payload) }); + if (password.value) sessionStorage.setItem(`rustpad:pad:${result.slug}:password`, password.value); + window.location.assign(`${result.url}?view=split&mode=markdown`); + } catch (requestError) { + error.textContent = requestError.message; + } finally { + setBusy(button, false, "Utwórz notatkę", "Tworzenie…"); + } +}); + +document.querySelector("#workspace-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const name = document.querySelector("#workspace-name"); + const password = document.querySelector("#workspace-password"); + const button = document.querySelector("#workspace-button"); + const error = document.querySelector("#workspace-error"); + error.textContent = ""; + setBusy(button, true, "Utwórz workspace", "Tworzenie…"); + try { + const payload = { name: name.value.trim() }; + if (password.value) payload.password = password.value; + const result = await api("/api/workspaces", { method: "POST", body: JSON.stringify(payload) }); + if (password.value) sessionStorage.setItem(`rustpad:workspace:${result.slug}:password`, password.value); + window.location.assign(result.url); + } catch (requestError) { + error.textContent = requestError.message; + } finally { + setBusy(button, false, "Utwórz workspace", "Tworzenie…"); + } +}); diff --git a/static/js/markdown.js b/static/js/markdown.js new file mode 100644 index 0000000..86ae262 --- /dev/null +++ b/static/js/markdown.js @@ -0,0 +1,26 @@ +function escapeHtml(value) { + return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); +} +function inline(value) { + return escapeHtml(value) + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/~~([^~]+)~~/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); +} +export function renderMarkdown(source) { + let html = "", inCode = false, list = null; + const closeList = () => { if (list) { html += ``; list = null; } }; + for (const line of source.split("\n")) { + if (line.startsWith("```")) { closeList(); html += inCode ? "" : "
"; inCode = !inCode; continue; }
+    if (inCode) { html += `${escapeHtml(line)}\n`; continue; }
+    const heading = line.match(/^(#{1,6})\s+(.+)$/);
+    const ul = line.match(/^\s*[-*+]\s+(.+)$/);
+    const ol = line.match(/^\s*\d+\.\s+(.+)$/);
+    if (heading) { closeList(); const n = heading[1].length; html += `${inline(heading[2])}`; }
+    else if (ul || ol) { const type = ul ? "ul" : "ol"; if (list !== type) { closeList(); html += `<${type}>`; list = type; } html += `
  • ${inline((ul || ol)[1])}
  • `; } + else { closeList(); if (/^---+$/.test(line)) html += "
    "; else if (line.startsWith("> ")) html += `
    ${inline(line.slice(2))}
    `; else if (line.trim()) html += `

    ${inline(line)}

    `; else html += "
    "; } + } + closeList(); if (inCode) html += "
    "; return html; +} diff --git a/static/js/note.js b/static/js/note.js new file mode 100644 index 0000000..9386572 --- /dev/null +++ b/static/js/note.js @@ -0,0 +1,53 @@ +import { api } from "./api.js?v=0.6.0"; +import { copyText } from "./clipboard.js?v=0.6.0"; +import { applyFormat } from "./editor-format.js?v=0.6.0"; +import { renderMarkdown } from "./markdown.js?v=0.6.0"; +import { getPassword, setPassword } from "./session.js?v=0.6.0"; +import { NoteSocket } from "./socket.js?v=0.6.0"; +import { currentShareUrl, readEditorState, writeEditorState } from "./url-state.js?v=0.6.0"; + +const parts = location.pathname.split("/").filter(Boolean); +const workspaceSlug = parts[1], noteSlug = parts[3]; +const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"); +const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"); +let password = getPassword(workspaceSlug), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(); + +function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1600); } +function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; } +function updateAddressLabel() { document.querySelector("#note-url").textContent = `${location.pathname}${location.search}`; } +function render() { + if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Podgląd Markdown"; } + else { preview.classList.add("preview--raw"); preview.textContent = editor.value; document.querySelector("#preview-label").textContent = "Tekst źródłowy"; } + document.querySelector("#characters").textContent = `${editor.value.length} znaków`; + const words = editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0; document.querySelector("#words").textContent = `${words} słów`; +} +function applyUi({ write = false, replace = false } = {}) { + editorWorkspace.className = `workspace view-${uiState.view}`; + document.querySelectorAll("[data-view]").forEach(button => { const active = button.dataset.view === uiState.view; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); + const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.setAttribute("aria-pressed", String(markdown)); modeToggle.textContent = markdown ? "Markdown" : "Tekst"; modeToggle.title = markdown ? "Kliknij, aby pokazać tekst bez interpretacji" : "Kliknij, aby interpretować Markdown"; + render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); +} +function applyRemote(content) { if (content === editor.value) return; const start = editor.selectionStart, end = editor.selectionEnd; applyingRemote = true; editor.value = content; editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); applyingRemote = false; render(); } +function connect() { + socket?.stop(); socket = new NoteSocket({ workspaceSlug, noteSlug, password, + onStatus: state => setStatus(state === "online" ? "online" : state === "offline" ? "offline" : null, state === "online" ? "Połączono" : state === "offline" ? "Ponowne łączenie…" : "Łączenie…"), + onAuthenticated: message => { if (passwordDialog.open) passwordDialog.close(); document.querySelector("#note-title").textContent = message.note_title; document.querySelector("#workspace-link").textContent = message.workspace_title; applyRemote(message.content); editor.focus(); }, + onDocument: message => { applyRemote(message.content); document.querySelector("#save-state").textContent = `Zapisano ${new Date(message.updated_at).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })}`; }, + onError: message => { document.querySelector("#password-error").textContent = message; if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } + }); socket.connect(); +} +async function initialize() { + try { info = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`); document.querySelector("#note-title").textContent = info.title; document.querySelector("#workspace-link").textContent = info.workspace_title; document.querySelector("#workspace-link").href = `/w/${encodeURIComponent(workspaceSlug)}`; document.querySelector("#back-workspace").href = `/w/${encodeURIComponent(workspaceSlug)}`; document.title = `${info.title} · ${info.workspace_title}`; applyUi({ write: true, replace: true }); if (info.protected && !password) passwordDialog.showModal(); else connect(); } catch (e) { document.body.innerHTML = `

    Nie znaleziono notatki

    ${e.message}

    Wróć do workspace
    `; } +} + +document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => { uiState = { ...uiState, view: button.dataset.view }; applyUi({ write: true }); })); +modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); +window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); +window.addEventListener("rustpad:urlchange", updateAddressLabel); +document.querySelector("#copy-link").addEventListener("click", async () => { try { const url = currentShareUrl(uiState); await copyText(url); toast("Skopiowano link z widokiem"); } catch (e) { toast(e.message); } }); +document.querySelectorAll("[data-format]").forEach(button => button.addEventListener("click", () => applyFormat(editor, button.dataset.format))); +editor.addEventListener("input", () => { render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Zapisywanie…"; saveTimer = setTimeout(() => socket?.update(editor.value), 250); }); +document.querySelector("#password-form").addEventListener("submit", event => { event.preventDefault(); password = document.querySelector("#open-password").value; setPassword(workspaceSlug, password); document.querySelector("#password-error").textContent = ""; connect(); }); +const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '

    Ładowanie…

    '; try { const revisions = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`, { method: "POST", body: JSON.stringify({ password: password || null }) }); list.innerHTML = revisions.length ? revisions.map(r => `
    `).join("") : '

    Brak historii.

    '; list.querySelectorAll("[data-revision]").forEach(button => button.addEventListener("click", async () => { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST", body: JSON.stringify({ password: password || null, revision_id: Number(button.dataset.revision) }) }); toast("Przywrócono wersję"); })); } catch (e) { list.innerHTML = `

    ${e.message}

    `; } }); +document.querySelector("#close-history").addEventListener("click", () => { historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); +initialize(); diff --git a/static/js/pad.js b/static/js/pad.js new file mode 100644 index 0000000..5ddb6e0 --- /dev/null +++ b/static/js/pad.js @@ -0,0 +1,173 @@ +import { api } from "./api.js?v=0.6.0"; +import { copyText } from "./clipboard.js?v=0.6.0"; +import { applyFormat } from "./editor-format.js?v=0.6.0"; +import { renderMarkdown } from "./markdown.js?v=0.6.0"; +import { PadSocket } from "./socket.js?v=0.6.0"; +import { currentShareUrl, readEditorState, writeEditorState } from "./url-state.js?v=0.6.0"; + +const slug = location.pathname.split("/").filter(Boolean)[1]; +const passwordKey = `rustpad:pad:${slug}:password`; +const editor = document.querySelector("#editor"); +const preview = document.querySelector("#preview"); +const editorWorkspace = document.querySelector("#editor-workspace"); +const modeToggle = document.querySelector("#mode-toggle"); +const passwordDialog = document.querySelector("#password-dialog"); +let password = sessionStorage.getItem(passwordKey) || ""; +let info; +let socket; +let saveTimer; +let applyingRemote = false; +let uiState = readEditorState(); + +function toast(text) { + const element = document.querySelector("#toast"); + element.textContent = text; + element.classList.add("visible"); + setTimeout(() => element.classList.remove("visible"), 1600); +} + +function setStatus(kind, text) { + document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; + document.querySelector("#status-text").textContent = text; +} + +function updateAddressLabel() { + document.querySelector("#pad-url").textContent = `${location.pathname}${location.search}`; +} + +function render() { + if (uiState.mode === "markdown") { + preview.classList.remove("preview--raw"); + preview.innerHTML = renderMarkdown(editor.value); + document.querySelector("#preview-label").textContent = "Podgląd Markdown"; + } else { + preview.classList.add("preview--raw"); + preview.textContent = editor.value; + document.querySelector("#preview-label").textContent = "Tekst źródłowy"; + } + document.querySelector("#characters").textContent = `${editor.value.length} znaków`; + const words = editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0; + document.querySelector("#words").textContent = `${words} słów`; +} + +function applyUi({ write = false, replace = false } = {}) { + editorWorkspace.className = `workspace view-${uiState.view}`; + document.querySelectorAll("[data-view]").forEach((button) => { + const active = button.dataset.view === uiState.view; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); + }); + const markdown = uiState.mode === "markdown"; + modeToggle.classList.toggle("active", markdown); + modeToggle.setAttribute("aria-pressed", String(markdown)); + modeToggle.textContent = markdown ? "Markdown" : "Tekst"; + modeToggle.title = markdown ? "Pokaż tekst bez interpretacji" : "Interpretuj Markdown"; + render(); + if (write) writeEditorState(uiState, { replace }); + updateAddressLabel(); +} + +function applyRemote(content) { + if (content === editor.value) return; + const start = editor.selectionStart; + const end = editor.selectionEnd; + applyingRemote = true; + editor.value = content; + editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); + applyingRemote = false; + render(); +} + +function connect() { + socket?.stop(); + socket = new PadSocket({ + slug, + password, + onStatus: (state) => setStatus(state === "online" ? "online" : state === "offline" ? "offline" : null, state === "online" ? "Połączono" : state === "offline" ? "Ponowne łączenie…" : "Łączenie…"), + onAuthenticated: (message) => { + if (passwordDialog.open) passwordDialog.close(); + document.querySelector("#pad-title").textContent = message.title; + applyRemote(message.content); + editor.focus(); + }, + onDocument: (message) => { + applyRemote(message.content); + document.querySelector("#save-state").textContent = `Zapisano ${new Date(message.updated_at).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })}`; + }, + onError: (message) => { + document.querySelector("#password-error").textContent = message; + if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); + }, + }); + socket.connect(); +} + +async function initialize() { + try { + info = await api(`/api/pads/${encodeURIComponent(slug)}`); + document.querySelector("#pad-title").textContent = info.title; + document.title = `${info.title} · RustPad`; + applyUi({ write: true, replace: true }); + if (info.protected && !password) passwordDialog.showModal(); + else connect(); + } catch (error) { + location.replace("/"); + } +} + +document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => { + uiState = { ...uiState, view: button.dataset.view }; + applyUi({ write: true }); +})); +modeToggle.addEventListener("click", () => { + uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; + applyUi({ write: true }); +}); +window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); +window.addEventListener("rustpad:urlchange", updateAddressLabel); +document.querySelector("#copy-link").addEventListener("click", async () => { + try { + await copyText(currentShareUrl(uiState)); + toast("Skopiowano link z widokiem"); + } catch (error) { + toast(error.message); + } +}); +document.querySelectorAll("[data-format]").forEach((button) => button.addEventListener("click", () => applyFormat(editor, button.dataset.format))); +editor.addEventListener("input", () => { + render(); + if (applyingRemote) return; + clearTimeout(saveTimer); + document.querySelector("#save-state").textContent = "Zapisywanie…"; + saveTimer = setTimeout(() => socket?.update(editor.value), 250); +}); +document.querySelector("#password-form").addEventListener("submit", (event) => { + event.preventDefault(); + password = document.querySelector("#open-password").value; + sessionStorage.setItem(passwordKey, password); + document.querySelector("#password-error").textContent = ""; + connect(); +}); +const historyPanel = document.querySelector("#history-panel"); +document.querySelector("#history-button").addEventListener("click", async () => { + historyPanel.setAttribute("aria-hidden", "false"); + document.body.classList.add("history-open"); + const list = document.querySelector("#history-list"); + list.innerHTML = '

    Ładowanie…

    '; + try { + const revisions = await api(`/api/pads/${encodeURIComponent(slug)}/history`, { method: "POST", body: JSON.stringify({ password: password || null }) }); + list.innerHTML = revisions.length ? revisions.map((revision) => `
    `).join("") : '

    Brak historii.

    '; + list.querySelectorAll("[data-revision]").forEach((button) => button.addEventListener("click", async () => { + await api(`/api/pads/${encodeURIComponent(slug)}/restore`, { method: "POST", body: JSON.stringify({ password: password || null, revision_id: Number(button.dataset.revision) }) }); + toast("Przywrócono wersję"); + })); + } catch (error) { + list.innerHTML = `

    ${error.message}

    `; + } +}); +document.querySelector("#close-history").addEventListener("click", () => { + historyPanel.setAttribute("aria-hidden", "true"); + document.body.classList.remove("history-open"); +}); + +initialize(); diff --git a/static/js/session.js b/static/js/session.js new file mode 100644 index 0000000..4c85e24 --- /dev/null +++ b/static/js/session.js @@ -0,0 +1,6 @@ +export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; } +export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; } +export function setPassword(workspaceSlug, password) { + if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); + else sessionStorage.removeItem(passwordKey(workspaceSlug)); +} diff --git a/static/js/socket.js b/static/js/socket.js new file mode 100644 index 0000000..2590f98 --- /dev/null +++ b/static/js/socket.js @@ -0,0 +1,60 @@ +export class NoteSocket { + constructor({ workspaceSlug, noteSlug, password, onStatus, onAuthenticated, onDocument, onError }) { + Object.assign(this, { workspaceSlug, noteSlug, password, onStatus, onAuthenticated, onDocument, onError }); + this.socket = null; this.timer = null; this.closed = false; + } + connect() { + clearTimeout(this.timer); this.onStatus?.("connecting"); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + this.socket = new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); + this.socket.addEventListener("open", () => this.socket.send(JSON.stringify({ type: "authenticate", password: this.password || null }))); + this.socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); } + if (message.type === "authenticated") { this.onStatus?.("online"); this.onAuthenticated?.(message); } + if (message.type === "document") this.onDocument?.(message); + }); + this.socket.addEventListener("close", () => { if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); } }); + this.socket.addEventListener("error", () => this.socket.close()); + } + update(content) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: "update", content })); } + stop() { this.closed = true; clearTimeout(this.timer); this.socket?.close(); } +} + +export class PadSocket { + constructor({ slug, password, onStatus, onAuthenticated, onDocument, onError }) { + Object.assign(this, { slug, password, onStatus, onAuthenticated, onDocument, onError }); + this.socket = null; + this.timer = null; + this.closed = false; + } + connect() { + clearTimeout(this.timer); + this.closed = false; + this.onStatus?.("connecting"); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + this.socket = new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`); + this.socket.addEventListener("open", () => this.socket.send(JSON.stringify({ type: "authenticate", password: this.password || null }))); + this.socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); } + if (message.type === "authenticated") { this.onStatus?.("online"); this.onAuthenticated?.(message); } + if (message.type === "document") this.onDocument?.(message); + }); + this.socket.addEventListener("close", () => { + if (!this.closed) { + this.onStatus?.("offline"); + this.timer = setTimeout(() => this.connect(), 1500); + } + }); + this.socket.addEventListener("error", () => this.socket.close()); + } + update(content) { + if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: "update", content })); + } + stop() { + this.closed = true; + clearTimeout(this.timer); + this.socket?.close(); + } +} diff --git a/static/js/url-state.js b/static/js/url-state.js new file mode 100644 index 0000000..6e70869 --- /dev/null +++ b/static/js/url-state.js @@ -0,0 +1,27 @@ +const VIEWS = new Set(["edit", "split", "preview"]); +const MODES = new Set(["markdown", "text"]); + +export function readEditorState() { + const params = new URLSearchParams(window.location.search); + return { + view: VIEWS.has(params.get("view")) ? params.get("view") : "split", + mode: MODES.has(params.get("mode")) ? params.get("mode") : "markdown", + }; +} + +export function writeEditorState(state, { replace = false } = {}) { + const url = new URL(window.location.href); + url.searchParams.set("view", state.view); + url.searchParams.set("mode", state.mode); + const method = replace ? "replaceState" : "pushState"; + window.history[method]({ ...state }, "", `${url.pathname}${url.search}${url.hash}`); + window.dispatchEvent(new CustomEvent("rustpad:urlchange", { detail: { url: url.href } })); + return url.href; +} + +export function currentShareUrl(state) { + const url = new URL(window.location.href); + url.searchParams.set("view", state.view); + url.searchParams.set("mode", state.mode); + return url.href; +} diff --git a/static/js/workspace.js b/static/js/workspace.js new file mode 100644 index 0000000..7f4bb3a --- /dev/null +++ b/static/js/workspace.js @@ -0,0 +1,11 @@ +import { api } from "./api.js?v=0.6.0"; import { copyText } from "./clipboard.js?v=0.6.0"; import { getPassword, setPassword } from "./session.js?v=0.6.0"; +const parts = location.pathname.split("/").filter(Boolean), slug = parts[1]; let info, password = getPassword(slug); const dialog = document.querySelector("#password-dialog"), notesList = document.querySelector("#notes-list"); +function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1600); } +function renderNotes(notes) { notesList.innerHTML = notes.length ? notes.map(note => `

    ${escapeHtml(note.title)}

    Aktualizacja: ${new Date(note.updated_at).toLocaleString("pl-PL")}

    `).join("") : '

    Brak notatek.

    '; } +function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; } +async function openWorkspace() { try { const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) }); info = data.workspace; document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; document.title = `${info.title} · RustPad`; renderNotes(data.notes); if (dialog.open) dialog.close(); } catch (e) { if (info?.protected || e.message.includes("hasło")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); } else document.querySelector("#workspace-error").textContent = e.message; } } +async function init() { try { info = await api(`/api/workspaces/${encodeURIComponent(slug)}`); document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; if (info.protected && !password) dialog.showModal(); else openWorkspace(); } catch (e) { document.querySelector("#workspace-error").textContent = e.message; } } +document.querySelector("#password-form").addEventListener("submit", e => { e.preventDefault(); password = document.querySelector("#open-password").value; setPassword(slug, password); openWorkspace(); }); +document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close()); +document.querySelector("#note-form").addEventListener("submit", async e => { e.preventDefault(); const error = document.querySelector("#note-error"); error.textContent = ""; try { const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { method: "POST", body: JSON.stringify({ name: document.querySelector("#note-name").value, password: password || null }) }); location.assign(`${note.url}?view=split&mode=markdown`); } catch (err) { error.textContent = err.message; } }); +document.querySelector("#copy-workspace-link").addEventListener("click", async () => { try { await copyText(new URL(location.pathname, location.origin).href); toast("Skopiowano link"); } catch (e) { toast(e.message); } }); init(); diff --git a/static/note.html b/static/note.html new file mode 100644 index 0000000..7bfb120 --- /dev/null +++ b/static/note.html @@ -0,0 +1,4 @@ +Notatka · RustPad +
    RustPad

    Ładowanie…

    Łączenie…
    +
    Edytor
    Podgląd Markdown
    0 znaków · 0 słów
    Zmiany zapisują się automatycznie
    +

    Workspace chroniony

    Wróć
    diff --git a/static/pad.html b/static/pad.html new file mode 100644 index 0000000..b518979 --- /dev/null +++ b/static/pad.html @@ -0,0 +1,19 @@ + + + + + + + Notatka · RustPad + + + + +
    +
    RustPad

    Ładowanie…

    +
    Łączenie…
    +
    +
    Edytor
    Podgląd Markdown
    0 znaków · 0 słów
    Zmiany zapisują się automatycznie
    +

    Notatka chroniona

    Wróć
    + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..803e5c6 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,207 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + background: #0b0d11; + color: #f4f6f8; + --bg: #0b0d11; + --surface: #11141a; + --surface-2: #151922; + --surface-3: #1a1f29; + --border: #272d38; + --border-strong: #343c49; + --text: #f4f6f8; + --muted: #929cab; + --muted-2: #697383; + --accent: #7c68ee; + --accent-hover: #8d79f8; + --accent-soft: #272246; + --danger: #ff7b91; + --success: #40d3a3; +} + +* { box-sizing: border-box; } +html { min-width: 320px; background: var(--bg); } +body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); } +button, input, select, textarea { font: inherit; } +button, a, input, select, textarea { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: wait; opacity: .65; } +a { color: inherit; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.brand { color: var(--text); font-weight: 700; text-decoration: none; letter-spacing: -.02em; } + +.site-header { width: min(720px, calc(100% - 32px)); min-height: 64px; margin: 0 auto; display: flex; align-items: center; border-bottom: 1px solid var(--border); } + +.home-layout { width: min(520px, calc(100% - 32px)); margin: 0 auto; padding: 72px 0; } +.home-intro { margin-bottom: 28px; } +.home-intro h1 { margin: 0; font-size: clamp(2rem, 7vw, 3rem); letter-spacing: -.04em; } +.home-intro p { margin: 10px 0 0; color: var(--muted); line-height: 1.6; } + +.panel { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } +.create-panel { padding: 24px; } +#create-form { display: grid; gap: 20px; padding-top: 22px; } +.field { display: grid; gap: 8px; } +.field label { color: #dce1e8; font-size: .86rem; font-weight: 750; } +.label-row, .field-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.label-row span, .field-meta, .field small { color: var(--muted-2); font-size: .73rem; } +input, select { border: 1px solid var(--border-strong); outline: none; background: #0e1116; color: var(--text); } +input { width: 100%; min-height: 46px; padding: 0 13px; border-radius: 10px; } +input:focus, select:focus, textarea:focus { border-color: #7567db; outline: 1px solid #7567db; outline-offset: 1px; } +.password-input { position: relative; } +.password-input input { padding-right: 72px; } +.text-button { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); border: 0; background: transparent; color: #a99ef8; padding: 7px; font-size: .78rem; } +.form-message { min-height: 1.2em; margin: -5px 0 0; font-size: .8rem; } +.error { color: var(--danger); } +.primary-button, .secondary-button, .inline-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; border-radius: 8px; font-weight: 650; text-decoration: none; } +.primary-button { width: 100%; border: 1px solid #8372ef; background: var(--accent); color: white; padding: 0 16px; } +.primary-button:hover { background: var(--accent-hover); } +.secondary-button { border: 1px solid var(--border-strong); background: var(--surface-2); color: #d2d8e1; padding: 0 13px; } +.secondary-button:hover { border-color: #4b5565; background: var(--surface-3); } +.inline-button { width: auto; margin-top: 18px; padding: 0 16px; } + +.app-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 70px; padding: 0 20px; border-bottom: 1px solid var(--border); background: #0d1015; } +.app-header__main, .header-actions { display: flex; align-items: center; gap: 14px; min-width: 0; } +.header-divider { width: 1px; height: 30px; background: var(--border); } +.document-heading { min-width: 0; } +.document-heading h1 { overflow: hidden; margin: 0; font-size: 1rem; white-space: nowrap; text-overflow: ellipsis; } +.document-url { overflow: hidden; max-width: 360px; margin: 3px 0 0; color: var(--muted-2); font-size: .72rem; white-space: nowrap; text-overflow: ellipsis; } +.status { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 8px; color: var(--muted); font-size: .78rem; } +.status__dot { width: 7px; height: 7px; border-radius: 50%; background: #e3a94d; } +.status__dot.is-online { background: var(--success); } +.status__dot.is-offline { background: var(--danger); } + +.editor-layout { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 0; height: calc(100vh - 70px); overflow: hidden; transition: grid-template-columns .18s ease; } +.history-open .editor-layout { grid-template-columns: minmax(0, 1fr) 340px; } +.editor-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; background: var(--surface); } +.editor-toolbar { display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: #101319; } +.toolbar-group, .view-switch { display: inline-flex; align-items: center; gap: 4px; padding-right: 8px; border-right: 1px solid var(--border); } +.toolbar-group:last-child, .view-switch { padding-right: 0; border-right: 0; } +.toolbar-fill { flex: 1; } +.editor-toolbar button, .editor-toolbar select { min-height: 34px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: #b8c0cc; padding: 0 9px; font-size: .78rem; } +.editor-toolbar button:hover, .editor-toolbar select:hover { border-color: var(--border); background: var(--surface-2); color: white; } +.editor-toolbar select { border-color: var(--border); background: #11151c; } +.view-switch { padding: 3px; border: 1px solid var(--border); border-radius: 9px; background: #0d1015; } +.view-switch button.active { background: var(--surface-3); color: white; } + +.workspace { display: grid; min-height: 0; background: #0e1116; } +.workspace.view-split { grid-template-columns: 1fr 1fr; } +.workspace.view-edit { grid-template-columns: 1fr; } +.workspace.view-preview { grid-template-columns: 1fr; } +.workspace.view-edit .preview-column, .workspace.view-preview .editor-column { display: none; } +.editor-column, .preview-column { display: grid; grid-template-rows: 30px minmax(0, 1fr); min-width: 0; min-height: 0; } +.preview-column { border-left: 1px solid var(--border); } +.column-label { display: flex; align-items: center; padding: 0 18px; border-bottom: 1px solid #202631; background: #10141a; color: var(--muted-2); font-size: .7rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } +textarea { display: block; width: 100%; height: 100%; min-height: 0; resize: none; padding: 24px; border: 0; outline: none; background: #0d1015; color: #edf1f6; font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; caret-color: #9b89ff; } +textarea::placeholder { color: #515a68; } +textarea::selection { background: rgba(124,104,238,.35); } +.preview { overflow: auto; min-height: 0; padding: 24px; color: #dce2eb; line-height: 1.72; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 1.25em 0 .5em; letter-spacing: -.03em; } +.markdown-body h1:first-child, .markdown-body h2:first-child, .markdown-body h3:first-child { margin-top: 0; } +.markdown-body h1 { font-size: 2rem; } +.markdown-body h2 { font-size: 1.5rem; } +.markdown-body p { margin: .72em 0; } +.markdown-body code { padding: .16em .36em; border: 1px solid #303745; border-radius: 5px; background: #1a2029; } +.markdown-body pre { overflow: auto; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: #0a0d12; } +.markdown-body pre code { padding: 0; border: 0; background: transparent; } +.markdown-body blockquote { margin: 1em 0; padding: .2em 1em; border-left: 3px solid var(--accent); color: #abb5c3; } +.markdown-body a { color: #aa9df8; } +.markdown-body hr { border: 0; border-top: 1px solid var(--border); } +.editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 38px; padding: 0 16px; border-top: 1px solid var(--border); color: var(--muted-2); font-size: .72rem; } +.document-stats { display: flex; gap: 14px; } + +.history-panel { position: relative; width: 340px; overflow: hidden; border-left: 1px solid var(--border); background: #101319; transform: translateX(100%); transition: transform .18s ease; } +.history-panel.open { transform: translateX(0); } +.history-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px; } +.history-help { margin: 0; padding: 0 18px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .8rem; line-height: 1.5; } +.icon-button { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: #c5ccd6; font-size: 1.2rem; } +.history-list { overflow: auto; height: calc(100vh - 185px); padding: 10px 18px 24px; } +.revision { position: relative; display: grid; grid-template-columns: 12px 1fr; gap: 10px; padding: 13px 0; } +.revision::after { content: ""; position: absolute; top: 25px; bottom: -13px; left: 5px; width: 1px; background: var(--border); } +.revision:last-child::after { display: none; } +.revision__marker { position: relative; z-index: 1; width: 11px; height: 11px; margin-top: 3px; border: 2px solid #8b7af4; border-radius: 50%; background: #101319; } +.revision time { display: block; color: #d1d7df; font-size: .8rem; } +.revision small { display: block; margin-top: 4px; color: var(--muted-2); } +.revision button { margin-top: 10px; border: 0; background: transparent; color: #9e92f5; padding: 0; font-size: .76rem; font-weight: 750; } +.empty { padding: 28px 0; color: var(--muted-2); text-align: center; font-size: .82rem; } + + dialog { width: min(420px, calc(100% - 24px)); padding: 0; border: 0; background: transparent; color: inherit; } +dialog::backdrop { background: rgba(4,6,9,.82); } +.dialog-panel { display: grid; gap: 12px; padding: 24px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); text-align: left; } +.dialog-panel input { margin-top: 6px; } +.dialog-panel .primary-button { margin-top: 2px; } +.dialog-link { color: var(--muted); font-size: .78rem; } +.toast { position: fixed; right: 20px; bottom: 20px; z-index: 20; padding: 11px 14px; border: 1px solid var(--border-strong); border-radius: 10px; background: #171b23; color: #e6eaf0; font-size: .8rem; opacity: 0; transform: translateY(8px); pointer-events: none; transition: .16s ease; } +.toast.visible { opacity: 1; transform: translateY(0); } +.error-page { display: grid; place-items: center; min-height: 100vh; padding: 24px; text-align: center; } +.error-page h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); letter-spacing: -.05em; } +.error-page p { color: var(--muted); } + +@media (max-width: 980px) { + .app-header { align-items: flex-start; min-height: auto; padding: 12px 14px; } + .app-header__main, .header-actions { flex-wrap: wrap; } + .editor-layout { height: calc(100vh - 92px); } + .toolbar-settings { display: none; } + .history-open .editor-layout { grid-template-columns: minmax(0, 1fr); } + .history-panel { position: absolute; top: 0; right: 0; bottom: 0; z-index: 10; } +} + +@media (max-width: 720px) { + .site-header, .home-layout { width: min(100% - 24px, 520px); } + .document-url, .header-divider { display: none; } + .home-layout { padding: 44px 0; } + .app-header { gap: 10px; } + .status { display: none; } + .editor-toolbar { overflow-x: auto; flex-wrap: nowrap; } + .toolbar-fill { display: none; } + .workspace.view-split { grid-template-columns: 1fr; } + .workspace.view-split .preview-column { display: none; } + .view-switch button[data-view="split"] { display: none; } + .preview-column { border-left: 0; } + textarea, .preview { padding: 18px; } + .editor-footer { align-items: flex-start; flex-direction: column; justify-content: center; gap: 2px; padding: 7px 12px; } +} + +@media (max-width: 480px) { + .create-panel { padding: 20px; } + .header-actions { gap: 6px; } + .secondary-button { min-height: 36px; padding: 0 10px; font-size: .75rem; } + .history-panel { width: 100%; } +} + +.markdown-toggle { border: 1px solid var(--border) !important; } +.markdown-toggle.active { background: var(--surface-3) !important; color: white !important; } +.preview--raw { white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } + +.workspace-page { width: min(1100px, calc(100% - 32px)); margin: 0 auto; padding: 44px 0 80px; } +.workspace-top { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--border); } +.workspace-top h2 { margin: 0; font-size: 2rem; } +.workspace-top p { margin: 7px 0 0; color: var(--muted); } +.notes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; padding-top: 20px; } +.note-card { min-height: 130px; padding: 18px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); text-decoration: none; transition: border-color .15s, background .15s; } +.note-card:hover { border-color: var(--border-strong); background: var(--surface-2); } +.note-card h3 { margin: 0; font-size: 1rem; } +.note-card p { margin: 36px 0 0; color: var(--muted); font-size: .75rem; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } +.inline-button { width: auto; padding-inline: 18px; } + +.error-card { width: min(560px, 100%); padding: 32px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } +.error-code { margin: 0 0 10px; color: var(--muted-2); font: 700 .78rem/1 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .12em; } +.error-card h1 { font-size: clamp(1.8rem, 6vw, 3rem); } +.error-card > p:not(.error-code) { max-width: 46ch; margin: 14px auto 0; line-height: 1.6; } +.error-actions { display: flex; justify-content: center; gap: 8px; margin-top: 24px; } +@media (max-width: 480px) { .error-card { padding: 24px 18px; } .error-actions { flex-direction: column; } .error-actions .inline-button { width: 100%; } } + +/* Home: standalone note and workspace are separate choices. */ +.home-layout--wide { width: min(1040px, calc(100% - 32px)); } +.create-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start; } +.create-panel__heading { margin-bottom: 22px; } +.create-panel__heading h2 { margin: 0; font-size: 1.35rem; } +.create-panel__heading p { margin: 7px 0 0; color: var(--muted); line-height: 1.5; } +.create-panel form { display: grid; gap: 18px; } +.create-panel .primary-button { width: 100%; } +@media (max-width: 760px) { + .home-layout--wide { width: min(100% - 24px, 560px); } + .create-grid { grid-template-columns: 1fr; } +} diff --git a/static/workspace.html b/static/workspace.html new file mode 100644 index 0000000..1a37246 --- /dev/null +++ b/static/workspace.html @@ -0,0 +1,5 @@ +Workspace · RustPad +
    RustPad

    Ładowanie…

    +

    Notatki

    Wybierz notatkę albo utwórz nową.

    +

    Workspace chroniony

    Anuluj
    +

    Nowa notatka