├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── build.rs ├── description.md ├── icons ├── appGrid.svg ├── grain.svg └── market.svg ├── main.jsx ├── package.json ├── pgp-keyring ├── pgp-signature ├── sandstorm-files.list ├── sandstorm-pkgdef.capnp ├── schema └── collections.capnp ├── screenshots └── screenshot-1.png ├── src ├── identity_map.rs ├── main.rs ├── server.rs └── web_socket.rs └── style.scss /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | target 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v1.2.0 2 | - Reverse the order of grains, so that the most recently added one is at the top. 3 | - Update backend to use new release of capnp-rpc-rust, based on futures-rs. 4 | 5 | # v1.1.1 6 | - Add validation of tokens returned from powerbox requests. 7 | 8 | # v1.1.0 9 | - Add "added-by" column. 10 | 11 | # v1.0.0 12 | - Add "retry now" button for when the websocket has disconnected. 13 | - Visually indicate broken links and add "refresh" button. 14 | 15 | # v0.0.2 16 | - Add "cancel" button to description editor form. 17 | 18 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "base64" 7 | version = "0.21.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "1.2.1" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 16 | 17 | [[package]] 18 | name = "bytes" 19 | version = "0.5.4" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "130aac562c0dd69c56b3b1cc8ffd2e17be31d0b6c25b61c96b76231aa23e39e1" 22 | 23 | [[package]] 24 | name = "capnp" 25 | version = "0.21.0" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "b1d1b4a00e80b7c4b1a49e845365f25c9d8fd0a19c9cd8d66f68afea47b1f020" 28 | dependencies = [ 29 | "embedded-io", 30 | ] 31 | 32 | [[package]] 33 | name = "capnp-futures" 34 | version = "0.21.0" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "d04478adeb234836f886ec554a0d96e3af3a939ba7b3962af5addddf7ab71231" 37 | dependencies = [ 38 | "capnp", 39 | "futures-channel", 40 | "futures-util", 41 | ] 42 | 43 | [[package]] 44 | name = "capnp-rpc" 45 | version = "0.21.0" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "85e9c19ef52ff1b9c9822fb21bfa68a72bc58711676295ff06eb88e64c7877f7" 48 | dependencies = [ 49 | "capnp", 50 | "capnp-futures", 51 | "futures", 52 | ] 53 | 54 | [[package]] 55 | name = "capnpc" 56 | version = "0.21.0" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "5af589f7a7f3e6d920120b913345bd9a2fc65dfd76c5053a142852a5ea2e8609" 59 | dependencies = [ 60 | "capnp", 61 | ] 62 | 63 | [[package]] 64 | name = "cfg-if" 65 | version = "0.1.10" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 68 | 69 | [[package]] 70 | name = "embedded-io" 71 | version = "0.6.1" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" 74 | 75 | [[package]] 76 | name = "fuchsia-zircon" 77 | version = "0.3.3" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" 80 | dependencies = [ 81 | "bitflags", 82 | "fuchsia-zircon-sys", 83 | ] 84 | 85 | [[package]] 86 | name = "fuchsia-zircon-sys" 87 | version = "0.3.3" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" 90 | 91 | [[package]] 92 | name = "futures" 93 | version = "0.3.28" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" 96 | dependencies = [ 97 | "futures-channel", 98 | "futures-core", 99 | "futures-executor", 100 | "futures-io", 101 | "futures-sink", 102 | "futures-task", 103 | "futures-util", 104 | ] 105 | 106 | [[package]] 107 | name = "futures-channel" 108 | version = "0.3.28" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" 111 | dependencies = [ 112 | "futures-core", 113 | "futures-sink", 114 | ] 115 | 116 | [[package]] 117 | name = "futures-core" 118 | version = "0.3.28" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" 121 | 122 | [[package]] 123 | name = "futures-executor" 124 | version = "0.3.28" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" 127 | dependencies = [ 128 | "futures-core", 129 | "futures-task", 130 | "futures-util", 131 | ] 132 | 133 | [[package]] 134 | name = "futures-io" 135 | version = "0.3.28" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" 138 | 139 | [[package]] 140 | name = "futures-macro" 141 | version = "0.3.28" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" 144 | dependencies = [ 145 | "proc-macro2", 146 | "quote", 147 | "syn", 148 | ] 149 | 150 | [[package]] 151 | name = "futures-sink" 152 | version = "0.3.28" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" 155 | 156 | [[package]] 157 | name = "futures-task" 158 | version = "0.3.28" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" 161 | 162 | [[package]] 163 | name = "futures-util" 164 | version = "0.3.28" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" 167 | dependencies = [ 168 | "futures-channel", 169 | "futures-core", 170 | "futures-io", 171 | "futures-macro", 172 | "futures-sink", 173 | "futures-task", 174 | "memchr", 175 | "pin-project-lite 0.2.9", 176 | "pin-utils", 177 | "slab", 178 | ] 179 | 180 | [[package]] 181 | name = "hex" 182 | version = "0.4.3" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 185 | 186 | [[package]] 187 | name = "idna" 188 | version = "0.1.5" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "38f09e0f0b1fb55fdee1f17470ad800da77af5186a1a76c026b679358b7e844e" 191 | dependencies = [ 192 | "matches", 193 | "unicode-bidi", 194 | "unicode-normalization", 195 | ] 196 | 197 | [[package]] 198 | name = "iovec" 199 | version = "0.1.4" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" 202 | dependencies = [ 203 | "libc", 204 | ] 205 | 206 | [[package]] 207 | name = "kernel32-sys" 208 | version = "0.2.2" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" 211 | dependencies = [ 212 | "winapi 0.2.8", 213 | "winapi-build", 214 | ] 215 | 216 | [[package]] 217 | name = "lazy_static" 218 | version = "1.4.0" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 221 | 222 | [[package]] 223 | name = "libc" 224 | version = "0.2.146" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" 227 | 228 | [[package]] 229 | name = "log" 230 | version = "0.4.8" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7" 233 | dependencies = [ 234 | "cfg-if", 235 | ] 236 | 237 | [[package]] 238 | name = "matches" 239 | version = "0.1.8" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" 242 | 243 | [[package]] 244 | name = "memchr" 245 | version = "2.3.3" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" 248 | 249 | [[package]] 250 | name = "mio" 251 | version = "0.6.23" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" 254 | dependencies = [ 255 | "cfg-if", 256 | "fuchsia-zircon", 257 | "fuchsia-zircon-sys", 258 | "iovec", 259 | "kernel32-sys", 260 | "libc", 261 | "log", 262 | "miow", 263 | "net2", 264 | "slab", 265 | "winapi 0.2.8", 266 | ] 267 | 268 | [[package]] 269 | name = "mio-uds" 270 | version = "0.6.8" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "afcb699eb26d4332647cc848492bbc15eafb26f08d0304550d5aa1f612e066f0" 273 | dependencies = [ 274 | "iovec", 275 | "libc", 276 | "mio", 277 | ] 278 | 279 | [[package]] 280 | name = "miow" 281 | version = "0.2.2" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" 284 | dependencies = [ 285 | "kernel32-sys", 286 | "net2", 287 | "winapi 0.2.8", 288 | "ws2_32-sys", 289 | ] 290 | 291 | [[package]] 292 | name = "multipoll" 293 | version = "0.1.0" 294 | source = "git+https://github.com/dwrensha/multipoll#91eddb3bf9281c5d300efbef9a7b605a3e1a0122" 295 | dependencies = [ 296 | "futures", 297 | ] 298 | 299 | [[package]] 300 | name = "net2" 301 | version = "0.2.39" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "b13b648036a2339d06de780866fbdfda0dde886de7b3af2ddeba8b14f4ee34ac" 304 | dependencies = [ 305 | "cfg-if", 306 | "libc", 307 | "winapi 0.3.8", 308 | ] 309 | 310 | [[package]] 311 | name = "percent-encoding" 312 | version = "1.0.1" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831" 315 | 316 | [[package]] 317 | name = "pin-project-lite" 318 | version = "0.1.7" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "282adbf10f2698a7a77f8e983a74b2d18176c19a7fd32a45446139ae7b02b715" 321 | 322 | [[package]] 323 | name = "pin-project-lite" 324 | version = "0.2.9" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" 327 | 328 | [[package]] 329 | name = "pin-utils" 330 | version = "0.1.0" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 333 | 334 | [[package]] 335 | name = "proc-macro2" 336 | version = "1.0.60" 337 | source = "registry+https://github.com/rust-lang/crates.io-index" 338 | checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406" 339 | dependencies = [ 340 | "unicode-ident", 341 | ] 342 | 343 | [[package]] 344 | name = "quote" 345 | version = "1.0.28" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" 348 | dependencies = [ 349 | "proc-macro2", 350 | ] 351 | 352 | [[package]] 353 | name = "sandstorm" 354 | version = "0.21.0" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "6dc50540bd7d6b41250db4c4fc6f0d828d4f1e7eb4c74a38a00d8fb49cce1106" 357 | dependencies = [ 358 | "capnp", 359 | "capnpc", 360 | ] 361 | 362 | [[package]] 363 | name = "sandstorm-collections-app" 364 | version = "1.3.0" 365 | dependencies = [ 366 | "base64", 367 | "capnp", 368 | "capnp-rpc", 369 | "capnpc", 370 | "futures", 371 | "hex", 372 | "multipoll", 373 | "sandstorm", 374 | "tokio", 375 | "tokio-util", 376 | "url", 377 | ] 378 | 379 | [[package]] 380 | name = "slab" 381 | version = "0.4.2" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8" 384 | 385 | [[package]] 386 | name = "smallvec" 387 | version = "1.10.0" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" 390 | 391 | [[package]] 392 | name = "syn" 393 | version = "2.0.18" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" 396 | dependencies = [ 397 | "proc-macro2", 398 | "quote", 399 | "unicode-ident", 400 | ] 401 | 402 | [[package]] 403 | name = "tokio" 404 | version = "0.2.21" 405 | source = "registry+https://github.com/rust-lang/crates.io-index" 406 | checksum = "d099fa27b9702bed751524694adbe393e18b36b204da91eb1cbbbbb4a5ee2d58" 407 | dependencies = [ 408 | "bytes", 409 | "iovec", 410 | "lazy_static", 411 | "libc", 412 | "mio", 413 | "mio-uds", 414 | "pin-project-lite 0.1.7", 415 | "slab", 416 | ] 417 | 418 | [[package]] 419 | name = "tokio-util" 420 | version = "0.3.1" 421 | source = "registry+https://github.com/rust-lang/crates.io-index" 422 | checksum = "be8242891f2b6cbef26a2d7e8605133c2c554cd35b3e4948ea892d6d68436499" 423 | dependencies = [ 424 | "bytes", 425 | "futures-core", 426 | "futures-io", 427 | "futures-sink", 428 | "log", 429 | "pin-project-lite 0.1.7", 430 | "tokio", 431 | ] 432 | 433 | [[package]] 434 | name = "unicode-bidi" 435 | version = "0.3.4" 436 | source = "registry+https://github.com/rust-lang/crates.io-index" 437 | checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" 438 | dependencies = [ 439 | "matches", 440 | ] 441 | 442 | [[package]] 443 | name = "unicode-ident" 444 | version = "1.0.9" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" 447 | 448 | [[package]] 449 | name = "unicode-normalization" 450 | version = "0.1.12" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "5479532badd04e128284890390c1e876ef7a993d0570b3597ae43dfa1d59afa4" 453 | dependencies = [ 454 | "smallvec", 455 | ] 456 | 457 | [[package]] 458 | name = "url" 459 | version = "1.7.2" 460 | source = "registry+https://github.com/rust-lang/crates.io-index" 461 | checksum = "dd4e7c0d531266369519a4aa4f399d748bd37043b00bde1e4ff1f60a120b355a" 462 | dependencies = [ 463 | "idna", 464 | "matches", 465 | "percent-encoding", 466 | ] 467 | 468 | [[package]] 469 | name = "winapi" 470 | version = "0.2.8" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" 473 | 474 | [[package]] 475 | name = "winapi" 476 | version = "0.3.8" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" 479 | dependencies = [ 480 | "winapi-i686-pc-windows-gnu", 481 | "winapi-x86_64-pc-windows-gnu", 482 | ] 483 | 484 | [[package]] 485 | name = "winapi-build" 486 | version = "0.1.1" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" 489 | 490 | [[package]] 491 | name = "winapi-i686-pc-windows-gnu" 492 | version = "0.4.0" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 495 | 496 | [[package]] 497 | name = "winapi-x86_64-pc-windows-gnu" 498 | version = "0.4.0" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 501 | 502 | [[package]] 503 | name = "ws2_32-sys" 504 | version = "0.2.1" 505 | source = "registry+https://github.com/rust-lang/crates.io-index" 506 | checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" 507 | dependencies = [ 508 | "winapi 0.2.8", 509 | "winapi-build", 510 | ] 511 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sandstorm-collections-app" 3 | version = "1.3.0" 4 | authors = ["David Renshaw "] 5 | build = "build.rs" 6 | edition = "2018" 7 | 8 | [[bin]] 9 | 10 | name = "server" 11 | path = "src/main.rs" 12 | 13 | [build-dependencies] 14 | capnpc = "0.21" 15 | 16 | [dependencies] 17 | futures = "0.3.28" 18 | capnp = "0.21" 19 | capnp-rpc = "0.21" 20 | hex = "0.4.3" 21 | base64 = "0.21.3" 22 | url = "1.2" 23 | sandstorm = "0.21.0" 24 | tokio = { version = "0.2.6", features = ["net", "rt-util", "time", "uds"]} 25 | tokio-util = { version = "0.3.0", features = ["compat"] } 26 | multipoll = { git = "https://github.com/dwrensha/multipoll" } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Sandstorm Development Group, Inc. and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SPK_DEPS=spk/server spk/script.js.gz spk/style.css.gz 2 | 3 | .PHONY: dev clean 4 | 5 | collections.spk: $(SPK_DEPS) 6 | spk pack collections.spk 7 | 8 | clean: 9 | rm -rf spk tmp bin lib collections.spk 10 | 11 | dev-deps: $(SPK_DEPS) 12 | 13 | dev: $(SPK_DEPS) 14 | spk dev 15 | 16 | spk/script.js.gz: package.json *.jsx 17 | @mkdir -p spk tmp 18 | npm run-script bundle 19 | npm run-script uglify 20 | gzip -c tmp/script-min.js > spk/script.js.gz 21 | 22 | spk/style.css.gz: package.json style.scss 23 | @mkdir -p spk tmp 24 | npm run-script sass 25 | npm run-script postcss 26 | gzip -c tmp/style.css > spk/style.css.gz 27 | 28 | target/release/server: src/ schema/ build.rs 29 | cargo build --release 30 | 31 | spk/server: target/release/server 32 | @mkdir -p spk 33 | cp target/release/server spk/collections-server 34 | 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sandstorm Collections App 2 | 3 | This is an app that runs on [Sandstorm](https://sandstorm.io). 4 | Its purpose is to aggregate a group of 5 | [grains](https://docs.sandstorm.io/en/latest/using/security-practices/#fine-grained-isolation) 6 | so that they can be shared as a single unit. 7 | 8 | You can install it from the Sandstorm App Market 9 | [here](https://apps.sandstorm.io/app/s3u2xgmqwznz2n3apf30sm3gw1d85y029enw5pymx734cnk5n78h). 10 | 11 | ## Developing 12 | 13 | You will need: 14 | - A recent build of [Cap'n Proto](https://github.com/sandstorm-io/capnproto) from the master branch, 15 | installed such that the `capnp` executable is on your PATH. 16 | - A [dev install of Sandstorm](https://docs.sandstorm.io/en/latest/developing/raw-packaging-guide/) 17 | - [Rust](https://rust-lang.org) 18 | - [Node](https://nodejs.org) and [NPM](https://www.npmjs.com/) 19 | 20 | 21 | ``` 22 | $ npm install 23 | $ make dev 24 | ``` -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | extern crate capnpc; 2 | 3 | fn main() { 4 | ::capnpc::CompilerCommand::new() 5 | .src_prefix("schema") 6 | .file("schema/collections.capnp") 7 | .run().expect("compiling"); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /description.md: -------------------------------------------------------------------------------- 1 | A collection is a list of grains that can be shared as a unit. 2 | You can add or remove grains at any time. You can also add or remove collaborators, 3 | and their permissions on the collected grains will be correspondingly updated. 4 | -------------------------------------------------------------------------------- /icons/appGrid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 17 | 19 | 21 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 83 | 84 | -------------------------------------------------------------------------------- /icons/grain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /icons/market.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /main.jsx: -------------------------------------------------------------------------------- 1 | import "babel-polyfill"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import Immutable from "immutable"; 5 | import _ from "underscore"; 6 | 7 | function http(url: string, method, data): Promise { 8 | return new Promise((resolve, reject) => { 9 | const xhr = new XMLHttpRequest(); 10 | if (method === "delete") { 11 | // Work around Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=521301 12 | xhr.responseType = "text"; 13 | } 14 | 15 | xhr.onload = () => { 16 | if (xhr.status >= 400) { 17 | reject(new Error("XHR returned status " + xhr.status + ":\n" + xhr.responseText)); 18 | } else { 19 | resolve(xhr.responseText); 20 | } 21 | }; 22 | xhr.onerror = (e: Error) => { reject(e); }; 23 | xhr.open(method, url); 24 | xhr.send(data); 25 | }); 26 | } 27 | 28 | let rpcCounter = 0; 29 | const rpcs: { [key: number]: (response: mixed) => void } = {}; 30 | 31 | window.addEventListener("message", (event) => { 32 | if (event.source !== window.parent || 33 | typeof event.data !== "object" || 34 | typeof event.data.rpcId !== "number") { 35 | console.warn("got unexpected postMessage:", event); 36 | return; 37 | } 38 | 39 | const handler = rpcs[event.data.rpcId]; 40 | if (!handler) { 41 | console.error("no such rpc ID for event", event); 42 | return; 43 | } 44 | 45 | delete rpcs[event.data.rpcId]; 46 | handler(event.data); 47 | }); 48 | 49 | function sendRpc(name: string, message: Object): Promise { 50 | const id = rpcCounter++; 51 | message.rpcId = id; 52 | const obj = {}; 53 | obj[name] = message; 54 | window.parent.postMessage(obj, "*"); 55 | return new Promise((resolve, reject) => { 56 | rpcs[id] = (response) => { 57 | if (response.error) { 58 | reject(new Error(response.error)); 59 | } else { 60 | resolve(response); 61 | } 62 | }; 63 | }); 64 | } 65 | 66 | const interfaces = { 67 | // Powerbox descriptors for various interface IDs. 68 | 69 | uiView: "EAZQAQEAABEBF1EEAQH_5-Jn6pjXtNsAAAA", // 15831515641881813735, 70 | // This is produced by: 71 | // urlsafeBase64(capnp.serializePacked(PowerboxDescriptor, { 72 | // tags: [ 73 | // { id: UiView.typeId }, 74 | // ], 75 | // })) 76 | }; 77 | 78 | function doRequest(serializedPowerboxDescriptor) { 79 | sendRpc("powerboxRequest", { 80 | query: [serializedPowerboxDescriptor] 81 | }).then((response) => { 82 | if (response.canceled) { 83 | console.log("powerbox request was canceled"); 84 | } else { 85 | if (response.token !== encodeURIComponent(response.token)) { 86 | throw new Error("Parent frame returned malformed token: " + response.token); 87 | } 88 | 89 | return http("/token/" + response.token, "post", response.descriptor); 90 | } 91 | }); 92 | } 93 | 94 | // Icons borrowed from the main Sandstorm repo. 95 | 96 | const SEARCH_ICON = 97 | 98 | ; 99 | 100 | const INSTALL_ICON = 101 | 102 | ; 103 | 104 | const EDIT_ICON = 105 | 106 | 107 | 108 | ; 109 | 110 | const REFRESH_ICON = 111 | 112 | 113 | 114 | 115 | ; 116 | 117 | class AddGrain extends React.Component { 118 | props: {}; 119 | state: {}; 120 | 121 | constructor(props) { 122 | super(props); 123 | } 124 | 125 | handleClick(event) { 126 | event.preventDefault(); 127 | doRequest(interfaces.uiView); 128 | } 129 | 130 | render() { 131 | return 132 | 133 | 134 | {INSTALL_ICON} 135 | 136 | 137 | ; 138 | } 139 | } 140 | 141 | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 142 | function makeDateString(date) { 143 | if (!date) { 144 | return ""; 145 | } 146 | 147 | let result; 148 | 149 | const now = new Date(); 150 | const diff = now.valueOf() - date.valueOf(); 151 | 152 | if (diff < 86400000 && now.getDate() === date.getDate()) { 153 | result = date.toLocaleTimeString(); 154 | } else { 155 | result = MONTHS[date.getMonth()] + " " + date.getDate() + " "; 156 | 157 | if (now.getFullYear() !== date.getFullYear()) { 158 | result = date.getFullYear() + " " + result; 159 | } 160 | } 161 | 162 | return result; 163 | }; 164 | 165 | class GrainList extends React.Component { 166 | props: { grains: Immutable.Map, 167 | viewInfos: Immutable.Map, 168 | users: Immutable.Map, 169 | canWrite: bool, 170 | userId: String, 171 | }; 172 | state: { selectedGrains: Immutable.Set, 173 | searchString: String, 174 | }; 175 | 176 | constructor(props) { 177 | super(props); 178 | this.state = { selectedGrains: Immutable.Set(), 179 | searchString: "", 180 | }; 181 | 182 | this._currentlyRendered = {}; 183 | } 184 | 185 | clickRemoveGrain(e) { 186 | let newSelected = this.state.selectedGrains; 187 | 188 | for (let e of this.state.selectedGrains.keys()) { 189 | if (e in this._currentlyRendered) { 190 | http("/sturdyref/" + e, "delete"); 191 | newSelected = newSelected.remove(e); 192 | } 193 | } 194 | 195 | this.setState({ selectedGrains: newSelected }); 196 | } 197 | 198 | selectGrain(token, e) { 199 | if (this.state.selectedGrains.get(token)) { 200 | this.setState({ selectedGrains: this.state.selectedGrains.remove(token) }); 201 | } else { 202 | this.setState({ selectedGrains: this.state.selectedGrains.add(token) }); 203 | } 204 | } 205 | 206 | clickCheckboxContainer(e) { 207 | if (e.target.tagName === "TD") { 208 | for (let ii = 0; ii < e.target.children.length; ++ii) { 209 | const c = e.target.children[ii]; 210 | if (c.tagName === "INPUT") { 211 | c.click(); 212 | return; 213 | } 214 | } 215 | } 216 | } 217 | 218 | selectAll(e) { 219 | if (!e.target.checked) { 220 | let newSelected = this.state.selectedGrains; 221 | for (let e of this.state.selectedGrains.keys()) { 222 | if (e in this._currentlyRendered) { 223 | newSelected = newSelected.remove(e); 224 | } 225 | } 226 | 227 | this.setState({ selectedGrains: newSelected }); 228 | } else { 229 | let newSelected = this.state.selectedGrains; 230 | for (const e in this._currentlyRendered) { 231 | newSelected = newSelected.add(e); 232 | } 233 | 234 | this.setState({ selectedGrains: newSelected }); 235 | } 236 | } 237 | 238 | offerUiView(token) { 239 | http("/offer/" + token, "post"); 240 | } 241 | 242 | searchStringChange(e) { 243 | this.setState({ searchString: e.target.value}); 244 | } 245 | 246 | matchesAppOrGrainTitle = function (needle, grain, info) { 247 | if (!info || info.err) return false; 248 | if (grain && grain.title && grain.title.toLowerCase().indexOf(needle) !== -1) return true; 249 | if (info.ok && info.ok.appTitle && info.ok.appTitle.toLowerCase().indexOf(needle) !== -1) { 250 | return true; 251 | } 252 | return false; 253 | } 254 | 255 | refresh(token) { 256 | http("/refresh/" + token, "post"); 257 | } 258 | 259 | remove(token){ 260 | http("/sturdyref/" + token, "delete"); 261 | } 262 | 263 | render() { 264 | const searchKeys = this.state.searchString.toLowerCase() 265 | .split(" ") 266 | .filter((k) => k !== ""); 267 | 268 | const matchFilter = (grain, info) => { 269 | if (searchKeys.length == 0) { 270 | return true; 271 | } else { 272 | return _.chain(searchKeys) 273 | .map((sk) => this.matchesAppOrGrainTitle(sk, grain, info)) 274 | .reduce((a,b) => a && b) 275 | .value(); 276 | } 277 | }; 278 | 279 | let numShownAndSelected = 0; 280 | this._currentlyRendered = {}; 281 | const grains = []; 282 | for (let e of this.props.grains.entries()) { 283 | const grain = e[1]; 284 | const info = this.props.viewInfos.get(e[0]) || {}; 285 | if ((info.ok || this.props.canWrite) && matchFilter(grain, info)) { 286 | if (this.state.selectedGrains.get(e[0])) { 287 | numShownAndSelected += 1; 288 | } 289 | this._currentlyRendered[e[0]] = true; 290 | 291 | grains.push({token: e[0], grain, info }); 292 | } 293 | } 294 | const grainRows = _.chain(grains).sortBy((r) => r.grain.dateAdded).reverse().map((r) => { 295 | const checkbox = this.props.canWrite ? 296 | 297 | 299 | : []; 300 | const appIcon = r.info.ok ? 301 | 302 | 303 | 304 | : 305 | 306 | 308 | ; 309 | 310 | const grainTitle = r.info.ok ? 311 | 312 | 313 | : 314 | 315 | {r.grain.title} 316 | (broken) 317 | 318 | ; 319 | 320 | const dateAdded = r.info.ok? 321 | 322 | {makeDateString(new Date(parseInt(r.grain.dateAdded)))} 323 | : 324 | {makeDateString(new Date(parseInt(r.grain.dateAdded)))}; 325 | 326 | 327 | const addedByUser = (r.grain.addedBy && this.props.users.get(r.grain.addedBy)) || {}; 328 | 329 | const addedBy = r.info.ok? 330 | 331 | 333 | 334 | : 335 | ; 336 | 337 | return 338 | {checkbox}{appIcon}{grainTitle}{addedBy}{dateAdded} 339 | ; 340 | }).value(); 341 | 342 | const bulkActionButtons = []; 343 | if (this.props.canWrite) { 344 | bulkActionButtons.push( 345 | ); 350 | } 351 | 352 | return
353 |
354 | 359 |
360 |
361 | {bulkActionButtons} 362 |
363 | 364 | 365 | 366 | {this.props.canWrite ? 367 | : [] } 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | {(this.props.canWrite && this.props.userId && !this.state.searchString) ? : [] } 381 | { grainRows } 382 | 383 |
369 | 0 ? "unselect all" : "select all"} 370 | onChange={this.selectAll.bind(this)} 371 | checked={numShownAndSelected > 0}/> 372 | NameAdded byDate added
384 |
; 385 | } 386 | } 387 | 388 | class Description extends React.Component { 389 | props: { description: String, canWrite: bool }; 390 | state: { editing: bool, editedDescription: String }; 391 | 392 | constructor(props) { 393 | super(props); 394 | this.state = { editing: false }; 395 | } 396 | 397 | clickEdit() { 398 | this.setState({ editing: true, editedDescription: this.props.description }); 399 | } 400 | 401 | clickCancel(e) { 402 | e.preventDefault(); 403 | this.setState({ editing: false, editedDescription: this.props.description }); 404 | } 405 | 406 | submitEdit(e) { 407 | e.preventDefault(); 408 | if (this.state.editedDescription !== this.props.description) { 409 | http("/description", "put", this.state.editedDescription); 410 | } 411 | this.setState({ editing: false }); 412 | } 413 | 414 | changeDesc(e) { 415 | this.setState({ editedDescription: e.target.value }); 416 | } 417 | 418 | render () { 419 | if (this.state.editing) { 420 | return
421 | 423 | 424 | 425 | 427 |
; 428 | } else if (this.props.description && this.props.description.length > 0) { 429 | let button = []; 430 | if (this.props.canWrite) { 431 | button = ; 434 | } 435 | return

{this.props.description}

436 | {button} 437 |
; 438 | } else { 439 | if (this.props.canWrite) { 440 | return 442 | } else { 443 | return null; 444 | } 445 | } 446 | } 447 | } 448 | 449 | class Main extends React.Component { 450 | props: {}; 451 | state: { canWrite: bool, 452 | userId: String, 453 | description: String, 454 | grains: Immutable.Map, 455 | viewInfos: Immutable.Map, 456 | users: Immutable.Map, 457 | socketReadyState: Object, 458 | }; 459 | 460 | constructor(props) { 461 | super(props); 462 | this.state = { grains: Immutable.Map(), 463 | viewInfos: Immutable.Map(), 464 | users: Immutable.Map(), 465 | socketReadyState: { initializing: true }, 466 | }; 467 | } 468 | 469 | componentDidMount() { 470 | this.openWebSocket(1000); 471 | } 472 | 473 | openWebSocket(delayOnFailure) { 474 | if (!!this.state.socketReadyState.open) { 475 | return; 476 | } 477 | 478 | this.setState({socketReadyState: { connecting: true } }); 479 | 480 | let wsProtocol = window.location.protocol == "http:" ? "ws" : "wss"; 481 | let ws = new WebSocket(wsProtocol + "://" + window.location.host); 482 | 483 | ws.onopen = (e) => { 484 | this.setState({ socketReadyState: { open: true } }); 485 | }; 486 | 487 | ws.onerror = (e) => { 488 | console.log("websocket got error: ", e); 489 | }; 490 | 491 | ws.onclose = (e) => { 492 | console.log("websocket closed: ", e); 493 | let newDelay = 0; 494 | if (!this.state.socketReadyState.open) { 495 | if (delayOnFailure == 0) { 496 | newDelay = 1000; 497 | } else { 498 | newDelay = Math.min(delayOnFailure * 2, 60 * 1000); // Don't go over a minute. 499 | } 500 | console.log("websocket failed to connect. Retrying in " + delayOnFailure + " milliseconds"); 501 | } 502 | 503 | const timeout = window.setTimeout(() => { 504 | this.openWebSocket(newDelay); 505 | }, delayOnFailure); 506 | 507 | this.setState({ 508 | socketReadyState: { 509 | tryingAgainLater: { timeout } 510 | } 511 | }); 512 | 513 | }; 514 | 515 | ws.onmessage = (m) => { 516 | const action = JSON.parse(m.data); 517 | if (action.canWrite) { 518 | this.setState({canWrite: action.canWrite}); 519 | } else if (action.userId) { 520 | this.setState({userId: action.userId}); 521 | } else if (action.description) { 522 | this.setState({ description: action.description }); 523 | } else if (action.insert) { 524 | const newGrains = this.state.grains.set(action.insert.token, 525 | action.insert.data); 526 | this.setState({grains: newGrains}); 527 | 528 | if (!this.state.viewInfos.get(action.insert.token)) { 529 | // HACK: We are likely in an intermediate state between receiving the info 530 | // about the grian and receiving its view info. If we don't add an "ok" viewinfo here, 531 | // then the UI will briefly display the grain as broken. 532 | // Maybe we should combine the `insert` and `viewInfo` messages? 533 | const newViewInfos = this.state.viewInfos.set(action.insert.token, { ok: {} }); 534 | this.setState({ viewInfos: newViewInfos }); 535 | } 536 | } else if (action.remove) { 537 | const newGrains = this.state.grains.delete(action.remove.token); 538 | this.setState({ grains: newGrains }); 539 | } else if (action.viewInfo) { 540 | const data = action.viewInfo.data ? 541 | { ok: action.viewInfo.data } : 542 | { err: action.viewInfo.failed.split("\n")[0] }; // HACK to drop the stack trace. 543 | 544 | const newViewInfos = this.state.viewInfos.set(action.viewInfo.token, data); 545 | this.setState({ viewInfos: newViewInfos }); 546 | } else if (action.user) { 547 | const newUsers = this.state.users.set(action.user.id, action.user.data); 548 | this.setState({ users: newUsers }); 549 | } 550 | }; 551 | 552 | } 553 | 554 | retryConnect() { 555 | if (this.state.socketReadyState.tryingAgainLater) { 556 | window.clearTimeout(this.state.socketReadyState.tryingAgainLater.timeout); 557 | this.openWebSocket(1000); 558 | } 559 | } 560 | 561 | render() { 562 | let maybeSocketWarning = null; 563 | if (!!this.state.socketReadyState.connecting) { 564 | maybeSocketWarning =

WebSocket connecting...

; 565 | } else if (!!this.state.socketReadyState.tryingAgainLater) { 566 | // TODO display timer for how long until next retry 567 | maybeSocketWarning =

WebSocket closed! Waiting and then retrying... 568 | 571 |

; 572 | } 573 | 574 | return
575 | {maybeSocketWarning} 576 | 577 |
578 | 581 |
; 582 | } 583 | } 584 | 585 | ReactDOM.render(
, document.getElementById("main")); 586 | 587 | 588 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sandstorm_collections_app", 3 | "license": "MIT", 4 | "scripts": { 5 | "flow": "flow; test $? -eq 0 -o $? -eq 2", 6 | "bundle": "browserify -t [ babelify --presets [ es2015 react stage-0 ] ] main.jsx -o tmp/script.js", 7 | "sass": "node-sass style.scss tmp/style-pre.css", 8 | "postcss": "postcss --use autoprefixer --use cssnano -o tmp/style.css tmp/style-pre.css", 9 | "icons2font": "svgicons2svgfont -o tmp/fk-font.svg deps/sandstorm/icons/download.svg deps/sandstorm/icons/email.svg", 10 | "font2ttf": "svg2ttf tmp/fk-font.svg tmp/fk-font.ttf", 11 | "ttf2woff": "ttf2woff tmp/fk-font.ttf tmp/fk-font.woff", 12 | "uglify": "uglifyjs --screw-ie8 -o tmp/script-min.js tmp/script.js" 13 | }, 14 | "devDependencies": { 15 | "autoprefixer": "^6.3.6", 16 | "babel-plugin-transform-flow-strip-types": "^6.7.0", 17 | "babel-polyfill": "^6.7.4", 18 | "babel-preset-es2015": "^6.6.0", 19 | "babel-preset-react": "^6.5.0", 20 | "babel-preset-stage-0": "^6.5.0", 21 | "babelify": "^7.3.0", 22 | "browserify": "^13.3.0", 23 | "cssnano": "^3.5.2", 24 | "flow-bin": "^0.22.1", 25 | "immutable": "3.8.1", 26 | "node-sass": "^3.4.2", 27 | "postcss": "^5.0.19", 28 | "postcss-cli": "^2.5.1", 29 | "react": "^15.2.1", 30 | "react-dom": "^15.2.1", 31 | "uglify-js": "^2.6.2", 32 | "underscore": "^1.8.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pgp-keyring: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandstorm-io/collections-app/d25bc1e41e24ef109bde47241215a02c65e50532/pgp-keyring -------------------------------------------------------------------------------- /pgp-signature: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandstorm-io/collections-app/d25bc1e41e24ef109bde47241215a02c65e50532/pgp-signature -------------------------------------------------------------------------------- /sandstorm-files.list: -------------------------------------------------------------------------------- 1 | # *** WARNING: GENERATED FILE *** 2 | # This file is automatically updated and rewritten in sorted order every time 3 | # the app runs in dev mode. You may manually add or remove files, but don't 4 | # expect comments or ordering to be retained. 5 | collections-server 6 | etc/ld.so.cache 7 | lib64 8 | proc/cpuinfo 9 | sandstorm-manifest 10 | script.js.gz 11 | style.css.gz 12 | usr/lib/ld-linux-x86-64.so.2 13 | usr/lib/libc.so.6 14 | usr/lib/libdl.so.2 15 | usr/lib/libgcc_s.so.1 16 | usr/lib/libpthread.so.0 17 | usr/lib/librt.so.1 18 | usr/lib64/ld-linux-x86-64.so.2 19 | usr/lib64/libc.so.6 20 | usr/lib64/libdl.so.2 21 | usr/lib64/libgcc_s.so.1 22 | usr/lib64/libm.so.6 23 | usr/lib64/libpthread.so.0 24 | usr/lib64/librt.so.1 25 | -------------------------------------------------------------------------------- /sandstorm-pkgdef.capnp: -------------------------------------------------------------------------------- 1 | @0xd7ed440331369f59; 2 | 3 | using Spk = import "/sandstorm/package.capnp"; 4 | 5 | const pkgdef :Spk.PackageDefinition = ( 6 | id = "s3u2xgmqwznz2n3apf30sm3gw1d85y029enw5pymx734cnk5n78h", 7 | 8 | manifest = ( 9 | appTitle = (defaultText = "Collections"), 10 | appVersion = 7, # Increment this for every release. 11 | appMarketingVersion = (defaultText = "1.2.0"), 12 | 13 | actions = [ 14 | ( 15 | nounPhrase = (defaultText = "collection"), 16 | command = .myCommand 17 | # The command to run when starting for the first time. (".myCommand" 18 | # is just a constant defined at the bottom of the file.) 19 | ) 20 | ], 21 | 22 | continueCommand = .myCommand, 23 | metadata = ( 24 | # Data which is not needed specifically to execute the app, but is useful 25 | # for purposes like marketing and display. These fields are documented at 26 | # https://docs.sandstorm.io/en/latest/developing/publishing-apps/#add-required-metadata 27 | # and (in deeper detail) in the sandstorm source code, in the Metadata section of 28 | # https://github.com/sandstorm-io/sandstorm/blob/master/src/sandstorm/package.capnp 29 | icons = ( 30 | # Various icons to represent the app in various contexts. 31 | appGrid = (svg = embed "icons/appGrid.svg"), 32 | grain = (svg = embed "icons/grain.svg"), 33 | market = (svg = embed "icons/market.svg"), 34 | ), 35 | 36 | website = "https://sandstorm.io", 37 | codeUrl = "https://github.com/sandstorm-io/collections-app", 38 | license = (openSource = mit), 39 | categories = [productivity], 40 | 41 | author = ( 42 | upstreamAuthor = "David Renshaw", 43 | contactEmail = "david@sandstorm.io", 44 | pgpSignature = embed "pgp-signature", 45 | ), 46 | 47 | pgpKeyring = embed "pgp-keyring", 48 | 49 | description = (defaultText = embed "description.md"), 50 | 51 | shortDescription = (defaultText = "Grain list sharing"), 52 | # A very short (one-to-three words) description of what the app does. For example, 53 | # "Document editor", or "Notetaking", or "Email client". This will be displayed under the app 54 | # title in the grid view in the app market. 55 | 56 | screenshots = [ 57 | (width = 1096, height = 545, png = embed "screenshots/screenshot-1.png"), 58 | ], 59 | changeLog = (defaultText = embed "CHANGELOG.md"), 60 | ), 61 | ), 62 | 63 | sourceMap = ( 64 | # Here we defined where to look for files to copy into your package. The 65 | # `spk dev` command actually figures out what files your app needs 66 | # automatically by running it on a FUSE filesystem. So, the mappings 67 | # here are only to tell it where to find files that the app wants. 68 | searchPath = [ 69 | ( sourcePath = "spk" ), 70 | ( sourcePath = "/", # Then search the system root directory. 71 | hidePaths = [ "home", "proc", "sys", 72 | "etc/passwd", "etc/hosts", "etc/host.conf", 73 | "etc/nsswitch.conf", "etc/resolv.conf" ] 74 | # You probably don't want the app pulling files from these places, 75 | # so we hide them. Note that /dev, /var, and /tmp are implicitly 76 | # hidden because Sandstorm itself provides them. 77 | ) 78 | ] 79 | ), 80 | 81 | fileList = "sandstorm-files.list", 82 | # `spk dev` will write a list of all the files your app uses to this file. 83 | # You should review it later, before shipping your app. 84 | 85 | alwaysInclude = [], 86 | # Fill this list with more names of files or directories that should be 87 | # included in your package, even if not listed in sandstorm-files.list. 88 | # Use this to force-include stuff that you know you need but which may 89 | # not have been detected as a dependency during `spk dev`. If you list 90 | # a directory here, its entire contents will be included recursively. 91 | 92 | ); 93 | 94 | const myCommand :Spk.Manifest.Command = ( 95 | argv = ["/collections-server"], 96 | environ = [ 97 | (key = "PATH", value = "/usr/local/bin:/usr/bin:/bin"), 98 | ] 99 | ); 100 | -------------------------------------------------------------------------------- /schema/collections.capnp: -------------------------------------------------------------------------------- 1 | @0xff3554128c156245; 2 | 3 | 4 | struct UiViewMetadata { 5 | title @0 :Text; 6 | dateAdded @1 :UInt64; # milliseconds since unix epoch 7 | addedBy @2 :Text; # Identity ID, encoded in hexadecimal format. 8 | } 9 | -------------------------------------------------------------------------------- /screenshots/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandstorm-io/collections-app/d25bc1e41e24ef109bde47241215a02c65e50532/screenshots/screenshot-1.png -------------------------------------------------------------------------------- /src/identity_map.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Sandstorm Development Group, Inc. 2 | // Licensed under the MIT License: 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in 12 | // all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | // THE SOFTWARE. 21 | 22 | use capnp::capability::Promise; 23 | use capnp::Error; 24 | use futures::{FutureExt, TryFutureExt}; 25 | use url::percent_encoding; 26 | use std::cell::RefCell; 27 | use std::rc::Rc; 28 | 29 | use sandstorm::identity_capnp::{identity}; 30 | use sandstorm::grain_capnp::{sandstorm_api}; 31 | 32 | fn read_sturdyref_symlink(pointed_to: ::std::path::PathBuf) -> Result, Error> 33 | { 34 | let encoded_sturdyref = match pointed_to.to_str() { 35 | Some(s) => s.to_string(), 36 | None => 37 | return Err(Error::failed( 38 | format!("invalid sturdyref symlink {:?}", pointed_to))), 39 | }; 40 | 41 | let mut sturdyref: Vec = encoded_sturdyref.as_bytes().into(); 42 | match percent_encoding::percent_decode(encoded_sturdyref.as_bytes()).if_any() { 43 | Some(s) => { sturdyref = s } 44 | None => (), 45 | } 46 | Ok(sturdyref) 47 | } 48 | 49 | 50 | struct Reaper; 51 | 52 | impl ::multipoll::Finisher for Reaper { 53 | fn task_failed(&mut self, error: Error) { 54 | println!("IdentityMap task failed: {}", error); 55 | } 56 | } 57 | 58 | struct IdentityMapInner { 59 | directory: ::std::path::PathBuf, 60 | trash_directory: ::std::path::PathBuf, 61 | api: sandstorm_api::Client<::capnp::any_pointer::Owned>, 62 | tasks: ::multipoll::PollerHandle, 63 | } 64 | 65 | impl IdentityMapInner { 66 | fn read_from_disk(inner: &Rc>, 67 | truncated_text_id: &str) -> Promise 68 | { 69 | let mut symlink = inner.borrow().directory.clone(); 70 | symlink.push(truncated_text_id); 71 | 72 | let pointed_to = pry!(::std::fs::read_link(symlink)); 73 | let sturdyref = pry!(read_sturdyref_symlink(pointed_to)); 74 | 75 | let mut req = inner.borrow().api.restore_request(); 76 | req.get().set_token(&sturdyref[..]); 77 | 78 | Promise::from_future(req.send().promise.map(move |r| match r { 79 | Ok(response) => response.get()?.get_cap().get_as_capability(), 80 | Err(e) => Err(e), 81 | })) 82 | } 83 | 84 | fn save_to_disk(inner: &Rc>, 85 | truncated_text_id: &str, 86 | identity: identity::Client) { 87 | let mut req = inner.borrow().api.save_request(); 88 | req.get().init_cap().set_as_capability(identity.client.hook); 89 | req.get().init_label().set_default_text("user identity"); 90 | let mut symlink = inner.borrow().directory.clone(); 91 | symlink.push(&truncated_text_id); 92 | 93 | let inner1 = inner.clone(); 94 | inner.borrow_mut().tasks.add(req.send().promise.map(move |r| match r { 95 | Ok(result) => { 96 | // We save the token as a symlink, which ext4 can store (up to 60 bytes) 97 | // directly in the inode, avoiding the need to allocate a block. 98 | // 99 | // Tokens are primarily text but can contain arbitrary bytes. 100 | // We percent-encode to be safe and to keep the length of the encoded 101 | // token under 60 bytes in the common case. 102 | 103 | let token = result.get()?.get_token()?; 104 | let encoded_token = percent_encoding::percent_encode( 105 | token, 106 | percent_encoding::DEFAULT_ENCODE_SET 107 | ).collect::(); 108 | 109 | IdentityMapInner::drop_identity(&inner1, &symlink)?; 110 | 111 | ::std::os::unix::fs::symlink(encoded_token, symlink)?; 112 | // TODO fsync? 113 | 114 | Ok(()) 115 | } 116 | Err(e) => Err(e), 117 | })); 118 | } 119 | 120 | fn drop_identity

(inner: &Rc>, 121 | symlink: &P) -> Result<(), Error> 122 | where P: AsRef<::std::path::Path> 123 | { 124 | match ::std::fs::read_link(symlink) { 125 | Ok(pointed_to) => { 126 | // symlink exists! 127 | let mut trash_file = inner.borrow().trash_directory.clone(); 128 | trash_file.push(&pointed_to); 129 | ::std::fs::rename(symlink, &trash_file)?; 130 | 131 | let mut req = inner.borrow().api.drop_request(); 132 | let sturdyref = read_sturdyref_symlink(pointed_to)?; 133 | req.get().set_token(&sturdyref[..]); 134 | inner.borrow_mut().tasks.add(req.send().promise.map(move |r| match r { 135 | Ok(_) => { 136 | ::std::fs::remove_file(trash_file)?; 137 | // TODO fsync? 138 | Ok(()) 139 | } 140 | Err(e) => Err(e), 141 | })); 142 | 143 | Ok(()) 144 | } 145 | _ => Ok(()), 146 | } 147 | } 148 | } 149 | 150 | #[derive(Clone)] 151 | pub struct IdentityMap { 152 | inner: Rc>, 153 | } 154 | 155 | impl IdentityMap { 156 | pub fn new(directory: P, 157 | trash_directory: Q, 158 | api: &sandstorm_api::Client<::capnp::any_pointer::Owned>) 159 | -> Result 160 | where P: AsRef<::std::path::Path>, 161 | Q: AsRef<::std::path::Path>, 162 | { 163 | // Create directories if they do not exist yet. 164 | ::std::fs::create_dir_all(&directory)?; 165 | ::std::fs::create_dir_all(&trash_directory)?; 166 | 167 | let (tx, poller) = ::multipoll::Poller::new(Box::new(Reaper)); 168 | tokio::task::spawn_local(poller.map_err(|_|())); 169 | 170 | Ok(IdentityMap { 171 | inner: Rc::new(RefCell::new(IdentityMapInner { 172 | directory: directory.as_ref().to_path_buf(), 173 | trash_directory: trash_directory.as_ref().to_path_buf(), 174 | api: api.clone(), 175 | tasks: tx, 176 | })), 177 | }) 178 | } 179 | 180 | pub fn put(&mut self, id: &[u8], identity: identity::Client) -> Result<(), Error> { 181 | let text_id = ::hex::encode(id); 182 | self.put_by_text(&text_id, identity) 183 | } 184 | 185 | pub fn put_by_text(&mut self, text_id: &str, identity: identity::Client) -> Result<(), Error> { 186 | if text_id.len() != 64 { 187 | return Err(Error::failed(format!("invalid identity ID {}", text_id))) 188 | } 189 | 190 | // truncate to 128 bits 191 | let truncated_text_id = &text_id[..32]; 192 | 193 | let mut symlink = self.inner.borrow().directory.clone(); 194 | symlink.push(&truncated_text_id); 195 | 196 | match ::std::fs::symlink_metadata(&symlink) { 197 | Err(ref e) if e.kind() == ::std::io::ErrorKind::NotFound => { 198 | IdentityMapInner::save_to_disk( 199 | &self.inner, 200 | truncated_text_id, 201 | identity 202 | ); 203 | Ok(()) 204 | } 205 | Ok(_) => { 206 | let inner1 = self.inner.clone(); 207 | let tti: String = truncated_text_id.into(); 208 | let task = IdentityMapInner::read_from_disk(&self.inner, truncated_text_id); 209 | self.inner.borrow_mut().tasks.add(task.map_err(move |e| { 210 | if e.kind == ::capnp::ErrorKind::Failed { 211 | IdentityMapInner::save_to_disk(&inner1, &tti, identity); 212 | } 213 | 214 | e 215 | }).map_ok(|_| ())); 216 | 217 | Ok(()) 218 | } 219 | Err(e) => { 220 | Err(e.into()) 221 | } 222 | } 223 | } 224 | 225 | pub fn get(&mut self, id: &[u8]) -> Promise { 226 | if id.len() != 32 { 227 | return Promise::err(Error::failed(format!("invalid identity ID {:?}", id))) 228 | } 229 | 230 | let text_id = ::hex::encode(&id[..16]); 231 | self.get_by_text(&text_id) 232 | } 233 | 234 | pub fn get_by_text(&mut self, text_id: &str) -> Promise { 235 | if text_id.len() != 64 { 236 | return Promise::err(Error::failed(format!("invalid identity ID {}", text_id))) 237 | } 238 | 239 | // truncate to 128 bits 240 | let truncated_text_id = &text_id[..32]; 241 | 242 | IdentityMapInner::read_from_disk(&self.inner, truncated_text_id) 243 | } 244 | 245 | } 246 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014-2016 Sandstorm Development Group, Inc. 2 | // Licensed under the MIT License: 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in 12 | // all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | // THE SOFTWARE. 21 | 22 | #[macro_use] extern crate capnp_rpc; 23 | 24 | pub mod collections_capnp { 25 | include!(concat!(env!("OUT_DIR"), "/collections_capnp.rs")); 26 | } 27 | 28 | pub mod identity_map; 29 | pub mod web_socket; 30 | pub mod server; 31 | 32 | fn main() { 33 | server::main().expect("top level error"); 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/server.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014-2016 Sandstorm Development Group, Inc. 2 | // Licensed under the MIT License: 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in 12 | // all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | // THE SOFTWARE. 21 | 22 | use multipoll::{Finisher, Poller, PollerHandle}; 23 | use capnp::Error; 24 | use capnp::capability::Promise; 25 | use capnp_rpc::{RpcSystem, twoparty, rpc_twoparty_capnp}; 26 | use base64::{self, Engine}; 27 | 28 | use std::collections::hash_map::HashMap; 29 | use std::collections::hash_set::HashSet; 30 | use std::cell::RefCell; 31 | use std::rc::Rc; 32 | 33 | use futures::{FutureExt, TryFutureExt}; 34 | use crate::collections_capnp::ui_view_metadata; 35 | use crate::web_socket; 36 | use crate::identity_map::IdentityMap; 37 | 38 | use sandstorm::powerbox_capnp::powerbox_descriptor; 39 | use sandstorm::identity_capnp::{user_info}; 40 | use sandstorm::grain_capnp::{session_context, ui_view, ui_session, sandstorm_api}; 41 | use sandstorm::util_capnp::{static_asset}; 42 | use sandstorm::web_session_capnp::{web_session}; 43 | use sandstorm::web_session_capnp::web_session::web_socket_stream; 44 | 45 | pub struct WebSocketStream { 46 | id: u64, 47 | saved_ui_views: SavedUiViewSet, 48 | } 49 | 50 | impl Drop for WebSocketStream { 51 | fn drop(&mut self) { 52 | self.saved_ui_views.inner.borrow_mut().subscribers.remove(&self.id); 53 | } 54 | } 55 | 56 | impl WebSocketStream { 57 | fn new(id: u64, 58 | saved_ui_views: SavedUiViewSet) 59 | -> WebSocketStream 60 | { 61 | WebSocketStream { 62 | id: id, 63 | saved_ui_views: saved_ui_views, 64 | } 65 | } 66 | } 67 | 68 | impl web_socket::MessageHandler for WebSocketStream { 69 | fn handle_message(&mut self, message: web_socket::Message) -> Promise<(), Error> { 70 | // TODO: move PUTs and POSTs into websocket requests? 71 | match message { 72 | web_socket::Message::Text(_t) => { 73 | } 74 | web_socket::Message::Data(_d) => { 75 | } 76 | } 77 | Promise::ok(()) 78 | } 79 | } 80 | 81 | #[derive(Clone)] 82 | struct SavedUiViewData { 83 | title: String, 84 | date_added: u64, 85 | added_by: Option, 86 | } 87 | 88 | // copied from rustc_serialize 89 | fn json_escape_str(v: &str) -> String { 90 | let mut result: String = "\"".into(); 91 | 92 | let mut start = 0; 93 | 94 | for (i, byte) in v.bytes().enumerate() { 95 | let escaped = match byte { 96 | b'\"' => "\\\"", 97 | b'\\' => "\\\\", 98 | b'\x00' => "\\u0000", 99 | b'\x01' => "\\u0001", 100 | b'\x02' => "\\u0002", 101 | b'\x03' => "\\u0003", 102 | b'\x04' => "\\u0004", 103 | b'\x05' => "\\u0005", 104 | b'\x06' => "\\u0006", 105 | b'\x07' => "\\u0007", 106 | b'\x08' => "\\b", 107 | b'\t' => "\\t", 108 | b'\n' => "\\n", 109 | b'\x0b' => "\\u000b", 110 | b'\x0c' => "\\f", 111 | b'\r' => "\\r", 112 | b'\x0e' => "\\u000e", 113 | b'\x0f' => "\\u000f", 114 | b'\x10' => "\\u0010", 115 | b'\x11' => "\\u0011", 116 | b'\x12' => "\\u0012", 117 | b'\x13' => "\\u0013", 118 | b'\x14' => "\\u0014", 119 | b'\x15' => "\\u0015", 120 | b'\x16' => "\\u0016", 121 | b'\x17' => "\\u0017", 122 | b'\x18' => "\\u0018", 123 | b'\x19' => "\\u0019", 124 | b'\x1a' => "\\u001a", 125 | b'\x1b' => "\\u001b", 126 | b'\x1c' => "\\u001c", 127 | b'\x1d' => "\\u001d", 128 | b'\x1e' => "\\u001e", 129 | b'\x1f' => "\\u001f", 130 | b'\x7f' => "\\u007f", 131 | _ => { continue; } 132 | }; 133 | 134 | if start < i { 135 | result.push_str(&v[start..i]); 136 | } 137 | 138 | result.push_str(escaped); 139 | 140 | start = i + 1; 141 | } 142 | 143 | if start != v.len() { 144 | result.push_str(&v[start..]); 145 | } 146 | 147 | result.push_str("\""); 148 | result 149 | } 150 | 151 | #[test] 152 | fn test_json_escape_string() { 153 | assert_eq!(json_escape_str("hello"), "\"hello\""); 154 | assert_eq!(json_escape_str("he\"\"llo"), "\"he\\\"\\\"llo\""); 155 | } 156 | 157 | fn optional_string_to_json(optional_string: &Option) -> String { 158 | match optional_string { 159 | &None => "null".into(), 160 | &Some(ref s) => format!("{}", json_escape_str(s)), 161 | } 162 | } 163 | 164 | impl SavedUiViewData { 165 | fn to_json(&self) -> String { 166 | format!("{{\"title\":{},\"dateAdded\": \"{}\",\"addedBy\":{}}}", 167 | json_escape_str(&self.title), 168 | self.date_added, 169 | optional_string_to_json(&self.added_by)) 170 | } 171 | } 172 | 173 | #[derive(Clone, Debug)] 174 | struct ViewInfoData { 175 | app_title: String, 176 | grain_icon_url: String, 177 | } 178 | 179 | impl ViewInfoData { 180 | fn to_json(&self) -> String { 181 | format!("{{\"appTitle\":{},\"grainIconUrl\":\"{}\"}}", 182 | json_escape_str(&self.app_title), 183 | self.grain_icon_url) 184 | } 185 | } 186 | 187 | #[derive(Clone, Debug)] 188 | struct ProfileData { 189 | display_name: String, 190 | picture_url: String, 191 | } 192 | 193 | impl ProfileData { 194 | fn to_json(&self) -> String { 195 | format!( 196 | "{{\"pictureUrl\":{}, \"displayName\":{}}}", 197 | json_escape_str(&self.picture_url), 198 | json_escape_str(&self.display_name)) 199 | } 200 | } 201 | 202 | #[derive(Clone)] 203 | enum Action { 204 | Insert { token: String, data: SavedUiViewData }, 205 | Remove { token: String }, 206 | ViewInfo { token: String, data: Result }, 207 | CanWrite(bool), 208 | UserId(Option), 209 | Description(String), 210 | User { id: String, data: ProfileData }, 211 | } 212 | 213 | impl Action { 214 | fn to_json(&self) -> String { 215 | match self { 216 | &Action::Insert { ref token, ref data } => { 217 | format!("{{\"insert\":{{\"token\":\"{}\",\"data\":{} }} }}", 218 | token, data.to_json()) 219 | } 220 | &Action::Remove { ref token } => { 221 | format!("{{\"remove\":{{\"token\":\"{}\"}}}}", token) 222 | } 223 | &Action::ViewInfo { ref token, data: Ok(ref data) } => { 224 | format!("{{\"viewInfo\":{{\"token\":\"{}\",\"data\":{} }} }}", 225 | token, data.to_json()) 226 | } 227 | &Action::ViewInfo { ref token, data: Err(ref e) } => { 228 | format!("{{\"viewInfo\":{{\"token\":\"{}\",\"failed\": {} }} }}", 229 | token, 230 | json_escape_str(&format!("{}", e))) 231 | } 232 | 233 | &Action::CanWrite(b) => { 234 | format!("{{\"canWrite\":{}}}", b) 235 | } 236 | &Action::UserId(ref s) => { 237 | format!("{{\"userId\":{}}}", optional_string_to_json(s)) 238 | } 239 | &Action::Description(ref s) => { 240 | format!("{{\"description\":{}}}", json_escape_str(s)) 241 | } 242 | &Action::User { ref id, ref data } => { 243 | format!( 244 | "{{\"user\":{{\"id\":{}, \"data\":{} }}}}", 245 | json_escape_str(id), data.to_json()) 246 | } 247 | } 248 | } 249 | } 250 | 251 | fn url_of_static_asset(asset: static_asset::Client) -> Promise { 252 | Promise::from_future(asset.get_url_request().send().promise.map( 253 | move |r| match r { 254 | Ok(response) => { 255 | let result = response.get()?; 256 | let protocol = match result.get_protocol()? { 257 | static_asset::Protocol::Https => "https".to_string(), 258 | static_asset::Protocol::Http => "http".to_string(), 259 | }; 260 | 261 | Ok(format!("{}://{}", protocol, result.get_host_path()?.to_str()?)) 262 | } 263 | Err(e) => Err(e), 264 | } 265 | )) 266 | } 267 | 268 | struct Reaper; 269 | 270 | impl Finisher for Reaper { 271 | fn task_failed(&mut self, error: Error) { 272 | // TODO better message. 273 | println!("task failed: {}", error); 274 | } 275 | } 276 | 277 | struct SavedUiViewSetInner { 278 | tmp_dir: ::std::path::PathBuf, 279 | sturdyref_dir: ::std::path::PathBuf, 280 | 281 | /// Invariant: Every entry in this map has been persisted to the filesystem and has sent 282 | /// out Action::Insert messages to each subscriber. 283 | views: HashMap, 284 | 285 | view_infos: HashMap>, 286 | next_id: u64, 287 | subscribers: HashMap, 288 | tasks: PollerHandle, 289 | description: String, 290 | sandstorm_api: sandstorm_api::Client<::capnp::any_pointer::Owned>, 291 | identity_map: IdentityMap, 292 | 293 | } 294 | 295 | impl SavedUiViewSetInner { 296 | fn get_saved_data<'a>(&'a self, token: &'a String) -> Option<&'a SavedUiViewData> { 297 | self.views.get(token) 298 | } 299 | } 300 | 301 | #[derive(Clone)] 302 | pub struct SavedUiViewSet { 303 | inner: Rc>, 304 | } 305 | 306 | impl SavedUiViewSet { 307 | pub fn new(tmp_dir: P1, 308 | sturdyref_dir: P2, 309 | sandstorm_api: &sandstorm_api::Client<::capnp::any_pointer::Owned>, 310 | identity_map: IdentityMap, 311 | ) 312 | -> ::capnp::Result 313 | where P1: AsRef<::std::path::Path>, 314 | P2: AsRef<::std::path::Path> 315 | { 316 | let description = match ::std::fs::File::open("/var/description") { 317 | Ok(mut f) => { 318 | use std::io::Read; 319 | let mut result = String::new(); 320 | f.read_to_string(&mut result)?; 321 | result 322 | } 323 | Err(ref e) if e.kind() == ::std::io::ErrorKind::NotFound => { 324 | use std::io::Write; 325 | let mut f = ::std::fs::File::create("/var/description")?; 326 | let result = ""; 327 | f.write_all(result.as_bytes())?; 328 | result.into() 329 | } 330 | Err(e) => { 331 | return Err(e.into()); 332 | } 333 | }; 334 | 335 | let (tx, poller) = Poller::new(Box::new(Reaper)); 336 | tokio::task::spawn_local(poller.map_err(|_|())); 337 | 338 | let result = SavedUiViewSet { 339 | inner: Rc::new(RefCell::new(SavedUiViewSetInner { 340 | tmp_dir: tmp_dir.as_ref().to_path_buf(), 341 | sturdyref_dir: sturdyref_dir.as_ref().to_path_buf(), 342 | views: HashMap::new(), 343 | view_infos: HashMap::new(), 344 | next_id: 0, 345 | subscribers: HashMap::new(), 346 | tasks: tx, 347 | description: description, 348 | sandstorm_api: sandstorm_api.clone(), 349 | identity_map: identity_map, 350 | })), 351 | }; 352 | 353 | // create sturdyref directory if it does not yet exist 354 | ::std::fs::create_dir_all(&sturdyref_dir)?; 355 | 356 | // clear and create tmp directory 357 | match ::std::fs::remove_dir_all(&tmp_dir) { 358 | Ok(()) => (), 359 | Err(ref e) if e.kind() == ::std::io::ErrorKind::NotFound => (), 360 | Err(e) => return Err(e.into()), 361 | } 362 | ::std::fs::create_dir_all(&tmp_dir)?; 363 | 364 | for token_file in ::std::fs::read_dir(&sturdyref_dir)? { 365 | let dir_entry = token_file?; 366 | let token: String = match dir_entry.file_name().to_str() { 367 | None => { 368 | println!("malformed token: {:?}", dir_entry.file_name()); 369 | continue 370 | } 371 | Some(s) => s.into(), 372 | }; 373 | 374 | if token.ends_with(".uploading") { 375 | // At one point, these temporary files got uploading directly into this directory. 376 | ::std::fs::remove_file(dir_entry.path())?; 377 | } else { 378 | let mut reader = ::std::fs::File::open(dir_entry.path())?; 379 | let message = ::capnp::serialize::read_message(&mut reader, 380 | Default::default())?; 381 | let metadata: ui_view_metadata::Reader = message.get_root()?; 382 | 383 | let added_by = if metadata.has_added_by() { 384 | Some(metadata.get_added_by()?.to_string()?) 385 | } else { 386 | None 387 | }; 388 | 389 | let entry = SavedUiViewData { 390 | title: metadata.get_title()?.to_string()?, 391 | date_added: metadata.get_date_added(), 392 | added_by: added_by, 393 | }; 394 | 395 | result.inner.borrow_mut().views.insert(token.clone(), entry); 396 | 397 | result.retrieve_view_info(token)?; 398 | } 399 | } 400 | 401 | Ok(result) 402 | } 403 | 404 | fn retrieve_view_info(&self, 405 | token: String) -> ::capnp::Result<()> { 406 | // SandstormApi.restore, then call getViewInfo, 407 | // then call get_url() on the grain static asset. 408 | 409 | let mut self1 = self.clone(); 410 | let binary_token = match base64::engine::general_purpose::URL_SAFE.decode(&token[..]) { 411 | Ok(b) => b, 412 | Err(e) => return Err(Error::failed(format!("{}", e))), 413 | }; 414 | 415 | let mut req = self.inner.borrow().sandstorm_api.restore_request(); 416 | req.get().set_token(&binary_token); 417 | let task = req.send().promise.and_then(move |response| { 418 | let view: ui_view::Client = 419 | pry!(pry!(response.get()).get_cap().get_as_capability()); 420 | Promise::from_future(view.get_view_info_request().send().promise.and_then(move |response| { 421 | let view_info = pry!(response.get()); 422 | let app_title = pry!(pry!(pry!(view_info.get_app_title()).get_default_text()).to_string()); 423 | Promise::from_future(url_of_static_asset(pry!(view_info.get_grain_icon())).map_ok(move |url| { 424 | ViewInfoData { 425 | app_title: app_title, 426 | grain_icon_url: url, 427 | } 428 | })) 429 | })) 430 | }).map(move |result| { 431 | self1.inner.borrow_mut().view_infos.insert(token.clone(), result.clone()); 432 | self1.send_action_to_subscribers(Action::ViewInfo { 433 | token: token, 434 | data: result, 435 | }); 436 | 437 | Ok(()) 438 | }); 439 | 440 | self.inner.borrow_mut().tasks.add(task); 441 | Ok(()) 442 | } 443 | 444 | fn get_user_profile(&mut self, 445 | identity_id: &str) -> Promise { 446 | Promise::from_future(self.inner.borrow_mut().identity_map.get_by_text(identity_id).and_then(move |identity| { 447 | identity.get_profile_request().send().promise 448 | }).and_then(move |response| { 449 | let profile = pry!(pry!(response.get()).get_profile()); 450 | let display_name = pry!(pry!(pry!(profile.get_display_name()).get_default_text()).to_string()); 451 | Promise::from_future(url_of_static_asset(pry!(profile.get_picture())).map_ok(move |url| { 452 | ProfileData { display_name: display_name, picture_url: url } 453 | })) 454 | })) 455 | } 456 | 457 | fn update_description(&mut self, description: &[u8]) -> ::capnp::Result<()> { 458 | use std::io::Write; 459 | 460 | let desc_string: String = match ::std::str::from_utf8(description) { 461 | Err(e) => return Err(::capnp::Error::failed(format!("{}", e))), 462 | Ok(d) => d.into(), 463 | }; 464 | 465 | let temp_path = format!("/var/description.uploading"); 466 | ::std::fs::File::create(&temp_path)?.write_all(description)?; 467 | ::std::fs::rename(temp_path, "/var/description")?; 468 | 469 | self.inner.borrow_mut().description = desc_string.clone(); 470 | self.send_action_to_subscribers(Action::Description(desc_string)); 471 | Ok(()) 472 | } 473 | 474 | fn insert(&mut self, 475 | token: String, 476 | title: String, 477 | added_by: Option) -> ::capnp::Result<()> { 478 | let dur = ::std::time::SystemTime::now().duration_since(::std::time::UNIX_EPOCH) 479 | .map_err(|e| Error::failed(format!("{}", e)))?; 480 | let date_added = dur.as_secs() * 1000 + (dur.subsec_nanos() / 1000000) as u64; 481 | 482 | let mut token_path = ::std::path::PathBuf::new(); 483 | token_path.push(self.inner.borrow().sturdyref_dir.clone()); 484 | token_path.push(token.clone()); 485 | 486 | let mut temp_path = ::std::path::PathBuf::new(); 487 | temp_path.push(self.inner.borrow().tmp_dir.clone()); 488 | temp_path.push(format!("{}.uploading", token)); 489 | 490 | let mut writer = ::std::fs::File::create(&temp_path)?; 491 | 492 | let mut message = ::capnp::message::Builder::new_default(); 493 | { 494 | let mut metadata: ui_view_metadata::Builder = message.init_root(); 495 | metadata.set_title(&title); 496 | metadata.set_date_added(date_added); 497 | match added_by { 498 | Some(ref s) => metadata.set_added_by(s), 499 | None => (), 500 | } 501 | } 502 | 503 | ::capnp::serialize::write_message(&mut writer, &message)?; 504 | ::std::fs::rename(temp_path, token_path)?; 505 | writer.sync_all()?; 506 | 507 | if !self.inner.borrow().subscribers.is_empty() { 508 | if let Some(ref id) = added_by { 509 | let mut self1 = self.clone(); 510 | let identity_id: String = id.to_string(); 511 | let task = self.get_user_profile(&identity_id).map_ok(move |profile_data| { 512 | self1.send_action_to_subscribers( 513 | Action::User { id: identity_id, data: profile_data }); 514 | }); 515 | self.inner.borrow_mut().tasks.add(task); 516 | } 517 | } 518 | 519 | let entry = SavedUiViewData { 520 | title: title, 521 | date_added: date_added, 522 | added_by: added_by, 523 | }; 524 | 525 | self.send_action_to_subscribers(Action::Insert { 526 | token: token.clone(), 527 | data: entry.clone(), 528 | }); 529 | self.inner.borrow_mut().views.insert(token, entry); 530 | 531 | Ok(()) 532 | } 533 | 534 | fn send_action_to_subscribers(&mut self, action: Action) { 535 | let json_string = action.to_json(); 536 | let &mut SavedUiViewSetInner { ref subscribers, ref mut tasks, ..} = 537 | &mut *self.inner.borrow_mut(); 538 | for (_, sub) in &*subscribers { 539 | let mut req = sub.send_bytes_request(); 540 | web_socket::encode_text_message(req.get(), &json_string); 541 | tasks.add(req.send().promise.map_ok(|_| ())); 542 | } 543 | } 544 | 545 | fn remove(&mut self, token: &str) -> Result<(), Error> { 546 | let mut path = self.inner.borrow().sturdyref_dir.clone(); 547 | path.push(token); 548 | if let Err(e) = ::std::fs::remove_file(path) { 549 | if e.kind() != ::std::io::ErrorKind::NotFound { 550 | return Err(e.into()) 551 | } 552 | } 553 | 554 | self.send_action_to_subscribers(Action::Remove { token: token.into() }); 555 | self.inner.borrow_mut().views.remove(token); 556 | Ok(()) 557 | } 558 | 559 | fn new_subscribed_websocket(&mut self, 560 | client_stream: web_socket_stream::Client, 561 | can_write: bool, 562 | user_id: Option) 563 | -> web_socket_stream::Client 564 | { 565 | fn send_action(task: Promise<(), Error>, 566 | client_stream: &web_socket_stream::Client, 567 | action: Action) -> Promise<(), Error> { 568 | let json_string = action.to_json(); 569 | let mut req = client_stream.send_bytes_request(); 570 | web_socket::encode_text_message(req.get(), &json_string); 571 | let promise = req.send().promise.map_ok(|_| ()); 572 | Promise::from_future(task.and_then(|_| promise)) 573 | } 574 | 575 | let id = self.inner.borrow().next_id; 576 | self.inner.borrow_mut().next_id = id + 1; 577 | 578 | self.inner.borrow_mut().subscribers.insert(id, client_stream.clone()); 579 | 580 | let mut task = Promise::ok(()); 581 | 582 | task = send_action(task, &client_stream, Action::CanWrite(can_write)); 583 | task = send_action(task, &client_stream, Action::UserId(user_id)); 584 | task = send_action(task, &client_stream, 585 | Action::Description(self.inner.borrow().description.clone())); 586 | 587 | let mut added_by_identities: HashSet = HashSet::new(); 588 | 589 | for (t, v) in &self.inner.borrow().views { 590 | if let &Some(ref id) = &v.added_by { 591 | added_by_identities.insert(id.clone()); 592 | } 593 | 594 | task = send_action( 595 | task, &client_stream, 596 | Action::Insert { 597 | token: t.clone(), 598 | data: v.clone() 599 | } 600 | ); 601 | } 602 | 603 | for (t, vi) in &self.inner.borrow().view_infos { 604 | task = send_action( 605 | task, &client_stream, 606 | Action::ViewInfo { 607 | token: t.clone(), 608 | data: vi.clone(), 609 | } 610 | ); 611 | } 612 | 613 | self.inner.borrow_mut().tasks.add(task); 614 | 615 | for ref text_id in &added_by_identities { 616 | let id = text_id.to_string(); 617 | let client_stream1 = client_stream.clone(); 618 | 619 | let task = self.get_user_profile(text_id).and_then(move |profile_data| { 620 | let action = Action::User { id: id, data: profile_data }; 621 | let json_string = action.to_json(); 622 | let mut req = client_stream1.send_bytes_request(); 623 | web_socket::encode_text_message(req.get(), &json_string); 624 | req.send().promise.map_ok(|_| ()) 625 | }); 626 | 627 | self.inner.borrow_mut().tasks.add(task); 628 | } 629 | 630 | capnp_rpc::new_client( 631 | web_socket::Adapter::new( 632 | WebSocketStream::new(id, self.clone()), 633 | client_stream, 634 | self.inner.borrow().tasks.clone())) 635 | } 636 | } 637 | 638 | const ADD_GRAIN_ACTIVITY_INDEX: u16 = 0; 639 | const REMOVE_GRAIN_ACTIVITY_INDEX: u16 = 1; 640 | const EDIT_DESCRIPTION_ACTIVITY_INDEX: u16 = 2; 641 | 642 | pub struct WebSession { 643 | can_write: bool, 644 | sandstorm_api: sandstorm_api::Client<::capnp::any_pointer::Owned>, 645 | context: session_context::Client, 646 | saved_ui_views: SavedUiViewSet, 647 | identity_id: Option, 648 | } 649 | 650 | impl WebSession { 651 | pub fn new(user_info: user_info::Reader, 652 | context: session_context::Client, 653 | _params: web_session::params::Reader, 654 | sandstorm_api: sandstorm_api::Client<::capnp::any_pointer::Owned>, 655 | saved_ui_views: SavedUiViewSet) 656 | -> ::capnp::Result 657 | { 658 | // Permission #0 is "write". Check if bit 0 in the PermissionSet is set. 659 | let permissions = user_info.get_permissions()?; 660 | let can_write = permissions.len() > 0 && permissions.get(0); 661 | let identity_id = if user_info.has_identity_id() { 662 | Some(::hex::encode(user_info.get_identity_id()?)) 663 | } else { 664 | None 665 | }; 666 | 667 | Ok(WebSession { 668 | can_write: can_write, 669 | sandstorm_api: sandstorm_api, 670 | context: context, 671 | saved_ui_views: saved_ui_views, 672 | identity_id: identity_id, 673 | }) 674 | 675 | // `UserInfo` is defined in `sandstorm/grain.capnp` and contains info like: 676 | // - A stable ID for the user, so you can correlate sessions from the same user. 677 | // - The user's display name, e.g. "Mark Miller", useful for identifying the user to other 678 | // users. 679 | // - The user's permissions (seen above). 680 | 681 | // `WebSession::Params` is defined in `sandstorm/web-session.capnp` and contains info like: 682 | // - The hostname where the grain was mapped for this user. Every time a user opens a grain, 683 | // it is mapped at a new random hostname for security reasons. 684 | // - The user's User-Agent and Accept-Languages headers. 685 | 686 | // `SessionContext` is defined in `sandstorm/grain.capnp` and implements callbacks for 687 | // sharing/access control and service publishing/discovery. 688 | } 689 | } 690 | 691 | impl ui_session::Server for WebSession {} 692 | 693 | impl web_session::Server for WebSession { 694 | fn get(&mut self, 695 | params: web_session::GetParams, 696 | mut results: web_session::GetResults) 697 | -> Promise<(), Error> 698 | { 699 | // HTTP GET request. 700 | let path = pry!(pry!(pry!(params.get()).get_path()).to_str()); 701 | pry!(self.require_canonical_path(path)); 702 | 703 | if path == "" { 704 | let text = "\ 705 | \ 706 | \ 707 | 708 |

"; 709 | let mut content = results.get().init_content(); 710 | content.set_mime_type("text/html; charset=UTF-8"); 711 | content.init_body().set_bytes(text.as_bytes()); 712 | Promise::ok(()) 713 | } else if path == "script.js" { 714 | self.read_file("/script.js.gz", results, "text/javascript; charset=UTF-8", Some("gzip")) 715 | } else if path == "style.css" { 716 | self.read_file("/style.css.gz", results, "text/css; charset=UTF-8", Some("gzip")) 717 | } else { 718 | let mut error = results.get().init_client_error(); 719 | error.set_status_code(web_session::response::ClientErrorCode::NotFound); 720 | Promise::ok(()) 721 | } 722 | } 723 | 724 | fn post(&mut self, 725 | params: web_session::PostParams, 726 | mut results: web_session::PostResults) 727 | -> Promise<(), Error> 728 | { 729 | let path = { 730 | let path = pry!(pry!(pry!(params.get()).get_path()).to_str()); 731 | pry!(self.require_canonical_path(path)); 732 | path.to_string() 733 | }; 734 | 735 | if path.starts_with("token/") { 736 | Promise::from_future(self.receive_request_token(path[6..].to_string(), params, results)) 737 | } else if path.starts_with("offer/") { 738 | let token = path[6..].to_string(); 739 | let title = match self.saved_ui_views.inner.borrow().get_saved_data(&token) { 740 | None => { 741 | let mut error = results.get().init_client_error(); 742 | error.set_status_code(web_session::response::ClientErrorCode::NotFound); 743 | return Promise::ok(()) 744 | } 745 | Some(saved_ui_view) => saved_ui_view.title.to_string(), 746 | }; 747 | 748 | self.offer_ui_view(token, title, params, results) 749 | 750 | } else if path.starts_with("refresh/") { 751 | let token = path[8..].to_string(); 752 | match SavedUiViewSet::retrieve_view_info(&self.saved_ui_views, token) { 753 | Ok(()) => { 754 | results.get().init_no_content(); 755 | } 756 | Err(e) => { 757 | fill_in_client_error(results, e); 758 | } 759 | } 760 | Promise::ok(()) 761 | } else { 762 | let mut error = results.get().init_client_error(); 763 | error.set_status_code(web_session::response::ClientErrorCode::NotFound); 764 | Promise::ok(()) 765 | } 766 | } 767 | 768 | fn put(&mut self, 769 | params: web_session::PutParams, 770 | mut results: web_session::PutResults) 771 | -> Promise<(), Error> 772 | { 773 | // HTTP PUT request. 774 | 775 | let params = pry!(params.get()); 776 | let path = pry!(pry!(params.get_path()).to_str()); 777 | pry!(self.require_canonical_path(path)); 778 | 779 | if !self.can_write { 780 | results.get().init_client_error() 781 | .set_status_code(web_session::response::ClientErrorCode::Forbidden); 782 | Promise::ok(()) 783 | } else if path == "description" { 784 | let content = pry!(pry!(params.get_content()).get_content()); 785 | pry!(self.saved_ui_views.update_description(content)); 786 | let mut req = self.context.activity_request(); 787 | req.get().init_event().set_type(EDIT_DESCRIPTION_ACTIVITY_INDEX); 788 | Promise::from_future(req.send().promise.map_ok(move |_| { 789 | results.get().init_no_content(); 790 | })) 791 | } else { 792 | results.get().init_client_error() 793 | .set_status_code(web_session::response::ClientErrorCode::Forbidden); 794 | Promise::ok(()) 795 | } 796 | } 797 | 798 | fn delete(&mut self, 799 | params: web_session::DeleteParams, 800 | mut results: web_session::DeleteResults) 801 | -> Promise<(), Error> 802 | { 803 | // HTTP DELETE request. 804 | 805 | let path = pry!(pry!(pry!(params.get()).get_path()).to_str()); 806 | pry!(self.require_canonical_path(path)); 807 | 808 | if !path.starts_with("sturdyref/") { 809 | return Promise::err(Error::failed("DELETE only supported under sturdyref/".to_string())); 810 | } 811 | 812 | if !self.can_write { 813 | results.get().init_client_error() 814 | .set_status_code(web_session::response::ClientErrorCode::Forbidden); 815 | Promise::ok(()) 816 | } else { 817 | let token_string = path[10..].to_string(); 818 | let binary_token = match base64::engine::general_purpose::URL_SAFE.decode(&token_string[..]) { 819 | Ok(b) => b, 820 | Err(e) => { 821 | results.get().init_client_error().set_description_html(&format!("{}", e)); 822 | return Promise::ok(()) 823 | } 824 | }; 825 | 826 | let mut saved_ui_views = self.saved_ui_views.clone(); 827 | let context = self.context.clone(); 828 | let mut req = self.sandstorm_api.drop_request(); 829 | req.get().set_token(&binary_token); 830 | Promise::from_future(req.send().promise.and_then(move |_| { 831 | pry!(saved_ui_views.remove(&token_string)); 832 | let mut req = context.activity_request(); 833 | req.get().init_event().set_type(REMOVE_GRAIN_ACTIVITY_INDEX); 834 | Promise::from_future(req.send().promise.and_then(move |_| { 835 | results.get().init_no_content(); 836 | Promise::ok(()) 837 | })) 838 | })) 839 | } 840 | } 841 | 842 | fn open_web_socket(&mut self, 843 | params: web_session::OpenWebSocketParams, 844 | mut results: web_session::OpenWebSocketResults) 845 | -> Promise<(), Error> 846 | { 847 | let client_stream = pry!(pry!(params.get()).get_client_stream()); 848 | 849 | results.get().set_server_stream( 850 | self.saved_ui_views.new_subscribed_websocket( 851 | client_stream, 852 | self.can_write, 853 | self.identity_id.clone())); 854 | 855 | Promise::ok(()) 856 | } 857 | } 858 | 859 | fn fill_in_client_error(mut results: web_session::PostResults, e: Error) 860 | { 861 | let mut client_error = results.get().init_client_error(); 862 | client_error.set_description_html(&format!("{}", e)); 863 | } 864 | 865 | impl WebSession { 866 | fn offer_ui_view(&mut self, 867 | text_token: String, 868 | title: String, 869 | _params: web_session::PostParams, 870 | mut results: web_session::PostResults) 871 | -> Promise<(), Error> 872 | { 873 | let token = match base64::engine::general_purpose::URL_SAFE.decode(&text_token[..]) { 874 | Ok(b) => b, 875 | Err(e) => return Promise::err(Error::failed(format!("{}", e))), 876 | }; 877 | 878 | let session_context = self.context.clone(); 879 | let mut set = self.saved_ui_views.clone(); 880 | let mut req = self.sandstorm_api.restore_request(); 881 | req.get().set_token(&token); 882 | Promise::from_future(req.send().promise.then(move |response| match response { 883 | Ok(v) => { 884 | let sealed_ui_view: ui_view::Client = 885 | pry!(pry!(v.get()).get_cap().get_as_capability()); 886 | let mut req = session_context.offer_request(); 887 | req.get().get_cap().set_as_capability(sealed_ui_view.client.hook); 888 | { 889 | use capnp::traits::HasTypeId; 890 | let tags = req.get().init_descriptor().init_tags(1); 891 | let mut tag = tags.get(0); 892 | tag.set_id(ui_view::Client::TYPE_ID); 893 | let mut value: ui_view::powerbox_tag::Builder = tag.get_value().init_as(); 894 | value.set_title(&title); 895 | } 896 | 897 | Promise::from_future(req.send().promise.map_ok(|_| ())) 898 | } 899 | Err(e) => { 900 | set.inner.borrow_mut().view_infos.insert(text_token.clone(), Err(e.clone())); 901 | set.send_action_to_subscribers(Action::ViewInfo { 902 | token: text_token, 903 | data: Err(e), 904 | }); 905 | Promise::ok(()) 906 | } 907 | }).then(move |r| match r { 908 | Ok(_) => { 909 | results.get().init_no_content(); 910 | Promise::ok(()) 911 | } 912 | Err(e) => { 913 | fill_in_client_error(results, e); 914 | Promise::ok(()) 915 | } 916 | })) 917 | } 918 | 919 | fn read_powerbox_tag(&mut self, decoded_content: Vec) -> ::capnp::Result 920 | { 921 | let mut cursor = ::std::io::Cursor::new(decoded_content); 922 | let message = ::capnp::serialize_packed::read_message(&mut cursor, 923 | Default::default())?; 924 | let desc: powerbox_descriptor::Reader = message.get_root()?; 925 | let tags = desc.get_tags()?; 926 | if tags.len() == 0 { 927 | Err(Error::failed("no powerbox tag".into())) 928 | } else { 929 | let value: ui_view::powerbox_tag::Reader = tags.get(0).get_value().get_as()?; 930 | Ok(value.get_title()?.to_string()?) 931 | } 932 | } 933 | 934 | fn receive_request_token(&mut self, 935 | token: String, 936 | params: web_session::PostParams, 937 | mut results: web_session::PostResults) 938 | -> Promise<(), Error> 939 | { 940 | let content = pry!(pry!(pry!(params.get()).get_content()).get_content()); 941 | 942 | let decoded_content = match base64::engine::general_purpose::URL_SAFE.decode(content) { 943 | Ok(c) => c, 944 | Err(_) => { 945 | fill_in_client_error(results, Error::failed("failed to convert from base64".into())); 946 | return Promise::ok(()) 947 | } 948 | }; 949 | let grain_title: String = match self.read_powerbox_tag(decoded_content) { 950 | Ok(t) => t, 951 | Err(e) => { 952 | fill_in_client_error(results, e); 953 | return Promise::ok(()); 954 | } 955 | }; 956 | 957 | // now let's save this thing into an actual uiview sturdyref 958 | let mut req = self.context.claim_request_request(); 959 | let sandstorm_api = self.sandstorm_api.clone(); 960 | req.get().set_request_token(&token); 961 | let mut saved_ui_views = self.saved_ui_views.clone(); 962 | let identity_id = self.identity_id.clone(); 963 | 964 | let do_stuff = req.send().promise.and_then(move |response| { 965 | let sealed_ui_view: ui_view::Client = 966 | pry!(pry!(response.get()).get_cap().get_as_capability()); 967 | let mut req = sandstorm_api.save_request(); 968 | req.get().get_cap().set_as_capability(sealed_ui_view.client.hook); 969 | { 970 | let mut save_label = req.get().init_label(); 971 | save_label.set_default_text(&format!("grain with title: {}", grain_title)); 972 | } 973 | Promise::from_future(req.send().promise.map(move |r| { 974 | let response = r?; 975 | let binary_token = response.get()?.get_token()?; 976 | let token = base64::engine::general_purpose::URL_SAFE.encode(binary_token); 977 | 978 | saved_ui_views.insert(token.clone(), grain_title, identity_id)?; 979 | 980 | SavedUiViewSet::retrieve_view_info(&saved_ui_views, token)?; 981 | Ok(()) 982 | })) 983 | }); 984 | 985 | let context = self.context.clone(); 986 | Promise::from_future(do_stuff.then(move |r| match r { 987 | Ok(()) => { 988 | let mut req = context.activity_request(); 989 | req.get().init_event().set_type(ADD_GRAIN_ACTIVITY_INDEX); 990 | Promise::from_future(req.send().promise.and_then(move |_| { 991 | let mut _content = results.get().init_content(); 992 | Promise::ok(()) 993 | })) 994 | } 995 | Err(e) => { 996 | let mut error = results.get().init_client_error(); 997 | error.set_description_html(&format!("error: {:?}", e)); 998 | Promise::ok(()) 999 | } 1000 | })) 1001 | } 1002 | 1003 | fn require_canonical_path(&self, path: &str) -> Result<(), Error> { 1004 | // Require that the path doesn't contain "." or ".." or consecutive slashes, to prevent path 1005 | // injection attacks. 1006 | // 1007 | // Note that such attacks wouldn't actually accomplish much since everything outside /var 1008 | // is a read-only filesystem anyway, containing the app package contents which are non-secret. 1009 | 1010 | for (idx, component) in path.split_terminator("/").enumerate() { 1011 | if component == "." || component == ".." || (component == "" && idx > 0) { 1012 | return Err(Error::failed(format!("non-canonical path: {:?}", path))); 1013 | } 1014 | } 1015 | Ok(()) 1016 | } 1017 | 1018 | fn read_file(&self, 1019 | filename: &str, 1020 | mut results: web_session::GetResults, 1021 | content_type: &str, 1022 | encoding: Option<&str>) 1023 | -> Promise<(), Error> 1024 | { 1025 | match ::std::fs::File::open(filename) { 1026 | Ok(mut f) => { 1027 | let size = pry!(f.metadata()).len(); 1028 | let mut content = results.get().init_content(); 1029 | content.set_status_code(web_session::response::SuccessCode::Ok); 1030 | content.set_mime_type(content_type); 1031 | encoding.map(|enc| content.set_encoding(enc)); 1032 | 1033 | let mut body = content.init_body().init_bytes(size as u32); 1034 | pry!(::std::io::copy(&mut f, &mut body)); 1035 | Promise::ok(()) 1036 | } 1037 | Err(ref e) if e.kind() == ::std::io::ErrorKind::NotFound => { 1038 | let mut error = results.get().init_client_error(); 1039 | error.set_status_code(web_session::response::ClientErrorCode::NotFound); 1040 | Promise::ok(()) 1041 | } 1042 | Err(e) => { 1043 | Promise::err(e.into()) 1044 | } 1045 | } 1046 | } 1047 | } 1048 | 1049 | pub struct UiView { 1050 | sandstorm_api: sandstorm_api::Client<::capnp::any_pointer::Owned>, 1051 | saved_ui_views: SavedUiViewSet, 1052 | } 1053 | 1054 | impl UiView { 1055 | fn new(client: sandstorm_api::Client<::capnp::any_pointer::Owned>, 1056 | saved_ui_views: SavedUiViewSet) 1057 | -> UiView 1058 | { 1059 | UiView { 1060 | sandstorm_api: client, 1061 | saved_ui_views: saved_ui_views, 1062 | } 1063 | } 1064 | } 1065 | 1066 | impl ui_view::Server for UiView { 1067 | fn get_view_info(&mut self, 1068 | _params: ui_view::GetViewInfoParams, 1069 | mut results: ui_view::GetViewInfoResults) 1070 | -> Promise<(), Error> 1071 | { 1072 | let mut view_info = results.get(); 1073 | 1074 | // Define a "write" permission, and then define roles "editor" and "viewer" where only 1075 | // "editor" has the "write" permission. This will allow people to share read-only. 1076 | { 1077 | let perms = view_info.reborrow().init_permissions(1); 1078 | let mut write = perms.get(0); 1079 | write.set_name("write"); 1080 | write.init_title().set_default_text("write"); 1081 | } 1082 | 1083 | { 1084 | let mut roles = view_info.reborrow().init_roles(2); 1085 | { 1086 | let mut editor = roles.reborrow().get(0); 1087 | editor.reborrow().init_title().set_default_text("editor"); 1088 | editor.reborrow().init_verb_phrase().set_default_text("can edit"); 1089 | editor.init_permissions(1).set(0, true); // has "write" permission 1090 | } 1091 | { 1092 | let mut viewer = roles.get(1); 1093 | viewer.set_default(true); 1094 | viewer.reborrow().init_title().set_default_text("viewer"); 1095 | viewer.reborrow().init_verb_phrase().set_default_text("can view"); 1096 | viewer.init_permissions(1).set(0, false); // does not have "write" permission 1097 | } 1098 | } 1099 | 1100 | { 1101 | let mut event_types = view_info.init_event_types(3); 1102 | { 1103 | let mut added = event_types.reborrow().get(ADD_GRAIN_ACTIVITY_INDEX as u32); 1104 | added.set_name("add"); 1105 | added.reborrow().init_verb_phrase().set_default_text("added grain"); 1106 | } 1107 | { 1108 | let mut removed = event_types.reborrow().get(REMOVE_GRAIN_ACTIVITY_INDEX as u32); 1109 | removed.set_name("remove"); 1110 | removed.reborrow().init_verb_phrase().set_default_text("removed grain"); 1111 | } 1112 | { 1113 | let mut removed = event_types.reborrow().get(EDIT_DESCRIPTION_ACTIVITY_INDEX as u32); 1114 | removed.set_name("description"); 1115 | removed.reborrow().init_verb_phrase().set_default_text("edited description"); 1116 | } 1117 | } 1118 | 1119 | Promise::ok(()) 1120 | } 1121 | 1122 | 1123 | fn new_session(&mut self, 1124 | params: ui_view::NewSessionParams, 1125 | mut results: ui_view::NewSessionResults) 1126 | -> Promise<(), Error> 1127 | { 1128 | use ::capnp::traits::HasTypeId; 1129 | let params = pry!(params.get()); 1130 | 1131 | if params.get_session_type() != web_session::Client::TYPE_ID { 1132 | return Promise::err(Error::failed("unsupported session type".to_string())); 1133 | } 1134 | 1135 | let user_info = pry!(params.get_user_info()); 1136 | 1137 | let session = pry!(WebSession::new( 1138 | user_info.clone(), 1139 | pry!(params.get_context()), 1140 | pry!(params.get_session_params().get_as()), 1141 | self.sandstorm_api.clone(), 1142 | self.saved_ui_views.clone())); 1143 | let client: web_session::Client = capnp_rpc::new_client(session); 1144 | 1145 | // We need to do this silly dance to upcast. 1146 | results.get().set_session(ui_session::Client { client : client.client}); 1147 | 1148 | if user_info.has_identity_id() { 1149 | let identity = pry!(user_info.get_identity()); 1150 | 1151 | // TODO(cleanup) 1152 | pry!(self.saved_ui_views.inner.borrow_mut().identity_map.put(pry!(user_info.get_identity_id()), identity)); 1153 | } 1154 | 1155 | Promise::ok(()) 1156 | } 1157 | } 1158 | 1159 | pub fn main() -> Result<(), Box> { 1160 | use ::std::os::unix::io::{FromRawFd}; 1161 | use futures::io::AsyncReadExt; 1162 | 1163 | let mut rt = tokio::runtime::Runtime::new().unwrap(); 1164 | let local = tokio::task::LocalSet::new(); 1165 | 1166 | local.block_on(&mut rt, async move { 1167 | let stream: ::std::os::unix::net::UnixStream = unsafe { FromRawFd::from_raw_fd(3) }; 1168 | let stream = tokio::net::UnixStream::from_std(stream)?; 1169 | let (read_half, write_half) = 1170 | tokio_util::compat::Tokio02AsyncReadCompatExt::compat(stream).split(); 1171 | 1172 | let network = 1173 | Box::new(twoparty::VatNetwork::new(read_half, write_half, 1174 | rpc_twoparty_capnp::Side::Client, 1175 | Default::default())); 1176 | 1177 | let (tx, rx) = futures::channel::oneshot::channel(); 1178 | let sandstorm_api: sandstorm_api::Client<::capnp::any_pointer::Owned> = 1179 | ::capnp_rpc::new_future_client(rx.map_err(|_e| capnp::Error::failed(format!("oneshot was canceled")))); 1180 | 1181 | let identity_map = IdentityMap::new( 1182 | "/var/identities", 1183 | "/var/trash", 1184 | &sandstorm_api)?; 1185 | let saved_uiviews = SavedUiViewSet::new( 1186 | "/var/tmp", 1187 | "/var/sturdyrefs", 1188 | &sandstorm_api, 1189 | identity_map)?; 1190 | 1191 | let uiview = UiView::new( 1192 | sandstorm_api, 1193 | saved_uiviews); 1194 | 1195 | let client: ui_view::Client = capnp_rpc::new_client(uiview); 1196 | let mut rpc_system = RpcSystem::new(network, Some(client.client)); 1197 | 1198 | let _ = tx.send(rpc_system.bootstrap::>( 1199 | ::capnp_rpc::rpc_twoparty_capnp::Side::Server)); 1200 | 1201 | Ok::<_, Box>(rpc_system.await?) 1202 | })?; 1203 | Ok(()) 1204 | } 1205 | -------------------------------------------------------------------------------- /src/web_socket.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Sandstorm Development Group, Inc. 2 | // Licensed under the MIT License: 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in 12 | // all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | // THE SOFTWARE. 21 | 22 | use capnp::capability::Promise; 23 | use capnp::Error; 24 | use std::cell::Cell; 25 | use std::pin::Pin; 26 | use std::rc::Rc; 27 | use sandstorm::web_session_capnp::web_session::web_socket_stream; 28 | use futures::{future, Future, FutureExt, TryFutureExt}; 29 | use futures::channel::oneshot; 30 | 31 | fn eagerly_evaluate(handle: & mut::multipoll::PollerHandle, task: F) -> Promise 32 | where F: Future> + 'static + Unpin, 33 | T: 'static 34 | { 35 | let (tx, rx) = oneshot::channel::>(); 36 | let (tx2, rx2) = oneshot::channel::<()>(); 37 | let f1 = Box::pin(task.map(move |r| { let _ = tx.send(r);})) 38 | as Pin + Unpin>>; 39 | let f2 = Box::pin(rx2.map(drop)) 40 | as Pin + Unpin>>; 41 | 42 | handle.add(future::select(f1, f2).map(|_| Ok(()))); 43 | Promise::from_future(rx.map_err(|_| Error::failed(format!("oneshot was canceled"))).map(|r| {drop(tx2); r?})) 44 | } 45 | 46 | #[repr(u8)] 47 | pub enum OpCode { 48 | Continue = 0, 49 | Utf8Payload = 1, 50 | BinaryPayload = 2, 51 | Terminate = 8, 52 | Ping = 9, 53 | Pong = 10, 54 | } 55 | 56 | pub fn encode_text_message(params: web_socket_stream::send_bytes_params::Builder, 57 | message: &str) 58 | { 59 | encode_message(params, OpCode::Utf8Payload, message.as_bytes()) 60 | } 61 | 62 | pub fn encode_message(mut params: web_socket_stream::send_bytes_params::Builder, 63 | opcode: OpCode, message: &[u8]) 64 | { 65 | // TODO(perf) avoid this allocation 66 | let mut bytes: Vec = Vec::new(); 67 | bytes.push(0x80 | opcode as u8); 68 | if message.len() < 126 { 69 | bytes.push(message.len() as u8); 70 | } else if message.len() < 1 << 16 { 71 | // 16 bits 72 | bytes.push(0x7e); 73 | bytes.push((message.len() >> 8) as u8); 74 | bytes.push(message.len() as u8); 75 | } else { 76 | // 64 bits 77 | bytes.push(0x7f); 78 | bytes.push((message.len() >> 56) as u8); 79 | bytes.push((message.len() >> 48) as u8); 80 | bytes.push((message.len() >> 40) as u8); 81 | bytes.push((message.len() >> 32) as u8); 82 | bytes.push((message.len() >> 16) as u8); 83 | bytes.push((message.len() >> 24) as u8); 84 | bytes.push((message.len() >> 8) as u8); 85 | bytes.push(message.len() as u8); 86 | } 87 | 88 | bytes.extend_from_slice(message); 89 | 90 | params.set_message(&bytes[..]); 91 | } 92 | 93 | pub enum Message { 94 | Text(String), 95 | Data(Vec), 96 | } 97 | 98 | pub trait MessageHandler { 99 | fn handle_message(&mut self, message: Message) -> Promise<(), Error>; 100 | } 101 | 102 | async fn do_ping_pong(client_stream: web_socket_stream::Client, 103 | awaiting_pong: Rc>) -> Result<(), Error> 104 | { 105 | loop { 106 | let mut req = client_stream.send_bytes_request(); 107 | req.get().set_message(&[0x89, 0]); // PING 108 | let promise = req.send().promise; 109 | awaiting_pong.set(true); 110 | let _ = promise.await?; 111 | let () = tokio::time::delay_for(::std::time::Duration::new(10, 0)).await; 112 | if awaiting_pong.get() { 113 | return Err(Error::failed("pong not received within 10 seconds".into())) 114 | } 115 | } 116 | } 117 | 118 | enum PreviousFrames { 119 | None, 120 | Text(String), 121 | Data(Vec) 122 | } 123 | 124 | #[derive(Debug)] 125 | enum ParserState { 126 | NotStarted, 127 | DoneFirstByte { fin: bool, opcode: u8}, 128 | ReadingLongPayloadLength {fin: bool, opcode: u8, masked: bool, 129 | payload_len_bytes_read: usize, payload_len_so_far: u64 }, 130 | ReadingMask { fin: bool, opcode: u8, mask_bytes_read: usize, payload_len: u64, 131 | mask_so_far: [u8; 4] }, 132 | ReadingPayload { fin: bool, opcode: u8, payload_len: u64, mask: [u8; 4], bytes_so_far: Vec }, 133 | } 134 | 135 | struct ParseResult { 136 | frame: Vec, 137 | opcode: u8, 138 | fin: bool, 139 | } 140 | 141 | impl ParserState { 142 | fn done_payload_length(bytes_read: usize, 143 | fin: bool, opcode: u8, masked: bool, payload_len: u64) 144 | -> (ParserState, (usize, Option)) 145 | { 146 | use self::ParserState::*; 147 | if masked { 148 | (ReadingMask { fin: fin, opcode: opcode, payload_len: payload_len, 149 | mask_bytes_read: 0, mask_so_far: [0; 4] }, 150 | (bytes_read, None)) 151 | } else if payload_len == 0 { 152 | (NotStarted, 153 | (bytes_read, Some(ParseResult { frame: Vec::new(), fin: fin, opcode: opcode }))) 154 | } else { 155 | (ReadingPayload { fin: fin, opcode: opcode, 156 | payload_len: payload_len, mask: [0; 4], 157 | bytes_so_far: Vec::new() }, 158 | (bytes_read, None)) 159 | } 160 | } 161 | 162 | 163 | /// returns number of bytes consumed and the complete message, if there is one. 164 | fn advance(&mut self, buf: &[u8]) -> (usize, Option) { 165 | use self::ParserState::*; 166 | let (new_state, result) = match self { 167 | &mut NotStarted => { 168 | if buf.len() < 1 { 169 | return (0, None) 170 | } 171 | 172 | (DoneFirstByte { fin: (buf[0] & 0x80) != 0, opcode: buf[0] & 0xf }, (1, None)) 173 | } 174 | &mut DoneFirstByte { fin, opcode } => { 175 | if buf.len() < 1 { 176 | return (0, None) 177 | } 178 | 179 | let masked = (buf[0] & 0x80) != 0; 180 | 181 | match buf[0] & 0x7f { 182 | 126 => { 183 | (ReadingLongPayloadLength { 184 | fin: fin, 185 | opcode: opcode, 186 | masked: masked, 187 | payload_len_bytes_read: 6, 188 | payload_len_so_far: 0, 189 | }, (1, None)) 190 | } 191 | 127 => { 192 | (ReadingLongPayloadLength { 193 | fin: fin, 194 | opcode: opcode, 195 | masked: masked, 196 | payload_len_bytes_read: 0, 197 | payload_len_so_far: 0, 198 | }, (1, None)) 199 | } 200 | n => ParserState::done_payload_length(1, fin, opcode, masked, n as u64) 201 | } 202 | } 203 | &mut ReadingLongPayloadLength { fin, opcode, masked, payload_len_bytes_read, 204 | payload_len_so_far } => { 205 | let mut idx = 0; 206 | let mut new_so_far = payload_len_so_far; 207 | while idx + payload_len_bytes_read < 8 && idx < buf.len() { 208 | new_so_far += (buf[idx] as u64) << (8 * (7 - idx - payload_len_bytes_read)); 209 | idx += 1; 210 | } 211 | 212 | if buf.len() + payload_len_bytes_read < 8 { 213 | (ReadingLongPayloadLength { 214 | fin: fin, 215 | opcode: opcode, 216 | masked: masked, 217 | payload_len_bytes_read: idx + payload_len_bytes_read, 218 | payload_len_so_far: new_so_far, 219 | }, (idx, None)) 220 | } else { 221 | ParserState::done_payload_length(idx, fin, opcode, masked, new_so_far) 222 | } 223 | } 224 | &mut ReadingMask { fin, opcode, mask_bytes_read, payload_len, mask_so_far } => { 225 | let mut idx = 0; 226 | let mut new_so_far = mask_so_far; 227 | while idx + mask_bytes_read < 4 && idx < buf.len() { 228 | new_so_far[idx] = buf[idx]; 229 | idx += 1; 230 | } 231 | 232 | if buf.len() + mask_bytes_read < 4 { 233 | (ReadingMask { 234 | fin: fin, 235 | opcode: opcode, 236 | payload_len: payload_len, 237 | mask_bytes_read: idx + mask_bytes_read, 238 | mask_so_far: new_so_far, 239 | }, (idx, None)) 240 | } else if payload_len == 0 { 241 | (NotStarted, 242 | (idx, Some(ParseResult { frame: Vec::new(), fin: fin, opcode: opcode }))) 243 | } else { 244 | (ReadingPayload { fin: fin, opcode: opcode, mask: new_so_far, 245 | bytes_so_far: Vec::new(), 246 | payload_len: payload_len }, 247 | (idx, None)) 248 | } 249 | } 250 | &mut ReadingPayload { fin, opcode, payload_len, mask, ref mut bytes_so_far } => { 251 | let mut idx = 0; 252 | 253 | while (bytes_so_far.len() as u64) < payload_len && idx < buf.len() { 254 | let mask_byte = mask[bytes_so_far.len() % 4]; 255 | bytes_so_far.push(buf[idx] ^ mask_byte); 256 | idx += 1; 257 | } 258 | 259 | if (bytes_so_far.len() as u64) < payload_len { 260 | return (idx, None) 261 | } else { 262 | let frame = ::std::mem::replace(bytes_so_far, Vec::new()); 263 | (NotStarted, 264 | (idx, Some(ParseResult { frame: frame, fin: fin, opcode: opcode }))) 265 | } 266 | 267 | } 268 | }; 269 | 270 | *self = new_state; 271 | result 272 | } 273 | } 274 | 275 | pub struct Adapter where T: MessageHandler { 276 | handler: Option, 277 | awaiting_pong: Rc>, 278 | ping_pong_promise: Promise<(), Error>, 279 | client_stream: Option, 280 | parser_state: ParserState, 281 | previous_frames: PreviousFrames, 282 | } 283 | 284 | impl Adapter where T: MessageHandler { 285 | pub fn new(handler: T, 286 | client_stream: web_socket_stream::Client, 287 | mut task_handle: ::multipoll::PollerHandle) 288 | -> Adapter { 289 | let awaiting = Rc::new(Cell::new(false)); 290 | let ping_pong_promise = Promise::from_future(eagerly_evaluate(&mut task_handle, Box::pin(do_ping_pong( 291 | client_stream.clone(), 292 | awaiting.clone() 293 | ).map(|r| match r { 294 | Ok(_) => Ok(()), 295 | Err(e) => { 296 | println!("error while pinging client: {}", e); 297 | Ok(()) 298 | } 299 | }))).map_ok(|_| ()).map_err(|e| e.into())); 300 | 301 | Adapter { 302 | handler: Some(handler), 303 | awaiting_pong: awaiting, 304 | ping_pong_promise: ping_pong_promise, 305 | client_stream: Some(client_stream), 306 | parser_state: ParserState::NotStarted, 307 | previous_frames: PreviousFrames::None, 308 | } 309 | } 310 | 311 | fn process_message(&mut self) -> Promise<(), Error> { 312 | let frames = ::std::mem::replace(&mut self.previous_frames, 313 | PreviousFrames::None); 314 | let message = match frames { 315 | PreviousFrames::None => { 316 | return Promise::err(Error::failed(format!("message has no frames"))); 317 | } 318 | PreviousFrames::Data(d) => { 319 | Message::Data(d) 320 | } 321 | PreviousFrames::Text(t) => { 322 | Message::Text(t) 323 | } 324 | }; 325 | 326 | match self.handler { 327 | Some(ref mut h) => h.handle_message(message), 328 | None => Promise::ok(()), 329 | } 330 | } 331 | } 332 | 333 | impl web_socket_stream::Server for Adapter where T: MessageHandler { 334 | fn send_bytes(&mut self, 335 | params: web_socket_stream::SendBytesParams, 336 | _results: web_socket_stream::SendBytesResults) 337 | -> Promise<(), Error> 338 | { 339 | let message = pry!(pry!(params.get()).get_message()); 340 | let mut result_promise = Promise::ok(()); 341 | let mut num_bytes_read = 0; 342 | while num_bytes_read < message.len() { 343 | let (n, result) = self.parser_state.advance(&message[num_bytes_read..]); 344 | num_bytes_read += n; 345 | match result { 346 | None => (), 347 | Some(ParseResult { frame, opcode, fin }) => { 348 | match opcode { 349 | 0x0 => { // CONTINUE 350 | match &mut self.previous_frames { 351 | &mut PreviousFrames::None => { 352 | return Promise::err(Error::failed( 353 | format!("CONTINUE frame received, but there are no \ 354 | previous frames."))); 355 | } 356 | &mut PreviousFrames::Data(ref mut data) => { 357 | data.extend_from_slice(&frame[..]); 358 | if data.len() > (1 << 20) { // 1 MB 359 | return Promise::err(Error::failed( 360 | format!("Websocket message is too big. Please split \ 361 | the message into chunks smaller than 1MB."))); 362 | } 363 | } 364 | &mut PreviousFrames::Text(ref mut text) => { 365 | text.push_str(&pry!(String::from_utf8(frame))); 366 | if text.len() > (1 << 20) { // 1 MB 367 | return Promise::err(Error::failed( 368 | format!("Websocket message is too big. Please split \ 369 | the message into chunks smaller than 1MB."))); 370 | } 371 | 372 | } 373 | } 374 | 375 | if fin { 376 | let promise = self.process_message(); 377 | result_promise = 378 | Promise::from_future(result_promise.and_then(|_| promise)); 379 | } 380 | } 381 | 0x1 => { // UTF-8 PAYLOAD 382 | self.previous_frames = 383 | PreviousFrames::Text(pry!(String::from_utf8(frame))); 384 | 385 | if fin { 386 | let promise = self.process_message(); 387 | result_promise = 388 | Promise::from_future(result_promise.and_then(|_| promise)); 389 | } 390 | } 391 | 0x2 => { // BINARY PAYLOAD 392 | self.previous_frames = PreviousFrames::Data(frame); 393 | 394 | if fin { 395 | let promise = self.process_message(); 396 | result_promise = 397 | Promise::from_future(result_promise.and_then(|_| promise)); 398 | } 399 | } 400 | 0x8 => { // TERMINATE 401 | self.handler = None; 402 | self.ping_pong_promise = Promise::ok(()); 403 | self.client_stream = None; 404 | } 405 | 0x9 => { // PING 406 | match &self.client_stream { 407 | &None => (), 408 | &Some(ref client) => { 409 | println!("responding to ping from client"); 410 | let req = client.send_bytes_request(); 411 | let promise = req.send().promise.map_ok(|_| ()); 412 | result_promise = 413 | Promise::from_future(result_promise.and_then(|_| promise)); 414 | } 415 | } 416 | } 417 | 0xa => { // PONG 418 | self.awaiting_pong.set(false); 419 | } 420 | _ => { // OTHER 421 | println!("unrecognized websocket opcode {}", opcode); 422 | } 423 | } 424 | } 425 | } 426 | } 427 | 428 | result_promise 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /style.scss: -------------------------------------------------------------------------------- 1 | $mobile: "(max-width: 900px)"; 2 | $desktop: "(min-width: 901px)"; 3 | 4 | // The purple that we use for 5 | $sandstorm-purple-color: #762F87; 6 | $darker-purple-color: #65468e; 7 | 8 | // The color that we use to outline focused elements, for accessibility 9 | $focus-outline-color: $sandstorm-purple-color; 10 | $half-focus-outline-color: rgba(118, 47, 135, 0.5); 11 | 12 | //////// Default colors, which may be reused in other modules 13 | $default-content-background-color: #efefef; 14 | $default-content-foreground-color: #191919; 15 | 16 | // Some table-related colors. 17 | $default-table-border-color: #ffffff; // Outlines the entire . 18 | 19 | $default-table-header-background-color: #d3d3d3; 20 | $default-table-header-foreground-color: #191919; 21 | $default-table-header-background-color-hover: #dddddd; 22 | $default-table-header-border-color: #ffffff; 23 | // ^ Used for the lines between multiple and 24 | 25 | $default-table-row-background-color: #ffffff; 26 | $default-table-row-foreground-color: #000000; 27 | $default-table-row-background-color-hover: #e8e8e8; 28 | $default-table-row-action-background-color: lighten($sandstorm-purple-color, 53%); 29 | $default-table-row-action-background-color-hover: lighten($sandstorm-purple-color, 40%); 30 | 31 | // Default text input field colors 32 | $default-textinput-background-color: #ffffff; 33 | $default-textinput-outline-color: #9e9e9e; 34 | $default-textinput-outline-color-focus: $darker-purple-color; 35 | 36 | 37 | $grainlist-background-color: $default-content-background-color; 38 | $grainlist-foreground-color: $default-content-foreground-color; 39 | 40 | $grainlist-searchbar-background-color: $default-textinput-background-color; 41 | $grainlist-searchbar-outline-color: $default-textinput-outline-color; 42 | $grainlist-searchbar-outline-color-focus: $default-textinput-outline-color-focus; 43 | 44 | $grainlist-table-header-background-color: $default-table-header-background-color; 45 | $grainlist-table-header-foreground-color: $default-table-header-foreground-color; 46 | $grainlist-table-header-background-color-hover: $default-table-header-background-color-hover; 47 | // icons fills should be #9e9e9e 48 | 49 | $grainlist-table-row-background-color: $default-table-row-background-color; 50 | $grainlist-table-row-foreground-color: #5d5d5d; 51 | $grainlist-table-row-background-color-hover: $default-table-row-background-color-hover; 52 | $grainlist-table-row-action-background-color: $default-table-row-action-background-color; 53 | $grainlist-table-row-action-background-color-hover: $default-table-row-action-background-color-hover; 54 | $grainlist-table-row-outline-color: #d3d3d3; 55 | 56 | 57 | %unstyled-button { 58 | // Styles for
es and between the