Browse Source

feat(hfeed): Add basis.

jackyalcine 3 weeks ago
parent
commit
0d40106d98
Signed by: Jacky Alciné <yo@jacky.wtf> GPG Key ID: 537A4F904B15268D

+ 6
- 1
lib/feed.ex View File

@@ -21,7 +21,11 @@ defmodule Koype.Feed do
21 21
   @semantic_types ~w(all responses content notifications category)a
22 22
 
23 23
   def formats() do
24
-    [%{"name" => "JSON Feed", "type" => "json"}, %{"name" => "ATOM 1.0", "type" => "atom"}]
24
+    [
25
+      %{"name" => "JSON Feed", "type" => "json"},
26
+      %{"name" => "ATOM 1.0", "type" => "atom"},
27
+      %{"name" => "hFeed", "type" => "hfeed"}
28
+    ]
25 29
   end
26 30
 
27 31
   def items(options \\ []) do
@@ -161,6 +165,7 @@ defmodule Koype.Feed do
161 165
   defp do_get_module_for_type(type)
162 166
   defp do_get_module_for_type(:json), do: Koype.Feed.Json
163 167
   defp do_get_module_for_type(:atom), do: Koype.Feed.Atom
168
+  defp do_get_module_for_type(:hfeed), do: Koype.Feed.HFeed
164 169
   defp do_get_module_for_type(_), do: :not_supported
165 170
 
166 171
   defp do_extract_semantic_types(type, options) when type in @semantic_types,

+ 212
- 2
lib/feed/hfeed.ex View File

@@ -17,7 +17,217 @@
17 17
 # You should have received a copy of the GNU Affero General Public License
18 18
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
19 19
 defmodule Koype.Feed.HFeed do
