From 771494671be1c50141cd695397d42ecc977c21f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 17 Jul 2026 15:29:08 +0200 Subject: [PATCH] first commit --- .dockerignore | 12 + .env.docker.example | 9 + .env.example | 12 + .gitignore | 7 + Cargo.lock | 2525 ++++++++++++++++++++++++++++++++ Cargo.toml | 23 + Dockerfile | 36 + README.md | 52 + docker-compose.yml | 22 + migrations/0001_init.sql | 19 + migrations/0002_workspaces.sql | 43 + src/api.rs | 500 +++++++ src/app.rs | 213 +++ src/config.rs | 35 + src/db.rs | 289 ++++ src/main.rs | 67 + src/state.rs | 52 + src/websocket.rs | 292 ++++ static/error.html | 24 + static/home.html | 63 + static/js/api.js | 19 + static/js/clipboard.js | 16 + static/js/editor-format.js | 23 + static/js/home.js | 76 + static/js/markdown.js | 26 + static/js/note.js | 53 + static/js/pad.js | 173 +++ static/js/session.js | 6 + static/js/socket.js | 60 + static/js/url-state.js | 27 + static/js/workspace.js | 11 + static/note.html | 4 + static/pad.html | 19 + static/styles.css | 207 +++ static/workspace.html | 5 + 35 files changed, 5020 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.docker.example create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 migrations/0001_init.sql create mode 100644 migrations/0002_workspaces.sql create mode 100644 src/api.rs create mode 100644 src/app.rs create mode 100644 src/config.rs create mode 100644 src/db.rs create mode 100644 src/main.rs create mode 100644 src/state.rs create mode 100644 src/websocket.rs create mode 100644 static/error.html create mode 100644 static/home.html create mode 100644 static/js/api.js create mode 100644 static/js/clipboard.js create mode 100644 static/js/editor-format.js create mode 100644 static/js/home.js create mode 100644 static/js/markdown.js create mode 100644 static/js/note.js create mode 100644 static/js/pad.js create mode 100644 static/js/session.js create mode 100644 static/js/socket.js create mode 100644 static/js/url-state.js create mode 100644 static/js/workspace.js create mode 100644 static/note.html create mode 100644 static/pad.html create mode 100644 static/styles.css create mode 100644 static/workspace.html 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