diff --git a/Cargo.lock b/Cargo.lock index b74b7d1..316328f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,19 +28,10 @@ dependencies = [ ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "anyhow" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "argon2" @@ -66,17 +57,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "async-trait" -version = "0.1.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -113,6 +93,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -136,9 +122,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82033247fd8e890df8f740e407ad4d038debb9eb1f40533fffb32e7d17dc6f7" +checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e" dependencies = [ "arrayref", "arrayvec", @@ -170,15 +156,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" dependencies = [ "shlex", ] @@ -190,18 +176,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "chrono" -version = "0.4.38" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-targets", -] +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "cipher" @@ -233,10 +211,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "cookie" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] [[package]] name = "cpufeatures" @@ -331,6 +332,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -424,10 +434,18 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ + "futures-core", + "futures-sink", "nanorand", "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -585,6 +603,46 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "http" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" + [[package]] name = "httpdate" version = "1.0.3" @@ -592,26 +650,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "iana-time-zone" -version = "0.1.61" +name = "hyper" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows-core", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "hyper-rustls" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ - "cc", + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -755,9 +846,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown", @@ -773,6 +864,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" + [[package]] name = "itoa" version = "1.0.14" @@ -781,18 +878,19 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "a865e038f7f6ed956f788f0d7d60c541fff74c7bd74272c5d4cf15c63743e705" dependencies = [ + "once_cell", "wasm-bindgen", ] [[package]] name = "libc" -version = "0.2.165" +version = "0.2.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb4d3d38eab6c5239a362fa8bae48c03baf980a6e7079f063942d563ef3533e" +checksum = "09d6582e104315a817dff97f75133544b2e094ee22447d2acf4a74e189ba06fc" [[package]] name = "litemap" @@ -844,7 +942,7 @@ dependencies = [ "serde_bencode", "serde_bytes", "sha1_smol", - "thiserror", + "thiserror 1.0.69", "tracing", ] @@ -854,6 +952,12 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -863,6 +967,17 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.52.0", +] + [[package]] name = "nanorand" version = "0.7.0" @@ -873,13 +988,10 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "num-conv" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "object" @@ -902,6 +1014,29 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -951,7 +1086,7 @@ dependencies = [ "self_cell", "serde", "simple-dns", - "thiserror", + "thiserror 1.0.69", "tracing", "wasm-bindgen", "wasm-bindgen-futures", @@ -981,9 +1116,9 @@ dependencies = [ [[package]] name = "postcard" -version = "1.0.10" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7f0a8d620d71c457dd1d47df76bb18960378da56af4527aaa10f515eee732e" +checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" dependencies = [ "cobs", "embedded-io 0.4.0", @@ -992,6 +1127,12 @@ dependencies = [ "serde", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1010,15 +1151,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "pubky" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e41b59ac157d121a1e8c6ed46aafc6666b3ff7c5f41488b7923e1e33d2ca73e" +dependencies = [ + "base64", + "bytes", + "js-sys", + "pkarr", + "pubky-common", + "reqwest", + "thiserror 1.0.69", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "pubky-app-specs" -version = "0.2.0" +version = "0.2.1" dependencies = [ - "async-trait", + "anyhow", "base32", "blake3", - "bytes", - "chrono", + "pubky", "pubky-common", "serde", "serde_json", @@ -1045,7 +1210,7 @@ dependencies = [ "pubky-timestamp", "rand", "serde", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1063,6 +1228,68 @@ dependencies = [ "serde", ] +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quinn" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.4", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +dependencies = [ + "bytes", + "getrandom", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.4", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a626c6807713b15cac82a6acaccd6043c9a5408c24baae07611fec3f243da" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.37" @@ -1102,12 +1329,86 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "windows-registry", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1117,6 +1418,49 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" +dependencies = [ + "web-time", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "ryu" version = "1.0.18" @@ -1140,9 +1484,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "self_cell" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d369a96f978623eb3dc28807c4852d6cc617fed53da5d3c400feff1ef34a714a" +checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" [[package]] name = "semver" @@ -1201,6 +1545,18 @@ dependencies = [ "serde", ] +[[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_smol" version = "1.0.1" @@ -1224,6 +1580,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -1257,6 +1622,16 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -1290,15 +1665,24 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.89" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" 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" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -1316,7 +1700,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f49a1853cf82743e3b7950f77e0f4d622ca36cf4317cba00c767838bac8d490" +dependencies = [ + "thiserror-impl 2.0.4", ] [[package]] @@ -1330,6 +1723,48 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8381894bb3efe0c4acac3ded651301ceee58a15d47c2e34885ed1908ad667061" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -1340,6 +1775,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +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.42.0" @@ -1347,8 +1797,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.52.0", ] [[package]] @@ -1362,11 +1819,27 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +dependencies = [ + "rustls", + "tokio", +] + +[[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.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1375,9 +1848,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", @@ -1393,6 +1866,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.17.0" @@ -1415,6 +1894,12 @@ dependencies = [ "subtle", ] +[[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.4" @@ -1467,6 +1952,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1475,9 +1969,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "d15e63b4482863c109d70a7b8706c1e364eb6ea449b201a76c5b89cedcec2d5c" dependencies = [ "cfg-if", "once_cell", @@ -1486,9 +1980,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "8d36ef12e3aaca16ddd3f67922bc63e48e953f126de60bd33ccc0101ef9998cd" dependencies = [ "bumpalo", "log", @@ -1501,21 +1995,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "9dfaf8f50e5f293737ee323940c7d8b08a66a95a419223d9f41610ca08b0833d" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "705440e08b42d3e4b36de7d66c944be628d579796b8090bfa3471478a2260051" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1523,9 +2018,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "98c9ae5a76e46f4deecd0f0255cc223cfa18dc9b261213b8aa0c7b36f61b3f1d" dependencies = [ "proc-macro2", "quote", @@ -1536,25 +2031,83 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "6ee99da9c5ba11bd675621338ef6fa52296b76b83305e9b6e5c77d4c286d6d49" [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "a98bc3c33f0fe7e59ad7cd041b89034fa82a7c2d4365ca538dda6cdaf513863c" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "windows-core" +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] diff --git a/Cargo.toml b/Cargo.toml index cc6a3bb..056d50f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pubky-app-specs" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Pubky.app Data Model Specifications" homepage = "https://pubky.app" @@ -9,16 +9,18 @@ license = "MIT" documentation = "https://github.com/pubky/pubky-app-specs" [dependencies] -async-trait = "0.1" -bytes = "^1.7.0" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" -utoipa = "5.2.0" -pubky-common = "0.1.0" url = "2.5.4" base32 = "0.5.1" blake3 = "1.5.4" -chrono = "0.4.38" +utoipa = { version = "5.2.0", optional = true } [dev-dependencies] -tokio = { version = "1", features = ["macros", "rt"] } +tokio = { version = "1.41.1", features = ["full"] } +pubky = "0.3.0" +pubky-common = "0.1.0" +anyhow = "1.0.93" + +[features] +openapi = ["utoipa"] diff --git a/README.md b/README.md index 2ee80aa..48dc363 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,115 @@ # Pubky.app Data Model Specification -_Version 0.2.0_ +_Version 0.2.1_ + +> ⚠️ **Warning: Rapid Development Phase** +> This specification is in an **early development phase** and is evolving quickly. Expect frequent changes and updates as the system matures. Consider this a **v0 draft**. +> +> When we reach the first stable, long-term support version of the schemas, paths will adopt the format: `pubky.app/v1/` to indicate compatibility and stability. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Quick Start](#quick-start) +3. [Data Models](#data-models) + - [PubkyAppUser](#pubkyappuser) + - [PubkyAppFile](#pubkyappfile) + - [PubkyAppPost](#pubkyapppost) + - [PubkyAppTag](#pubkyapptag) + - [PubkyAppBookmark](#pubkyappbookmark) + - [PubkyAppFollow](#pubkyappfollow) + - [PubkyAppMute](#pubkyappmute) + - [PubkyAppFeed](#pubkyappfeed) + - [PubkyAppLastRead](#pubkyapplastread) +4. [Validation Rules](#validation-rules) + - [Common Rules](#common-rules) + - [ID Generation](#id-generation) +5. [Glossary](#glossary) +6. [Examples](#examples) + - [PubkyAppUser](#example-pubkyappuser) + - [PubkyAppPost](#example-pubkyapppost) + - [PubkyAppTag](#example-pubkyapptag) +7. [License](#license) + +--- ## Introduction -This document specifies the data models and validation rules for the Pubky.app client and homeserver interactions. It defines the structures of data entities, their properties, and the validation rules to ensure data integrity and consistency. This specification is intended for developers who wish to implement their own libraries or clients compatible with Pubky.app. +This document specifies the data models and validation rules for the **Pubky.app** clients interactions. It defines the structure of data entities, their properties, and the validation rules to ensure data integrity and consistency. This is intended for developers building compatible libraries or clients. This document intents to be a faithful representation of our [Rust pubky.app models](https://github.com/pubky/pubky-app-specs/tree/main/src). If you intend to develop in Rust, use them directly. In case of disagreement between this document and the Rust implementation, the Rust implementation prevails. -## Data Models - -### PubkyAppUser - -**Description:** Represents a user's profile information. - -**URI:** `/pub/pubky.app/profile.json` +--- -**Fields:** +## Quick Start -- `name` (string, required): The user's name. -- `bio` (string, optional): A short biography. -- `image` (string, optional): A URL to the user's profile image. -- `links` (array of `UserLink`, optional): A list of links associated with the user. -- `status` (string, optional): The user's current status. +Pubky.app models are designed for decentralized content sharing. The system uses a combination of timestamp-based IDs and Blake3-hashed IDs encoded in Crockford Base32 to ensure unique identifiers for each entity. -**`UserLink` Object:** +### Concepts: -- `title` (string, required): The title of the link. -- `url` (string, required): The URL of the link. +- **Timestamp IDs** for sequential objects like posts and files. +- **Hash IDs** for content-based uniqueness (e.g., tags and bookmarks). +- **Validation Rules** ensure consistent and interoperable data formats. -**Validation Rules:** +--- -- **`name`:** +## Data Models - - Must be at least **3** and at most **50** characters. - - Cannot be the keyword `[DELETED]`; this is reserved for deleted profiles. +### PubkyAppUser -- **`bio`:** +**Description:** Represents a user's profile information. - - Maximum length of **160** characters if provided. +**URI:** `/pub/pubky.app/profile.json` -- **`image`:** +| **Field** | **Type** | **Description** | **Validation Rules** | +| --------- | -------- | --------------------------------------- | -------------------------------------------------------------------------------------------- | +| `name` | String | User's name. | Required. Length: 3–50 characters. Cannot be `"[DELETED]"`. | +| `bio` | String | Short biography. | Optional. Maximum length: 160 characters. | +| `image` | String | URL to the user's profile image. | Optional. Valid URL. Maximum length: 300 characters. | +| `links` | Array | List of associated links (title + URL). | Optional. Maximum of 5 links, each with title (100 chars max) and valid URL (300 chars max). | +| `status` | String | User's current status. | Optional. Maximum length: 50 characters. | - - If provided, must be a valid URL. - - Maximum length of **300** characters. +**Validation Notes:** -- **`links`:** +- Reserved keyword `[DELETED]` cannot be used for `name`. +- Each `UserLink` in `links` must have a valid title and URL. - - Maximum of **5** links. - - Each `UserLink` must have: - - `title`: Maximum length of **100** characters. - - `url`: Must be a valid URL, maximum length of **300** characters. +**Example: Valid User** -- **`status`:** - - Maximum length of **50** characters if provided. +```json +{ + "name": "Alice", + "bio": "Toxic maximalist.", + "image": "pubky://user_id/pub/pubky.app/files/0000000000000", + "links": [ + { + "title": "GitHub", + "url": "https://github.com/alice" + } + ], + "status": "Exploring decentralized tech." +} +``` --- ### PubkyAppFile -**Description:** Represents a file uploaded by the user. +**Description:** Represents metadata of file uploaded by the user. **URI:** `/pub/pubky.app/files/:file_id` -**Fields:** - -- `name` (string, required): The name of the file. -- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the file was created. -- `src` (string, required): The source URL or path of the file. -- `content_type` (string, required): The MIME type of the file. -- `size` (integer, required): The size of the file in bytes. +| **Field** | **Type** | **Description** | **Validation Rules** | +| -------------- | -------- | --------------------------- | --------------------------- | +| `name` | String | Name of the file. | Required. | +| `created_at` | Integer | Unix timestamp of creation. | Required. | +| `src` | String | File blob URL | Required. | +| `content_type` | String | MIME type of the file. | Required. | +| `size` | Integer | Size of the file in bytes. | Required. Positive integer. | -**Validation Rules:** +**Validation Notes:** -- **ID Validation:** - - - The `file_id` in the URI must be a valid **Timestamp ID** (see [ID Generation](#id-generation)). - -- **Additional Validation:** - - Validation for `content_type`, `size`, and other fields should be implemented as needed. +- The `file_id` in the URI must be a valid **Timestamp ID**. --- @@ -88,54 +119,37 @@ This document intents to be a faithful representation of our [Rust pubky.app mod **URI:** `/pub/pubky.app/posts/:post_id` -**Fields:** - -- `content` (string, required): The content of the post. -- `kind` (string, required): The type of post. Possible values are: - - - `Short` - - `Long` - - `Image` - - `Video` - - `Link` - - `File` - -- `parent` (string, optional): URI of the parent post if this is a reply. -- `embed` (object, optional): Embedded content. -- `attachments` (array of strings, optional): A list of attachment URIs. - -**`embed` Object:** - -- `kind` (string, required): Type of the embedded content. Same as `kind` in `PubkyAppPost`. -- `uri` (string, required): URI of the embedded content. - -**Validation Rules:** - -- **ID Validation:** +| **Field** | **Type** | **Description** | **Validation Rules** | +| ------------- | -------- | ------------------------------------ | -------------------------------------------------------------------------- | +| `content` | String | Content of the post. | Required. Max length: 1000 (short), 50000 (long). Cannot be `"[DELETED]"`. | +| `kind` | String | Type of post. | Required. Must be a valid `PubkyAppPostKind` value. | +| `parent` | String | URI of the parent post (if a reply). | Optional. Must be a valid URI if present. | +| `embed` | Object | Embedded content (type + URI). | Optional. URI must be valid if present. | +| `attachments` | Array | List of attachment URIs. | Optional. Each must be a valid URI. | - - The `post_id` in the URI must be a valid **Timestamp ID** (see [ID Generation](#id-generation)). +**Post Kinds:** -- **`content`:** +- `short` +- `long` +- `image` +- `video` +- `link` +- `file` - - Must not be the keyword `[DELETED]`; this is reserved for deleted posts. - - **For `kind` of `Short`:** - - Maximum length of **1000** characters. - - **For `kind` of `Long`:** - - Maximum length of **50000** characters. - - **For other `kind` values:** - - Maximum length of **1000** characters. +**Example: Valid Post** -- **`parent`:** - - - If provided, must be a valid URI. - -- **`embed`:** - - - If provided: - - `uri` must be a valid URI. - -- **Additional Validation:** - - Validation for `attachments` and other fields should be implemented as needed. +```json +{ + "content": "Hello world! This is my first post.", + "kind": "short", + "parent": null, + "embed": { + "kind": "short", + "uri": "pubky://user_id/pub/pubky.app/posts/0000000000000" + }, + "attachments": ["pubky://user_id/pub/pubky.app/files/0000000000000"] +} +``` --- @@ -145,25 +159,15 @@ This document intents to be a faithful representation of our [Rust pubky.app mod **URI:** `/pub/pubky.app/tags/:tag_id` -**Fields:** - -- `uri` (string, required): The URI that is tagged. -- `label` (string, required): The tag label. -- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the tag was created. - -**Validation Rules:** - -- **ID Validation:** +| **Field** | **Type** | **Description** | **Validation Rules** | +| ------------ | -------- | --------------------------- | -------------------------------------------------------- | +| `uri` | String | URI of the tagged object. | Required. Must be a valid URI. | +| `label` | String | Label for the tag. | Required. Trimmed, lowercase. Max length: 20 characters. | +| `created_at` | Integer | Unix timestamp of creation. | Required. | - - The `tag_id` in the URI must be a valid **Hash ID** generated from the `uri` and `label` (see [ID Generation](#id-generation)). +**Validation Notes:** -- **`uri`:** - - - Must be a valid URI. - -- **`label`:** - - Must be trimmed and converted to lowercase. - - Maximum length of **20** characters. +- The `tag_id` is a **Hash ID** derived from the `uri` and `label`. --- @@ -173,53 +177,43 @@ This document intents to be a faithful representation of our [Rust pubky.app mod **URI:** `/pub/pubky.app/bookmarks/:bookmark_id` -**Fields:** - -- `uri` (string, required): The URI that is bookmarked. -- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the bookmark was created. - -**Validation Rules:** +| **Field** | **Type** | **Description** | **Validation Rules** | +| ------------ | -------- | ---------------------- | ------------------------------ | +| `uri` | String | URI of the bookmark. | Required. Must be a valid URI. | +| `created_at` | Integer | Timestamp of creation. | Required. | -- **ID Validation:** +**Validation Notes:** - - The `bookmark_id` in the URI must be a valid **Hash ID** generated from the `uri` (see [ID Generation](#id-generation)). - -- **`uri`:** - - Must be a valid URI. +- The `bookmark_id` is a **Hash ID** derived from the `uri`. --- ### PubkyAppFollow -**Description:** Represents a follow relationship to another user. +**Description:** Represents a follow relationship. **URI:** `/pub/pubky.app/follows/:user_id` -**Fields:** - -- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the follow was created. - -**Validation Rules:** - -- **`created_at`:** - - Should be validated as needed. +| **Field** | **Type** | **Description** | **Validation Rules** | +| ------------ | -------- | ---------------------- | -------------------- | +| `created_at` | Integer | Timestamp of creation. | Required. | --- -### PubkyAppMute - -**Description:** Represents a mute relationship to another user. - -**URI:** `/pub/pubky.app/mutes/:user_id` - -**Fields:** +### PubkyAppFeed -- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the mute was created. +**Description:** Represents a feed configuration. -**Validation Rules:** +**URI:** `/pub/pubky.app/feeds/:feed_id` -- **`created_at`:** - - Should be validated as needed. +| **Field** | **Type** | **Description** | **Validation Rules** | +| --------- | -------- | ----------------------------------------- | ---------------------------------- | +| `tags` | Array | List of tags for filtering. | Optional. Strings must be trimmed. | +| `reach` | String | Feed visibility (e.g., `all`, `friends`). | Required. Must be a valid reach. | +| `layout` | String | Feed layout style (e.g., `columns`). | Required. Must be valid layout. | +| `sort` | String | Sort order (e.g., `recent`). | Required. Must be valid sort. | +| `content` | String | Type of content filtered. | Optional. | +| `name` | String | Name of the feed. | Required. | --- @@ -227,183 +221,12 @@ This document intents to be a faithful representation of our [Rust pubky.app mod ### Common Rules -#### IDs - -- **Timestamp IDs**: IDs generated based on the current timestamp, encoded in Crockford Base32. - - - Must be **13** characters long. - - Decoded ID must represent a valid timestamp after **October 1st, 2024**. - - Timestamp must not be more than **2 hours** in the future. - -- **Hash IDs**: IDs generated by hashing certain fields of the object using Blake3 and encoding in Crockford Base32. - - For `PubkyAppTag`: Hash of `uri:label`. - - For `PubkyAppBookmark`: Hash of `uri`. - - The generated ID must match the provided ID. - -### URL Validation - -- All URLs must be valid according to standard URL parsing rules. - -### String Lengths - -- Fields have maximum lengths as specified in their validation rules. - -### Content Restrictions - -- The content of posts and profiles must not be `[DELETED]`. This keyword is reserved for indicating deleted content. - -### Label Formatting - -- Labels for tags must be: - - Trimmed. - - Converted to lowercase. - - Maximum length of 20 characters. - ---- - -### PubkyAppFeed - -**Description:** Represents a feed configuration, allowing users to customize the content they see based on tags, reach, layout, and sort order. - -**URI:** `/feeds/:feed_id` - -**Fields:** - -- `feed` (object, required): The main configuration object for the feed. - - - `tags` (array of strings, optional): Tags used to filter content within the feed. - - `reach` (string, required): Defines the visibility or scope of the feed. Possible values are: - - `following`: Content from followed users. - - `followers`: Content from follower users. - - `friends`: Content from mutual following users. - - `all`: Public content accessible to everyone. - - `layout` (string, required): Specifies the layout of the feed. Options include: - - `columns`: Organizes feed content in a columnar format. - - `wide`: Arranges content in a standard wide format. - - `visual`: Arranges content in visual format. - - `sort` (string, required): Determines the sorting order of the feed content. Supported values are: - - `recent`: Most recent content first. - - `popularity`: Content with the highest engagement. - - `content` (string, required): Defines the type of content displayed. Options include: - - `all`: Includes all content types. - - `posts`: Only posts are shown. - - `images`: Only media images. - - `videos`: Only media videos. - - `links`: Only links. - -- `name` (string, required): The user-defined name for this feed configuration. -- `created_at` (integer, required): Timestamp (Unix epoch in milliseconds) representing when the feed was created. - -**Validation Rules:** - -- **ID Validation:** - - The `feed_id` in the URI is a **Hash ID** generated from the serialized feed object (the JSON object for `feed`), computed using Blake3 and encoded in Crockford Base32. - - The generated `feed_id` must match the provided `feed_id`. - ---- - -### PubkyAppLastRead - -**Description:** Represents the last read timestamp for notifications, used to track when the user last checked for new activity. - -**URI:** `/pub/pubky.app/last_read` - -**Fields:** - -- `timestamp` (integer, required): Unix epoch time in milliseconds of the last time the user checked notifications. - -**Validation Rules:** - -- **`timestamp`:** Must be a valid timestamp in milliseconds. - ---- - -## ID Generation - -### TimestampId - -**Description:** Generates an ID based on the current timestamp. - -**Generation Steps:** - -1. Obtain the current timestamp in microseconds. -2. Convert the timestamp to an 8-byte big-endian representation. -3. Encode the bytes using Crockford Base32 to get a 13-character ID. - -**Validation:** - -- The ID must be **13** characters long. -- Decoded timestamp must represent a date after **October 1st, 2024**. -- The timestamp must not be more than **2 hours** in the future. - -### HashId - -**Description:** Generates an ID based on hashing certain fields of the object. - -**Generation Steps:** - -1. Concatenate the relevant fields (e.g., `uri:label` for tags). -2. Compute the Blake3 hash of the concatenated string. -3. Take the first half of the hash bytes. -4. Encode the bytes using Crockford Base32. - -**Validation:** - -- The generated ID must match the provided ID. +1. **Timestamp IDs:** 13-character Crockford Base32 strings derived from timestamps (in microseconds). +2. **Hash IDs:** First half of the bytes from the resulting Blake3-hashed strings encoded in Crockford Base32. +3. **URLs:** All URLs must pass standard validation. --- -## Examples - -### Example of PubkyAppUser - -```json -{ - "name": "Alice", - "bio": "Blockchain enthusiast and developer.", - "image": "https://example.com/images/alice.png", - "links": [ - { - "title": "GitHub", - "url": "https://github.com/alice" - }, - { - "title": "Website", - "url": "https://alice.dev" - } - ], - "status": "Exploring the decentralized web." -} -``` - -### Example of PubkyAppPost - -```json -{ - "content": "Hello world! This is my first post.", - "kind": "short", - "parent": null, - "embed": null, - "attachments": null -} -``` - -### Example of PubkyAppTag - -```json -{ - "uri": "/pub/pubky.app/posts/00321FCW75ZFY", - "label": "blockchain", - "created_at": 1700000000 -} -``` - -## Notes - -- All timestamps are Unix epoch times in seconds. -- Developers should ensure that all validation rules are enforced to maintain data integrity and interoperability between clients. -- This specification may be updated in future versions to include additional fields or validation rules. - ## License This specification is released under the MIT License. diff --git a/a.txt b/a.txt new file mode 100644 index 0000000..d1c74fa --- /dev/null +++ b/a.txt @@ -0,0 +1,2387 @@ +./src/feed.rs +``` +use crate::{ + common::timestamp, + traits::{HasPath, HashId, Validatable}, + PubkyAppPostKind, APP_PATH, +}; +use serde::{Deserialize, Serialize}; +use serde_json; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Enum representing the reach of the feed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub enum PubkyAppFeedReach { + Following, + Followers, + Friends, + All, +} + +/// Enum representing the layout of the feed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub enum PubkyAppFeedLayout { + Columns, + Wide, + Visual, +} + +/// Enum representing the sort order of the feed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub enum PubkyAppFeedSort { + Recent, + Popularity, +} + +/// Configuration object for the feed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppFeedConfig { + pub tags: Option>, + pub reach: PubkyAppFeedReach, + pub layout: PubkyAppFeedLayout, + pub sort: PubkyAppFeedSort, + pub content: Option, +} + +/// Represents a feed configuration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppFeed { + pub feed: PubkyAppFeedConfig, + pub name: String, + pub created_at: i64, +} + +impl PubkyAppFeed { + /// Creates a new `PubkyAppFeed` instance and sanitizes it. + pub fn new( + tags: Option>, + reach: PubkyAppFeedReach, + layout: PubkyAppFeedLayout, + sort: PubkyAppFeedSort, + content: Option, + name: String, + ) -> Self { + let created_at = timestamp(); + let feed = PubkyAppFeedConfig { + tags, + reach, + layout, + sort, + content, + }; + Self { + feed, + name, + created_at, + } + .sanitize() + } +} + +impl HashId for PubkyAppFeed { + /// Generates an ID based on the serialized `feed` object. + fn get_id_data(&self) -> String { + serde_json::to_string(&self.feed).unwrap_or_default() + } +} + +impl HasPath for PubkyAppFeed { + fn create_path(&self) -> String { + format!("{}feeds/{}", APP_PATH, self.create_id()) + } +} + +impl Validatable for PubkyAppFeed { + fn validate(&self, id: &str) -> Result<(), String> { + self.validate_id(id)?; + + // Validate name + if self.name.trim().is_empty() { + return Err("Validation Error: Feed name cannot be empty".into()); + } + + // Additional validations can be added here + Ok(()) + } + + fn sanitize(self) -> Self { + // Sanitize name + let name = self.name.trim().to_string(); + + // Sanitize tags + let feed = PubkyAppFeedConfig { + tags: self.feed.tags.map(|tags| { + tags.into_iter() + .map(|tag| tag.trim().to_lowercase()) + .collect() + }), + ..self.feed + }; + + PubkyAppFeed { + feed, + name, + created_at: self.created_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let feed = PubkyAppFeed::new( + Some(vec!["bitcoin".to_string(), "rust".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + Some(PubkyAppPostKind::Image), + "Rust Bitcoiners".to_string(), + ); + + let feed_config = PubkyAppFeedConfig { + tags: Some(vec!["bitcoin".to_string(), "rust".to_string()]), + reach: PubkyAppFeedReach::Following, + layout: PubkyAppFeedLayout::Columns, + sort: PubkyAppFeedSort::Recent, + content: Some(PubkyAppPostKind::Image), + }; + assert_eq!(feed.feed, feed_config); + assert_eq!(feed.name, "Rust Bitcoiners"); + // Check that created_at is recent + let now = timestamp(); + assert!(feed.created_at <= now && feed.created_at >= now - 1_000_000); + } + + #[test] + fn test_create_id() { + let feed = PubkyAppFeed::new( + Some(vec!["bitcoin".to_string(), "rust".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + None, + "Rust Bitcoiners".to_string(), + ); + + let feed_id = feed.create_id(); + println!("Feed ID: {}", feed_id); + // The ID should not be empty + assert!(!feed_id.is_empty()); + } + + #[test] + fn test_validate() { + let feed = PubkyAppFeed::new( + Some(vec!["bitcoin".to_string(), "rust".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + None, + "Rust Bitcoiners".to_string(), + ); + let feed_id = feed.create_id(); + + let result = feed.validate(&feed_id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_id() { + let feed = PubkyAppFeed::new( + Some(vec!["bitcoin".to_string(), "rust".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + None, + "Rust Bitcoiners".to_string(), + ); + let invalid_id = "INVALIDID"; + let result = feed.validate(&invalid_id); + assert!(result.is_err()); + } + + #[test] + fn test_sanitize() { + let feed = PubkyAppFeed::new( + Some(vec![" BiTcoin ".to_string(), " RUST ".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + None, + " Rust Bitcoiners".to_string(), + ); + assert_eq!(feed.name, "Rust Bitcoiners"); + assert_eq!( + feed.feed.tags, + Some(vec!["bitcoin".to_string(), "rust".to_string()]) + ); + } + + #[test] + fn test_try_from_valid() { + let feed_json = r#" + { + "feed": { + "tags": ["bitcoin", "rust"], + "reach": "following", + "layout": "columns", + "sort": "recent", + "content": "video" + }, + "name": "My Feed", + "created_at": 1700000000 + } + "#; + + let feed: PubkyAppFeed = serde_json::from_str(feed_json).unwrap(); + let feed_id = feed.create_id(); + + let blob = feed_json.as_bytes(); + let feed_parsed = ::try_from(&blob, &feed_id).unwrap(); + + assert_eq!(feed_parsed.name, "My Feed"); + assert_eq!( + feed_parsed.feed.tags, + Some(vec!["bitcoin".to_string(), "rust".to_string()]) + ); + } +} +``` +./src/user.rs +``` +use crate::{ + traits::{HasPath, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +// Validation constants +const MIN_USERNAME_LENGTH: usize = 3; +const MAX_USERNAME_LENGTH: usize = 50; +const MAX_BIO_LENGTH: usize = 160; +const MAX_IMAGE_LENGTH: usize = 300; +const MAX_LINKS: usize = 5; +const MAX_LINK_TITLE_LENGTH: usize = 100; +const MAX_LINK_URL_LENGTH: usize = 300; +const MAX_STATUS_LENGTH: usize = 50; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// URI: /pub/pubky.app/profile.json +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppUser { + pub name: String, + pub bio: Option, + pub image: Option, + pub links: Option>, + pub status: Option, +} + +/// Represents a user's single link with a title and URL. +#[derive(Serialize, Deserialize, Default, Clone, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppUserLink { + pub title: String, + pub url: String, +} + +impl PubkyAppUser { + /// Creates a new `PubkyAppUser` instance and sanitizes it. + pub fn new( + name: String, + bio: Option, + image: Option, + links: Option>, + status: Option, + ) -> Self { + Self { + name, + bio, + image, + links, + status, + } + .sanitize() + } +} + +impl HasPath for PubkyAppUser { + fn create_path(&self) -> String { + format!("{}profile.json", APP_PATH) + } +} + +impl Validatable for PubkyAppUser { + fn sanitize(self) -> Self { + // Sanitize name + let sanitized_name = self.name.trim(); + // Crop name to a maximum length of MAX_USERNAME_LENGTH characters + let mut name = sanitized_name + .chars() + .take(MAX_USERNAME_LENGTH) + .collect::(); + + // We use username keyword `[DELETED]` for a user whose `profile.json` has been deleted + // Therefore this is not a valid username. + if name == *"[DELETED]" { + name = "anonymous".to_string(); // default username + } + + // Sanitize bio + let bio = self + .bio + .map(|b| b.trim().chars().take(MAX_BIO_LENGTH).collect::()); + + // Sanitize image URL with URL parsing + let image = match &self.image { + Some(image_url) => { + let sanitized_image_url = image_url.trim(); + + match Url::parse(sanitized_image_url) { + Ok(_) => { + // Ensure the URL is within the allowed limit + let url = sanitized_image_url + .chars() + .take(MAX_IMAGE_LENGTH) + .collect::(); + Some(url) // Valid image URL + } + Err(_) => None, // Invalid image URL, set to None + } + } + None => None, + }; + + // Sanitize status + let status = self + .status + .map(|s| s.trim().chars().take(MAX_STATUS_LENGTH).collect::()); + + // Sanitize links + let links = self.links.map(|links_vec| { + links_vec + .into_iter() + .take(MAX_LINKS) + .map(|link| link.sanitize()) + .filter(|link| !link.url.is_empty()) + .collect() + }); + + PubkyAppUser { + name, + bio, + image, + links, + status, + } + } + + fn validate(&self, _id: &str) -> Result<(), String> { + // Validate name length + let name_length = self.name.chars().count(); + if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&name_length) { + return Err("Validation Error: Invalid name length".into()); + } + + // Validate bio length + if let Some(bio) = &self.bio { + if bio.chars().count() > MAX_BIO_LENGTH { + return Err("Validation Error: Bio exceeds maximum length".into()); + } + } + + // Validate image length + if let Some(image) = &self.image { + if image.chars().count() > MAX_IMAGE_LENGTH { + return Err("Validation Error: Image URI exceeds maximum length".into()); + } + } + + // Validate links + if let Some(links) = &self.links { + if links.len() > MAX_LINKS { + return Err("Too many links".to_string()); + } + + for link in links { + link.validate(_id)?; + } + } + + // Validate status length + if let Some(status) = &self.status { + if status.chars().count() > MAX_STATUS_LENGTH { + return Err("Validation Error: Status exceeds maximum length".into()); + } + } + + Ok(()) + } +} + +impl Validatable for PubkyAppUserLink { + fn sanitize(self) -> Self { + let title = self + .title + .trim() + .chars() + .take(MAX_LINK_TITLE_LENGTH) + .collect::(); + + let url = match Url::parse(self.url.trim()) { + Ok(parsed_url) => { + let sanitized_url = parsed_url.to_string(); + sanitized_url + .chars() + .take(MAX_LINK_URL_LENGTH) + .collect::() + } + Err(_) => "".to_string(), // Default to empty string for invalid URLs + }; + + PubkyAppUserLink { title, url } + } + + fn validate(&self, _id: &str) -> Result<(), String> { + if self.title.chars().count() > MAX_LINK_TITLE_LENGTH { + return Err("Validation Error: Link title exceeds maximum length".to_string()); + } + + if self.url.chars().count() > MAX_LINK_URL_LENGTH { + return Err("Validation Error: Link URL exceeds maximum length".to_string()); + } + + match Url::parse(&self.url) { + Ok(_) => Ok(()), + Err(_) => Err("Validation Error: Invalid URL format".to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + use crate::APP_PATH; + + #[test] + fn test_new() { + let user = PubkyAppUser::new( + "Alice".to_string(), + Some("Maximalist".to_string()), + Some("https://example.com/image.png".to_string()), + Some(vec![ + PubkyAppUserLink { + title: "GitHub".to_string(), + url: "https://github.com/alice".to_string(), + }, + PubkyAppUserLink { + title: "Website".to_string(), + url: "https://alice.dev".to_string(), + }, + ]), + Some("Exploring the decentralized web.".to_string()), + ); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + assert_eq!(user.links.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_create_path() { + let user = PubkyAppUser::default(); + let path = user.create_path(); + assert_eq!(path, format!("{}profile.json", APP_PATH)); + } + + #[test] + fn test_sanitize() { + let user = PubkyAppUser::new( + " Alice ".to_string(), + Some(" Maximalist and developer. ".to_string()), + Some("https://example.com/image.png".to_string()), + Some(vec![ + PubkyAppUserLink { + title: " GitHub ".to_string(), + url: " https://github.com/alice ".to_string(), + }, + PubkyAppUserLink { + title: "Website".to_string(), + url: "invalid_url".to_string(), // Invalid URL + }, + ]), + Some(" Exploring the decentralized web. ".to_string()), + ); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist and developer.")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + let links = user.links.unwrap(); + assert_eq!(links.len(), 1); // Invalid URL link should be filtered out + assert_eq!(links[0].title, "GitHub"); + assert_eq!(links[0].url, "https://github.com/alice"); + } + + #[test] + fn test_validate_valid() { + let user = PubkyAppUser::new( + "Alice".to_string(), + Some("Maximalist".to_string()), + Some("https://example.com/image.png".to_string()), + None, + Some("Exploring the decentralized web.".to_string()), + ); + + let result = user.validate(""); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_name() { + let user = PubkyAppUser::new( + "Al".to_string(), // Too short + None, + None, + None, + None, + ); + + let result = user.validate(""); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Validation Error: Invalid name length" + ); + } + + #[test] + fn test_try_from_valid() { + let user_json = r#" + { + "name": "Alice", + "bio": "Maximalist", + "image": "https://example.com/image.png", + "links": [ + { + "title": "GitHub", + "url": "https://github.com/alice" + }, + { + "title": "Website", + "url": "https://alice.dev" + } + ], + "status": "Exploring the decentralized web." + } + "#; + + let blob = user_json.as_bytes(); + let user = ::try_from(&blob, "").unwrap(); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + assert_eq!(user.links.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_try_from_invalid_link() { + let user_json = r#" + { + "name": "Alice", + "links": [ + { + "title": "GitHub", + "url": "invalid_url" + } + ] + } + "#; + + let blob = user_json.as_bytes(); + let user = ::try_from(&blob, "").unwrap(); + + // Since the link URL is invalid, it should be filtered out + assert!(user.links.is_none() || user.links.as_ref().unwrap().is_empty()); + } +} +``` +./src/last_read.rs +``` +use crate::{ + common::timestamp, + traits::{HasPath, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents the last read timestamp for notifications. +/// URI: /pub/pubky.app/last_read +#[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppLastRead { + pub timestamp: i64, // Unix epoch time in milliseconds +} + +impl PubkyAppLastRead { + /// Creates a new `PubkyAppLastRead` instance. + pub fn new() -> Self { + let timestamp = timestamp() / 1_000; // to millis + Self { timestamp } + } +} + +impl Validatable for PubkyAppLastRead { + fn validate(&self, _id: &str) -> Result<(), String> { + // Validate timestamp is a positive integer + if self.timestamp <= 0 { + return Err("Validation Error: Timestamp must be a positive integer".into()); + } + Ok(()) + } +} + +impl HasPath for PubkyAppLastRead { + fn create_path(&self) -> String { + format!("{}last_read", APP_PATH) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let last_read = PubkyAppLastRead::new(); + let now = timestamp() / 1_000; + // within 1 second + assert!(last_read.timestamp <= now && last_read.timestamp >= now - 1_000); + } + + #[test] + fn test_create_path() { + let last_read = PubkyAppLastRead::new(); + let path = last_read.create_path(); + assert_eq!(path, format!("{}last_read", APP_PATH)); + } + + #[test] + fn test_validate() { + let last_read = PubkyAppLastRead::new(); + let result = last_read.validate(""); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_timestamp() { + let last_read = PubkyAppLastRead { timestamp: -1 }; + let result = last_read.validate(""); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_valid() { + let last_read_json = r#" + { + "timestamp": 1700000000 + } + "#; + + let blob = last_read_json.as_bytes(); + let last_read = ::try_from(&blob, "").unwrap(); + assert_eq!(last_read.timestamp, 1700000000); + } +} +``` +./src/common.rs +``` +use std::time::{SystemTime, UNIX_EPOCH}; + +pub static VERSION: &str = "0.2.1"; +pub static APP_PATH: &str = "/pub/pubky.app/"; +pub static PROTOCOL: &str = "pubky://"; + +/// Returns the current timestamp in microseconds since the UNIX epoch. +pub fn timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_micros() as i64 +} +``` +./src/file.rs +``` +use crate::{ + common::timestamp, + traits::{HasPath, TimestampId, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents a file uploaded by the user. +/// URI: /pub/pubky.app/files/:file_id +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppFile { + pub name: String, + pub created_at: i64, + pub src: String, + pub content_type: String, + pub size: i64, +} + +impl PubkyAppFile { + /// Creates a new `PubkyAppFile` instance. + pub fn new(name: String, src: String, content_type: String, size: i64) -> Self { + let created_at = timestamp(); + Self { + name, + created_at, + src, + content_type, + size, + } + } +} + +impl TimestampId for PubkyAppFile {} + +impl HasPath for PubkyAppFile { + fn create_path(&self) -> String { + format!("{}files/{}", APP_PATH, self.create_id()) + } +} + +impl Validatable for PubkyAppFile { + // TODO: content_type validation. + fn validate(&self, id: &str) -> Result<(), String> { + self.validate_id(id)?; + // TODO: content_type validation. + // TODO: size and other validation. + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let file = PubkyAppFile::new( + "example.png".to_string(), + "pubky://user_id/pub/pubky.app/blobs/id".to_string(), + "image/png".to_string(), + 1024, + ); + assert_eq!(file.name, "example.png"); + assert_eq!(file.src, "pubky://user_id/pub/pubky.app/blobs/id"); + assert_eq!(file.content_type, "image/png"); + assert_eq!(file.size, 1024); + // Check that created_at is recent + let now = timestamp(); + assert!(file.created_at <= now && file.created_at >= now - 1_000_000); // within 1 second + } + + #[test] + fn test_create_path() { + let file = PubkyAppFile::new( + "example.png".to_string(), + "pubky://user_id/pub/pubky.app/blobs/id".to_string(), + "image/png".to_string(), + 1024, + ); + let file_id = file.create_id(); + let path = file.create_path(); + + // Check if the path starts with the expected prefix + let prefix = format!("{}files/", APP_PATH); + assert!(path.starts_with(&prefix)); + + let expected_path_len = prefix.len() + file_id.len(); + assert_eq!(path.len(), expected_path_len); + } + + #[test] + fn test_validate_valid() { + let file = PubkyAppFile::new( + "example.png".to_string(), + "/uploads/example.png".to_string(), + "image/png".to_string(), + 1024, + ); + let id = file.create_id(); + let result = file.validate(&id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_id() { + let file = PubkyAppFile::new( + "example.png".to_string(), + "/uploads/example.png".to_string(), + "image/png".to_string(), + 1024, + ); + let invalid_id = "INVALIDID"; + let result = file.validate(&invalid_id); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_valid() { + let file_json = r#" + { + "name": "example.png", + "created_at": 1627849723, + "src": "/uploads/example.png", + "content_type": "image/png", + "size": 1024 + } + "#; + + let file = PubkyAppFile::new( + "example.png".to_string(), + "/uploads/example.png".to_string(), + "image/png".to_string(), + 1024, + ); + let id = file.create_id(); + + let blob = file_json.as_bytes(); + let file_parsed = ::try_from(&blob, &id).unwrap(); + + assert_eq!(file_parsed.name, "example.png"); + assert_eq!(file_parsed.src, "/uploads/example.png"); + assert_eq!(file_parsed.content_type, "image/png"); + assert_eq!(file_parsed.size, 1024); + } +} +``` +./src/traits.rs +``` +use crate::common::timestamp; +use base32::{decode, encode, Alphabet}; +use blake3::Hasher; +use serde::de::DeserializeOwned; + +pub trait TimestampId { + /// Creates a unique identifier based on the current timestamp. + fn create_id(&self) -> String { + // Get current time in microseconds since UNIX epoch + let now = timestamp(); + + // Convert to big-endian bytes + let bytes = now.to_be_bytes(); + + // Encode the bytes using Base32 with the Crockford alphabet + encode(Alphabet::Crockford, &bytes) + } + + /// Validates that the provided ID is a valid Crockford Base32-encoded timestamp, + /// 13 characters long, and represents a reasonable timestamp. + fn validate_id(&self, id: &str) -> Result<(), String> { + // Ensure ID is 13 characters long + if id.len() != 13 { + return Err("Validation Error: Invalid ID length: must be 13 characters".into()); + } + + // Decode the Crockford Base32-encoded ID + let decoded_bytes = + decode(Alphabet::Crockford, id).ok_or("Failed to decode Crockford Base32 ID")?; + + if decoded_bytes.len() != 8 { + return Err("Validation Error: Invalid ID length after decoding".into()); + } + + // Convert the decoded bytes to a timestamp in microseconds + let timestamp_micros = i64::from_be_bytes(decoded_bytes.try_into().unwrap()); + + // Get current time in microseconds + let now_micros = timestamp(); + + // Define October 1st, 2024, in microseconds since UNIX epoch + let oct_first_2024_micros = 1727740800000000; // Timestamp for 2024-10-01 00:00:00 UTC + + // Allowable future duration (2 hours) in microseconds + let max_future_micros = now_micros + 2 * 60 * 60 * 1_000_000; + + // Validate that the ID's timestamp is after October 1st, 2024 + if timestamp_micros < oct_first_2024_micros { + return Err( + "Validation Error: Invalid ID, timestamp must be after October 1st, 2024".into(), + ); + } + + // Validate that the ID's timestamp is not more than 2 hours in the future + if timestamp_micros > max_future_micros { + return Err("Validation Error: Invalid ID, timestamp is too far in the future".into()); + } + + Ok(()) + } +} + +/// Trait for generating an ID based on the struct's data. +pub trait HashId { + fn get_id_data(&self) -> String; + + /// Creates a unique identifier for bookmarks and tag homeserver paths instance. + /// + /// The ID is generated by: + /// 1. Concatenating the `uri` and `label` fields of the `PubkyAppTag` with a colon (`:`) separator. + /// 2. Hashing the concatenated string using the `blake3` hashing algorithm. + /// 3. Taking the first half of the bytes from the resulting `blake3` hash. + /// 4. Encoding those bytes using the Crockford alphabet (Base32 variant). + /// + /// The resulting Crockford-encoded string is returned as the tag ID. + /// + /// # Returns + /// - A `String` representing the Crockford-encoded tag ID derived from the `blake3` hash of the concatenated `uri` and `label`. + fn create_id(&self) -> String { + let data = self.get_id_data(); + + // Create a Blake3 hash of the input data + let mut hasher = Hasher::new(); + hasher.update(data.as_bytes()); + let blake3_hash = hasher.finalize(); + + // Get the first half of the hash bytes + let half_hash_length = blake3_hash.as_bytes().len() / 2; + let half_hash = &blake3_hash.as_bytes()[..half_hash_length]; + + // Encode the first half of the hash in Base32 using the Z-base32 alphabet + encode(Alphabet::Crockford, half_hash) + } + + /// Validates that the provided ID matches the generated ID. + fn validate_id(&self, id: &str) -> Result<(), String> { + let generated_id = self.create_id(); + if generated_id != id { + return Err(format!("Invalid ID: expected {}, found {}", generated_id, id).into()); + } + Ok(()) + } +} + +pub trait Validatable: Sized + DeserializeOwned { + fn try_from(blob: &[u8], id: &str) -> Result { + let mut instance: Self = serde_json::from_slice(blob).map_err(|e| e.to_string())?; + instance = instance.sanitize(); + instance.validate(id)?; + Ok(instance) + } + + fn validate(&self, id: &str) -> Result<(), String>; + + fn sanitize(self) -> Self { + self + } +} + +pub trait HasPath { + fn create_path(&self) -> String; +} + +pub trait HasPubkyIdPath { + fn create_path(&self, pubky_id: &str) -> String; +} +``` +./src/bookmark.rs +``` +use crate::{ + common::timestamp, + traits::{HasPath, HashId, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents raw homeserver bookmark with id +/// URI: /pub/pubky.app/bookmarks/:bookmark_id +/// +/// Example URI: +/// +/// `/pub/pubky.app/bookmarks/AF7KQ6NEV5XV1EG5DVJ2E74JJ4` +/// +/// Where bookmark_id is Crockford-base32(Blake3("{uri_bookmarked}"")[:half]) +#[derive(Serialize, Deserialize, Default)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppBookmark { + pub uri: String, + pub created_at: i64, +} + +impl PubkyAppBookmark { + /// Creates a new `PubkyAppBookmark` instance. + pub fn new(uri: String) -> Self { + let created_at = timestamp(); + Self { uri, created_at }.sanitize() + } +} + +impl HashId for PubkyAppBookmark { + /// Bookmark ID is created based on the hash of the URI bookmarked + fn get_id_data(&self) -> String { + self.uri.clone() + } +} + +impl HasPath for PubkyAppBookmark { + fn create_path(&self) -> String { + format!("{}bookmarks/{}", APP_PATH, self.create_id()) + } +} + +impl Validatable for PubkyAppBookmark { + fn validate(&self, id: &str) -> Result<(), String> { + self.validate_id(id)?; + // TODO: more bookmarks validation? + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_create_bookmark_id() { + let bookmark = PubkyAppBookmark { + uri: "user_id/pub/pubky.app/posts/post_id".to_string(), + created_at: 1627849723, + }; + + let bookmark_id = bookmark.create_id(); + assert_eq!(bookmark_id, "AF7KQ6NEV5XV1EG5DVJ2E74JJ4"); + } + + #[test] + fn test_create_path() { + let bookmark = PubkyAppBookmark { + uri: "pubky://user_id/pub/pubky.app/posts/post_id".to_string(), + created_at: 1627849723, + }; + let expected_id = bookmark.create_id(); + let expected_path = format!("{}bookmarks/{}", APP_PATH, expected_id); + let path = bookmark.create_path(); + assert_eq!(path, expected_path); + } + + #[test] + fn test_validate_valid() { + let bookmark = + PubkyAppBookmark::new("pubky://user_id/pub/pubky.app/posts/post_id".to_string()); + let id = bookmark.create_id(); + let result = bookmark.validate(&id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_id() { + let bookmark = PubkyAppBookmark::new("user_id/pub/pubky.app/posts/post_id".to_string()); + let invalid_id = "INVALIDID"; + let result = bookmark.validate(&invalid_id); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_valid() { + let bookmark_json = r#" + { + "uri": "user_id/pub/pubky.app/posts/post_id", + "created_at": 1627849723 + } + "#; + + let uri = "user_id/pub/pubky.app/posts/post_id".to_string(); + let bookmark = PubkyAppBookmark::new(uri.clone()); + let id = bookmark.create_id(); + + let blob = bookmark_json.as_bytes(); + let bookmark_parsed = ::try_from(&blob, &id).unwrap(); + + assert_eq!(bookmark_parsed.uri, uri); + } +} +``` +./src/post.rs +``` +use crate::{ + traits::{HasPath, TimestampId, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use url::Url; + +// Validation +const MAX_SHORT_CONTENT_LENGTH: usize = 1000; +const MAX_LONG_CONTENT_LENGTH: usize = 50000; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents the type of pubky-app posted data +/// Used primarily to best display the content in UI +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub enum PubkyAppPostKind { + #[default] + Short, + Long, + Image, + Video, + Link, + File, +} + +impl fmt::Display for PubkyAppPostKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let string_repr = serde_json::to_value(self) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + write!(f, "{}", string_repr) + } +} + +/// Represents embedded content within a post +#[derive(Serialize, Deserialize, Default, Clone)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppPostEmbed { + pub kind: PubkyAppPostKind, // Kind of the embedded content + pub uri: String, // URI of the embedded content +} + +/// Represents raw post in homeserver with content and kind +/// URI: /pub/pubky.app/posts/:post_id +/// Where post_id is CrockfordBase32 encoding of timestamp +/// +/// Example URI: +/// +/// `/pub/pubky.app/posts/00321FCW75ZFY` +#[derive(Serialize, Deserialize, Default, Clone)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppPost { + pub content: String, + pub kind: PubkyAppPostKind, + pub parent: Option, // If a reply, the URI of the parent post. + pub embed: Option, + pub attachments: Option>, +} + +impl PubkyAppPost { + /// Creates a new `PubkyAppPost` instance and sanitizes it. + pub fn new( + content: String, + kind: PubkyAppPostKind, + parent: Option, + embed: Option, + attachments: Option>, + ) -> Self { + let post = PubkyAppPost { + content, + kind, + parent, + embed, + attachments, + }; + post.sanitize() + } +} + +impl TimestampId for PubkyAppPost {} + +impl HasPath for PubkyAppPost { + fn create_path(&self) -> String { + format!("{}posts/{}", APP_PATH, self.create_id()) + } +} + +impl Validatable for PubkyAppPost { + fn sanitize(self) -> Self { + // Sanitize content + let mut content = self.content.trim().to_string(); + + // We are using content keyword `[DELETED]` for deleted posts from a homeserver that still have relationships + // placed by other users (replies, tags, etc). This content is exactly matched by the client to apply effects to deleted content. + // Placing posts with content `[DELETED]` is not allowed. + if content == *"[DELETED]" { + content = "empty".to_string() + } + + // Define content length limits based on PubkyAppPostKind + let max_content_length = match self.kind { + PubkyAppPostKind::Short => MAX_SHORT_CONTENT_LENGTH, + PubkyAppPostKind::Long => MAX_LONG_CONTENT_LENGTH, + _ => MAX_SHORT_CONTENT_LENGTH, // Default limit for other kinds + }; + + let content = content.chars().take(max_content_length).collect::(); + + // Sanitize parent URI if present + let parent = if let Some(uri_str) = &self.parent { + match Url::parse(uri_str) { + Ok(url) => Some(url.to_string()), // Valid URI, use normalized version + Err(_) => None, // Invalid URI, discard or handle appropriately + } + } else { + None + }; + + // Sanitize embed if present + let embed = if let Some(embed) = &self.embed { + match Url::parse(&embed.uri) { + Ok(url) => Some(PubkyAppPostEmbed { + kind: embed.kind.clone(), + uri: url.to_string(), // Use normalized version + }), + Err(_) => None, // Invalid URI, discard or handle appropriately + } + } else { + None + }; + + PubkyAppPost { + content, + kind: self.kind, + parent, + embed, + attachments: self.attachments, + } + } + + fn validate(&self, id: &str) -> Result<(), String> { + self.validate_id(id)?; + + // Validate content length + match self.kind { + PubkyAppPostKind::Short => { + if self.content.chars().count() > MAX_SHORT_CONTENT_LENGTH { + return Err( + "Validation Error: Post content exceeds maximum length for Short kind" + .into(), + ); + } + } + PubkyAppPostKind::Long => { + if self.content.chars().count() > MAX_LONG_CONTENT_LENGTH { + return Err( + "Validation Error: Post content exceeds maximum length for Short kind" + .into(), + ); + } + } + _ => (), + }; + + // TODO: additional validation. Attachement URLs...? + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_create_id() { + let post = PubkyAppPost::new( + "Hello World!".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ); + + let post_id = post.create_id(); + println!("Generated Post ID: {}", post_id); + + // Assert that the post ID is 13 characters long + assert_eq!(post_id.len(), 13); + } + + #[test] + fn test_new() { + let content = "This is a test post".to_string(); + let kind = PubkyAppPostKind::Short; + let post = PubkyAppPost::new(content.clone(), kind.clone(), None, None, None); + + assert_eq!(post.content, content); + assert_eq!(post.kind, kind); + assert!(post.parent.is_none()); + assert!(post.embed.is_none()); + assert!(post.attachments.is_none()); + } + + #[test] + fn test_create_path() { + let post = PubkyAppPost::new( + "Test post".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ); + + let post_id = post.create_id(); + let path = post.create_path(); + + // Check if the path starts with the expected prefix + let prefix = format!("{}posts/", APP_PATH); + assert!(path.starts_with(&prefix)); + + let expected_path_len = prefix.len() + post_id.len(); + assert_eq!(path.len(), expected_path_len); + } + + #[test] + fn test_sanitize() { + let content = " This is a test post with extra whitespace ".to_string(); + let post = PubkyAppPost::new( + content.clone(), + PubkyAppPostKind::Short, + Some("invalid uri".to_string()), + Some(PubkyAppPostEmbed { + kind: PubkyAppPostKind::Link, + uri: "invalid uri".to_string(), + }), + None, + ); + + let sanitized_post = post.sanitize(); + assert_eq!(sanitized_post.content, content.trim()); + assert!(sanitized_post.parent.is_none()); + assert!(sanitized_post.embed.is_none()); + } + + #[test] + fn test_validate_valid() { + let post = PubkyAppPost::new( + "Valid content".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ); + + let id = post.create_id(); + let result = post.validate(&id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_id() { + let post = PubkyAppPost::new( + "Valid content".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ); + + let invalid_id = "INVALIDID12345"; + let result = post.validate(&invalid_id); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_valid() { + let post_json = r#" + { + "content": "Hello World!", + "kind": "short", + "parent": null, + "embed": null, + "attachments": null + } + "#; + + let id = PubkyAppPost::new( + "Hello World!".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ) + .create_id(); + + let blob = post_json.as_bytes(); + let post = ::try_from(&blob, &id).unwrap(); + + assert_eq!(post.content, "Hello World!"); + } + + #[test] + fn test_try_from_invalid_content() { + let content = "[DELETED]".to_string(); + let post_json = format!( + r#"{{ + "content": "{}", + "kind": "short", + "parent": null, + "embed": null, + "attachments": null + }}"#, + content + ); + + let id = PubkyAppPost::new(content.clone(), PubkyAppPostKind::Short, None, None, None) + .create_id(); + + let blob = post_json.as_bytes(); + let post = ::try_from(&blob, &id).unwrap(); + + assert_eq!(post.content, "empty"); // After sanitization + } +} +``` +./src/tag.rs +``` +use crate::{ + common::timestamp, + traits::{HasPath, HashId, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +// Validation +const MAX_TAG_LABEL_LENGTH: usize = 20; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents raw homeserver tag with id +/// URI: /pub/pubky.app/tags/:tag_id +/// +/// Example URI: +/// +/// `/pub/pubky.app/tags/FPB0AM9S93Q3M1GFY1KV09GMQM` +/// +/// Where tag_id is Crockford-base32(Blake3("{uri_tagged}:{label}")[:half]) +#[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppTag { + pub uri: String, + pub label: String, + pub created_at: i64, +} + +impl PubkyAppTag { + pub fn new(uri: String, label: String) -> Self { + let created_at = timestamp(); + Self { + uri, + label, + created_at, + } + .sanitize() + } +} + +impl HasPath for PubkyAppTag { + fn create_path(&self) -> String { + format!("{}tags/{}", APP_PATH, self.create_id()) + } +} + +impl HashId for PubkyAppTag { + /// Tag ID is created based on the hash of the URI tagged and the label used + fn get_id_data(&self) -> String { + format!("{}:{}", self.uri, self.label) + } +} + +impl Validatable for PubkyAppTag { + fn sanitize(self) -> Self { + // Convert label to lowercase and trim + let label = self.label.trim().to_lowercase(); + + // Enforce maximum label length safely + let label = label.chars().take(MAX_TAG_LABEL_LENGTH).collect::(); + + // Sanitize URI + let uri = match Url::parse(&self.uri) { + Ok(url) => { + // If the URL is valid, reformat it to a sanitized string representation + url.to_string() + } + Err(_) => { + // If the URL is invalid, return as-is for error reporting later + self.uri.trim().to_string() + } + }; + + PubkyAppTag { + uri, + label, + created_at: self.created_at, + } + } + + fn validate(&self, id: &str) -> Result<(), String> { + // Validate the tag ID + self.validate_id(id)?; + + // Validate label length + if self.label.chars().count() > MAX_TAG_LABEL_LENGTH { + return Err("Validation Error: Tag label exceeds maximum length".to_string()); + } + + // Validate URI format + match Url::parse(&self.uri) { + Ok(_) => Ok(()), + Err(_) => Err(format!( + "Validation Error: Invalid URI format: {}", + self.uri + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{traits::Validatable, APP_PATH}; + + #[test] + fn test_create_id() { + let tag = PubkyAppTag { + uri: "https://example.com/post/1".to_string(), + created_at: 1627849723000, + label: "cool".to_string(), + }; + + let tag_id = tag.create_id(); + println!("Generated Tag ID: {}", tag_id); + + // Assert that the tag ID is of expected length + // The length depends on your implementation of create_id + assert!(!tag_id.is_empty()); + } + + #[test] + fn test_new() { + let uri = "https://example.com/post/1".to_string(); + let label = "interesting".to_string(); + let tag = PubkyAppTag::new(uri.clone(), label.clone()); + + assert_eq!(tag.uri, uri); + assert_eq!(tag.label, label); + // Check that created_at is recent + let now = timestamp(); + println!("TIMESTAMP {}", tag.created_at); + println!("TIMESTAMP {}", now); + + assert!(tag.created_at <= now && tag.created_at >= now - 1_000_000); // within 1 second + } + + #[test] + fn test_create_path() { + let tag = PubkyAppTag { + uri: "pubky://operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0032FNCGXE3R0".to_string(), + created_at: 1627849723000, + label: "cool".to_string(), + }; + + let expected_id = tag.create_id(); + let expected_path = format!("{}tags/{}", APP_PATH, expected_id); + let path = tag.create_path(); + + assert_eq!(path, expected_path); + } + + #[test] + fn test_sanitize() { + let tag = PubkyAppTag { + uri: "pubky://user_id/pub/pubky.app/posts/0000000000000".to_string(), + label: " CoOl ".to_string(), + created_at: 1627849723000, + }; + + let sanitized_tag = tag.sanitize(); + assert_eq!(sanitized_tag.label, "cool"); + } + + #[test] + fn test_validate_valid() { + let tag = PubkyAppTag { + uri: "pubky://user_id/pub/pubky.app/posts/0000000000000".to_string(), + label: "cool".to_string(), + created_at: 1627849723000, + }; + + let id = tag.create_id(); + let result = tag.validate(&id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_label_length() { + let tag = PubkyAppTag { + uri: "pubky://user_id/pub/pubky.app/posts/0000000000000".to_string(), + label: "a".repeat(MAX_TAG_LABEL_LENGTH + 1), + created_at: 1627849723000, + }; + + let id = tag.create_id(); + let result = tag.validate(&id); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Validation Error: Tag label exceeds maximum length" + ); + } + + #[test] + fn test_validate_invalid_id() { + let tag = PubkyAppTag { + uri: "pubky://user_id/pub/pubky.app/posts/0000000000000".to_string(), + label: "cool".to_string(), + created_at: 1627849723000, + }; + + let invalid_id = "INVALIDID"; + let result = tag.validate(&invalid_id); + assert!(result.is_err()); + // You can check the specific error message if necessary + } + + #[test] + fn test_try_from_valid() { + let tag_json = r#" + { + "uri": "pubky://user_pubky_id/pub/pubky.app/profile.json", + "label": "Cool Tag", + "created_at": 1627849723000 + } + "#; + + let id = PubkyAppTag::new( + "pubky://user_pubky_id/pub/pubky.app/profile.json".to_string(), + "Cool Tag".to_string(), + ) + .create_id(); + + let blob = tag_json.as_bytes(); + let tag = ::try_from(&blob, &id).unwrap(); + assert_eq!(tag.uri, "pubky://user_pubky_id/pub/pubky.app/profile.json"); + assert_eq!(tag.label, "cool tag"); // After sanitization + } + + #[test] + fn test_try_from_invalid_uri() { + let tag_json = r#" + { + "uri": "invalid_uri", + "label": "Cool Tag", + "created_at": 1627849723000 + } + "#; + + let id = "B55PGPFV1E5E0HQ2PB76EQGXPR"; + let blob = tag_json.as_bytes(); + let result = ::try_from(&blob, &id); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Validation Error: Invalid URI format: invalid_uri" + ); + } +} +``` +./src/follow.rs +``` +use crate::{ + common::timestamp, + traits::{HasPubkyIdPath, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents raw homeserver follow object with timestamp +/// +/// On follow objects, the main data is encoded in the path +/// +/// URI: /pub/pubky.app/follows/:user_id +/// +/// Example URI: +/// +/// `/pub/pubky.app/follows/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy` +/// +#[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppFollow { + pub created_at: i64, +} + +impl PubkyAppFollow { + /// Creates a new `PubkyAppFollow` instance. + pub fn new() -> Self { + let created_at = timestamp(); + Self { created_at } + } +} + +impl Validatable for PubkyAppFollow { + fn validate(&self, _id: &str) -> Result<(), String> { + // TODO: additional follow validation? E.g., validate `created_at`? + Ok(()) + } +} + +impl HasPubkyIdPath for PubkyAppFollow { + fn create_path(&self, pubky_id: &str) -> String { + format!("{}follows/{}", APP_PATH, pubky_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let follow = PubkyAppFollow::new(); + // Check that created_at is recent + let now = timestamp(); + // within 1 second + assert!(follow.created_at <= now && follow.created_at >= now - 1_000_000); + } + + #[test] + fn test_create_path_with_id() { + let mute = PubkyAppFollow::new(); + let path = mute.create_path("user_id123"); + assert_eq!(path, "/pub/pubky.app/follows/user_id123"); + } + + #[test] + fn test_validate() { + let follow = PubkyAppFollow::new(); + let result = follow.validate("some_user_id"); + assert!(result.is_ok()); + } + + #[test] + fn test_try_from_valid() { + let follow_json = r#" + { + "created_at": 1627849723 + } + "#; + + let blob = follow_json.as_bytes(); + let follow_parsed = + ::try_from(&blob, "some_user_id").unwrap(); + + assert_eq!(follow_parsed.created_at, 1627849723); + } +} +``` +./src/mute.rs +``` +use crate::{ + common::timestamp, + traits::{HasPubkyIdPath, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents raw homeserver Mute object with timestamp +/// URI: /pub/pubky.app/mutes/:user_id +/// +/// Example URI: +/// +/// `/pub/pubky.app/mutes/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy` +/// +#[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppMute { + pub created_at: i64, +} + +impl PubkyAppMute { + /// Creates a new `PubkyAppMute` instance. + pub fn new() -> Self { + let created_at = timestamp(); + Self { created_at } + } +} + +impl Validatable for PubkyAppMute { + fn validate(&self, _id: &str) -> Result<(), String> { + // TODO: additional Mute validation? E.g., validate `created_at` ? + Ok(()) + } +} + +impl HasPubkyIdPath for PubkyAppMute { + fn create_path(&self, pubky_id: &str) -> String { + format!("{}mutes/{}", APP_PATH, pubky_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::timestamp; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let mute = PubkyAppMute::new(); + // Check that created_at is recent + let now = timestamp(); + assert!(mute.created_at <= now && mute.created_at >= now - 1_000_000); + // within 1 second + } + + #[test] + fn test_create_path_with_id() { + let mute = PubkyAppMute::new(); + let path = mute.create_path("user_id123"); + assert_eq!(path, "/pub/pubky.app/mutes/user_id123"); + } + + #[test] + fn test_validate() { + let mute = PubkyAppMute::new(); + let result = mute.validate("some_user_id"); + assert!(result.is_ok()); + } + + #[test] + fn test_try_from_valid() { + let mute_json = r#" + { + "created_at": 1627849723 + } + "#; + + let blob = mute_json.as_bytes(); + let mute_parsed = ::try_from(&blob, "some_user_id").unwrap(); + + assert_eq!(mute_parsed.created_at, 1627849723); + } +} +``` +./src/lib.rs +``` +mod bookmark; +mod common; +mod feed; +mod file; +mod follow; +mod last_read; +mod mute; +mod post; +mod tag; +pub mod traits; +mod user; + +pub use bookmark::PubkyAppBookmark; +pub use common::{APP_PATH, PROTOCOL, VERSION}; +pub use feed::{PubkyAppFeed, PubkyAppFeedLayout, PubkyAppFeedReach, PubkyAppFeedSort}; +pub use file::PubkyAppFile; +pub use follow::PubkyAppFollow; +pub use last_read::PubkyAppLastRead; +pub use mute::PubkyAppMute; +pub use post::{PubkyAppPost, PubkyAppPostEmbed, PubkyAppPostKind}; +pub use tag::PubkyAppTag; +pub use user::{PubkyAppUser, PubkyAppUserLink}; +``` +./README.md +``` +# Pubky.app Data Model Specification + +_Version 0.2.0_ + +## Introduction + +This document specifies the data models and validation rules for the Pubky.app client and homeserver interactions. It defines the structures of data entities, their properties, and the validation rules to ensure data integrity and consistency. This specification is intended for developers who wish to implement their own libraries or clients compatible with Pubky.app. + +This document intents to be a faithful representation of our [Rust pubky.app models](https://github.com/pubky/pubky-app-specs/tree/main/src). If you intend to develop in Rust, use them directly. In case of disagreement between this document and the Rust implementation, the Rust implementation prevails. + +## Data Models + +### PubkyAppUser + +**Description:** Represents a user's profile information. + +**URI:** `/pub/pubky.app/profile.json` + +**Fields:** + +- `name` (string, required): The user's name. +- `bio` (string, optional): A short biography. +- `image` (string, optional): A URL to the user's profile image. +- `links` (array of `UserLink`, optional): A list of links associated with the user. +- `status` (string, optional): The user's current status. + +**`UserLink` Object:** + +- `title` (string, required): The title of the link. +- `url` (string, required): The URL of the link. + +**Validation Rules:** + +- **`name`:** + + - Must be at least **3** and at most **50** characters. + - Cannot be the keyword `[DELETED]`; this is reserved for deleted profiles. + +- **`bio`:** + + - Maximum length of **160** characters if provided. + +- **`image`:** + + - If provided, must be a valid URL. + - Maximum length of **300** characters. + +- **`links`:** + + - Maximum of **5** links. + - Each `UserLink` must have: + - `title`: Maximum length of **100** characters. + - `url`: Must be a valid URL, maximum length of **300** characters. + +- **`status`:** + - Maximum length of **50** characters if provided. + +--- + +### PubkyAppFile + +**Description:** Represents a file uploaded by the user. + +**URI:** `/pub/pubky.app/files/:file_id` + +**Fields:** + +- `name` (string, required): The name of the file. +- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the file was created. +- `src` (string, required): The source URL or path of the file. +- `content_type` (string, required): The MIME type of the file. +- `size` (integer, required): The size of the file in bytes. + +**Validation Rules:** + +- **ID Validation:** + + - The `file_id` in the URI must be a valid **Timestamp ID** (see [ID Generation](#id-generation)). + +- **Additional Validation:** + - Validation for `content_type`, `size`, and other fields should be implemented as needed. + +--- + +### PubkyAppPost + +**Description:** Represents a user's post. + +**URI:** `/pub/pubky.app/posts/:post_id` + +**Fields:** + +- `content` (string, required): The content of the post. +- `kind` (string, required): The type of post. Possible values are: + + - `Short` + - `Long` + - `Image` + - `Video` + - `Link` + - `File` + +- `parent` (string, optional): URI of the parent post if this is a reply. +- `embed` (object, optional): Embedded content. +- `attachments` (array of strings, optional): A list of attachment URIs. + +**`embed` Object:** + +- `kind` (string, required): Type of the embedded content. Same as `kind` in `PubkyAppPost`. +- `uri` (string, required): URI of the embedded content. + +**Validation Rules:** + +- **ID Validation:** + + - The `post_id` in the URI must be a valid **Timestamp ID** (see [ID Generation](#id-generation)). + +- **`content`:** + + - Must not be the keyword `[DELETED]`; this is reserved for deleted posts. + - **For `kind` of `Short`:** + - Maximum length of **1000** characters. + - **For `kind` of `Long`:** + - Maximum length of **50000** characters. + - **For other `kind` values:** + - Maximum length of **1000** characters. + +- **`parent`:** + + - If provided, must be a valid URI. + +- **`embed`:** + + - If provided: + - `uri` must be a valid URI. + +- **Additional Validation:** + - Validation for `attachments` and other fields should be implemented as needed. + +--- + +### PubkyAppTag + +**Description:** Represents a tag applied to a URI. + +**URI:** `/pub/pubky.app/tags/:tag_id` + +**Fields:** + +- `uri` (string, required): The URI that is tagged. +- `label` (string, required): The tag label. +- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the tag was created. + +**Validation Rules:** + +- **ID Validation:** + + - The `tag_id` in the URI must be a valid **Hash ID** generated from the `uri` and `label` (see [ID Generation](#id-generation)). + +- **`uri`:** + + - Must be a valid URI. + +- **`label`:** + - Must be trimmed and converted to lowercase. + - Maximum length of **20** characters. + +--- + +### PubkyAppBookmark + +**Description:** Represents a bookmark to a URI. + +**URI:** `/pub/pubky.app/bookmarks/:bookmark_id` + +**Fields:** + +- `uri` (string, required): The URI that is bookmarked. +- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the bookmark was created. + +**Validation Rules:** + +- **ID Validation:** + + - The `bookmark_id` in the URI must be a valid **Hash ID** generated from the `uri` (see [ID Generation](#id-generation)). + +- **`uri`:** + - Must be a valid URI. + +--- + +### PubkyAppFollow + +**Description:** Represents a follow relationship to another user. + +**URI:** `/pub/pubky.app/follows/:user_id` + +**Fields:** + +- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the follow was created. + +**Validation Rules:** + +- **`created_at`:** + - Should be validated as needed. + +--- + +### PubkyAppMute + +**Description:** Represents a mute relationship to another user. + +**URI:** `/pub/pubky.app/mutes/:user_id` + +**Fields:** + +- `created_at` (integer, required): Timestamp (Unix epoch in seconds) of when the mute was created. + +**Validation Rules:** + +- **`created_at`:** + - Should be validated as needed. + +--- + +## Validation Rules + +### Common Rules + +#### IDs + +- **Timestamp IDs**: IDs generated based on the current timestamp, encoded in Crockford Base32. + + - Must be **13** characters long. + - Decoded ID must represent a valid timestamp after **October 1st, 2024**. + - Timestamp must not be more than **2 hours** in the future. + +- **Hash IDs**: IDs generated by hashing certain fields of the object using Blake3 and encoding in Crockford Base32. + - For `PubkyAppTag`: Hash of `uri:label`. + - For `PubkyAppBookmark`: Hash of `uri`. + - The generated ID must match the provided ID. + +### URL Validation + +- All URLs must be valid according to standard URL parsing rules. + +### String Lengths + +- Fields have maximum lengths as specified in their validation rules. + +### Content Restrictions + +- The content of posts and profiles must not be `[DELETED]`. This keyword is reserved for indicating deleted content. + +### Label Formatting + +- Labels for tags must be: + - Trimmed. + - Converted to lowercase. + - Maximum length of 20 characters. + +--- + +### PubkyAppFeed + +**Description:** Represents a feed configuration, allowing users to customize the content they see based on tags, reach, layout, and sort order. + +**URI:** `/feeds/:feed_id` + +**Fields:** + +- `feed` (object, required): The main configuration object for the feed. + + - `tags` (array of strings, optional): Tags used to filter content within the feed. + - `reach` (string, required): Defines the visibility or scope of the feed. Possible values are: + - `following`: Content from followed users. + - `followers`: Content from follower users. + - `friends`: Content from mutual following users. + - `all`: Public content accessible to everyone. + - `layout` (string, required): Specifies the layout of the feed. Options include: + - `columns`: Organizes feed content in a columnar format. + - `wide`: Arranges content in a standard wide format. + - `visual`: Arranges content in visual format. + - `sort` (string, required): Determines the sorting order of the feed content. Supported values are: + - `recent`: Most recent content first. + - `popularity`: Content with the highest engagement. + - `content` (string, optional): Defines the type of content to filter. Possible values are the same as post kinds: + - `short` + - `long` + - `image` + - `video` + - `link` + - `file` + +- `name` (string, required): The user-defined name for this feed configuration. +- `created_at` (integer, required): Timestamp (Unix epoch in milliseconds) representing when the feed was created. + +**Validation Rules:** + +- **ID Validation:** + - The `feed_id` in the URI is a **Hash ID** generated from the serialized feed object (the JSON object for `feed`), computed using Blake3 and encoded in Crockford Base32. + - The generated `feed_id` must match the provided `feed_id`. + +--- + +### PubkyAppLastRead + +**Description:** Represents the last read timestamp for notifications, used to track when the user last checked for new activity. + +**URI:** `/pub/pubky.app/last_read` + +**Fields:** + +- `timestamp` (integer, required): Unix epoch time in milliseconds of the last time the user checked notifications. + +**Validation Rules:** + +- **`timestamp`:** Must be a valid timestamp in milliseconds. + +--- + +## ID Generation + +### TimestampId + +**Description:** Generates an ID based on the current timestamp. + +**Generation Steps:** + +1. Obtain the current timestamp in microseconds. +2. Convert the timestamp to an 8-byte big-endian representation. +3. Encode the bytes using Crockford Base32 to get a 13-character ID. + +**Validation:** + +- The ID must be **13** characters long. +- Decoded timestamp must represent a date after **October 1st, 2024**. +- The timestamp must not be more than **2 hours** in the future. + +### HashId + +**Description:** Generates an ID based on hashing certain fields of the object. + +**Generation Steps:** + +1. Concatenate the relevant fields (e.g., `uri:label` for tags). +2. Compute the Blake3 hash of the concatenated string. +3. Take the first half of the hash bytes. +4. Encode the bytes using Crockford Base32. + +**Validation:** + +- The generated ID must match the provided ID. + +--- + +## Examples + +### Example of PubkyAppUser + +```json +{ + "name": "Alice", + "bio": "Blockchain enthusiast and developer.", + "image": "https://example.com/images/alice.png", + "links": [ + { + "title": "GitHub", + "url": "https://github.com/alice" + }, + { + "title": "Website", + "url": "https://alice.dev" + } + ], + "status": "Exploring the decentralized web." +} +``` + +### Example of PubkyAppPost + +```json +{ + "content": "Hello world! This is my first post.", + "kind": "short", + "parent": null, + "embed": null, + "attachments": null +} +``` + +### Example of PubkyAppTag + +```json +{ + "uri": "/pub/pubky.app/posts/00321FCW75ZFY", + "label": "blockchain", + "created_at": 1700000000 +} +``` + +## Notes + +- All timestamps are Unix epoch times in seconds. +- Developers should ensure that all validation rules are enforced to maintain data integrity and interoperability between clients. +- This specification may be updated in future versions to include additional fields or validation rules. + +## License + +This specification is released under the MIT License. +``` +./Cargo.toml +``` +[package] +name = "pubky-app-specs" +version = "0.2.1" +edition = "2021" +description = "Pubky.app Data Model Specifications" +homepage = "https://pubky.app" +repository = "https://github.com/pubky/pubky-app-specs" +license = "MIT" +documentation = "https://github.com/pubky/pubky-app-specs" + +[dependencies] +serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0.133" +url = "2.5.4" +base32 = "0.5.1" +blake3 = "1.5.4" +utoipa = { version = "5.2.0", optional = true } + +[features] +openapi = ["utoipa"] +``` diff --git a/examples/create_user.rs b/examples/create_user.rs new file mode 100644 index 0000000..8e24fcd --- /dev/null +++ b/examples/create_user.rs @@ -0,0 +1,101 @@ +/// cargo run --example create_user +use anyhow::Result; +use pubky::PubkyClient; +use pubky_app_specs::{ + traits::{HasPath, Validatable}, + PubkyAppUser, PROTOCOL, +}; +use pubky_common::crypto::{Keypair, PublicKey}; +use serde_json::to_vec; + +// Replace this with your actual homeserver public key +const HOMESERVER: &str = "ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy"; + +#[tokio::main] +async fn main() -> Result<()> { + // Print an introduction for the developer + println!("Welcome to the Pubky User Creator Example!"); + + // Step 1: Initialize the Pubky client + println!("\nStep 1: Initializing the Pubky client..."); + + let client = PubkyClient::default(); + let homeserver = PublicKey::try_from(HOMESERVER).expect("Invalid homeserver public key."); + + println!("Pubky client initialized successfully."); + + // Step 2: Generate a keypair for the new user + println!("\nStep 2: Generating a random keypair for the new user..."); + + let keypair = Keypair::random(); + let user_id = keypair.public_key().to_z32(); + + println!("Generated keypair with User ID: {}", user_id); + + // Step 3: Sign up a new identity on the homeserver + println!("\nStep 3: Signing up the new identity on the homeserver..."); + + client + .signup(&keypair, &homeserver) + .await + .expect("Failed to sign up the user on the homeserver."); + + println!("User signed up successfully!"); + + // Step 4: Create a new user profile + println!("\nStep 4: Creating a new user profile..."); + + let user_profile = PubkyAppUser::new( + "Test User".to_string(), // User display name + None, // Optional fields set to None + None, + None, + None, + ); + + println!("User profile created: {:?}", user_profile); + + // Step 5: Write the user profile to the homeserver + println!("\nStep 5: Writing the user profile to the homeserver..."); + + let url = format!( + "{protocol}{pubky_id}{path}", + protocol = PROTOCOL, + pubky_id = user_id, + path = user_profile.create_path() + ); + let content = to_vec(&user_profile)?; + + client + .put(url.as_str(), &content) + .await + .expect("Failed to write the user profile to the homeserver."); + + println!( + "User profile written successfully to:\nURL: {}\nContent: {}", + url, + String::from_utf8_lossy(&content) + ); + + // Step 6: Retrieve the user profile from the homeserver + println!("\nStep 6: Retrieving the user profile from the homeserver..."); + + let retrieved_content = client + .get(url.as_str()) + .await + .expect("Failed to retrieve the user profile from the homeserver.") + .unwrap(); + + let retrieved_profile = ::try_from(&retrieved_content, "") + .expect("Failed to deserialize the retrieved user profile."); + + println!( + "User profile retrieved successfully:\n{}", + serde_json::to_string_pretty(&retrieved_profile).unwrap() + ); + + // Final message to indicate completion + println!("\nAll steps completed successfully! The new user is now registered and their profile is stored on the homeserver."); + + Ok(()) +} diff --git a/print_files.sh b/print_files.sh new file mode 100755 index 0000000..480b372 --- /dev/null +++ b/print_files.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Array of directories to skip +skip_dirs=(./target ./benches ./examples ./pubky) + +# Build the find command with exclusion patterns +find_cmd="find ." + +for dir in "${skip_dirs[@]}"; do + find_cmd+=" -path $dir -prune -o" +done + +# Add the file types to include and the actions to perform +find_cmd+=" \( -name '*.rs' -o -name '*.toml' -o -name '*.md' \) -print" + +# Execute the constructed find command +eval $find_cmd | while read -r file; do + # Print the path to the file + echo "$file" + echo '```' + # Print the content of the file + cat "$file" + echo '```' +done diff --git a/src/bookmark.rs b/src/bookmark.rs index 1b92cdd..90a323f 100644 --- a/src/bookmark.rs +++ b/src/bookmark.rs @@ -1,8 +1,13 @@ -use crate::traits::{HashId, Validatable}; -use crate::types::DynError; -use async_trait::async_trait; +use crate::{ + common::timestamp, + traits::{HasPath, HashId, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + /// Represents raw homeserver bookmark with id /// URI: /pub/pubky.app/bookmarks/:bookmark_id /// @@ -12,12 +17,20 @@ use serde::{Deserialize, Serialize}; /// /// Where bookmark_id is Crockford-base32(Blake3("{uri_bookmarked}"")[:half]) #[derive(Serialize, Deserialize, Default)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] pub struct PubkyAppBookmark { pub uri: String, pub created_at: i64, } -#[async_trait] +impl PubkyAppBookmark { + /// Creates a new `PubkyAppBookmark` instance. + pub fn new(uri: String) -> Self { + let created_at = timestamp(); + Self { uri, created_at }.sanitize() + } +} + impl HashId for PubkyAppBookmark { /// Bookmark ID is created based on the hash of the URI bookmarked fn get_id_data(&self) -> String { @@ -25,22 +38,81 @@ impl HashId for PubkyAppBookmark { } } -#[async_trait] +impl HasPath for PubkyAppBookmark { + fn create_path(&self) -> String { + format!("{}bookmarks/{}", APP_PATH, self.create_id()) + } +} + impl Validatable for PubkyAppBookmark { - async fn validate(&self, id: &str) -> Result<(), DynError> { - self.validate_id(id).await?; + fn validate(&self, id: &str) -> Result<(), String> { + self.validate_id(id)?; // TODO: more bookmarks validation? Ok(()) } } -#[test] -fn test_create_bookmark_id() { - let bookmark = PubkyAppBookmark { - uri: "user_id/pub/pubky.app/posts/post_id".to_string(), - created_at: 1627849723, - }; +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_create_bookmark_id() { + let bookmark = PubkyAppBookmark { + uri: "user_id/pub/pubky.app/posts/post_id".to_string(), + created_at: 1627849723, + }; + + let bookmark_id = bookmark.create_id(); + assert_eq!(bookmark_id, "AF7KQ6NEV5XV1EG5DVJ2E74JJ4"); + } + + #[test] + fn test_create_path() { + let bookmark = PubkyAppBookmark { + uri: "pubky://user_id/pub/pubky.app/posts/post_id".to_string(), + created_at: 1627849723, + }; + let expected_id = bookmark.create_id(); + let expected_path = format!("{}bookmarks/{}", APP_PATH, expected_id); + let path = bookmark.create_path(); + assert_eq!(path, expected_path); + } + + #[test] + fn test_validate_valid() { + let bookmark = + PubkyAppBookmark::new("pubky://user_id/pub/pubky.app/posts/post_id".to_string()); + let id = bookmark.create_id(); + let result = bookmark.validate(&id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_id() { + let bookmark = PubkyAppBookmark::new("user_id/pub/pubky.app/posts/post_id".to_string()); + let invalid_id = "INVALIDID"; + let result = bookmark.validate(&invalid_id); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_valid() { + let bookmark_json = r#" + { + "uri": "user_id/pub/pubky.app/posts/post_id", + "created_at": 1627849723 + } + "#; + + let uri = "user_id/pub/pubky.app/posts/post_id".to_string(); + let bookmark = PubkyAppBookmark::new(uri.clone()); + let id = bookmark.create_id(); - let bookmark_id = bookmark.create_id(); - println!("Generated Bookmark ID: {}", bookmark_id); + let blob = bookmark_json.as_bytes(); + let bookmark_parsed = ::try_from(&blob, &id).unwrap(); + + assert_eq!(bookmark_parsed.uri, uri); + } } diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 0000000..81392c8 --- /dev/null +++ b/src/common.rs @@ -0,0 +1,13 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub static VERSION: &str = "0.2.1"; +pub static APP_PATH: &str = "/pub/pubky.app/"; +pub static PROTOCOL: &str = "pubky://"; + +/// Returns the current timestamp in microseconds since the UNIX epoch. +pub fn timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_micros() as i64 +} diff --git a/src/feed.rs b/src/feed.rs new file mode 100644 index 0000000..77f10cb --- /dev/null +++ b/src/feed.rs @@ -0,0 +1,260 @@ +use crate::{ + common::timestamp, + traits::{HasPath, HashId, Validatable}, + PubkyAppPostKind, APP_PATH, +}; +use serde::{Deserialize, Serialize}; +use serde_json; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Enum representing the reach of the feed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub enum PubkyAppFeedReach { + Following, + Followers, + Friends, + All, +} + +/// Enum representing the layout of the feed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub enum PubkyAppFeedLayout { + Columns, + Wide, + Visual, +} + +/// Enum representing the sort order of the feed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub enum PubkyAppFeedSort { + Recent, + Popularity, +} + +/// Configuration object for the feed. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppFeedConfig { + pub tags: Option>, + pub reach: PubkyAppFeedReach, + pub layout: PubkyAppFeedLayout, + pub sort: PubkyAppFeedSort, + pub content: Option, +} + +/// Represents a feed configuration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppFeed { + pub feed: PubkyAppFeedConfig, + pub name: String, + pub created_at: i64, +} + +impl PubkyAppFeed { + /// Creates a new `PubkyAppFeed` instance and sanitizes it. + pub fn new( + tags: Option>, + reach: PubkyAppFeedReach, + layout: PubkyAppFeedLayout, + sort: PubkyAppFeedSort, + content: Option, + name: String, + ) -> Self { + let created_at = timestamp(); + let feed = PubkyAppFeedConfig { + tags, + reach, + layout, + sort, + content, + }; + Self { + feed, + name, + created_at, + } + .sanitize() + } +} + +impl HashId for PubkyAppFeed { + /// Generates an ID based on the serialized `feed` object. + fn get_id_data(&self) -> String { + serde_json::to_string(&self.feed).unwrap_or_default() + } +} + +impl HasPath for PubkyAppFeed { + fn create_path(&self) -> String { + format!("{}feeds/{}", APP_PATH, self.create_id()) + } +} + +impl Validatable for PubkyAppFeed { + fn validate(&self, id: &str) -> Result<(), String> { + self.validate_id(id)?; + + // Validate name + if self.name.trim().is_empty() { + return Err("Validation Error: Feed name cannot be empty".into()); + } + + // Additional validations can be added here + Ok(()) + } + + fn sanitize(self) -> Self { + // Sanitize name + let name = self.name.trim().to_string(); + + // Sanitize tags + let feed = PubkyAppFeedConfig { + tags: self.feed.tags.map(|tags| { + tags.into_iter() + .map(|tag| tag.trim().to_lowercase()) + .collect() + }), + ..self.feed + }; + + PubkyAppFeed { + feed, + name, + created_at: self.created_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let feed = PubkyAppFeed::new( + Some(vec!["bitcoin".to_string(), "rust".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + Some(PubkyAppPostKind::Image), + "Rust Bitcoiners".to_string(), + ); + + let feed_config = PubkyAppFeedConfig { + tags: Some(vec!["bitcoin".to_string(), "rust".to_string()]), + reach: PubkyAppFeedReach::Following, + layout: PubkyAppFeedLayout::Columns, + sort: PubkyAppFeedSort::Recent, + content: Some(PubkyAppPostKind::Image), + }; + assert_eq!(feed.feed, feed_config); + assert_eq!(feed.name, "Rust Bitcoiners"); + // Check that created_at is recent + let now = timestamp(); + assert!(feed.created_at <= now && feed.created_at >= now - 1_000_000); + } + + #[test] + fn test_create_id() { + let feed = PubkyAppFeed::new( + Some(vec!["bitcoin".to_string(), "rust".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + None, + "Rust Bitcoiners".to_string(), + ); + + let feed_id = feed.create_id(); + println!("Feed ID: {}", feed_id); + // The ID should not be empty + assert!(!feed_id.is_empty()); + } + + #[test] + fn test_validate() { + let feed = PubkyAppFeed::new( + Some(vec!["bitcoin".to_string(), "rust".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + None, + "Rust Bitcoiners".to_string(), + ); + let feed_id = feed.create_id(); + + let result = feed.validate(&feed_id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_id() { + let feed = PubkyAppFeed::new( + Some(vec!["bitcoin".to_string(), "rust".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + None, + "Rust Bitcoiners".to_string(), + ); + let invalid_id = "INVALIDID"; + let result = feed.validate(&invalid_id); + assert!(result.is_err()); + } + + #[test] + fn test_sanitize() { + let feed = PubkyAppFeed::new( + Some(vec![" BiTcoin ".to_string(), " RUST ".to_string()]), + PubkyAppFeedReach::Following, + PubkyAppFeedLayout::Columns, + PubkyAppFeedSort::Recent, + None, + " Rust Bitcoiners".to_string(), + ); + assert_eq!(feed.name, "Rust Bitcoiners"); + assert_eq!( + feed.feed.tags, + Some(vec!["bitcoin".to_string(), "rust".to_string()]) + ); + } + + #[test] + fn test_try_from_valid() { + let feed_json = r#" + { + "feed": { + "tags": ["bitcoin", "rust"], + "reach": "following", + "layout": "columns", + "sort": "recent", + "content": "video" + }, + "name": "My Feed", + "created_at": 1700000000 + } + "#; + + let feed: PubkyAppFeed = serde_json::from_str(feed_json).unwrap(); + let feed_id = feed.create_id(); + + let blob = feed_json.as_bytes(); + let feed_parsed = ::try_from(&blob, &feed_id).unwrap(); + + assert_eq!(feed_parsed.name, "My Feed"); + assert_eq!( + feed_parsed.feed.tags, + Some(vec!["bitcoin".to_string(), "rust".to_string()]) + ); + } +} diff --git a/src/file.rs b/src/file.rs index 1d69c62..7e54577 100644 --- a/src/file.rs +++ b/src/file.rs @@ -1,10 +1,17 @@ -use crate::traits::{TimestampId, Validatable}; -use crate::types::DynError; -use async_trait::async_trait; +use crate::{ + common::timestamp, + traits::{HasPath, TimestampId, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; -/// Profile schema -#[derive(Deserialize, Serialize, Debug)] +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents a file uploaded by the user. +/// URI: /pub/pubky.app/files/:file_id +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] pub struct PubkyAppFile { pub name: String, pub created_at: i64, @@ -13,15 +20,131 @@ pub struct PubkyAppFile { pub size: i64, } +impl PubkyAppFile { + /// Creates a new `PubkyAppFile` instance. + pub fn new(name: String, src: String, content_type: String, size: i64) -> Self { + let created_at = timestamp(); + Self { + name, + created_at, + src, + content_type, + size, + } + } +} + impl TimestampId for PubkyAppFile {} -#[async_trait] +impl HasPath for PubkyAppFile { + fn create_path(&self) -> String { + format!("{}files/{}", APP_PATH, self.create_id()) + } +} + impl Validatable for PubkyAppFile { // TODO: content_type validation. - async fn validate(&self, id: &str) -> Result<(), DynError> { - self.validate_id(id).await?; + fn validate(&self, id: &str) -> Result<(), String> { + self.validate_id(id)?; // TODO: content_type validation. // TODO: size and other validation. Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let file = PubkyAppFile::new( + "example.png".to_string(), + "pubky://user_id/pub/pubky.app/blobs/id".to_string(), + "image/png".to_string(), + 1024, + ); + assert_eq!(file.name, "example.png"); + assert_eq!(file.src, "pubky://user_id/pub/pubky.app/blobs/id"); + assert_eq!(file.content_type, "image/png"); + assert_eq!(file.size, 1024); + // Check that created_at is recent + let now = timestamp(); + assert!(file.created_at <= now && file.created_at >= now - 1_000_000); // within 1 second + } + + #[test] + fn test_create_path() { + let file = PubkyAppFile::new( + "example.png".to_string(), + "pubky://user_id/pub/pubky.app/blobs/id".to_string(), + "image/png".to_string(), + 1024, + ); + let file_id = file.create_id(); + let path = file.create_path(); + + // Check if the path starts with the expected prefix + let prefix = format!("{}files/", APP_PATH); + assert!(path.starts_with(&prefix)); + + let expected_path_len = prefix.len() + file_id.len(); + assert_eq!(path.len(), expected_path_len); + } + + #[test] + fn test_validate_valid() { + let file = PubkyAppFile::new( + "example.png".to_string(), + "/uploads/example.png".to_string(), + "image/png".to_string(), + 1024, + ); + let id = file.create_id(); + let result = file.validate(&id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_id() { + let file = PubkyAppFile::new( + "example.png".to_string(), + "/uploads/example.png".to_string(), + "image/png".to_string(), + 1024, + ); + let invalid_id = "INVALIDID"; + let result = file.validate(&invalid_id); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_valid() { + let file_json = r#" + { + "name": "example.png", + "created_at": 1627849723, + "src": "/uploads/example.png", + "content_type": "image/png", + "size": 1024 + } + "#; + + let file = PubkyAppFile::new( + "example.png".to_string(), + "/uploads/example.png".to_string(), + "image/png".to_string(), + 1024, + ); + let id = file.create_id(); + + let blob = file_json.as_bytes(); + let file_parsed = ::try_from(&blob, &id).unwrap(); + + assert_eq!(file_parsed.name, "example.png"); + assert_eq!(file_parsed.src, "/uploads/example.png"); + assert_eq!(file_parsed.content_type, "image/png"); + assert_eq!(file_parsed.size, 1024); + } +} diff --git a/src/follow.rs b/src/follow.rs index d14ff83..b22305f 100644 --- a/src/follow.rs +++ b/src/follow.rs @@ -1,24 +1,90 @@ -use crate::traits::Validatable; -use crate::types::DynError; -use async_trait::async_trait; +use crate::{ + common::timestamp, + traits::{HasPubkyIdPath, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + /// Represents raw homeserver follow object with timestamp +/// +/// On follow objects, the main data is encoded in the path +/// /// URI: /pub/pubky.app/follows/:user_id /// /// Example URI: /// -/// `/pub/pubky.app/follows/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy`` +/// `/pub/pubky.app/follows/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy` /// -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] pub struct PubkyAppFollow { pub created_at: i64, } -#[async_trait] +impl PubkyAppFollow { + /// Creates a new `PubkyAppFollow` instance. + pub fn new() -> Self { + let created_at = timestamp(); + Self { created_at } + } +} + impl Validatable for PubkyAppFollow { - async fn validate(&self, _id: &str) -> Result<(), DynError> { - // TODO: additional follow validation? E.g, validate `created_at` ? + fn validate(&self, _id: &str) -> Result<(), String> { + // TODO: additional follow validation? E.g., validate `created_at`? Ok(()) } } + +impl HasPubkyIdPath for PubkyAppFollow { + fn create_path(&self, pubky_id: &str) -> String { + format!("{}follows/{}", APP_PATH, pubky_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let follow = PubkyAppFollow::new(); + // Check that created_at is recent + let now = timestamp(); + // within 1 second + assert!(follow.created_at <= now && follow.created_at >= now - 1_000_000); + } + + #[test] + fn test_create_path_with_id() { + let mute = PubkyAppFollow::new(); + let path = mute.create_path("user_id123"); + assert_eq!(path, "/pub/pubky.app/follows/user_id123"); + } + + #[test] + fn test_validate() { + let follow = PubkyAppFollow::new(); + let result = follow.validate("some_user_id"); + assert!(result.is_ok()); + } + + #[test] + fn test_try_from_valid() { + let follow_json = r#" + { + "created_at": 1627849723 + } + "#; + + let blob = follow_json.as_bytes(); + let follow_parsed = + ::try_from(&blob, "some_user_id").unwrap(); + + assert_eq!(follow_parsed.created_at, 1627849723); + } +} diff --git a/src/last_read.rs b/src/last_read.rs new file mode 100644 index 0000000..eff2600 --- /dev/null +++ b/src/last_read.rs @@ -0,0 +1,89 @@ +use crate::{ + common::timestamp, + traits::{HasPath, Validatable}, + APP_PATH, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +/// Represents the last read timestamp for notifications. +/// URI: /pub/pubky.app/last_read +#[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppLastRead { + pub timestamp: i64, // Unix epoch time in milliseconds +} + +impl PubkyAppLastRead { + /// Creates a new `PubkyAppLastRead` instance. + pub fn new() -> Self { + let timestamp = timestamp() / 1_000; // to millis + Self { timestamp } + } +} + +impl Validatable for PubkyAppLastRead { + fn validate(&self, _id: &str) -> Result<(), String> { + // Validate timestamp is a positive integer + if self.timestamp <= 0 { + return Err("Validation Error: Timestamp must be a positive integer".into()); + } + Ok(()) + } +} + +impl HasPath for PubkyAppLastRead { + fn create_path(&self) -> String { + format!("{}last_read", APP_PATH) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let last_read = PubkyAppLastRead::new(); + let now = timestamp() / 1_000; + // within 1 second + assert!(last_read.timestamp <= now && last_read.timestamp >= now - 1_000); + } + + #[test] + fn test_create_path() { + let last_read = PubkyAppLastRead::new(); + let path = last_read.create_path(); + assert_eq!(path, format!("{}last_read", APP_PATH)); + } + + #[test] + fn test_validate() { + let last_read = PubkyAppLastRead::new(); + let result = last_read.validate(""); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_timestamp() { + let last_read = PubkyAppLastRead { timestamp: -1 }; + let result = last_read.validate(""); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_valid() { + let last_read_json = r#" + { + "timestamp": 1700000000 + } + "#; + + let blob = last_read_json.as_bytes(); + let last_read = ::try_from(&blob, "").unwrap(); + assert_eq!(last_read.timestamp, 1700000000); + } +} diff --git a/src/lib.rs b/src/lib.rs index a768d44..a3f32c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,17 +1,22 @@ -pub mod bookmark; -pub mod file; -pub mod follow; -pub mod mute; -pub mod post; -pub mod tag; +mod bookmark; +mod common; +mod feed; +mod file; +mod follow; +mod last_read; +mod mute; +mod post; +mod tag; pub mod traits; -pub mod types; -pub mod user; +mod user; pub use bookmark::PubkyAppBookmark; +pub use common::{APP_PATH, PROTOCOL, VERSION}; +pub use feed::{PubkyAppFeed, PubkyAppFeedLayout, PubkyAppFeedReach, PubkyAppFeedSort}; pub use file::PubkyAppFile; pub use follow::PubkyAppFollow; +pub use last_read::PubkyAppLastRead; pub use mute::PubkyAppMute; -pub use post::{PostEmbed, PostKind, PubkyAppPost}; +pub use post::{PubkyAppPost, PubkyAppPostEmbed, PubkyAppPostKind}; pub use tag::PubkyAppTag; -pub use user::{PubkyAppUser, UserLink}; +pub use user::{PubkyAppUser, PubkyAppUserLink}; diff --git a/src/mute.rs b/src/mute.rs index 20b6f8e..15d4f00 100644 --- a/src/mute.rs +++ b/src/mute.rs @@ -1,24 +1,87 @@ -use crate::traits::Validatable; -use crate::types::DynError; -use async_trait::async_trait; +use crate::{ + common::timestamp, + traits::{HasPubkyIdPath, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + /// Represents raw homeserver Mute object with timestamp /// URI: /pub/pubky.app/mutes/:user_id /// /// Example URI: /// -/// `/pub/pubky.app/mutes/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy`` +/// `/pub/pubky.app/mutes/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy` /// -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] pub struct PubkyAppMute { pub created_at: i64, } -#[async_trait] +impl PubkyAppMute { + /// Creates a new `PubkyAppMute` instance. + pub fn new() -> Self { + let created_at = timestamp(); + Self { created_at } + } +} + impl Validatable for PubkyAppMute { - async fn validate(&self, _id: &str) -> Result<(), DynError> { - // TODO: additional Mute validation? E.g, validate `created_at` ? + fn validate(&self, _id: &str) -> Result<(), String> { + // TODO: additional Mute validation? E.g., validate `created_at` ? Ok(()) } } + +impl HasPubkyIdPath for PubkyAppMute { + fn create_path(&self, pubky_id: &str) -> String { + format!("{}mutes/{}", APP_PATH, pubky_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::timestamp; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let mute = PubkyAppMute::new(); + // Check that created_at is recent + let now = timestamp(); + assert!(mute.created_at <= now && mute.created_at >= now - 1_000_000); + // within 1 second + } + + #[test] + fn test_create_path_with_id() { + let mute = PubkyAppMute::new(); + let path = mute.create_path("user_id123"); + assert_eq!(path, "/pub/pubky.app/mutes/user_id123"); + } + + #[test] + fn test_validate() { + let mute = PubkyAppMute::new(); + let result = mute.validate("some_user_id"); + assert!(result.is_ok()); + } + + #[test] + fn test_try_from_valid() { + let mute_json = r#" + { + "created_at": 1627849723 + } + "#; + + let blob = mute_json.as_bytes(); + let mute_parsed = ::try_from(&blob, "some_user_id").unwrap(); + + assert_eq!(mute_parsed.created_at, 1627849723); + } +} diff --git a/src/post.rs b/src/post.rs index 2ebc770..185c1e7 100644 --- a/src/post.rs +++ b/src/post.rs @@ -1,20 +1,24 @@ -use crate::traits::{TimestampId, Validatable}; -use crate::types::DynError; -use async_trait::async_trait; +use crate::{ + traits::{HasPath, TimestampId, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; use std::fmt; use url::Url; -use utoipa::ToSchema; // Validation const MAX_SHORT_CONTENT_LENGTH: usize = 1000; const MAX_LONG_CONTENT_LENGTH: usize = 50000; +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + /// Represents the type of pubky-app posted data /// Used primarily to best display the content in UI -#[derive(Serialize, Deserialize, ToSchema, Default, Debug, Clone)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(rename_all = "lowercase")] -pub enum PostKind { +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub enum PubkyAppPostKind { #[default] Short, Long, @@ -24,7 +28,7 @@ pub enum PostKind { File, } -impl fmt::Display for PostKind { +impl fmt::Display for PubkyAppPostKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let string_repr = serde_json::to_value(self) .ok() @@ -34,11 +38,12 @@ impl fmt::Display for PostKind { } } -/// Used primarily to best display the content in UI +/// Represents embedded content within a post #[derive(Serialize, Deserialize, Default, Clone)] -pub struct PostEmbed { - pub kind: PostKind, - pub uri: String, // If a repost a `Short` and uri of the reposted post. +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppPostEmbed { + pub kind: PubkyAppPostKind, // Kind of the embedded content + pub uri: String, // URI of the embedded content } /// Represents raw post in homeserver with content and kind @@ -49,19 +54,45 @@ pub struct PostEmbed { /// /// `/pub/pubky.app/posts/00321FCW75ZFY` #[derive(Serialize, Deserialize, Default, Clone)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] pub struct PubkyAppPost { pub content: String, - pub kind: PostKind, + pub kind: PubkyAppPostKind, pub parent: Option, // If a reply, the URI of the parent post. - pub embed: Option, + pub embed: Option, pub attachments: Option>, } +impl PubkyAppPost { + /// Creates a new `PubkyAppPost` instance and sanitizes it. + pub fn new( + content: String, + kind: PubkyAppPostKind, + parent: Option, + embed: Option, + attachments: Option>, + ) -> Self { + let post = PubkyAppPost { + content, + kind, + parent, + embed, + attachments, + }; + post.sanitize() + } +} + impl TimestampId for PubkyAppPost {} -#[async_trait] +impl HasPath for PubkyAppPost { + fn create_path(&self) -> String { + format!("{}posts/{}", APP_PATH, self.create_id()) + } +} + impl Validatable for PubkyAppPost { - async fn sanitize(self) -> Result { + fn sanitize(self) -> Self { // Sanitize content let mut content = self.content.trim().to_string(); @@ -72,10 +103,10 @@ impl Validatable for PubkyAppPost { content = "empty".to_string() } - // Define content length limits based on PostKind + // Define content length limits based on PubkyAppPostKind let max_content_length = match self.kind { - PostKind::Short => MAX_SHORT_CONTENT_LENGTH, - PostKind::Long => MAX_LONG_CONTENT_LENGTH, + PubkyAppPostKind::Short => MAX_SHORT_CONTENT_LENGTH, + PubkyAppPostKind::Long => MAX_LONG_CONTENT_LENGTH, _ => MAX_SHORT_CONTENT_LENGTH, // Default limit for other kinds }; @@ -94,7 +125,7 @@ impl Validatable for PubkyAppPost { // Sanitize embed if present let embed = if let Some(embed) = &self.embed { match Url::parse(&embed.uri) { - Ok(url) => Some(PostEmbed { + Ok(url) => Some(PubkyAppPostEmbed { kind: embed.kind.clone(), uri: url.to_string(), // Use normalized version }), @@ -104,36 +135,198 @@ impl Validatable for PubkyAppPost { None }; - Ok(PubkyAppPost { + PubkyAppPost { content, kind: self.kind, parent, embed, attachments: self.attachments, - }) + } } - //TODO: implement full validation rules. Min/Max lengths, post kinds, etc. - async fn validate(&self, id: &str) -> Result<(), DynError> { - self.validate_id(id).await?; + fn validate(&self, id: &str) -> Result<(), String> { + self.validate_id(id)?; // Validate content length match self.kind { - PostKind::Short => { + PubkyAppPostKind::Short => { if self.content.chars().count() > MAX_SHORT_CONTENT_LENGTH { - return Err("Post content exceeds maximum length for Short kind".into()); + return Err( + "Validation Error: Post content exceeds maximum length for Short kind" + .into(), + ); } } - PostKind::Long => { + PubkyAppPostKind::Long => { if self.content.chars().count() > MAX_LONG_CONTENT_LENGTH { - return Err("Post content exceeds maximum length for Short kind".into()); + return Err( + "Validation Error: Post content exceeds maximum length for Short kind" + .into(), + ); } } _ => (), }; - // TODO: additional validation? + // TODO: additional validation. Attachement URLs...? Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_create_id() { + let post = PubkyAppPost::new( + "Hello World!".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ); + + let post_id = post.create_id(); + println!("Generated Post ID: {}", post_id); + + // Assert that the post ID is 13 characters long + assert_eq!(post_id.len(), 13); + } + + #[test] + fn test_new() { + let content = "This is a test post".to_string(); + let kind = PubkyAppPostKind::Short; + let post = PubkyAppPost::new(content.clone(), kind.clone(), None, None, None); + + assert_eq!(post.content, content); + assert_eq!(post.kind, kind); + assert!(post.parent.is_none()); + assert!(post.embed.is_none()); + assert!(post.attachments.is_none()); + } + + #[test] + fn test_create_path() { + let post = PubkyAppPost::new( + "Test post".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ); + + let post_id = post.create_id(); + let path = post.create_path(); + + // Check if the path starts with the expected prefix + let prefix = format!("{}posts/", APP_PATH); + assert!(path.starts_with(&prefix)); + + let expected_path_len = prefix.len() + post_id.len(); + assert_eq!(path.len(), expected_path_len); + } + + #[test] + fn test_sanitize() { + let content = " This is a test post with extra whitespace ".to_string(); + let post = PubkyAppPost::new( + content.clone(), + PubkyAppPostKind::Short, + Some("invalid uri".to_string()), + Some(PubkyAppPostEmbed { + kind: PubkyAppPostKind::Link, + uri: "invalid uri".to_string(), + }), + None, + ); + + let sanitized_post = post.sanitize(); + assert_eq!(sanitized_post.content, content.trim()); + assert!(sanitized_post.parent.is_none()); + assert!(sanitized_post.embed.is_none()); + } + + #[test] + fn test_validate_valid() { + let post = PubkyAppPost::new( + "Valid content".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ); + + let id = post.create_id(); + let result = post.validate(&id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_id() { + let post = PubkyAppPost::new( + "Valid content".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ); + + let invalid_id = "INVALIDID12345"; + let result = post.validate(&invalid_id); + assert!(result.is_err()); + } + + #[test] + fn test_try_from_valid() { + let post_json = r#" + { + "content": "Hello World!", + "kind": "short", + "parent": null, + "embed": null, + "attachments": null + } + "#; + + let id = PubkyAppPost::new( + "Hello World!".to_string(), + PubkyAppPostKind::Short, + None, + None, + None, + ) + .create_id(); + + let blob = post_json.as_bytes(); + let post = ::try_from(&blob, &id).unwrap(); + + assert_eq!(post.content, "Hello World!"); + } + + #[test] + fn test_try_from_invalid_content() { + let content = "[DELETED]".to_string(); + let post_json = format!( + r#"{{ + "content": "{}", + "kind": "short", + "parent": null, + "embed": null, + "attachments": null + }}"#, + content + ); + + let id = PubkyAppPost::new(content.clone(), PubkyAppPostKind::Short, None, None, None) + .create_id(); + + let blob = post_json.as_bytes(); + let post = ::try_from(&blob, &id).unwrap(); + + assert_eq!(post.content, "empty"); // After sanitization + } +} diff --git a/src/tag.rs b/src/tag.rs index abf7374..0e6a9cf 100644 --- a/src/tag.rs +++ b/src/tag.rs @@ -1,12 +1,17 @@ -use crate::traits::{HashId, Validatable}; -use crate::types::DynError; -use async_trait::async_trait; +use crate::{ + common::timestamp, + traits::{HasPath, HashId, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; use url::Url; // Validation const MAX_TAG_LABEL_LENGTH: usize = 20; +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + /// Represents raw homeserver tag with id /// URI: /pub/pubky.app/tags/:tag_id /// @@ -16,13 +21,31 @@ const MAX_TAG_LABEL_LENGTH: usize = 20; /// /// Where tag_id is Crockford-base32(Blake3("{uri_tagged}:{label}")[:half]) #[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] pub struct PubkyAppTag { pub uri: String, pub label: String, pub created_at: i64, } -#[async_trait] +impl PubkyAppTag { + pub fn new(uri: String, label: String) -> Self { + let created_at = timestamp(); + Self { + uri, + label, + created_at, + } + .sanitize() + } +} + +impl HasPath for PubkyAppTag { + fn create_path(&self) -> String { + format!("{}tags/{}", APP_PATH, self.create_id()) + } +} + impl HashId for PubkyAppTag { /// Tag ID is created based on the hash of the URI tagged and the label used fn get_id_data(&self) -> String { @@ -30,52 +53,64 @@ impl HashId for PubkyAppTag { } } -#[async_trait] impl Validatable for PubkyAppTag { - async fn sanitize(self) -> Result { + fn sanitize(self) -> Self { // Remove spaces from the tag and keep it as one word - let mut label = self - .label - .chars() - .filter(|c| !c.is_whitespace()) - .collect::(); + // let mut label = self + // .label + // .chars() + // .filter(|c| !c.is_whitespace()) + // .collect::(); + // Convert label to lowercase and trim - label = label.trim().to_lowercase(); + let mut label = self.label.trim().to_lowercase(); // Enforce maximum label length safely label = label.chars().take(MAX_TAG_LABEL_LENGTH).collect::(); // Sanitize URI let uri = match Url::parse(&self.uri) { - Ok(url) => url.to_string(), - Err(_) => return Err("Invalid URI in tag".into()), + Ok(url) => { + // If the URL is valid, reformat it to a sanitized string representation + url.to_string() + } + Err(_) => { + // If the URL is invalid, return as-is for error reporting later + self.uri.trim().to_string() + } }; - Ok(PubkyAppTag { + PubkyAppTag { uri, label, created_at: self.created_at, - }) + } } - async fn validate(&self, id: &str) -> Result<(), DynError> { - self.validate_id(id).await?; + fn validate(&self, id: &str) -> Result<(), String> { + // Validate the tag ID + self.validate_id(id)?; - // Validate label length based on characters + // Validate label length if self.label.chars().count() > MAX_TAG_LABEL_LENGTH { - return Err("Tag label exceeds maximum length".into()); + return Err("Validation Error: Tag label exceeds maximum length".to_string()); } - // TODO: more validation? - - Ok(()) + // Validate URI format + match Url::parse(&self.uri) { + Ok(_) => Ok(()), + Err(_) => Err(format!( + "Validation Error: Invalid URI format: {}", + self.uri + )), + } } } #[cfg(test)] mod tests { use super::*; - use tokio; + use crate::{traits::Validatable, APP_PATH}; #[test] fn test_label_id() { @@ -87,9 +122,13 @@ mod tests { created_at: 1627849723, label: "cool".to_string(), }; + + let new_tag_id = tag.create_id(); + assert!(!tag_id.is_empty()); + // Check if the tag ID is correct assert_eq!( - tag.create_id(), + new_tag_id, tag_id ); @@ -106,16 +145,162 @@ mod tests { ); } - #[tokio::test] - async fn test_incorrect_label() -> Result<(), DynError> { + #[test] + fn test_create_id() { + let tag = PubkyAppTag { + uri: "https://example.com/post/1".to_string(), + created_at: 1627849723000, + label: "cool".to_string(), + }; + + let tag_id = tag.create_id(); + println!("Generated Tag ID: {}", tag_id); + + // Assert that the tag ID is of expected length + // The length depends on your implementation of create_id + assert!(!tag_id.is_empty()); + } + + #[test] + fn test_new() { + let uri = "https://example.com/post/1".to_string(); + let label = "interesting".to_string(); + let tag = PubkyAppTag::new(uri.clone(), label.clone()); + + assert_eq!(tag.uri, uri); + assert_eq!(tag.label, label); + // Check that created_at is recent + let now = timestamp(); + println!("TIMESTAMP {}", tag.created_at); + println!("TIMESTAMP {}", now); + + assert!(tag.created_at <= now && tag.created_at >= now - 1_000_000); // within 1 second + } + + #[test] + fn test_create_path() { + let tag = PubkyAppTag { + uri: "pubky://operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0032FNCGXE3R0".to_string(), + created_at: 1627849723000, + label: "cool".to_string(), + }; + + let expected_id = tag.create_id(); + let expected_path = format!("{}tags/{}", APP_PATH, expected_id); + let path = tag.create_path(); + + assert_eq!(path, expected_path); + } + + #[test] + fn test_sanitize() { + let tag = PubkyAppTag { + uri: "pubky://user_id/pub/pubky.app/posts/0000000000000".to_string(), + label: " CoOl ".to_string(), + created_at: 1627849723000, + }; + + let sanitized_tag = tag.sanitize(); + assert_eq!(sanitized_tag.label, "cool"); + } + + #[test] + fn test_validate_valid() { + let tag = PubkyAppTag { + uri: "pubky://user_id/pub/pubky.app/posts/0000000000000".to_string(), + label: "cool".to_string(), + created_at: 1627849723000, + }; + + let id = tag.create_id(); + let result = tag.validate(&id); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_label_length() { + let tag = PubkyAppTag { + uri: "pubky://user_id/pub/pubky.app/posts/0000000000000".to_string(), + label: "a".repeat(MAX_TAG_LABEL_LENGTH + 1), + created_at: 1627849723000, + }; + + let id = tag.create_id(); + let result = tag.validate(&id); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Validation Error: Tag label exceeds maximum length" + ); + } + + #[test] + fn test_validate_invalid_id() { + let tag = PubkyAppTag { + uri: "pubky://user_id/pub/pubky.app/posts/0000000000000".to_string(), + label: "cool".to_string(), + created_at: 1627849723000, + }; + + let invalid_id = "INVALIDID"; + let result = tag.validate(&invalid_id); + assert!(result.is_err()); + // You can check the specific error message if necessary + } + + #[test] + fn test_try_from_valid() { + let tag_json = r#" + { + "uri": "pubky://user_pubky_id/pub/pubky.app/profile.json", + "label": "Cool Tag", + "created_at": 1627849723000 + } + "#; + + let id = PubkyAppTag::new( + "pubky://user_pubky_id/pub/pubky.app/profile.json".to_string(), + "Cool Tag".to_string(), + ) + .create_id(); + + let blob = tag_json.as_bytes(); + let tag = ::try_from(&blob, &id).unwrap(); + assert_eq!(tag.uri, "pubky://user_pubky_id/pub/pubky.app/profile.json"); + assert_eq!(tag.label, "cool tag"); // After sanitization + } + + #[test] + fn test_try_from_invalid_uri() { + let tag_json = r#" + { + "uri": "invalid_uri", + "label": "Cool Tag", + "created_at": 1627849723000 + } + "#; + + let id = "B55PGPFV1E5E0HQ2PB76EQGXPR"; + let blob = tag_json.as_bytes(); + let result = ::try_from(&blob, &id); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Validation Error: Invalid URI format: invalid_uri" + ); + } + + #[test] + fn test_incorrect_label() { let tag = PubkyAppTag { uri: "user_id/pub/pubky.app/posts/post_id".to_string(), created_at: 1627849723, label: "cool".to_string(), }; + let tag_id = tag.create_id(); - match tag.sanitize().await { - Err(e) => assert_eq!(e.to_string(), "Invalid URI in tag".to_string(), "The error message is not related URI or the message description is wrong"), + match tag.validate(&tag_id) { + Err(e) => assert_eq!(e.to_string(), format!("Validation Error: Invalid URI format: {}", tag.uri), "The error message is not related URI or the message description is wrong"), _ => () }; @@ -126,20 +311,16 @@ mod tests { }; // Precomputed earlier - let label_id = "8WXXXXHK028RH8AWBZZNHJYDN4"; + let label_id = tag.create_id(); - match tag.validate(label_id).await { - Err(e) => assert_eq!(e.to_string(), "Tag label exceeds maximum length".to_string(), "The error message is not related tag length or the message description is wrong"), + match tag.validate(&label_id) { + Err(e) => assert_eq!(e.to_string(), "Validation Error: Tag label exceeds maximum length".to_string(), "The error message is not related tag length or the message description is wrong"), _ => () }; - - Ok(()) - - } - #[tokio::test] - async fn test_white_space_tag() -> Result<(), DynError> { + #[test] + fn test_white_space_tag() { // All the tags has to be that label after sanitation let label = "cool"; @@ -148,7 +329,7 @@ mod tests { created_at: 1627849723, label: " cool".to_string(), }; - let mut sanitazed_label = leading_whitespace.sanitize().await?; + let mut sanitazed_label = leading_whitespace.sanitize(); assert_eq!(sanitazed_label.label, label); let trailing_whitespace = PubkyAppTag { @@ -156,15 +337,7 @@ mod tests { created_at: 1627849723, label: " cool".to_string(), }; - sanitazed_label = trailing_whitespace.sanitize().await?; - assert_eq!(sanitazed_label.label, label); - - let space_between = PubkyAppTag { - uri: "pubky://user_id/pub/pubky.app/posts/post_id".to_string(), - created_at: 1627849723, - label: "co ol".to_string(), - }; - sanitazed_label = space_between.sanitize().await?; + sanitazed_label = trailing_whitespace.sanitize(); assert_eq!(sanitazed_label.label, label); let space_between = PubkyAppTag { @@ -172,9 +345,7 @@ mod tests { created_at: 1627849723, label: " co ol ".to_string(), }; - sanitazed_label = space_between.sanitize().await?; - assert_eq!(sanitazed_label.label, label); - - Ok(()) + sanitazed_label = space_between.sanitize(); + assert_eq!(sanitazed_label.label, "co ol"); } } diff --git a/src/traits.rs b/src/traits.rs index 78f0a41..8c09983 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,26 +1,27 @@ -use crate::types::DynError; -use async_trait::async_trait; +use crate::common::timestamp; use base32::{decode, encode, Alphabet}; use blake3::Hasher; -use bytes::Bytes; -use chrono::{DateTime, Duration, NaiveDate, Utc}; -use pubky_common::timestamp::Timestamp; use serde::de::DeserializeOwned; -#[async_trait] pub trait TimestampId { /// Creates a unique identifier based on the current timestamp. fn create_id(&self) -> String { - let timestamp = Timestamp::now(); - timestamp.to_string() + // Get current time in microseconds since UNIX epoch + let now = timestamp(); + + // Convert to big-endian bytes + let bytes = now.to_be_bytes(); + + // Encode the bytes using Base32 with the Crockford alphabet + encode(Alphabet::Crockford, &bytes) } /// Validates that the provided ID is a valid Crockford Base32-encoded timestamp, /// 13 characters long, and represents a reasonable timestamp. - async fn validate_id(&self, id: &str) -> Result<(), DynError> { + fn validate_id(&self, id: &str) -> Result<(), String> { // Ensure ID is 13 characters long if id.len() != 13 { - return Err("Invalid ID length: must be 13 characters".into()); + return Err("Validation Error: Invalid ID length: must be 13 characters".into()); } // Decode the Crockford Base32-encoded ID @@ -28,32 +29,31 @@ pub trait TimestampId { decode(Alphabet::Crockford, id).ok_or("Failed to decode Crockford Base32 ID")?; if decoded_bytes.len() != 8 { - return Err("Invalid ID length after decoding".into()); + return Err("Validation Error: Invalid ID length after decoding".into()); } // Convert the decoded bytes to a timestamp in microseconds - let timestamp_micros = i64::from_be_bytes(decoded_bytes.try_into().unwrap_or_default()); - let timestamp: i64 = timestamp_micros / 1_000_000; + let timestamp_micros = i64::from_be_bytes(decoded_bytes.try_into().unwrap()); - // Convert the timestamp to a DateTime - let id_datetime = DateTime::from_timestamp(timestamp, 0) - .unwrap_or_default() - .date_naive(); + // Get current time in microseconds + let now_micros = timestamp(); - // Define October 1st, 2024, at 00:00:00 UTC - let oct_first_2024 = NaiveDate::from_ymd_opt(2024, 10, 1).expect("Invalid date"); + // Define October 1st, 2024, in microseconds since UNIX epoch + let oct_first_2024_micros = 1727740800000000; // Timestamp for 2024-10-01 00:00:00 UTC - // Allowable future duration (2 hours) - let max_future = Utc::now().date_naive() + Duration::hours(2); + // Allowable future duration (2 hours) in microseconds + let max_future_micros = now_micros + 2 * 60 * 60 * 1_000_000; // Validate that the ID's timestamp is after October 1st, 2024 - if id_datetime < oct_first_2024 { - return Err("Invalid ID: timestamp must be after October 1st, 2024".into()); + if timestamp_micros < oct_first_2024_micros { + return Err( + "Validation Error: Invalid ID, timestamp must be after October 1st, 2024".into(), + ); } // Validate that the ID's timestamp is not more than 2 hours in the future - if id_datetime > max_future { - return Err("Invalid ID: timestamp is too far in the future".into()); + if timestamp_micros > max_future_micros { + return Err("Validation Error: Invalid ID, timestamp is too far in the future".into()); } Ok(()) @@ -61,7 +61,6 @@ pub trait TimestampId { } /// Trait for generating an ID based on the struct's data. -#[async_trait] pub trait HashId { fn get_id_data(&self) -> String; @@ -94,7 +93,7 @@ pub trait HashId { } /// Validates that the provided ID matches the generated ID. - async fn validate_id(&self, id: &str) -> Result<(), DynError> { + fn validate_id(&self, id: &str) -> Result<(), String> { let generated_id = self.create_id(); if generated_id != id { return Err(format!("Invalid ID: expected {}, found {}", generated_id, id).into()); @@ -103,18 +102,25 @@ pub trait HashId { } } -#[async_trait] pub trait Validatable: Sized + DeserializeOwned { - async fn try_from(blob: &Bytes, id: &str) -> Result { - let mut instance: Self = serde_json::from_slice(blob)?; - instance = instance.sanitize().await?; - instance.validate(id).await?; + fn try_from(blob: &[u8], id: &str) -> Result { + let mut instance: Self = serde_json::from_slice(blob).map_err(|e| e.to_string())?; + instance = instance.sanitize(); + instance.validate(id)?; Ok(instance) } - async fn validate(&self, id: &str) -> Result<(), DynError>; + fn validate(&self, id: &str) -> Result<(), String>; - async fn sanitize(self) -> Result { - Ok(self) + fn sanitize(self) -> Self { + self } } + +pub trait HasPath { + fn create_path(&self) -> String; +} + +pub trait HasPubkyIdPath { + fn create_path(&self, pubky_id: &str) -> String; +} diff --git a/src/types.rs b/src/types.rs deleted file mode 100644 index 706e3cf..0000000 --- a/src/types.rs +++ /dev/null @@ -1 +0,0 @@ -pub type DynError = Box; diff --git a/src/user.rs b/src/user.rs index 379800c..f3ad78f 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,11 +1,11 @@ -use crate::traits::Validatable; -use crate::types::DynError; -use async_trait::async_trait; +use crate::{ + traits::{HasPath, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; use url::Url; -use utoipa::ToSchema; -// Validation +// Validation constants const MIN_USERNAME_LENGTH: usize = 3; const MAX_USERNAME_LENGTH: usize = 50; const MAX_BIO_LENGTH: usize = 160; @@ -15,27 +15,56 @@ const MAX_LINK_TITLE_LENGTH: usize = 100; const MAX_LINK_URL_LENGTH: usize = 300; const MAX_STATUS_LENGTH: usize = 50; -/// Profile schema +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + /// URI: /pub/pubky.app/profile.json -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] pub struct PubkyAppUser { pub name: String, pub bio: Option, pub image: Option, - pub links: Option>, + pub links: Option>, pub status: Option, } /// Represents a user's single link with a title and URL. -#[derive(Serialize, Deserialize, ToSchema, Default, Clone, Debug)] -pub struct UserLink { +#[derive(Serialize, Deserialize, Default, Clone, Debug)] +#[cfg_attr(feature = "openapi", derive(ToSchema))] +pub struct PubkyAppUserLink { pub title: String, pub url: String, } -#[async_trait] +impl PubkyAppUser { + /// Creates a new `PubkyAppUser` instance and sanitizes it. + pub fn new( + name: String, + bio: Option, + image: Option, + links: Option>, + status: Option, + ) -> Self { + Self { + name, + bio, + image, + links, + status, + } + .sanitize() + } +} + +impl HasPath for PubkyAppUser { + fn create_path(&self) -> String { + format!("{}profile.json", APP_PATH) + } +} + impl Validatable for PubkyAppUser { - async fn sanitize(self) -> Result { + fn sanitize(self) -> Self { // Sanitize name let sanitized_name = self.name.trim(); // Crop name to a maximum length of MAX_USERNAME_LENGTH characters @@ -85,87 +114,263 @@ impl Validatable for PubkyAppUser { links_vec .into_iter() .take(MAX_LINKS) - .filter_map(|link| { - let title = link.title.trim(); - let sanitized_url = link.url.trim(); - - // Parse and validate the URL - match Url::parse(sanitized_url) { - Ok(_) => { - // Ensure the title is within the allowed limit - let title = title - .chars() - .take(MAX_LINK_TITLE_LENGTH) - .collect::(); - - // Ensure the URL is within the allowed limit - let url = sanitized_url - .chars() - .take(MAX_LINK_URL_LENGTH) - .collect::(); - - // Only keep valid URLs - Some(UserLink { title, url }) - } - Err(_) => { - None // Discard invalid links - } - } - }) + .map(|link| link.sanitize()) + .filter(|link| !link.url.is_empty()) .collect() }); - Ok(PubkyAppUser { + PubkyAppUser { name, bio, image, links, status, - }) + } } - async fn validate(&self, _id: &str) -> Result<(), DynError> { + fn validate(&self, _id: &str) -> Result<(), String> { // Validate name length let name_length = self.name.chars().count(); if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&name_length) { - return Err("Invalid name length".into()); + return Err("Validation Error: Invalid name length".into()); } // Validate bio length if let Some(bio) = &self.bio { if bio.chars().count() > MAX_BIO_LENGTH { - return Err("Bio exceeds maximum length".into()); + return Err("Validation Error: Bio exceeds maximum length".into()); } } // Validate image length if let Some(image) = &self.image { if image.chars().count() > MAX_IMAGE_LENGTH { - return Err("Image URI exceeds maximum length".into()); + return Err("Validation Error: Image URI exceeds maximum length".into()); } } // Validate links if let Some(links) = &self.links { if links.len() > MAX_LINKS { - return Err("Too many links".into()); + return Err("Too many links".to_string()); } + for link in links { - if link.title.chars().count() > MAX_LINK_TITLE_LENGTH - || link.url.chars().count() > MAX_LINK_URL_LENGTH - { - return Err("Link title or URL too long".into()); - } + link.validate(_id)?; } } // Validate status length if let Some(status) = &self.status { if status.chars().count() > MAX_STATUS_LENGTH { - return Err("Status exceeds maximum length".into()); + return Err("Validation Error: Status exceeds maximum length".into()); } } Ok(()) } } + +impl Validatable for PubkyAppUserLink { + fn sanitize(self) -> Self { + let title = self + .title + .trim() + .chars() + .take(MAX_LINK_TITLE_LENGTH) + .collect::(); + + let url = match Url::parse(self.url.trim()) { + Ok(parsed_url) => { + let sanitized_url = parsed_url.to_string(); + sanitized_url + .chars() + .take(MAX_LINK_URL_LENGTH) + .collect::() + } + Err(_) => "".to_string(), // Default to empty string for invalid URLs + }; + + PubkyAppUserLink { title, url } + } + + fn validate(&self, _id: &str) -> Result<(), String> { + if self.title.chars().count() > MAX_LINK_TITLE_LENGTH { + return Err("Validation Error: Link title exceeds maximum length".to_string()); + } + + if self.url.chars().count() > MAX_LINK_URL_LENGTH { + return Err("Validation Error: Link URL exceeds maximum length".to_string()); + } + + match Url::parse(&self.url) { + Ok(_) => Ok(()), + Err(_) => Err("Validation Error: Invalid URL format".to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + use crate::APP_PATH; + + #[test] + fn test_new() { + let user = PubkyAppUser::new( + "Alice".to_string(), + Some("Maximalist".to_string()), + Some("https://example.com/image.png".to_string()), + Some(vec![ + PubkyAppUserLink { + title: "GitHub".to_string(), + url: "https://github.com/alice".to_string(), + }, + PubkyAppUserLink { + title: "Website".to_string(), + url: "https://alice.dev".to_string(), + }, + ]), + Some("Exploring the decentralized web.".to_string()), + ); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + assert_eq!(user.links.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_create_path() { + let user = PubkyAppUser::default(); + let path = user.create_path(); + assert_eq!(path, format!("{}profile.json", APP_PATH)); + } + + #[test] + fn test_sanitize() { + let user = PubkyAppUser::new( + " Alice ".to_string(), + Some(" Maximalist and developer. ".to_string()), + Some("https://example.com/image.png".to_string()), + Some(vec![ + PubkyAppUserLink { + title: " GitHub ".to_string(), + url: " https://github.com/alice ".to_string(), + }, + PubkyAppUserLink { + title: "Website".to_string(), + url: "invalid_url".to_string(), // Invalid URL + }, + ]), + Some(" Exploring the decentralized web. ".to_string()), + ); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist and developer.")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + let links = user.links.unwrap(); + assert_eq!(links.len(), 1); // Invalid URL link should be filtered out + assert_eq!(links[0].title, "GitHub"); + assert_eq!(links[0].url, "https://github.com/alice"); + } + + #[test] + fn test_validate_valid() { + let user = PubkyAppUser::new( + "Alice".to_string(), + Some("Maximalist".to_string()), + Some("https://example.com/image.png".to_string()), + None, + Some("Exploring the decentralized web.".to_string()), + ); + + let result = user.validate(""); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_name() { + let user = PubkyAppUser::new( + "Al".to_string(), // Too short + None, + None, + None, + None, + ); + + let result = user.validate(""); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Validation Error: Invalid name length" + ); + } + + #[test] + fn test_try_from_valid() { + let user_json = r#" + { + "name": "Alice", + "bio": "Maximalist", + "image": "https://example.com/image.png", + "links": [ + { + "title": "GitHub", + "url": "https://github.com/alice" + }, + { + "title": "Website", + "url": "https://alice.dev" + } + ], + "status": "Exploring the decentralized web." + } + "#; + + let blob = user_json.as_bytes(); + let user = ::try_from(&blob, "").unwrap(); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + assert_eq!(user.links.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_try_from_invalid_link() { + let user_json = r#" + { + "name": "Alice", + "links": [ + { + "title": "GitHub", + "url": "invalid_url" + } + ] + } + "#; + + let blob = user_json.as_bytes(); + let user = ::try_from(&blob, "").unwrap(); + + // Since the link URL is invalid, it should be filtered out + assert!(user.links.is_none() || user.links.as_ref().unwrap().is_empty()); + } +}