20
-  def generate(_items, _options) do
21
-    # TODO: Generate h-feed HTML using Floki
20
+  require Logger
21
+
22
+  def default_options() do
23
+    [
24
+      title: Koype.host(),
25
+      description: "A feed available at #{Koype.host()}",
26
+      uri: [
27
+        icon: Koype.Profile.photo_uri(),
28
+        favicon: Koype.Profile.photo_uri()
29
+      ]
30
+    ]
31
+  end
32
+
33
+  def generate(items, options) do
34
+    uri = Keyword.merge(default_options()[:uri], Keyword.get(options, :uri, []))
35
+    final_options = Keyword.merge(default_options(), options) |> Keyword.put(:uri, uri)
36
+
37
+    result = %{
38
+      "title" => final_options[:title],
39
+      "home_page_url" => final_options[:uri][:self],
40
+      "feed_url" => final_options[:uri][:feed],
41
+      "description" => final_options[:description],
42
+      "next_url" => final_options[:uri][:next],
43
+      "previous_url" => final_options[:uri][:previous],
44
+      "last_url" => final_options[:uri][:last],
45
+      "first_url" => final_options[:uri][:first],
46
+      "icon" => final_options[:uri][:icon],
47
+      "favicon" => final_options[:uri][:favicon],
48
+      "items" => items.entries,
49
+      "hubs" => [
50
+        %{
51
+          "url" => final_options[:uri][:hub],
52
+          "type" => "websub"
53
+        }
54
+      ],
55
+      "author" => %{
56
+        "name" => Koype.Profile.displayed_name(),
57
+        "url" => Koype.host(),
58
+        "avatar" => final_options[:uri][:icon]
59
+      },
60
+      "_koype" => %{
61
+        "about" => "https://koype.net",
62
+        "version" => Koype.version()
63
+      }
64
+    }
65
+
66
+    Enum.join(
67
+      [do_generate_header(result), do_generate_contents(result), do_generate_footer(result)],
68
+      "\n"
69
+    )
70
+  end
71
+
72
+  defp do_generate_header(result) do
73
+    hub_strings =
74
+      Enum.map(result["hubs"], fn hub -> '<link rel="#{hub["type"]}" href="#{hub["url"]}" />' end)
75
+      |> Enum.join("\n")
76
+
77
+    """
78
+    <html>
79
+      <head>
80
+        <title>#{result["title"]}</title>
81
+        #{hub_strings}
82
+        <link rel="shortcut icon" href="#{result["favicon"]}" />
83
+        <link rel="self" href="#{result["feed_url"]}" />
84
+        <link rel="next" href="#{result["next_url"]}" />
85
+        <link rel="first" href="#{result["first_url"]}" />
86
+        <link rel="last" href="#{result["last_url"]}" />
87
+        <link rel="previous" href="#{result["previous_url"]}" />
88
+      </head>
89
+      <body class="h-feed">
90
+        <h1 class="p-name"><a href="#{result["feed_url"]}">#{result["title"]}</a></h1>
91
+        <p class="e-note">#{result["description"]}</p>
92
+        <p>
93
+          Generated for <a href="#{result["author"]["url"]}" class="u-author p-name">#{
94
+      result["author"]["name"]
95
+    }</a>.
96
+        </p>
97
+    """
98
+  end
99
+
100
+  defp do_generate_footer(_) do
101
+    """
102
+      </body>
103
+    </html>
104
+    """
105
+  end
106
+
107
+  defp do_generate_contents(result) do
108
+    result["items"]
109
+    |> Enum.map(fn item ->
110
+      Task.async(fn ->
111
+        do_convert_item_to_html(item) |> Apex.ap()
112
+      end)
113
+    end)
114
+    |> Task.yield_many(20_000)
115
+    |> Enum.map(fn
116
+      {task, nil} -> Task.shutdown(task, :brutual_kill)
117
+      {_, {:ok, res}} when not is_nil(res) -> res
118
+      _ -> nil
119
+    end)
120
+    |> Enum.reject(&is_nil/1)
121
+    |> Enum.join()
122
+  end
123
+
124
+  defp do_convert_item_to_html(item)
125
+
126
+  defp do_convert_item_to_html(%Koype.Repo.Webmention{} = item) do
127
+    with(
128
+      {:ok, webmention_json} <- Koype.Repo.Webmention.json_find(item),
129
+      {:ok, _entry} <- IndieWeb.Webmention.resolve_target_from_url(item.target)
130
+    ) do
131
+      Apex.ap(webmention_json)
132
+      wm_mf2 = webmention_json["mf2"]
133
+
134
+      """
135
+      <div class="h-entry">
136
+        <a class="u-url" href="#{item.source}"></a>
137
+      #{
138
+        Enum.join(
139
+          [
140
+            do_generate_author(webmention_json["author"]),
141
+            do_extract_reply_links(wm_mf2),
142
+            do_extract_media(wm_mf2),
143
+            do_extract_content(wm_mf2)
144
+          ]
145
+          |> Enum.reject(&is_nil/1),
146
+          "\n"
147
+        )
148
+      }
149
+      </div>
150
+      """
151
+    else
152
+      _ -> ""
153
+    end
154
+  end
155
+
156
+  def do_generate_author(author) do
157
+    '<a class="u-author p-name" href="#{author["url"]}">#{author["name"]}</a>'
158
+  end
159
+
160
+  def do_get_url_of_reply(mf2)
161
+  def do_get_url_of_reply(mf2) when is_binary(mf2), do: mf2
162
+  def do_get_url_of_reply(mf2) when is_map(mf2), do: mf2["url"]
163
+
164
+  def do_extract_reply_links(wm_mf2) do
165
+    props = wm_mf2["properties"]
166
+    resolved_types = IndieWeb.Post.extract_types(props)
167
+    dominant_type = IndieWeb.Post.determine_type(props, resolved_types)
168
+
169
+    case dominant_type do
170
+      :like ->
171
+        '<a class="u-like-of" href="#{do_get_url_of_reply(props["like-of"])}"></a>'
172
+
173
+      :repost ->
174
+        '<a class="u-repost-of" href="#{do_get_url_of_reply(props["repost-of"])}"></a>'
175
+
176
+      :bookmark ->
177
+        '<a class="u-bookmark-of" href="#{do_get_url_of_reply(props["bookmark-of"])}"></a>'
178
+
179
+      :reply ->
180
+        '<a class="u-in-reply-to" href="#{do_get_url_of_reply(props["in-reply-to"])}"></a>'
181
+
182
+      :rsvp ->
183
+        '<a class="u-in-reply-to" href="#{do_get_url_of_reply(props["in-reply-to"])}"></a>'
184
+
185
+      _ ->
186
+        ""
187
+    end
188
+  end
189
+
190
+  def do_extract_media(wm_mf2) do
191
+    wm_mf2["properties"]
192
+    |> Map.take(~w(audio video photo))
193
+    |> Enum.map(fn
194
+      {"photo", media} ->
195
+        media
196
+        |> Enum.map(fn entry -> '<img src="#{entry}" class="u-photo" />' end)
197
+        |> Enum.join("\n")
198
+
199
+      {"video", media} ->
200
+        media
201
+        |> Enum.map(fn entry ->
202
+          '<video controls><source src="#{entry}" class="u-video" /></video>'
203
+        end)
204
+        |> Enum.join("\n")
205
+
206
+      {"audio", media} ->
207
+        media
208
+        |> Enum.map(fn entry ->
209
+          '<audio controls><source src="#{entry}" class="u-audio" /></audio>'
210
+        end)
211
+        |> Enum.join("\n")
212
+
213
+      _ ->
214
+        nil
215
+    end)
216
+    |> Enum.reject(&is_nil/1)
217
+    |> Enum.join("\n")
218
+  end
219
+
220
+  def do_extract_content(wm_mf2) do
221
+    content_mf2 = wm_mf2["properties"]["content"]
222
+    is_html = Map.has_key?(content_mf2, "html")
223
+
224
+    content =
225
+      if is_html do
226
+        content_mf2["html"]
227
+      else
228
+        content_mf2["text"]
229
+      end
230
+
231
+    '<div class="#{if is_html, do: "e-content", else: "p-content"}">#{content}</div>'
22 232
   end
