A music player that connects to your cloud/distributed storage.
at main 17 kB view raw
1module UI.Syncing.State exposing (..) 2 3import Alien 4import Base64 5import Binary 6import Browser.Navigation as Nav 7import Common 8import Coordinates 9import Dict 10import Html 11import Html.Attributes 12import Html.Events.Extra.Mouse as Mouse 13import Http 14import Http.Ext as Http 15import Json.Decode as Json 16import Json.Encode 17import Lens.Ext as Lens 18import Management 19import Maybe.Extra as Maybe 20import Monocle.Lens exposing (Lens) 21import Notifications 22import Return exposing (andThen, return) 23import SHA 24import String.Ext as String 25import Svg 26import Time 27import UI.Backdrop as Backdrop 28import UI.Common.State as Common exposing (showNotification, showNotificationWithModel) 29import UI.Ports as Ports 30import UI.Sources.State as Sources 31import UI.Svg.Elements 32import UI.Syncing.ContextMenu as Syncing 33import UI.Syncing.Types as Syncing exposing (..) 34import UI.Types as UI exposing (..) 35import Url exposing (Protocol(..), Url) 36import Url.Ext as Url 37import UrlBase64 38import User.Layer exposing (..) 39import User.Layer.Methods.Dropbox as Dropbox 40import User.Layer.Methods.RemoteStorage as RemoteStorage 41 42 43 44-- ⛩ 45 46 47minimumPassphraseLength : Int 48minimumPassphraseLength = 49 16 50 51 52passphraseLengthErrorMessage : String 53passphraseLengthErrorMessage = 54 "Your passphrase should be atleast *16 characters* long." 55 56 57 58-- 🌳 59 60 61initialModel : Url -> Syncing.State 62initialModel url = 63 case Url.action url of 64 [ "authenticate", "remotestorage", encodedUserAddress ] -> 65 let 66 dict = 67 Url.queryDictionary { url | query = url.fragment } 68 69 userAddress = 70 encodedUserAddress 71 |> Url.percentDecode 72 |> Maybe.andThen (UrlBase64.decode Base64.decode >> Result.toMaybe) 73 |> Maybe.withDefault encodedUserAddress 74 in 75 case Dict.get "access_token" dict of 76 Just t -> 77 NewEncryptionKeyScreen 78 (RemoteStorage 79 { userAddress = userAddress 80 , token = t 81 } 82 ) 83 Nothing 84 85 Nothing -> 86 NotSynced 87 88 _ -> 89 NotSynced 90 91 92initialCommand : Url -> Cmd Syncing.Msg 93initialCommand url = 94 case Url.action url of 95 [ "authenticate", "dropbox" ] -> 96 case Dict.get "code" (Url.queryDictionary url) of 97 Just code -> 98 Dropbox.exchangeAuthCode 99 ExchangeDropboxAuthCode 100 url 101 code 102 103 _ -> 104 Cmd.none 105 106 _ -> 107 Cmd.none 108 109 110lens : Lens UI.Model Syncing.State 111lens = 112 { get = .syncing 113 , set = \a m -> { m | syncing = a } 114 } 115 116 117 118-- 📣 119 120 121update : Syncing.Msg -> Manager 122update msg = 123 case msg of 124 Syncing.Bypass -> 125 Return.singleton 126 127 ActivateSync a -> 128 activateSync a 129 130 ActivateSyncWithPassphrase a b -> 131 activateSyncWithPassphrase a b 132 133 BootFailure a -> 134 bootFailure a 135 136 ExchangeDropboxAuthCode a -> 137 exchangeDropboxAuthCode a 138 139 GotSyncMethod a -> 140 gotSyncMethod a 141 142 RemoteStorageWebfinger a b -> 143 remoteStorageWebfinger a b 144 145 ShowSyncDataMenu a -> 146 showSyncDataMenu a 147 148 StartedSyncing a -> 149 startedSyncing a 150 151 StopSync -> 152 stopSync 153 154 TriggerExternalAuth a b -> 155 externalAuth a b 156 157 ----------------------------------------- 158 -- Encryption 159 ----------------------------------------- 160 KeepPassphraseInMemory a -> 161 keepPassphraseInMemory a 162 163 NeedEncryptionKey a -> 164 needEncryptionKey a 165 166 RemoveEncryptionKey a -> 167 removeEncryptionKey a 168 169 ShowNewEncryptionKeyScreen a -> 170 showNewEncryptionKeyScreen a 171 172 ShowUpdateEncryptionKeyScreen a -> 173 showUpdateEncryptionKeyScreen a 174 175 UpdateEncryptionKey a b -> 176 updateEncryptionKey a b 177 178 ----------------------------------------- 179 -- IPFS 180 ----------------------------------------- 181 PingIpfs -> 182 pingIpfs 183 184 PingIpfsCallback a -> 185 pingIpfsCallback a 186 187 PingOtherIpfs a -> 188 pingOtherIpfs a 189 190 PingOtherIpfsCallback a b -> 191 pingOtherIpfsCallback a b 192 193 ----------------------------------------- 194 -- More Input 195 ----------------------------------------- 196 AskForInput a b -> 197 askForInput a b 198 199 CancelInput -> 200 cancelInput 201 202 ConfirmInput -> 203 confirmInput 204 205 Input a -> 206 input a 207 208 209organize : Organizer Syncing.State -> Manager 210organize = 211 Management.organize lens 212 213 214replaceState : Syncing.State -> Manager 215replaceState state = 216 lens.set state >> Return.singleton 217 218 219 220-- 🔱 221 222 223activateSync : Method -> Manager 224activateSync method model = 225 [ ( "method", encodeMethod method ) 226 , ( "passphrase", Json.Encode.null ) 227 ] 228 |> Json.Encode.object 229 |> Alien.broadcast Alien.SetSyncMethod 230 |> Ports.toBrain 231 -- 232 |> return model 233 234 235activateSyncWithPassphrase : Method -> String -> Manager 236activateSyncWithPassphrase method passphrase model = 237 if String.length passphrase < minimumPassphraseLength then 238 passphraseLengthErrorMessage 239 |> Notifications.error 240 |> Common.showNotificationWithModel model 241 242 else 243 [ ( "method", encodeMethod method ) 244 , ( "passphrase", Json.Encode.string <| hashPassphrase passphrase ) 245 ] 246 |> Json.Encode.object 247 |> Alien.broadcast Alien.SetSyncMethod 248 |> Ports.toBrain 249 -- 250 |> return model 251 252 253bootFailure : String -> Manager 254bootFailure err model = 255 model 256 |> showNotification (Notifications.error err) 257 |> andThen Backdrop.setDefault 258 259 260externalAuth : Method -> String -> Manager 261externalAuth method string model = 262 case method of 263 Dropbox _ -> 264 [ ( "client_id", Dropbox.clientId ) 265 , ( "redirect_uri", Dropbox.redirectUri model.url ) 266 , ( "response_type", "code" ) 267 , ( "token_access_type", "offline" ) 268 ] 269 |> Common.queryString 270 |> String.append "https://www.dropbox.com/oauth2/authorize" 271 |> Nav.load 272 |> return model 273 274 RemoteStorage _ -> 275 string 276 |> RemoteStorage.parseUserAddress 277 |> Maybe.map (RemoteStorage.webfingerRequest RemoteStorageWebfinger model.url.protocol) 278 |> Maybe.map (Cmd.map SyncingMsg) 279 |> Maybe.unwrap 280 (RemoteStorage.userAddressError 281 |> Notifications.error 282 |> Common.showNotificationWithModel model 283 ) 284 (return model) 285 286 _ -> 287 Return.singleton model 288 289 290exchangeDropboxAuthCode : Result Http.Error Dropbox.Tokens -> Manager 291exchangeDropboxAuthCode result model = 292 case result of 293 Ok tokens -> 294 case tokens.refreshToken of 295 Just refreshToken -> 296 Nothing 297 |> NewEncryptionKeyScreen 298 (Dropbox 299 { accessToken = tokens.accessToken 300 , expiresAt = Time.posixToMillis model.currentTime // 1000 + tokens.expiresIn 301 , refreshToken = refreshToken 302 } 303 ) 304 |> Lens.replace lens model 305 |> Return.singleton 306 307 Nothing -> 308 "Missing refresh token in Dropbox code exchange flow." 309 |> Notifications.stickyError 310 |> showNotificationWithModel 311 (Lens.replace lens model NotSynced) 312 313 Err err -> 314 [] 315 |> Notifications.errorWithCode 316 "Failed to authenticate with Dropbox" 317 (Http.errorToString err) 318 |> showNotificationWithModel 319 (Lens.replace lens model NotSynced) 320 321 322gotSyncMethod : Json.Value -> Manager 323gotSyncMethod json model = 324 let 325 afterwards a = 326 andThen 327 (\m -> 328 if m.processAutomatically then 329 Sources.process m 330 331 else 332 Return.singleton m 333 ) 334 (case model.syncing of 335 Syncing { notificationId } -> 336 Common.dismissNotification { id = notificationId } a 337 338 _ -> 339 Return.singleton a 340 ) 341 in 342 -- 🧠 told me which auth method we're using, 343 -- so we can tell the user in the UI. 344 case decodeMethod json of 345 Just method -> 346 model 347 |> replaceState (Synced method) 348 |> andThen afterwards 349 350 Nothing -> 351 afterwards model 352 353 354remoteStorageWebfinger : RemoteStorage.Attributes -> Result Http.Error String -> Manager 355remoteStorageWebfinger remoteStorage result model = 356 case result of 357 Ok oauthOrigin -> 358 let 359 origin = 360 Common.urlOrigin model.url 361 in 362 remoteStorage 363 |> RemoteStorage.oauthAddress 364 { oauthOrigin = oauthOrigin 365 , origin = origin 366 } 367 |> Nav.load 368 |> return model 369 370 Err _ -> 371 RemoteStorage.webfingerError 372 |> Notifications.error 373 |> showNotificationWithModel model 374 375 376showSyncDataMenu : Mouse.Event -> Manager 377showSyncDataMenu mouseEvent model = 378 mouseEvent.clientPos 379 |> Coordinates.fromTuple 380 |> Syncing.syncDataMenu 381 |> Common.showContextMenuWithModel model 382 383 384startedSyncing : Json.Value -> Manager 385startedSyncing json = 386 -- 🧠 started syncing 387 case decodeMethod json of 388 Just method -> 389 Common.showSyncingNotification method 390 391 Nothing -> 392 Return.singleton 393 394 395stopSync : Manager 396stopSync model = 397 Alien.UnsetSyncMethod 398 |> Alien.trigger 399 |> Ports.toBrain 400 |> return model 401 |> andThen (replaceState NotSynced) 402 403 404 405-- ENCRYPTION 406 407 408keepPassphraseInMemory : String -> Manager 409keepPassphraseInMemory passphrase model = 410 (\state -> 411 case state of 412 NewEncryptionKeyScreen method _ -> 413 NewEncryptionKeyScreen method (Just passphrase) 414 415 UpdateEncryptionKeyScreen method _ -> 416 UpdateEncryptionKeyScreen method (Just passphrase) 417 418 s -> 419 s 420 ) 421 |> Lens.adjust lens model 422 |> Return.singleton 423 424 425needEncryptionKey : { error : String } -> Manager 426needEncryptionKey { error } model = 427 (case lens.get model of 428 Syncing { notificationId } -> 429 Common.dismissNotification { id = notificationId } model 430 431 m -> 432 replaceState m model 433 ) 434 |> andThen 435 (error 436 |> Notifications.stickyError 437 |> Common.showNotification 438 ) 439 |> andThen 440 stopSync 441 442 443removeEncryptionKey : Method -> Manager 444removeEncryptionKey method model = 445 Alien.RemoveEncryptionKey 446 |> Alien.trigger 447 |> Ports.toBrain 448 -- 449 |> return 450 (lens.set (Synced method) model) 451 |> andThen 452 ("Saving data without encryption ..." 453 |> Notifications.success 454 |> Common.showNotification 455 ) 456 |> andThen 457 Common.forceTracksRerender 458 459 460showNewEncryptionKeyScreen : Method -> Manager 461showNewEncryptionKeyScreen method = 462 replaceState (NewEncryptionKeyScreen method Nothing) 463 464 465showUpdateEncryptionKeyScreen : Method -> Manager 466showUpdateEncryptionKeyScreen method = 467 replaceState (UpdateEncryptionKeyScreen method Nothing) 468 469 470updateEncryptionKey : Method -> String -> Manager 471updateEncryptionKey method passphrase model = 472 if String.length passphrase < minimumPassphraseLength then 473 passphraseLengthErrorMessage 474 |> Notifications.error 475 |> Common.showNotificationWithModel model 476 477 else 478 passphrase 479 |> hashPassphrase 480 |> Json.Encode.string 481 |> Alien.broadcast Alien.UpdateEncryptionKey 482 |> Ports.toBrain 483 -- 484 |> return 485 (lens.set (Synced method) model) 486 |> andThen 487 ("Encrypting data with new passphrase ..." 488 |> Notifications.success 489 |> Common.showNotification 490 ) 491 |> andThen 492 Common.forceTracksRerender 493 494 495 496-- IPFS 497 498 499pingIpfs : Manager 500pingIpfs model = 501 case model.url.protocol of 502 Https -> 503 """ 504 Unfortunately the local IPFS API doesn't work with HTTPS. 505 Install the [IPFS Companion](https://github.com/ipfs-shipyard/ipfs-companion#release-channel) browser extension to get around this issue 506 (and make sure it redirects to the local gateway). 507 """ 508 |> Notifications.error 509 |> Common.showNotificationWithModel model 510 511 Http -> 512 { url = "//localhost:5001/api/v0/id" 513 , expect = Http.expectWhatever (SyncingMsg << PingIpfsCallback) 514 , body = Http.emptyBody 515 } 516 |> Http.post 517 |> return model 518 519 520pingIpfsCallback : Result Http.Error () -> Manager 521pingIpfsCallback result = 522 case result of 523 Ok _ -> 524 { apiOrigin = "//localhost:5001" } 525 |> Ipfs 526 |> showNewEncryptionKeyScreen 527 528 Err _ -> 529 askForInput 530 (Ipfs { apiOrigin = "" }) 531 { icon = \size _ -> Svg.map never (UI.Svg.Elements.ipfsLogo size) 532 , placeholder = "//localhost:5001" 533 , question = 534 { question = 535 "Where's your IPFS API located?" 536 , info = 537 [ Html.text "You can find this address on the IPFS Web UI." 538 , Html.br [] [] 539 , Html.text "Most likely you'll also need to setup CORS." 540 , Html.br [] [] 541 , Html.text "You can find the instructions for that " 542 , Html.a 543 [ Html.Attributes.class "border-b border-current-color font-semibold inline-block leading-tight" 544 , Html.Attributes.href "about/cors/#CORS__IPFS" 545 , Html.Attributes.target "_blank" 546 ] 547 [ Html.text "here" ] 548 ] 549 } 550 , value = "//localhost:5001" 551 } 552 553 554pingOtherIpfs : String -> Manager 555pingOtherIpfs origin model = 556 { url = origin ++ "/api/v0/id" 557 , expect = Http.expectWhatever (SyncingMsg << PingOtherIpfsCallback origin) 558 , body = Http.emptyBody 559 } 560 |> Http.post 561 |> return model 562 563 564pingOtherIpfsCallback : String -> Result Http.Error () -> Manager 565pingOtherIpfsCallback origin result = 566 case result of 567 Ok _ -> 568 { apiOrigin = origin } 569 |> Ipfs 570 |> showNewEncryptionKeyScreen 571 572 Err _ -> 573 "Can't reach this IPFS API, maybe it's offline? Or I don't have access?" 574 |> Notifications.error 575 |> Common.showNotification 576 577 578 579-- MORE INPUT 580 581 582askForInput : Method -> Question -> Manager 583askForInput method question = 584 question 585 |> InputScreen method 586 |> replaceState 587 588 589cancelInput : Manager 590cancelInput model = 591 case lens.get model of 592 InputScreen _ _ -> 593 replaceState NotSynced model 594 595 NewEncryptionKeyScreen _ _ -> 596 replaceState NotSynced model 597 598 UpdateEncryptionKeyScreen method _ -> 599 replaceState (Synced method) model 600 601 m -> 602 replaceState m model 603 604 605confirmInput : Manager 606confirmInput model = 607 case lens.get model of 608 InputScreen (Ipfs _) { value } -> 609 pingOtherIpfs (String.chopEnd "/" value) model 610 611 InputScreen (RemoteStorage r) { value } -> 612 externalAuth (RemoteStorage r) value model 613 614 _ -> 615 Return.singleton model 616 617 618input : String -> Manager 619input string model = 620 (\state -> 621 case state of 622 InputScreen method opts -> 623 InputScreen method { opts | value = string } 624 625 s -> 626 s 627 ) 628 |> Lens.adjust lens model 629 |> Return.singleton 630 631 632 633-- 🛠 634 635 636hashPassphrase : String -> String 637hashPassphrase phrase = 638 phrase 639 |> Binary.fromStringAsUtf8 640 |> SHA.sha256 641 |> Binary.toHex 642 |> String.toLower