Browse Source

Merge branch 'feature/associate-devices' into develop

* feature/associate-devices:
  chore(project): Just merge them in.
  chore(project): Resolve update and device sync.
  feat(device): Add logic to generate and store pairable devices.
jackyalcine 1 month ago
parent
commit
0b30bcf9ba
Signed by: Jacky Alciné <yo@jacky.wtf> GPG Key ID: 537A4F904B15268D
47 changed files with 763 additions and 120 deletions
  1. 14
    1
      assets/app.ts
  2. 13
    0
      assets/package-lock.json
  3. 1
    0
      assets/package.json
  4. 5
    3
      assets/site.scss
  5. 0
    1
      config/dev.exs
  6. 5
    1
      config/test.exs
  7. 1
    0
      lib/application.ex
  8. 46
    0
      lib/device.ex
  9. 4
    2
      lib/guardian.ex
  10. 57
    9
      lib/provider.ex
  11. 5
    2
      lib/provider/federation.ex
  12. 21
    9
      lib/provider/silo.ex
  13. 54
    0
      lib/repo/device.ex
  14. 26
    10
      lib/repo/site_provider.ex
  15. 13
    13
      lib/site.ex
  16. 47
    0
      lib/worker/chronic.ex
  17. 17
    3
      lib/worker/provider.ex
  18. 6
    1
      mix.exs
  19. 4
    0
      mix.lock
  20. 18
    0
      priv/repo/migrations/20191120110918_create_devices.exs
  21. 10
    0
      priv/repo/migrations/20191120113102_add_totp_secret_to_devices.exs
  22. 9
    0
      priv/repo/migrations/20191120114409_add_pairing_code_to_devices.exs
  23. 7
    0
      priv/repo/migrations/20191121210909_add_metadata_columns_for_device.exs
  24. 21
    0
      test/integration/controllers/device_test.exs
  25. 23
    0
      test/integration/helpers/auth_test.exs
  26. 24
    0
      test/support/conn_case.ex
  27. 36
    0
      test/support/factory.ex
  28. 29
    0
      test/support/helpers/auth.ex
  29. 43
    0
      web/controllers/client.ex
  30. 24
    0
      web/controllers/device.ex
  31. 1
    1
      web/controllers/indie/auth_controller.ex
  32. 4
    0
      web/controllers/oauth2.ex
  33. 34
    17
      web/controllers/resolver.ex
  34. 19
    1
      web/helpers/auth.ex
  35. 1
    1
      web/plug/auth_pipeline/indie.ex
  36. 3
    3
      web/plug/auth_pipeline/owner.ex
  37. 15
    3
      web/router.ex
  38. 1
    0
      web/templates/device/bond.html.eex
  39. 14
    0
      web/templates/device/new.html.eex
  40. 1
    1
      web/templates/layout/app.html.eex
  41. 5
    2
      web/templates/page/homepage-login.html.leex
  42. 29
    9
      web/templates/page/index.html.eex
  43. 3
    1
      web/templates/resolver/present.html.leex
  44. 3
    0
      web/views/device.ex
  45. 26
    19
      web/views/live/homepage_auth_view.ex
  46. 4
    3
      web/views/live/provider-component.ex
  47. 17
    4
      web/views/live/resolver.ex

+ 14
- 1
assets/app.ts View File

@@ -6,7 +6,7 @@ import * as Feather from 'feather-icons'
6 6
 require('phoenix_html');
7 7
 
8 8
 [
9
-    ['credit-card', 'patreon'], ['key', 'keybase'], ['align-left', 'medium'], ['check-square', 'flash-success'], ['alert-triangle', 'flash-warning'], ['info', 'flash-info'], ['x-octagon', 'flash-error'], ['at-sign', 'indieauth']
9
+    ['key', 'keybase'], ['align-left', 'medium'], ['check-square', 'flash-success'], ['alert-triangle', 'flash-warning'], ['info', 'flash-info'], ['x-octagon', 'flash-error'], ['at-sign', 'indieauth'], ['codesandbox', 'itch'], ['activity', 'stackoverflow'], ['message-circle', 'mastodon']
10 10
 ].forEach((mapping) => {
11 11
     Feather.icons[mapping[1]] = Feather.icons[mapping[0]]
12 12
 })
