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