23 233
 end

+ 1
- 1
lib/indieweb/mf2.ex View File

@@ -42,7 +42,7 @@ defmodule IndieWeb.MF2 do
42 42
   def parse_item(item, format) do
43 43
     types = Map.get(item, "type", [])
44 44
 
45
-    if Enum.member?(types, "h-#{format}") do
45
+    if Enum.member?(types |> Apex.ap(), "h-#{format}") |> Apex.ap() do
46 46
       [item]
47 47
     else
48 48
       Logger.info(

+ 5
- 4
lib/indieweb/websub.ex View File

@@ -11,11 +11,12 @@ defmodule IndieWeb.WebSub do
11 11
 
12 12
   def notify!(%Koype.Repo.Entry{} = entry), do: notify!(feeds_for(entry))
13 13
 
14
-  def notify!(urls) when is_binary(urls) do
14
+  def notify!(urls) when is_list(urls) do
15 15
     payload = %{"hub.mode" => "publish", "hub.url" => urls} |> Plug.Conn.Query.encode()
16
+    endpoint = hub_endpoint()
16 17
 
17
-    if hub_endpoint() != nil do
18
-      case Koype.Http.post(hub_endpoint(),
18
+    if endpoint != nil do
19
+      case Koype.Http.post(endpoint,
19 20
              body: payload,
20 21
              headers: %{"Content-Type" => "application/x-www-form-urlencoded"}
21 22
            ) do
@@ -34,7 +35,7 @@ defmodule IndieWeb.WebSub do
34 35
           error
35 36
       end
36 37
     else
37
-      Logger.warn("No WebSub endpoint was defined so no publish action will occur.", urls: urls)
38
+      Logger.info("No WebSub endpoint was defined so no publish action will occur.", urls: urls)
38 39
     end
39 40
   end
40 41
 

+ 2
- 2
lib/web.ex View File

@@ -81,13 +81,13 @@ defmodule Koype.Web do
81 81
 
82 82
           {:error, %Plug.Conn{} = error_conn} ->
83 83
             error_conn
84
-            |> put_resp_content_type("text/plain")
84
+            |> put_resp_content_type("text/plain", nil)
85 85
 
86 86
           {:error, error} ->
87 87
             Logger.warn("Failed to render the page.", error: inspect(error))
88 88
 
89 89
             conn
90
-            |> put_resp_content_type("text/html")
90
+            |> put_resp_content_type("text/html", nil)
91 91
             |> Explode.internal_server_error("Failed to render the page.")
92 92
         end
93 93
       end

+ 4
- 4
test/integration/controllers/entry_controller_test.exs View File

@@ -37,8 +37,8 @@ defmodule Koype.Web.EntryControllerTest do
37 37
       resp =
38 38
         build_conn() |> put_req_header("accept", "application/json") |> get(Model.get_path(entry))
39 39
 
40
-      assert response(resp, :bad_request)
41
-      # assert resp.resp_headers |> Map.new() |> Map.get("content-type", "") =~ "mf2+json"
40
+      assert response(resp, :ok)
41
+      assert resp.resp_headers |> Map.new() |> Map.get("content-type", "") =~ "mf2+json"
42 42
     end
43 43
 
44 44
     test "200 when visiting public post as MF2+JSON" do
@@ -51,8 +51,8 @@ defmodule Koype.Web.EntryControllerTest do
51 51
         |> put_req_header("accept", "application/mf2+json")
52 52
         |> get(Model.get_path(entry))
53 53
 
54
-      assert response(mf2_resp, :bad_request)
55
-      # assert mf2_resp.resp_headers |> Map.new() |> Map.get("content-type", "") =~ "mf2+json"
54
+      assert response(mf2_resp, :ok)
55
+      assert mf2_resp.resp_headers |> Map.new() |> Map.get("content-type", "") =~ "mf2+json"
56 56
     end
57 57
 
58 58
     @tag skip: true

+ 5
- 2
test/support/factory.ex View File

@@ -503,8 +503,11 @@ defmodule Koype.Factory do
503 503
   def key_for_post_type(:bookmark), do: "bookmark-of"
504 504
   def key_for_post_type(:repost), do: "repost-of"
505 505
 
506
-  def with_mf2(%Koype.Repo.Webmention{} = model) do
507
-    mf2 = %{"properties" => entry_json_factory() |> with_html_content, "type" => ["h-entry"]}
506
+  def with_mf2(%Koype.Repo.Webmention{} = model, mf2 \\ %{}) do
507
+    mf2 = %{
508
+      "properties" => entry_json_factory() |> with_html_content |> Map.merge(mf2),
509
+      "type" => ["h-entry"]
510
+    }
508 511
 
509 512
     {:ok, _} =
510 513
       Koype.Repo.Webmention.json_persist(model, %{

+ 32
- 0
test/unit/feed/hfeed_test.exs View File

@@ -0,0 +1,32 @@
1
+defmodule Koype.Feed.HFeedTest do
2
+  use Koype.DataCase
3
+  alias Koype.Feed.HFeed, as: Subject
4
+
5
+  describe ".generate/2" do
6
+    test "renders a empty h-feed with no items" do
7
+      entries = insert_list(5, :webmention, source: "https://jacky.wtf")
8
+      Enum.each(entries, fn wm -> {:ok, _} = wm |> with_mf2 end)
9
+
10
+      hfeed_html =
11
+        Subject.generate(
12
+          %{
13
+            entries: entries,
14
+            uri: %{
15
+              self: "/self",
16
+              feed: "/the_feed",
17
+              previous: Faker.Internet.url(),
18
+              next: Faker.Internet.url()
19
+            }
20
+          },
21
+          []
22
+        )
23
+
24
+      hfeed_mf2 =
25
+        hfeed_html
26
+        |> Microformats2.parse("https://jacky.wtf")
27
+
28
+      assert hfeed_mf2
29
+      assert IndieWeb.MF2.get_format(hfeed_mf2, :feed)
30
+    end
31
+  end
32
+end

+ 1
- 1
web/controllers/entry_controller.ex View File

@@ -198,7 +198,7 @@ defmodule Koype.Web.EntryController do
198 198
         format: format
199 199
       )
200 200
 
201
-      do_entry_content_generation(conn, format, template_data: template_data, entry: entry)
201
+      do_entry_content_generation(conn, "html", template_data: template_data, entry: entry)
202 202
     else
203 203
       {:ok, %Koype.Repo.Entry{deleted_at: date}} when not is_nil(date) ->
204 204
         conn

+ 4
- 1
web/controllers/feed_controller.ex View File

@@ -23,6 +23,7 @@ defmodule Koype.Web.FeedController do
23 23
   defp do_get_feed(format, options)
24 24
   defp do_get_feed("atom" <> _, options), do: Koype.Feed.generate(:atom, options)
25 25
   defp do_get_feed("json", options), do: Koype.Feed.generate(:json, options)
26
+  defp do_get_feed("hfeed", options), do: Koype.Feed.generate(:hfeed, options)
26 27
 
27 28
   defp do_get_title(kind)
28 29
   defp do_get_title("mentions"), do: "Mentions received for #{Koype.Profile.displayed_name()}."
@@ -42,6 +43,7 @@ defmodule Koype.Web.FeedController do
42 43
 
43 44
   defp do_get_content_type_for_feed("atom"), do: "application/atom+xml"
44 45
   defp do_get_content_type_for_feed("json"), do: "application/json"
46
+  defp do_get_content_type_for_feed("hfeed"), do: "text/html"
45 47
 
46 48
   defp do_get_feed_self_path(conn, assigns) do
47 49
     case Map.get(assigns, "type", "content") do
@@ -105,7 +107,8 @@ defmodule Koype.Web.FeedController do
105 107
   def render(conn, assigns)
106 108
 
107 109
   @doc false
108
-  def render(conn, %{"format" => format, "type" => type} = assigns) when format in ~w(atom json) do
110
+  def render(conn, %{"format" => format, "type" => type} = assigns)
111
+      when format in ~w(atom json hfeed) do
109 112
     options = [
110 113
       description: do_get_title(type),
111 114
       uri: [

+ 0
- 5
web/router.ex View File

@@ -176,11 +176,6 @@ defmodule Koype.Web.Router do
176 176
     get("/schema/:version", NodeInfoController, :version)
177 177
   end
178 178
 
179
-  scope "/", Koype.Web do
180
-    pipe_through([:browser])
181
-    get("/*path", FallbackController, :catch_all)
182
-  end
183
-
184 179
   def swagger_info do
185 180
     %{
186 181
       info: %{

+ 2
- 2
web/templates/settings/_view-metadata.html.eex View File

@@ -5,7 +5,7 @@
5 5
   <dl>
6 6
     <dt class="lh-copy pv2">
7 7
     <div class="pretty p-success p-fill p-switch center">
8
-      <input type="checkbox" name="silo[]" value="facebook" <%= if Koype.Setting.get("metadata:facebook", false), do: "checked" %> />
8
+      <input type="checkbox" name="silo[]" value="facebook" <%= if Koype.Setting.get("metadata:facebook", "no") == "yes", do: "checked" %> />
9 9
       <div class="state">
10 10
         <label>Facebook</label>
11 11
       </div>
@@ -13,7 +13,7 @@
13 13
     </dt>
14 14
     <dt class="lh-copy pv2">
15 15
     <div class="pretty p-success p-fill p-switch center">
16
-      <input type="checkbox" name="silo[]" value="twitter" <%= if Koype.Setting.get("metadata:twitter", false), do: "checked" %> />
16
+      <input type="checkbox" name="silo[]" value="twitter" <%= if Koype.Setting.get("metadata:twitter", "no") == "yes", do: "checked" %> />
17 17
       <div class="state">
18 18
         <label>Twitter</label>
19 19
       </div>

Loading…
Cancel
Save