@@ -66,4 +66,17 @@ document.addEventListener('phx:update', () => {
66 66
 
67 67
 function rebuildPage () {
68 68
     Feather.replace()
69
+    document.querySelectorAll('input[type=url]').forEach((urlInput) => 
70
+        urlInput.addEventListener('change', () => 
71
+            urlInput.value = forcePrefix(urlInput.value)
72
+        )
73
+    )
74
+}
75
+
76
+function forcePrefix (url) {
77
+    if (url.indexOf('http') != 0) {
78
+        return `http://${url}`
79
+    } else {
80
+        return url
81
+    }
69 82
 }

+ 13
- 0
assets/package-lock.json View File

@@ -5818,6 +5818,14 @@
5818 5818
       "integrity": "sha512-bEZlJzXo5V/ApNNa5z375mJC6Nrz4vG43UgcSCrg2OHC+yuB6j0iDSrY7RQ/+PRofFB03wNIIt9iXIVLr4wc7w==",
5819 5819
       "dev": true
5820 5820
     },
5821
+    "ladda": {
5822
+      "version": "2.0.1",
5823
+      "resolved": "https://registry.npmjs.org/ladda/-/ladda-2.0.1.tgz",
5824
+      "integrity": "sha512-ynRaMdW9y9ABRdTUWa60HSQjtTkoEZMiQDzzYsX8yxIEm7mxL/sxpvoLutYox1XluRif3DxLDg+3Dl1wurjnJg==",
5825
+      "requires": {
5826
+        "spin.js": "^4.0.0"
5827
+      }
5828
+    },
5821 5829
     "lcid": {
5822 5830
       "version": "1.0.0",
5823 5831
       "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz",
@@ -8865,6 +8873,11 @@
8865 8873
       "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==",
8866 8874
       "dev": true
8867 8875
     },
8876
+    "spin.js": {
8877
+      "version": "4.1.0",
8878
+      "resolved": "https://registry.npmjs.org/spin.js/-/spin.js-4.1.0.tgz",
8879
+      "integrity": "sha512-WI8O1OdJlKjialIhB9Z5RfFFM4pI7Hohik76bB3N4Ep3N/vTDPZiRU8QeefyLbmpI5n2bFQXgticl2g+/KiKYA=="
8880
+    },
8868 8881
     "split-string": {
8869 8882
       "version": "3.1.0",
8870 8883
       "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",

+ 1
- 0
assets/package.json View File

@@ -44,6 +44,7 @@
44 44
     "animate.css": "3.7.0",
45 45
     "feather": "0.0.6",
46 46
     "feather-icons": "4.24.1",
47
+    "ladda": "2.0.1",
47 48
     "pretty-checkbox": "3.0.3",
48 49
     "sass": "1.17.2",
49 50
     "typeface-merriweather": "0.0.72",

+ 5
- 3
assets/site.scss View File

@@ -52,8 +52,10 @@ div.provider--status {
52 52
 
53 53
   a { @extend .color-inherit, .link; }
54 54
 
55
+  @extend .black-70;
56
+
55 57
   &[data-status="pending"] {
56
-    @extend .dark-gray, .bg-moon-gray, .b--dark-gray;
58
+    @extend .bg-moon-gray, .b--dark-gray;
57 59
 
58 60
     svg.feather-refresh-cw {
59 61
       @extend .spin;
@@ -61,11 +63,11 @@ div.provider--status {
61 63
   }
62 64
 
63 65
   &[data-status="broken"] {
64
-    @extend .dark-red, .b--dark-red, .bg-washed-red;
66
+    @extend .b--red, .bg-washed-red;
65 67
   }
66 68
 
67 69
   &[data-status="found"] {
68
-    @extend .dark-green, .b--dark-green, .bg-washed-green;
70
+    @extend .b--green, .bg-washed-green;
69 71
   }
70 72
 }
71 73
 

+ 0
- 1
config/dev.exs View File

@@ -15,7 +15,6 @@ config :fortress, Fortress.Web.Endpoint,
15 15
       ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
16 16
       ~r{priv/gettext/.*(po)$},
17 17
       ~r{web/views/.*(ex)$},
18
-      ~r{web/views/live/.*(ex)$},
19 18
       ~r{web/channels/.*(ex)$},
20 19
       ~r{web/controllers/.*(ex)$},
21 20
       ~r{web/templates/.*(eex)$}

+ 5
- 1
config/test.exs View File

@@ -4,4 +4,8 @@ config :fortress, Fortress.Web.Endpoint,
4 4
   http: [port: {:system, "PORT", 5000}],
5 5
   server: false
6 6
 
7
-config :logger, level: :warn
7
+config :logger, level: :debug
8
+
9
+config :fortress, Fortress.Repo,
10
+  database: "fortress_test",
11
+  pool: Ecto.Adapters.SQL.Sandbox

+ 1
- 0
lib/application.ex View File

@@ -9,6 +9,7 @@ defmodule Fortress.Application do
9 9
     children = [
10 10
       worker(Guardian.DB.Token.SweeperServer, []),
11 11
       worker(Cachex, [:fortress, []]),
12
+      worker(Fortress.Workers.Chronic, []),
12 13
       {Task.Supervisor, name: Fortress.Workers.Provider},
13 14
       supervisor(Fortress.Repo, []),
14 15
       supervisor(Fortress.Web.Endpoint, []),

+ 46
- 0
lib/device.ex View File

@@ -0,0 +1,46 @@
1
+defmodule Fortress.Device do
2
+  require Ecto.Query
3
+  import Ecto.Query, only: [from: 2]
4
+
5
+  @otp_secret_size 64
6
+
7
+  def park(key) do
8
+    Fortress.Repo.Device.upsert(key)
9
+  end
10
+
11
+  def pair(code, site) do
12
+    with(
13
+      device when not is_nil(device) <- Fortress.Repo.Device.find_by_code(code),
14
+      secret <- @otp_secret_size |> :crypto.strong_rand_bytes() |> Base.encode32(),
15
+      {:ok, refreshed_device} <-
16
+        device
17
+        |> Fortress.Repo.Device.changeset(%{site: site, otp_secret: secret, pairing_code: ""})
18
+        |> Fortress.Repo.update()
19
+    ) do
20
+      {:ok, device: refreshed_device}
21
+    else
22
+      error -> error
23
+    end
24
+  end
25
+
26
+  def sync(key) do
27
+    # TODO: Generate TOTP value for key.
28
+  end
29
+
30
+  def unclaimed() do
31
+    age = DateTime.utc_now() |> DateTime.add(-600, :second)
32
+    from(device in Fortress.Repo.Device, where: device.inserted_at > ^age and device.site == "")
33
+  end
34
+
35
+  def sweep() do
36
+    unclaimed()
37
+    |> Fortress.Repo.delete_all()
38
+  end
39
+
40
+  def verify_signature(device, {payload, signature}) do
41
+    true
42
+  end
43
+
44
+  def update_information(payload) do
45
+  end
46
+end

+ 4
- 2
lib/guardian.ex View File

@@ -6,8 +6,10 @@ defmodule Fortress.Guardian do
6 6
   end
7 7
 
8 8
   def resource_from_claims(claims) do
9
-    url = claims["sub"]
10
-    {:ok, Fortress.Repo.Site.find_by_url(url)}
9
+    case Fortress.Repo.Site.find_by_url(claims["sub"]) do
10
+      nil -> {:error, :resource_not_found}
11
+      site -> {:ok, site}
12
+    end
11 13
   end
12 14
 
13 15
   def after_encode_and_sign(resource, claims, token, _options) do

+ 57
- 9
lib/provider.ex View File

@@ -17,8 +17,9 @@ defmodule Fortress.Provider do
17 17
   end
18 18
 
19 19
   defdelegate fetch_all_for(url), to: Fortress.Repo.SiteProvider
20
+  defdelegate resolve_indieauth_providers(url), to: Fortress.Repo.SiteProvider
20 21
 
21
-   defp do_fetch_indieauth_provider(url) do
22
+  defp do_fetch_indieauth_provider(url) do
22 23
     case IndieWeb.LinkRel.find(url, "authorization_endpoint") do
23 24
       [endpoint] ->
24 25
         %Instance{
@@ -51,17 +52,32 @@ defmodule Fortress.Provider do
51 52
   def update_for(instance)
52 53
 
53 54
   def update_for(%Instance{form: :silo} = sioled_instance),
54
-    do: Fortress.Provider.Silo.update_for(sioled_instance)
55
+  do: Fortress.Provider.Silo.update_for(sioled_instance |> Apex.ap)
55 56
 
56 57
   def update_for(%Instance{form: :federation} = federated_instance),
57 58
     do: Fortress.Provider.Federation.update_for(federated_instance)
58 59
 
60
+  def update_for(%Instance{} = instance) do
61
+    if Enum.member?(Fortress.Provider.Federation.supported(), instance.platform) do
62
+      update_for(%Instance{instance | form: :federation})
63
+    else
64
+      update_for(%Instance{instance | form: :silo})
65
+    end
66
+  end
67
+
59 68
   def broadcast(instance) do
60 69
     Logger.info("Updating status of provider for user.", instance: instance)
61 70
     Phoenix.PubSub.broadcast(Fortress.PubSub, Instance.topic_for(instance), instance)
62 71
     instance
63 72
   end
64 73
 
74
+  def broadcast_site(me) do
75
+    Logger.info("Informing of new list of providers.", me: me)
76
+    Phoenix.PubSub.broadcast(Fortress.PubSub, topic_for(me), me)
77
+  end
78
+
79
+  def topic_for(me), do: "providers:#{me}"
80
+
65 81
   def supported?(platform),
66 82
     do:
67 83
       Enum.member?(
@@ -101,32 +117,64 @@ defmodule Fortress.Provider do
101 117
       status: "pending"
102 118
     }
103 119
 
104
-  defp do_resolve_to_provider(%URI{host: "patreon.com"} = url, me),
120
+  defp do_resolve_to_provider(%URI{host: "keybase.io"} = url, me),
105 121
     do: %Instance{
106
-      platform: :patreon,
122
+      platform: :keybase,
107 123
       me: me,
108 124
       url: url,
109 125
       form: :silo,
110 126
       status: "pending"
111 127
     }
112 128
 
113
-  defp do_resolve_to_provider(%URI{host: "keybase.io"} = url, me),
129
+  defp do_resolve_to_provider(%URI{host: "medium.com"} = url, me),
114 130
     do: %Instance{
115
-      platform: :keybase,
131
+      platform: :medium,
116 132
       me: me,
117 133
       url: url,
118 134
       form: :silo,
119 135
       status: "pending"
120 136
     }
121 137
 
122
-  defp do_resolve_to_provider(%URI{host: "medium.com"} = url, me),
138
+  defp do_resolve_to_provider(%URI{host: "itch.io"} = url, me),
123 139
     do: %Instance{
124
-      platform: :medium,
140
+      platform: :itch,
125 141
       me: me,
126 142
       url: url,
127 143
       form: :silo,
128 144
       status: "pending"
129 145
     }
130 146
 
131
-  defp do_resolve_to_provider(_, _), do: nil
147
+  defp do_resolve_to_provider(%URI{host: "stackoverflow.com"} = url, me),
148
+    do: %Instance{
149
+      platform: :stackoverflow,
150
+      me: me,
151
+      url: url,
152
+      form: :silo,
153
+      status: "pending"
154
+    }
155
+
156
+  defp do_resolve_to_provider(%URI{host: "twitter.com"} = url, me),
157
+    do: %Instance{
158
+      platform: :twitter,
159
+      me: me,
160
+      url: url,
161
+      form: :silo,
162
+      status: "pending"
163
+    }
164
+
165
+  defp do_resolve_to_provider(url, me) do
166
+    # TODO: 
167
+    nil
168
+  end
169
+
170
+  def fetch_resolved_relmes(url) do
171
+    url
172
+    |> IndieWeb.LinkRel.find("me")
173
+    |> Enum.map(&IndieWeb.URL.resolve_redirect/1)
174
+    |> Enum.map(&URI.parse/1)
175
+    |> Enum.map(&IndieWeb.URL.canonalize/1)
176
+    |> Enum.map(&URI.to_string/1)
177
+    |> Enum.reject(&is_nil/1)
178
+  end
179
+
132 180
 end

+ 5
- 2
lib/provider/federation.ex View File

@@ -1,6 +1,7 @@
1 1
 defmodule Fortress.Provider.Federation do
2 2
   def update_for(instance)
3 3
 
4
+  # FIXME: Make URLs in question absolute.
4 5
   def update_for(%Fortress.Provider.Instance{platform: :indieauth} = instance) do
5 6
     instance_url = URI.to_string(instance.url)
6 7
     resolved_mes = IndieWeb.LinkRel.find(instance.me, "authorization_endpoint")
@@ -10,9 +11,11 @@ defmodule Fortress.Provider.Federation do
10 11
     else
11 12
       "broken"
12 13
     end
13
-        %{instance | status: status}
14
+    %{instance | status: status}
15
+  end
16
+  def update_for(instance) do
17
+    %{instance | status: "broken"}
14 18
   end
15
-  def update_for(instance), do: %{instance | status: "broken"}
16 19
 
17 20
   def supported(), do: ~w(mastodon pixelfed pleroma indieauth)a
18 21
 end

+ 21
- 9
lib/provider/silo.ex View File

@@ -1,18 +1,30 @@
1 1
 defmodule Fortress.Provider.Silo do
2
-  @friendly_silo ~w(github gitlab itch)a
2
+  @friendly_silo ~w(github gitlab itch twitter stackoverflow keybase)a
3 3
   def update_for(instance)
4 4
 
5
+  def update_for(
6
+        %Fortress.Provider.Instance{
7
+          platform: :twitter,
8
+          url: %URI{path: "/intent/user", query: "screen_name=" <> username}
9
+        } = instance
10
+      ) do
11
+    # TODO: Delete the old reference somehow.
12
+    update_for(%{instance | url: %{instance.url | path: "/" <> username, query: nil}})
13
+  end
14
+
5 15
   def update_for(%Fortress.Provider.Instance{platform: platform} = instance)
6 16
       when platform in @friendly_silo do
7
-    instance_url = URI.to_string(instance.url)
8
-    resolved_mes = IndieWeb.LinkRel.find(instance_url, "me")
17
+    instance_url = instance.url |> IndieWeb.URL.canonalize() |> URI.to_string()
18
+    resolved_mes = Fortress.Provider.fetch_resolved_relmes(instance_url) |> Apex.ap()
19
+
20
+    status =
21
+      if Enum.member?(resolved_mes, instance.me) do
22
+        "found"
23
+      else
24
+        "broken"
25
+      end
9 26
 
10
-    status = if Enum.member?(resolved_mes, instance.me) do
11
-      "found"
12
-    else
13
-      "broken"
14
-    end
15
-        %{instance | status: status}
27
+    %{instance | status: status}
16 28
   end
17 29
 
18 30
   def update_for(instance), do: %{instance | status: "broken"}

+ 54
- 0
lib/repo/device.ex View File

@@ -0,0 +1,54 @@
1
+defmodule Fortress.Repo.Device do
2
+  use Ecto.Schema
3
+  import Ecto.Query, only: [from: 2]
4
+  import Ecto.Changeset
5
+
6
+  @primary_key {:id, :binary_id, autogenerate: true}
7
+  @foreign_key_type :binary_id
8
+  @required_keys ~w(key)a
9
+  @optional_keys ~w(last_used_at site name pairing_code otp_secret)a
10
+  schema "devices" do
11
+    field :key, :string
12
+    field :last_used_at, :utc_datetime
13
+    field :name, :string
14
+    field :otp_secret, :string
15
+      field :pairing_code, :string
16
+    belongs_to :owning_site, Fortress.Repo.Site, foreign_key: :site, type: :string
17
+
18
+    timestamps()
19
+  end
20
+
21
+  @doc false
22
+  def changeset(device, attrs) do
23
+    device
24
+    |> cast(attrs, @required_keys ++ @optional_keys)
25
+    |> validate_required(@required_keys)
26
+    |> unique_constraint(:key)
27
+  end
28
+
29
+  def generate_pairing_code(), do: 4 |> :crypto.strong_rand_bytes() |> Base.encode32(case: :upper, padding: false)
30
+
31
+  def create(key) do
32
+    encoded_key = key |> Base.encode64(padding: false)
33
+
34
+    %__MODULE__{}
35
+    |> changeset(%{key: encoded_key, pairing_code: generate_pairing_code()})
36
+    |> Fortress.Repo.insert
37
+  end
38
+
39
+  def upsert(key) do
40
+    case find_by_key(key) do
41
+      nil -> create(key)
42
+      device -> {:ok, device}
43
+    end
44
+  end
45
+
46
+  def find_by_key(key) do
47
+    encoded_key = key |> Base.encode64(padding: false)
48
+    Fortress.Repo.get_by(__MODULE__, key: encoded_key)
49
+  end
50
+
51
+  def find_by_code(code) do
52
+    Fortress.Repo.get_by(__MODULE__, pairing_code: code)
53
+  end
54
+end

+ 26
- 10
lib/repo/site_provider.ex View File

@@ -18,7 +18,7 @@ defmodule Fortress.Repo.SiteProvider do
18 18
   end
19 19
 
20 20
   @doc false
21
-  def changeset(site_provider, attrs) do
21
+  def changeset(site_provider, attrs \\ %{}) do
22 22
     site_provider
23 23
     |> cast(
24 24
       attrs,
@@ -26,15 +26,15 @@ defmodule Fortress.Repo.SiteProvider do
26 26
         @optional_keys
27 27
     )
28 28
     |> validate_required(@required_keys)
29
-    |> unique_constraint(:url)
30 29
     |> foreign_key_constraint(:site, name: :site_providers_site_fkey)
30
+    |> unique_constraint(:url)
31 31
   end
32 32
 
33 33
   def refresh(instance) do
34 34
     case Fortress.Repo.get(__MODULE__, URI.to_string(instance.url)) do
35 35
       %__MODULE__{} = record ->
36 36
         record
37
-        |> Ecto.Changeset.change(%{status: instance.status})
37
+        |> changeset(%{status: instance.status})
38 38
         |> Fortress.Repo.update()
39 39
 
40 40
       nil ->
@@ -44,7 +44,7 @@ defmodule Fortress.Repo.SiteProvider do
44 44
           status: instance.status,
45 45
           site: instance.me
46 46
         }
47
-        |> Ecto.Changeset.change()
47
+        |> changeset()
48 48
         |> Fortress.Repo.insert()
49 49
     end
50 50
   end
@@ -57,13 +57,15 @@ defmodule Fortress.Repo.SiteProvider do
57 57
     |> Enum.map(&to_instance/1)
58 58
   end
59 59
 
60
-  def internal_endpoint(), do: Fortress.Web.Router.Helpers.indie_auth_url(Fortress.Web.Endpoint, :authorize)
60
+  def internal_endpoint(),
61
+    do: Fortress.Web.Router.Helpers.indie_auth_url(Fortress.Web.Endpoint, :authorize)
61 62
 
62 63
   def uses_system?(url) do
63 64
     endpoint = internal_endpoint()
65
+
64 66
     __MODULE__
65
-    |> Ecto.Query.where(site: ^url, url: ^endpoint)
66
-    |> Fortress.Repo.exists?
67
+    |> Ecto.Query.where(site: ^url, url: ^endpoint, provider: "indieauth")
68
+    |> Fortress.Repo.exists?()
67 69
   end
68 70
 
69 71
   def to_instance(%__MODULE{} = record) do
@@ -71,18 +73,32 @@ defmodule Fortress.Repo.SiteProvider do
71 73
       platform: String.to_existing_atom(record.provider),
72 74
       status: record.status,
73 75
       url: URI.parse(record.url),
74
-      me: record.site
76
+      me: record.site |> IndieWeb.URL.resolve_redirect |> URI.parse |> IndieWeb.URL.canonalize |> URI.to_string
75 77
     }
76 78
   end
77 79
 
80
+  def clear_providers_for(site, provider) do
81
+    __MODULE__
82
+    |> Ecto.Query.where(provider: ^provider)
83
+    |> Fortress.Repo.delete_all()
84
+  end
85
+
78 86
   def bump_last_use(site, provider) do
79 87
     case Fortress.Repo.get_by(__MODULE__, site: site, provider: provider) do
80 88
       %__MODULE__{} = record ->
81 89
         record
82
-        |> Ecto.Changeset.change(%{last_used_at: DateTime.utc_now})
83
-        |> Fortress.Repo.update
90
+        |> Ecto.Changeset.change(%{last_used_at: DateTime.utc_now()})
91
+        |> Fortress.Repo.update()
92
+
84 93
       _ ->
85 94
         Logger.warn("No provider stored to be bumped.", provider: provider, site: site)
86 95
     end
87 96
   end
97
+
98
+  def resolve_indieauth_providers(me) do
99
+    Fortress.Repo.SiteProvider
100
+    |> Ecto.Query.where(site: ^me, provider: "indieauth")
101
+    |> Fortress.Repo.all()
102
+    |> Enum.map(&Fortress.Repo.SiteProvider.to_instance/1)
103
+  end
88 104
 end

+ 13
- 13
lib/site.ex View File

@@ -12,18 +12,10 @@ defmodule Fortress.Site do
12 12
     end
13 13
   end
14 14
 
15
-  def do_create_site(hcard) do
16
-    with({:ok, record} <- Fortress.Repo.Site.upsert(hcard)) do
17
-      Logger.info("Created new site #{record.url}")
18
-      {:ok, record}
19
-    else
20
-      err -> {:error, :failed_to_make_site, reason: err}
21
-    end
22
-  end
23
-
24
-  def validate_auth(auth)
15
+  def validate_auth(conn)
25 16
 
26 17
   def validate_auth(conn) do
18
+    # TODO: Make this not rely on Plug.Conn info
27 19
     auth = conn.assigns[:ueberauth_auth]
28 20
     github_url = auth.info.urls.html_url
29 21
     state_code = Plug.Conn.get_session(conn, :fortress_state)
@@ -41,8 +33,16 @@ defmodule Fortress.Site do
41 33
     end
42 34
   end
43 35
 
44
-  def import(site) do
45
-    # TODO: Resolve the proper URI for the site.
46
-    # TODO: Add the site to the platform.
36
+  def sweep() do
37
+    # TODO: Find all sites that have no providers and are older than a week old.
38
+  end
39
+
40
+  defp do_create_site(hcard) do
41
+    with({:ok, record} <- Fortress.Repo.Site.upsert(hcard)) do
42
+      Logger.info("Created new site #{record.url}")
43
+      {:ok, record}
44
+    else
45
+      err -> {:error, :failed_to_make_site, reason: err}
46
+    end
47 47
   end
48 48
 end

+ 47
- 0
lib/worker/chronic.ex View File

@@ -0,0 +1,47 @@
1
+defmodule Fortress.Workers.Chronic do
2
+  use GenServer
3
+  require Logger
4
+
5
+  @interval :timer.seconds(30)
6
+
7
+  def start_link() do
8
+    GenServer.start_link(__MODULE__, [], name: __MODULE__)
9
+  end
10
+
11
+  def init(state) do
12
+    {:ok, schedule(self(), state)}
13
+  end
14
+
15
+  def handle_call(:schedule, _from, state) do
16
+    {:reply, :ok, schedule(self(), state)}
17
+  end
18
+
19
+  def handle_call(:compute, _from, state) do
20
+    {:reply, :ok, execute(self(), state)}
21
+  end
22
+
23
+  def handle_info(:compute, state) do
24
+    {:noreply, execute(self(), state)}
25
+  end
26
+
27
+  def handle_info(_, state), do: {:noreply, state}
28
+
29
+  def schedule(pid, state) do
30
+    Process.send_after(pid, :execute, @interval)
31
+    state
32
+  end
33
+
34
+  # TODO: Move collection into [mod.func/1] in a Task.async_stream
35
+  def execute(pid, state) do
36
+    [
37
+      Task.async(fn ->
38
+        Fortress.Device.sweep()
39
+        Fortress.Site.sweep()
40
+      end)
41
+    ]
42
+    |> Task.yield_many(@interval)
43
+    |> Stream.run()
44
+
45
+    schedule(pid, state)
46
+  end
47
+end

+ 17
- 3
lib/worker/provider.ex View File

@@ -1,4 +1,5 @@
1 1
 defmodule Fortress.Workers.Provider do
2
+  require Logger
2 3
   def update_records(url) do
3 4
     Task.Supervisor.start_child(
4 5
       __MODULE__,
@@ -6,6 +7,8 @@ defmodule Fortress.Workers.Provider do
6 7
         url
7 8
         |> Fortress.Provider.resolve_for()
8 9
         |> Enum.each(&(check_instance(&1)))
10
+
11
+        Fortress.Provider.broadcast_site(url)
9 12
       end,
10 13
       restart: :transient
11 14
     )
@@ -15,10 +18,21 @@ defmodule Fortress.Workers.Provider do
15 18
     Task.Supervisor.start_child(
16 19
       __MODULE__,
17 20
       fn ->
18
-    instance
21
+    cs = instance
19 22
           |> Fortress.Provider.update_for()
20
-          |> Fortress.Provider.broadcast()
21
-          |> Fortress.Repo.SiteProvider.refresh end,
23
+          |> Fortress.Repo.SiteProvider.refresh
24
+        case cs do
25
+          {:ok, record} ->
26
+            record
27
+            |> Fortress.Repo.SiteProvider.to_instance
28
+          |> Fortress.Provider.broadcast
29
+
30
+            Fortress.Provider.broadcast_site(instance.me)
31
+          {:error, cs} ->
32
+            Apex.ap cs
33
+            Logger.error("Failed to update instance info.", instance: instance)
34
+        end
35
+      end,
22 36
       restart: :transient
23 37
     )
24 38
   end

+ 6
- 1
mix.exs View File

@@ -72,8 +72,13 @@ defmodule Fortress.Mixfile do
72 72
       {:tesla_request_id, "~> 0.2.0"},
73 73
       {:ueberauth, "~> 0.6.1"},
74 74
       {:indieweb, git: "https://git.jacky.wtf/indieweb/elixir", branch: :develop, override: true},
75
-      {:ueberauth_indieauth, git: "https://git.jacky.wtf/indieweb/ueberauth_indieauth", branch: :develop},
75
+      {:ueberauth_indieauth,
76
+       git: "https://git.jacky.wtf/indieweb/ueberauth_indieauth", branch: :develop},
76 77
       {:ueberauth_github, "~> 0.7"},
78
+      {:ex_crypto, "~> 0.10.0"},
79
+      {:ex_machina, "~> 2.3", only: :test},
80
+      {:totpex, "~> 0.1.3"},
81
+{:poison, "~> 3.0", override: true}
77 82
     ]
78 83
   end
79 84
 

+ 4
- 0
mix.lock View File

@@ -14,6 +14,8 @@
14 14
   "ecto": {:hex, :ecto, "3.1.7", "fa21d06ef56cdc2fdaa62574e8c3ba34a2751d44ea34c30bc65f0728421043e5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
15 15
   "ecto_sql": {:hex, :ecto_sql, "3.1.6", "1e80e30d16138a729c717f73dcb938590bcdb3a4502f3012414d0cbb261045d8", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0 or ~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
16 16
   "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm"},
17
+  "ex_crypto": {:hex, :ex_crypto, "0.10.0", "af600a89b784b36613a989da6e998c1b200ff1214c3cfbaf8deca4aa2f0a1739", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
18
+  "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm"},
17 19
   "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm"},
18 20
   "explode": {:hex, :explode, "1.0.0", "e9b9acae7a7fe15027a8813b19b8cd424d8747903428ed4ae13c1460aaaa2934", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:poison, "~> 2.1 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
19 21
   "faker": {:hex, :faker, "0.12.0", "796cbac868c86c2df6f273ea4cdf2e271860863820e479e04a374b7ee6c376b6", [:mix], [], "hexpm"},
@@ -54,6 +56,7 @@
54 56
   "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
55 57
   "que": {:hex, :que, "0.8.0", "873d46b86966c16776ee9c5482597d71c83a49dc9a5ea9a77e6095218a74ece0", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.2.1", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm"},
56 58
   "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
59
+  "rsa": {:hex, :rsa, "0.0.1", "a63069f88ce342ffdf8448b7cdef4b39ba7dee3c1510644a39385c7e63ba246f", [:mix], [], "hexpm"},
57 60
   "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm"},
58 61
   "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
59 62
   "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm"},
@@ -61,6 +64,7 @@
61 64
   "tesla_cache": {:hex, :tesla_cache, "0.1.2", "ef5a23c10c146fffc3c2c2a1e8fbff45a64fd28447b73112d955c92e7bf0b21a", [:mix], [{:cachex, "~> 2.1", [hex: :cachex, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"},
62 65
   "tesla_request_id": {:hex, :tesla_request_id, "0.2.0", "9745364ed3c850864fb2744daefb9b7104fe7f1efcf34eb350c61da805de2a46", [:mix], [{:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"},
63 66
   "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
67
+  "totpex": {:hex, :totpex, "0.1.3", "ae022eab70e8e230a0a65300b0d0dd67f510e0b7243c0d608a827c7b22ca6b51", [:mix], [], "hexpm"},
64 68
   "tzdata": {:hex, :tzdata, "1.0.2", "6c4242c93332b8590a7979eaf5e11e77d971e579805c44931207e32aa6ad3db1", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
65 69
   "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
66 70
   "ueberauth_github": {:hex, :ueberauth_github, "0.7.0", "637067c5500f7b13c18caca3db66d09eba661524e0d0e9518b54151e99484bad", [:mix], [{:oauth2, "~> 0.9", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.4", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm"},

+ 18
- 0
priv/repo/migrations/20191120110918_create_devices.exs View File

@@ -0,0 +1,18 @@
1
+defmodule Fortress.Repo.Migrations.CreateDevices do
2
+  use Ecto.Migration
3
+
4
+  def change do
5
+    create table(:devices, primary_key: false) do
6
+      add :id, :binary_id, primary_key: true
7
+      add :key, :text
8
+      add :last_used_at, :utc_datetime
9
+      add :name, :string
10
+      add :site, references(:sites, on_delete: :nothing, type: :string, column: :url)
11
+
12
+      timestamps()
13
+    end
14
+
15
+    create unique_index(:devices, [:key])
16
+    create index(:devices, [:site])
17
+  end
18
+end

+ 10
- 0
priv/repo/migrations/20191120113102_add_totp_secret_to_devices.exs View File

@@ -0,0 +1,10 @@
1
+defmodule Fortress.Repo.Migrations.AddTotpSecretToDevices do
2
+  use Ecto.Migration
3
+
4
+  def change do
5
+
6
+    alter table(:devices) do
7
+      add :otp_secret, :string, null: true
8
+    end
9
+  end
10
+end

+ 9
- 0
priv/repo/migrations/20191120114409_add_pairing_code_to_devices.exs View File

@@ -0,0 +1,9 @@
1
+defmodule Fortress.Repo.Migrations.AddPairingCodeToDevices do
2
+  use Ecto.Migration
3
+
4
+  def change do
5
+    alter table(:devices) do
6
+      add :pairing_code, :string 
7
+    end
8
+  end
9
+end

+ 7
- 0
priv/repo/migrations/20191121210909_add_metadata_columns_for_device.exs View File

@@ -0,0 +1,7 @@
1
+defmodule Fortress.Repo.Migrations.AddMetadataColumnsForDevice do
2
+  use Ecto.Migration
3
+
4
+  def change do
5
+
6
+  end
7
+end

+ 21
- 0
test/integration/controllers/device_test.exs View File

@@ -0,0 +1,21 @@
1
+defmodule Fortress.Web.DeviceControllerTest do
2
+  use Fortress.Web.ConnCase
3
+
4
+  describe "POST .bond/2" do
5
+    @tag async: false
6
+    test "successfully binds a device to a user" do
7
+      device = insert(:device) |> with_public_key
8
+      site = insert(:site)
9
+
10
+      params = %{
11
+        code: device.pairing_code
12
+      }
13
+
14
+      resp = build_conn()
15
+             |> owner_sign_in(site)
16
+             |> put(Routes.device_path(Fortress.Web.Endpoint, :bond), params)
17
+
18
+      assert resp = html_response(resp, :ok)
19
+    end
20
+  end
21
+end

+ 23
- 0
test/integration/helpers/auth_test.exs View File

@@ -0,0 +1,23 @@
1
+defmodule Fortress.Web.Helpers.AuthenticationTest do
2
+  use Fortress.Web.ConnCase
3
+  alias Fortress.Web.Helpers.Authentication, as: Subject
4
+
5
+  describe ".owner_sign_in/1" do
6
+    test "signs in a site" do
7
+      site = insert(:site)
8
+      assigns = %{
9
+        ueberauth_auth: %Ueberauth.Auth{
10
+          info: %{
11
+            urls: %{
12
+              url: site.url
13
+            }
14
+          }
15
+        }
16
+      }
17
+      base_conn = build_conn() |> Map.put(:assigns, assigns)
18
+
19
+      assert authed_conn = Subject.owner_sign_in(base_conn)
20
+      assert {:ok, ^site} = Subject.owner_current_resource(authed_conn)
21
+    end
22
+  end
23
+end

+ 24
- 0
test/support/conn_case.ex View File

@@ -0,0 +1,24 @@
1
+defmodule Fortress.Web.ConnCase do
2
+  use ExUnit.CaseTemplate
3
+
4
+  using do
5
+    quote location: :keep do
6
+      use Phoenix.ConnTest
7
+      import Fortress.Factory
8
+      alias Fortress.Web.Router.Helpers, as: Routes
9
+      import Fortress.Web.Test.Helpers.Authentication
10
+
11
+      @endpoint Fortress.Web.Endpoint
12
+    end
13
+  end
14
+
15
+  setup tags do
16
+    :ok = Ecto.Adapters.SQL.Sandbox.checkout(Fortress.Repo)
17
+
18
+    unless tags[:async] do
19
+      Ecto.Adapters.SQL.Sandbox.mode(Fortress.Repo, {:shared, self()})
20
+    end
21
+
22
+    {:ok, conn: Phoenix.ConnTest.build_conn()}
23
+  end
24
+end

+ 36
- 0
test/support/factory.ex View File

@@ -0,0 +1,36 @@
1
+defmodule Fortress.Factory do
2
+  use ExMachina.Ecto, repo: Fortress.Repo
3
+
4
+  @rsa_priv_key
5
+
6
+  alias Fortress.Repo.{
7
+    Site,
8
+    Device
9
+  }
10
+
11
+  def site_factory(params \\ %{}) do
12
+    %Site{
13
+      url: Faker.Internet.url(),
14
+      photo: Faker.Internet.image_url(),
15
+      name: Faker.Name.name()
16
+    }
17
+  end
18
+
19
+  def device_factory(params \\ %{}) do
20
+    %Device{}
21
+  end
22
+
23
+  def with_public_key(device) do
24
+    device
25
+    |> Device.changeset(%{pairing_code: Device.generate_pairing_code(), key: generate_public_key()})
26
+    |> Ecto.Changeset.change
27
+    |> Fortress.Repo.update!
28
+  end
29
+
30
+  def generate_public_key() do
31
+    {:ok, rsa_priv_key} = ExPublicKey.generate_key(128)
32
+    {:ok, rsa_pub_key} = ExPublicKey.public_key_from_private_key(rsa_priv_key)
33
+    {:ok, pem_string} = ExPublicKey.pem_encode(rsa_pub_key)
34
+    pem_string
35
+  end
36
+end

+ 29
- 0
test/support/helpers/auth.ex View File

@@ -0,0 +1,29 @@
1
+defmodule Fortress.Web.Test.Helpers.Authentication do
2
+  import Fortress.Guardian
3
+  import Plug.Conn, only: [put_req_header: 3]
4
+
5
+  def generate_indieauth_token(options) do
6
+    client_id = Keyword.get(options, :client_id, Faker.Internet.url())
7
+    scope = Keyword.get(options, :scope, ~w(read follow block mute)s)
8
+
9
+    {:ok, token, _} =
10
+      encode_and_sign(
11
+        client_id,
12
+        %{"id" => client_id, "scope" => IndieWeb.Auth.Scope.to_string(scope)},
13
+        key: :indie
14
+      )
15
+
16
+    token
17
+  end
18
+
19
+  def indie_sign_in(conn, client_id, scope, user_url) do
20
+    token = generate_indieauth_token(client_id: client_id, scope: scope)
21
+    {:ok, _} = Fortress.Web.Plug.InjectIndieAuthCredentials.add(user_url, token)
22
+    put_req_header(conn, "authorization", "bearer #{token}")
23
+  end
24
+
25
+  def owner_sign_in(conn, site) do
26
+    Fortress.Guardian.Plug.sign_in(conn, site, %{}, key: :owner)
27
+  end
28
+
29
+end

+ 43
- 0
web/controllers/client.ex View File

@@ -0,0 +1,43 @@
1
+defmodule Fortress.Web.Api.ClientController do
2
+  use Fortress.Web, :controller
3
+
4
+  def pair(conn, params)
5
+
6
+  def pair(conn, %{"publicKey" => publicKey}) do
7
+    case Fortress.Device.park(publicKey) do
8
+      {:ok, device} ->
9
+        json(conn, %{
10
+          pairing_code: device.pairing_code,
11
+          device_id: device.id
12
+        })
13
+
14
+      {:error, _error} ->
15
+        conn
16
+        |> Explode.unprocessible_entity()
17
+    end
18
+  end
19
+
20
+  def pair(conn, _params), do: conn |> Explode.bad_request()
21
+
22
+  def synchronize(conn, params)
23
+
24
+  def synchronize(conn, %{"keyId" => keyId, "signature" => signature, "payload" => payload}) do
25
+    with(
26
+      key when not is_nil(key) <- Fortress.Repo.get(Fortress.Device, keyId),
27
+      true <- Fortress.Device.verify_signature(key, {payload, signature}),
28
+      {:ok, device} <- Fortress.Device.update_information(payload)
29
+    ) do
30
+      conn
31
+      |> json(%{
32
+        totp_secret: device.totp_secret
33
+      })
34
+    else
35
+      _ -> conn |> Explode.bad_request()
36
+    end
37
+  end
38
+
39
+  def synchronize(conn, _params), do: conn |> Explode.bad_request()
40
+
41
+  def request(conn, params)
42
+  def invoke(conn, params)
43
+end

+ 24
- 0
web/controllers/device.ex View File

@@ -0,0 +1,24 @@
1
+defmodule Fortress.Web.DeviceController do
2
+  use Fortress.Web, :controller
3
+
4
+  def new(conn, params), do: render(conn, "new.html")
5
+  def bond(conn, params)
6
+
7
+  def bond(conn, %{"code" => code}) do
8
+    with(
9
+      {:ok, user} <- owner_current_resource(conn),
10
+      {:ok, device} <- Fortress.Device.pair(code, user.url)
11
+    ) do
12
+      render(conn, "bond.html")
13
+    else
14
+      {:error, error} -> conn |> put_flash(:error, "Bond failed.") |> render("new.html")
15
+    end
16
+  end
17
+
18
+  def bond(conn, _params) do
19
+    conn
20
+    |> put_flash(:error, "That didn't work with what Fortress expected.")
21
+    |> put_status(:bad_request)
22
+    |> render("new.html")
23
+  end
24
+end

+ 1
- 1
web/controllers/indie/auth_controller.ex View File

@@ -196,7 +196,7 @@ defmodule Fortress.Web.Indie.AuthController do
196 196
 
197 197
         {:ok,
198 198
           %{
199
-            "logo" => Koype.Web.LayoutView.asset_path_for(:core, "images/logo/icon.svg"),
199
+            "logo" => Fortress.Web.LayoutView.asset_path_for(:core, "images/default-app.svg"),
200 200
             "name" => URI.parse(client_id).host,
201 201
             "url" => client_id
202 202
           }}

+ 4
- 0
web/controllers/oauth2.ex View File

@@ -34,6 +34,7 @@ defmodule Fortress.Web.OAuth2Controller do
34 34
       {:ok, site} <- Fortress.Repo.Site.upsert(hcard)
35 35
     ) do
36 36
     # TODO: Destroy state.
37
+      Fortress.Repo.SiteProvider.clear_providers_for(user_url, Atom.to_string(auth.provider))
37 38
       Fortress.Repo.SiteProvider.bump_last_use(user_url, Atom.to_string(auth.provider))
38 39
       conn
39 40
       |> delete_session(:requesting_user)
@@ -51,4 +52,7 @@ defmodule Fortress.Web.OAuth2Controller do
51 52
         |> redirect(to: "/")
52 53
     end
53 54
   end
55
+
56
+  defp get_provider_url(auth)
57
+  defp get_provider_url(auth), do: auth.info.urls.url
54 58
 end

+ 34
- 17
web/controllers/resolver.ex View File

@@ -4,11 +4,22 @@ defmodule Fortress.Web.ResolverController do
4 4
   def present(conn, params)
5 5
 
6 6
   def present(conn, %{"me" => me}) when is_binary(me) do
7
-    instances = Fortress.Provider.fetch_all_for(me)
8
-    live_render(conn, Fortress.Web.ResolverLiveView, session: %{me: me, instances: instances})
7
+    fixed_me =
8
+      me
9
+      |> URI.parse()
10
+      |> IndieWeb.URL.canonalize()
11
+      |> URI.to_string()
12
+      |> IndieWeb.URL.resolve_redirect()
13
+
14
+    instances = Fortress.Provider.fetch_all_for(fixed_me)
15
+    live_render(conn, Fortress.Web.ResolverLiveView, session: %{me: fixed_me, instances: instances})
9 16
   end
10 17
 
11
-  def present(conn, _), do: render(conn, "present-no_url.html")
18
+  def present(conn, _),
19
+    do:
20
+      conn
21
+      |> put_flash(:warning, "Please provide your URL.")
22
+      |> redirect(to: Routes.page_path(conn, :index))
12 23
 
13 24
   def request(conn, params)
14 25
 
@@ -17,25 +28,31 @@ defmodule Fortress.Web.ResolverController do
17 28
     if !Fortress.Provider.supported?(platform) do
18 29
       render(conn, "request-unsupported.html")
19 30
     else
31
+      fixed_me =
32
+        me
33
+        |> URI.parse()
34
+        |> IndieWeb.URL.canonalize()
35
+        |> URI.to_string()
36
+        |> IndieWeb.URL.resolve_redirect()
37
+
20 38
       state = Fortress.Auth.State.generate_token()
21
-      Fortress.Auth.State.persist(state, me, me, me)
39
+      Fortress.Auth.State.persist(token, fixed_me, params["redirect_url"], params["client_id"])
22 40
 
23 41
       primed_conn =
24 42
         conn
25
-        |> put_session(:requested_profile, url)
26
-        |> put_session(:requesting_user, me)
27
-        |> put_session(:state, state)
28
-
29
-      redirect(primed_conn, to: resolve_oauth_path(primed_conn, platform))
43
+        |> put_session(:auth_state, state)
44
+
45
+      redirect(primed_conn,
46
+        to:
47
+          Routes.oauth2_path(conn, :request, platform.provider,
48
+            me: fixed_me,
49
+            state: state,
50
+            redirect_url: params["redirect_url"],
51
+            client_id: params["client_id"]
52
+          )
53
+      )
30 54
     end
31 55
   end
32 56
 
33
-  def request(conn, _), do: render(conn, "request-invalid.html")
34
-
35
-  defp resolve_oauth_path(conn, platform)
36
-
37
-  defp resolve_oauth_path(conn, "indieauth"),
38
-    do: Routes.oauth2_path(conn, :request, "indieauth", me: get_session(conn, "requesting_user"), state: get_session(conn, "state"))
39
-
40
-  defp resolve_oauth_path(conn, platform), do: Routes.oauth2_path(conn, :request, platform)
57
+  def request(conn, _), do: conn |> put_status(:bad_request) |> render("request-invalid.html")
41 58
 end

+ 19
- 1
web/helpers/auth.ex View File

@@ -9,7 +9,7 @@ defmodule Fortress.Web.Helpers.Authentication do
9 9
   def is_signed_out?(conn, type \\ :owner), do: !is_signed_in?(conn, type)
10 10
 
11 11
   def owner_sign_in(%{assigns: %{ueberauth_auth: %Ueberauth.Auth{} = auth}} = conn) do
12
-    user_url = auth.info.urls.url
12
+    user_url = resolve_user_url(auth)
13 13
     site = Fortress.Repo.Site.find_by_url(user_url)
14 14
 
15 15
     conn
@@ -60,7 +60,25 @@ defmodule Fortress.Web.Helpers.Authentication do
60 60
     end
61 61
   end
62 62
 
63
+  def owner_current_token(conn) do
64
+    case FGPlug.current_token(conn, key: :owner) do
65
+      nil -> {:error, :no_token}
66
+      token -> {:ok, token}
67
+    end
68
+  end
69
+
70
+  def owner_current_resource(conn) do
71
+    case FGPlug.current_resource(conn, key: :owner) do
72
+      nil -> {:error, :no_resource}
73
+      resource -> {:ok, resource}
74
+    end
75
+  end
76
+
63 77
   def revoke_token(token) do
64 78
     Fortress.Guardian.revoke(token)
65 79
   end
80
+
81
+  def resolve_user_url(auth) do
82
+    auth.info.urls.url
83
+  end
66 84
 end

+ 1
- 1
web/plug/auth_pipeline/indie.ex View File

@@ -6,5 +6,5 @@ defmodule Fortress.Web.Plug.AuthPipeline.Indie do
6 6
   plug Guardian.Plug.VerifySession, key: :indie
7 7
   plug Guardian.Plug.VerifyCookie, key: :indie
8 8
   plug Guardian.Plug.VerifyHeader, key: :indie
9
-  plug Guardian.Plug.LoadResource, allow_blank: true
9
+  plug Guardian.Plug.LoadResource, key: :indie, allow_blank: false
10 10
 end

+ 3
- 3
web/plug/auth_pipeline/owner.ex View File

@@ -3,8 +3,8 @@ defmodule Fortress.Web.Plug.AuthPipeline.Owner do
3 3
     module: Fortress.Guardian,
4 4
     error_handler: Fortress.Web.Plug.AuthErrorHandler.Browser
5 5
 
6
-  plug Guardian.Plug.VerifySession, key: :owner
7
-  plug Guardian.Plug.VerifyCookie, key: :owner
8 6
   plug Guardian.Plug.VerifyHeader, key: :owner
9
-  plug Guardian.Plug.LoadResource, allow_blank: true
7
+  plug Guardian.Plug.VerifyCookie, key: :owner
8
+  plug Guardian.Plug.VerifySession, key: :owner
9
+  plug Guardian.Plug.LoadResource, key: :owner, allow_blank: true
10 10
 end

+ 15
- 3
web/router.ex View File

@@ -57,9 +57,21 @@ defmodule Fortress.Web.Router do
57 57
     get("/token", Indie.TokenApiController, :validate)
58 58
   end
59 59
 
60
-  scope "/api", Fortress.Web do
60
+  scope "/devices", Fortress.Web do
61
+    pipe_through([:browser, :owner_auth, :requires_owner_auth])
62
+    get("/new", DeviceController, :new)
63
+    put("/bond", DeviceController, :bond)
64
+  end
65
+
66
+  scope "/api/client", Fortress.Web do
67
+    pipe_through([:api])
68
+    put("/pair", Api.ClientController, :pair)
69
+    put("/synchronize", Api.ClientController, :synchronize)
70
+  end
71
+
72
+  scope "/api/client", Fortress.Web do
61 73
     pipe_through([:api, :owner_auth, :requires_owner_auth])
62
-    get("/client", Api.ClientController, :request)
63
-    post("/client", Api.ClientController, :invoke)
74
+    get("/", Api.ClientController, :request)
75
+    post("/", Api.ClientController, :invoke)
64 76
   end
65 77
 end

+ 1
- 0
web/templates/device/bond.html.eex View File

@@ -0,0 +1 @@
1
+Wow

+ 14
- 0
web/templates/device/new.html.eex View File

@@ -0,0 +1,14 @@
1
+<main class="w-100 bg-near-white near-black pa2">
2
+  <div class="flex flex-column items-start justify-start mw7 center pv3">
3
+    <h1 class="f2 measure fw5 dark-gray tracked-tight lh-title center tc">
4
+      Pair New Device
5
+    </h1>
6
+    <p class="lh-copy measure-wide center f5">
7
+      Open Fortress for your mobile desktop or desktop application and head to the
8
+      "Pairing" view. Enter the code that's on your desktop in the textbox below.
9
+    </p>
10
+    <%= form_for @conn, Routes.device_path(@conn, :bond), [method: :put], fn f ->  %>
11
+<%= text_input f, :code, class: "w-100 measure-wide input-reset bg-washed-yellow navy mv2 pa2 bw1 b--navy b--solid f4 f2-l code center" %>
12
+  <% end %>
13
+  </div>
14
+</main>

+ 1
- 1
web/templates/layout/app.html.eex View File

@@ -35,7 +35,7 @@
35 35
           <%= link "Apps", to: "#" %>
36 36
           <%= link "Code", to: "#" %>
37 37
           <%= link "Platforms", to: "#" %>
38
-          <%= if is_signed_in?(@conn), do: link("Sign Out", to: Routes.page_path(@conn, :signout)) %>
38
+          <%= if is_signed_in?(@conn), do: link("Sign Out", to: Routes.page_path(@conn, :signout), method: :delete) %>
39 39
       </div>
40 40
       </div>
41 41
       <div id="loader" class="absolute right-2 top-2 br-pill shadow-2 bg-dark-gray near-white pa3 dn">

+ 5
- 2
web/templates/page/homepage-login.html.leex View File

@@ -4,14 +4,17 @@
4 4
 <%= f = form_for :scanner, "#", [phx_submit: :scan, method: :get] %>
5 5
 <%= label f, :url, class: "f4" do %>
6 6
   URL
7
-  <%= text_input f, :url, class: "w-100 measure-wide input-reset bg-washed-yellow navy mv2 pa2 bw1 b--navy b--solid f4 f2-l code", value: (@scanner["url"] || @scanner["hcard"]["url"]) %>
7
+  <%= url_input f, :url, class: "w-100 measure-wide input-reset bg-washed-yellow navy mv2 pa2 bw1 b--navy b--solid f4 f3-l code", value: (@scanner["url"] || @scanner["hcard"]["url"]) %>
8 8
 <% end %>
9 9
 <div class="flex flex-row items-center justify-between center mw6 w-100" phx-hook="Generic">
10 10
   <%= if @scanner["hcard"] do %>
11
+    <div class="flex flex-column">
12
+    <%= if @indieauth_url do %><%= link "IndieAuth", to: @indieauth_url, class: "link navy hover-blue pa2" %><% end %>
11 13
     <button phx-click="begin-signin" class="sans b ph3 pv2 input-reset ba near-white b--dark-green bg-dark-green grow pointer f4 dib">Sign In</button>
14
+    </div>
12 15
     <div class="flex flex-column">
13 16
       <span class="v-mid pa2 animated fadeInUp">
14
-        <img src="<%= @scanner["hcard"]["photo"] %>" class="w2 h2 v-mid ma2" />
17
+        <img src="<%= @scanner["hcard"]["photo"] %>" class="w-auto h3 v-mid ma1" />
15 18
         <span class="f4 fw1"><%= @scanner["hcard"]["name"] %></span>
16 19
       </span>
17 20
       <span>

+ 29
- 9
web/templates/page/index.html.eex View File

@@ -3,29 +3,49 @@
3 3
     <h1 class="f1 f-headline-l measure fw1 dark-gray tracked-tight lh-title tc center">
4 4
       Log In As You.
5 5
     </h1>
6
-    <p class="f5 f3-l gray mt0 lh-copy center tc">
6
+    <p class="f5 f3-l near-black mt0 lh-copy center tc ttu">
7 7
       Interact with the open Web with your authentic self.
8 8
     </p>
9 9
     <%= live_render(@conn, Fortress.Web.HomepageAuthLiveView, container: {:div, class: "form--homepageLogin flex-auto mw7 tc center"}, session: %{scanner: %{"url" => @conn.params["url"]}}) %>
10 10
   </div>
11 11
 </main>
12
-<main class="w-100 bg-near-black near-white pa2">
12
+<main class="w-100 bg-near-white navy pa2">
13 13
   <div class="flex flex-column items-start justify-start mw8 center pv3">
14
-    <h1 class="f3 measure fw1 light-gray lh-title">What is Fortress?</h1>
15
-    <p class="lh-copy f4 measure">
14
+    <h1 class="f2-l f1 measure fw9 dark-gray lh-title">What is Fortress?</h1>
15
+    <p class="lh-copy f4 f3-l measure-narrow">
16 16
       <strong>Fortress</strong> provides Web sign-in to services and one's site using their domain name.
17 17
     </p>
18 18
     <p class="lh-copy f5 measure-wide">
19
-      Using <a class="link light-blue" href="https://indieauth.spec.indieweb.org">IndieAuth</a>, a site can allow
19
+      Using <a class="link blue" href="https://indieauth.spec.indieweb.org">IndieAuth</a>, a site can allow
20 20
       people to log in using identities that they already own. This makes it easier for you to protect things
21 21
       you want and share it only with people you expect to without them having to create a new account.
22 22
     </p>
23 23
     <p class="lh-copy f5 measure-wide">
24
-      Currently, Fortress allows people to verify their identities using the following:
24
+      Currently, Fortress allows people to verify their identities using the following friendly
25
+      <a class="link blue" href="https://indieweb.org/silo">silos</a>:
25 26
     </p>
26
-    <ul>
27
+    <ul class="list">
28
+      <li class="lh-copy v-mid"><i data-feather="github" class="v-mid w1 h1 ma1"></i>GitHub</li>
29
+      <li class="lh-copy v-mid"><i data-feather="gitlab" class="v-mid w1 h1 ma1"></i>GitLab</li>
30
+      <li class="lh-copy v-mid"><i data-feather="twitter" class="v-mid w1 h1 ma1"></i>Twitter</li>
31
+      <li class="lh-copy v-mid"><i data-feather="stackoverflow" class="v-mid w1 h1 ma1"></i>Stackoverflow</li>
32
+      <li class="lh-copy v-mid"><i data-feather="itch" class="v-mid w1 h1 ma1"></i>Itch</li>
33
+      <li class="lh-copy v-mid"><i data-feather="keybase" class="v-mid w1 h1 ma1"></i>Keybase</li>
34
+      <li class="lh-copy v-mid"><i data-feather="mastodon" class="v-mid w1 h1 ma1"></i>Mastodon instances</li>
35
+    </ul>
36
+    <h2 class="f3-l f2 measure fw9 dark-gray lh-title">Extending The Community</h2>
37
+    <p class="lh-copy f5 measure-wide">
38
+      Outside of leveraging rel=me-based sign-in, Fortress allows you to use your identities
39
+      from silos to authenticate to IndieWeb platforms. This is helpful if you currently
40
+      don't have an IndieWeb site but need access to protected content.
41
+    </p>
42
+     <p class="lh-copy f5 measure-wide">
43
+      Fortress supports the following <a class="link blue" href="https://indieweb.org/silo">silos</a> for extended identities:
44
+    </p>
45
+     <ul>
27 46
       <li class="lh-copy">GitHub</li>
28
-      <li class="lh-copy">GitLab</li>
47
+      <li class="lh-copy">Twitter</li>
48
+      <li class="lh-copy">Patreon</li>
29 49
     </ul>
30
-  </div>
50
+</div>
31 51
 </main>

+ 3
- 1
web/templates/resolver/present.html.leex View File

@@ -12,6 +12,8 @@
12 12
         <%= live_component @socket, Fortress.Web.ProviderLiveComponent, id: instance.url, instance: instance %>
13 13
       <% end %>
14 14
     </div>
15
-    <button phx-click="refresh-all-providers">Update</button>
15
+    <button class="ladda center button-reset dim ph3 pv2 mb2 dib white bg-black pointer bw0 br1" phx-click="refresh-all-providers">
16
+      Update
17
+    </button>
16 18
   </div>
17 19
 </main>

+ 3
- 0
web/views/device.ex View File

@@ -0,0 +1,3 @@
1
+defmodule Fortress.Web.DeviceView do
2
+  use Fortress.Web, :view
3
+end

+ 26
- 19
web/views/live/homepage_auth_view.ex View File

@@ -16,31 +16,38 @@ defmodule Fortress.Web.HomepageAuthLiveView do
16 16
   def handle_event(event, params, socket)
17 17
 
18 18
   def handle_event("scan", %{"scanner" => scanner}, socket) do
19
-    %{"url" => url} = scanner
19
+    url =
20
+      scanner["url"]
21
+      |> IndieWeb.URL.resolve_redirect()
22
+      |> URI.parse()
23
+      |> IndieWeb.URL.canonalize()
24
+      |> URI.to_string()
20 25
 
21 26
     with(
22 27
       {:ok, hcard} <- IndieWeb.HCard.fetch_representative(url),
23 28
       {:ok, site} <- Fortress.Repo.Site.upsert(hcard),
24 29
       _ <- Fortress.Workers.Provider.update_records(url),
25 30
       providers <- Fortress.Provider.fetch_all_for(site.url),
26
-      indieauth_provider <- Enum.find(providers, &(&1.platform == :indieauth))
31
+      indieauth_provider <-
32
+        Fortress.Provider.resolve_indieauth_providers(site.url) |> List.first()
27 33
     ) do
28
-      if indieauth_provider == nil do
29
-        {:noreply,
30
-         socket
31
-         |> assign(:scanner, %{"hcard" => hcard, "url" => hcard["url"]})
32
-         |> assign(:provider_count, Enum.count(providers))}
33
-      else
34
-        {:stop,
35
-         socket
36
-         |> redirect(
37
-           to:
38
-             Routes.resolver_path(socket, :request, indieauth_provider.platform,
39
-               me: indieauth_provider.me,
40
-               url: URI.to_string(indieauth_provider.url)
41
-             )
42
-         )}
43
-      end
34
+      {:noreply,
35
+       socket
36
+       |> assign(:scanner, %{"hcard" => hcard, "url" => url})
37
+       |> assign(:provider_count, Enum.count(providers))
38
+      |> assign(
39
+        :indieauth_url,
40
+        if indieauth_provider != nil do
41
+          Routes.resolver_path(socket, :request, indieauth_provider.platform,
42
+            me: indieauth_provider.me,
43
+            url: URI.to_string(indieauth_provider.url),
44
+            client_id: Fortress.Web.Endpoint.url(),
45
+            redirect_url: Routes.page_url(Fortress.Web.Endpoint, :index)
46
+          )
47
+        else
48
+          nil
49
+        end
50
+      )}
44 51
     else
45 52
       nil ->
46 53
         {:noreply,
@@ -50,6 +57,6 @@ defmodule Fortress.Web.HomepageAuthLiveView do
50 57
 
51 58
   def handle_event("begin-signin", _params, socket) do
52 59
     url = socket.assigns[:scanner]["hcard"]["url"]
53
-    {:stop, socket |> redirect(to: Routes.resolver_path(socket, :present, %{url: url}))}
60
+    {:stop, socket |> redirect(to: Routes.resolver_path(socket, :present, %{me: url}))}
54 61
   end
55 62
 end

+ 4
- 3
web/views/live/provider-component.ex View File

@@ -17,8 +17,9 @@ defmodule Fortress.Web.ProviderLiveComponent do
17 17
   end
18 18
 
19 19
   def get_message_for(instance)
20
-  def get_message_for(%{status: "pending"}), do: "Resolving..."
21
-  def get_message_for(%{status: "broken"}), do: "The remote link to your site is broken."
22
-  def get_message_for(%{status: "found"}), do: "Sign in is supported."
20
+  def get_message_for(%{status: "found", url: url}), do: "#{url} is yours."
21
+  def get_message_for(%{status: "broken", url: url}), do: "The remote link to your site is broken at #{url}."
22
+  def get_message_for(%{status: "found", provider: :indieauth}), do: "Directly use IndieAuth on your site."
23
+  def get_message_for(%{status: "pending", url: url}), do: "Resolving #{url}..."
23 24
   def get_message_for(_), do: "Undefined status for this platform."
24 25
 end

+ 17
- 4
web/views/live/resolver.ex View File

@@ -13,15 +13,19 @@ defmodule Fortress.Web.ResolverLiveView do
13 13
         instances,
14 14
         &Phoenix.PubSub.subscribe(Fortress.PubSub, self(), Fortress.Provider.Instance.topic_for(&1))
15 15
       )
16
+
17
+      Phoenix.PubSub.subscribe(Fortress.PubSub, self(), Fortress.Provider.topic_for(me))
16 18
     end
17 19
 
18 20
     {:ok, socket |> assign(assigns)}
19 21
   end
20 22
 
21
-  def handle_event("refresh-all-providers",_, socket) do
22
-    url = socket.assigns[:me]
23
+  def handle_event("refresh-all-providers", _, socket) do
24
+    url = socket.assigns[:me] |> IndieWeb.URL.canonalize
25
+    instances = socket.assigns[:instances]
26
+
23 27
     Fortress.Workers.Provider.update_records(url)
24
-    {:noreply, socket}
28
+    {:noreply, socket |> assign(:instances, Enum.map(instances, &Map.put(&1, :status, "pending")))}
25 29
   end
26 30
 
27 31
   def handle_event(_, _, socket), do: {:noreply, socket}
@@ -30,7 +34,10 @@ defmodule Fortress.Web.ResolverLiveView do
30 34
     Fortress.Workers.Provider.check_instance(instance)
31 35
     instances = socket.assigns[:instances]
32 36
     index = Enum.find_index(instances, fn inst -> instance.url == inst.url end)
33
-    {:noreply, socket |> assign(:instances, List.replace_at(instances, index, %{instance | status: "pending"}))}
37
+
38
+    {:noreply,
39
+     socket
40
+     |> assign(:instances, List.replace_at(instances, index, %{instance | status: "pending"}))}
34 41
   end
35 42
 
36 43
   def handle_info(%Fortress.Provider.Instance{} = instance, socket) do
@@ -38,4 +45,10 @@ defmodule Fortress.Web.ResolverLiveView do
38 45
     index = Enum.find_index(instances, fn inst -> instance.url == inst.url end)
39 46
     {:noreply, socket |> assign(:instances, List.replace_at(instances, index, instance))}
40 47
   end
48
+  def handle_info(me, socket) when is_binary(me) do
49
+    if me == socket.assigns[:me] do
50
+      instances = Fortress.Provider.fetch_all_for(me)
51
+      {:noreply, socket |> assign(:instances, instances)}
52
+    end
53
+  end
41 54
 end

Loading…
Cancel
Save