logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma
commit: 2cc043591c0d69a8793a70db39ddd2429b1c03f5
parent: 55ac38c1d26eb2e59b66eea224f621283d187212
Author: kaniini <ariadne@dereferenced.org>
Date:   Mon, 11 Nov 2019 19:10:44 +0000

Merge branch 'feature/static-fe' into 'develop'

Static frontend

See merge request pleroma/pleroma!1917

Diffstat:

MCHANGELOG.md1+
Mconfig/config.exs2++
Mdocs/configuration/cheatsheet.md7+++++++
Alib/pleroma/plugs/static_fe_plug.ex26++++++++++++++++++++++++++
Mlib/pleroma/web/router.ex1+
Alib/pleroma/web/static_fe/static_fe_controller.ex124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/pleroma/web/static_fe/static_fe_view.ex47+++++++++++++++++++++++++++++++++++++++++++++++
Alib/pleroma/web/templates/layout/static_fe.html.eex15+++++++++++++++
Alib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex8++++++++
Alib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex37+++++++++++++++++++++++++++++++++++++
Alib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex11+++++++++++
Alib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex11+++++++++++
Alib/pleroma/web/templates/static_fe/static_fe/error.html.eex7+++++++
Alib/pleroma/web/templates/static_fe/static_fe/profile.html.eex31+++++++++++++++++++++++++++++++
Apriv/static/static/static-fe.css176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/web/static_fe/static_fe_controller_test.exs181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
16 files changed, 685 insertions(+), 0 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Refreshing poll results for remote polls - Authentication: Added rate limit for password-authorized actions / login existence checks +- Static Frontend: Add the ability to render user profiles and notices server-side without requiring JS app. - Mix task to re-count statuses for all users (`mix pleroma.count_statuses`) - Support for `X-Forwarded-For` and similar HTTP headers which used by reverse proxies to pass a real user IP address to the backend. Must not be enabled unless your instance is behind at least one reverse proxy (such as Nginx, Apache HTTPD or Varnish Cache). <details> diff --git a/config/config.exs b/config/config.exs @@ -605,6 +605,8 @@ config :pleroma, Pleroma.ActivityExpiration, enabled: true config :pleroma, Pleroma.Plugs.RemoteIp, enabled: false +config :pleroma, :static_fe, enabled: false + config :pleroma, :web_cache_ttl, activity_pub: nil, activity_pub_question: 30_000 diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md @@ -798,3 +798,10 @@ config :auto_linker, ] ``` +## :static_fe + +Render profiles and posts using server-generated HTML that is viewable without using JavaScript. + +Available options: + +* `enabled` - Enables the rendering of static HTML. Defaults to `false`. diff --git a/lib/pleroma/plugs/static_fe_plug.ex b/lib/pleroma/plugs/static_fe_plug.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.StaticFEPlug do + import Plug.Conn + alias Pleroma.Web.StaticFE.StaticFEController + + def init(options), do: options + + def call(conn, _) do + if enabled?() and accepts_html?(conn) do + conn + |> StaticFEController.call(:show) + |> halt() + else + conn + end + end + + defp enabled?, do: Pleroma.Config.get([:static_fe, :enabled], false) + + defp accepts_html?(conn) do + conn |> get_req_header("accept") |> List.first() |> String.contains?("text/html") + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex @@ -495,6 +495,7 @@ defmodule Pleroma.Web.Router do pipeline :ostatus do plug(:accepts, ["html", "xml", "atom", "activity+json", "json"]) + plug(Pleroma.Plugs.StaticFEPlug) end pipeline :oembed do diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -0,0 +1,124 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.StaticFE.StaticFEController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Metadata + alias Pleroma.Web.Router.Helpers + + plug(:put_layout, :static_fe) + plug(:put_view, Pleroma.Web.StaticFE.StaticFEView) + plug(:assign_id) + + @page_keys ["max_id", "min_id", "limit", "since_id", "order"] + + defp get_title(%Object{data: %{"name" => name}}) when is_binary(name), + do: name + + defp get_title(%Object{data: %{"summary" => summary}}) when is_binary(summary), + do: summary + + defp get_title(_), do: nil + + def get_counts(%Activity{} = activity) do + %Object{data: data} = Object.normalize(activity) + + %{ + likes: data["like_count"] || 0, + replies: data["repliesCount"] || 0, + announces: data["announcement_count"] || 0 + } + end + + def represent(%Activity{} = activity), do: represent(activity, false) + + def represent(%Activity{object: %Object{data: data}} = activity, selected) do + {:ok, user} = User.get_or_fetch(activity.object.data["actor"]) + + link = + case user.local do + true -> Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) + _ -> data["url"] || data["external_url"] || data["id"] + end + + %{ + user: user, + title: get_title(activity.object), + content: data["content"] || nil, + attachment: data["attachment"], + link: link, + published: data["published"], + sensitive: data["sensitive"], + selected: selected, + counts: get_counts(activity), + id: activity.id + } + end + + def show(%{assigns: %{notice_id: notice_id}} = conn, _params) do + with %Activity{local: true} = activity <- + Activity.get_by_id_with_object(notice_id), + true <- Visibility.is_public?(activity.object), + %User{} = user <- User.get_by_ap_id(activity.object.data["actor"]) do + meta = Metadata.build_tags(%{activity_id: notice_id, object: activity.object, user: user}) + + timeline = + activity.object.data["context"] + |> ActivityPub.fetch_activities_for_context(%{}) + |> Enum.reverse() + |> Enum.map(&represent(&1, &1.object.id == activity.object.id)) + + render(conn, "conversation.html", %{activities: timeline, meta: meta}) + else + _ -> + conn + |> put_status(404) + |> render("error.html", %{message: "Post not found.", meta: ""}) + end + end + + def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do + case User.get_cached_by_nickname_or_id(username_or_id) do + %User{} = user -> + meta = Metadata.build_tags(%{user: user}) + + timeline = + ActivityPub.fetch_user_activities(user, nil, Map.take(params, @page_keys)) + |> Enum.map(&represent/1) + + prev_page_id = + (params["min_id"] || params["max_id"]) && + List.first(timeline) && List.first(timeline).id + + next_page_id = List.last(timeline) && List.last(timeline).id + + render(conn, "profile.html", %{ + user: user, + timeline: timeline, + prev_page_id: prev_page_id, + next_page_id: next_page_id, + meta: meta + }) + + _ -> + conn + |> put_status(404) + |> render("error.html", %{message: "User not found.", meta: ""}) + end + end + + def assign_id(%{path_info: ["notice", notice_id]} = conn, _opts), + do: assign(conn, :notice_id, notice_id) + + def assign_id(%{path_info: ["users", user_id]} = conn, _opts), + do: assign(conn, :username_or_id, user_id) + + def assign_id(conn, _opts), do: conn +end diff --git a/lib/pleroma/web/static_fe/static_fe_view.ex b/lib/pleroma/web/static_fe/static_fe_view.ex @@ -0,0 +1,47 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.StaticFE.StaticFEView do + use Pleroma.Web, :view + + alias Calendar.Strftime + alias Pleroma.Emoji.Formatter + alias Pleroma.User + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Gettext + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Metadata.Utils + alias Pleroma.Web.Router.Helpers + + use Phoenix.HTML + + @media_types ["image", "audio", "video"] + + def emoji_for_user(%User{} = user) do + user.source_data + |> Map.get("tag", []) + |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) + |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> + {String.trim(name, ":"), url} + end) + end + + def fetch_media_type(%{"mediaType" => mediaType}) do + Utils.fetch_media_type(@media_types, mediaType) + end + + def format_date(date) do + {:ok, date, _} = DateTime.from_iso8601(date) + Strftime.strftime!(date, "%Y/%m/%d %l:%M:%S %p UTC") + end + + def instance_name, do: Pleroma.Config.get([:instance, :name], "Pleroma") + + def open_content? do + Pleroma.Config.get( + [:frontend_configurations, :collapse_message_with_subjects], + true + ) + end +end diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1,minimal-ui" /> + <title><%= Pleroma.Config.get([:instance, :name]) %></title> + <%= Phoenix.HTML.raw(assigns[:meta] || "") %> + <link rel="stylesheet" href="/static/static-fe.css"> + </head> + <body> + <div class="container"> + <%= render @view_module, @view_template, assigns %> + </div> + </body> +</html> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex @@ -0,0 +1,8 @@ +<%= case @mediaType do %> +<% "audio" -> %> +<audio src="<%= @url %>" controls="controls"></audio> +<% "video" -> %> +<video src="<%= @url %>" controls="controls"></video> +<% _ -> %> +<img src="<%= @url %>" alt="<%= @name %>" title="<%= @name %>"> +<% end %> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex @@ -0,0 +1,37 @@ +<div class="activity" <%= if @selected do %> id="selected" <% end %>> + <p class="pull-right"> + <%= link format_date(@published), to: @link, class: "activity-link" %> + </p> + <%= render("_user_card.html", %{user: @user}) %> + <div class="activity-content"> + <%= if @title != "" do %> + <details <%= if open_content?() do %>open<% end %>> + <summary><%= raw @title %></summary> + <div class="e-content"><%= raw @content %></div> + </details> + <% else %> + <div class="e-content"><%= raw @content %></div> + <% end %> + <%= for %{"name" => name, "url" => [url | _]} <- @attachment do %> + <%= if @sensitive do %> + <details class="nsfw"> + <summary><%= Gettext.gettext("sensitive media") %></summary> + <div> + <%= render("_attachment.html", %{name: name, url: url["href"], + mediaType: fetch_media_type(url)}) %> + </div> + </details> + <% else %> + <%= render("_attachment.html", %{name: name, url: url["href"], + mediaType: fetch_media_type(url)}) %> + <% end %> + <% end %> + </div> + <%= if @selected do %> + <dl class="counts"> + <dt><%= Gettext.gettext("replies") %></dt><dd><%= @counts.replies %></dd> + <dt><%= Gettext.gettext("announces") %></dt><dd><%= @counts.announces %></dd> + <dt><%= Gettext.gettext("likes") %></dt><dd><%= @counts.likes %></dd> + </dl> + <% end %> +</div> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex @@ -0,0 +1,11 @@ +<div class="p-author h-card"> + <a class="u-url" rel="author noopener" href="<%= User.profile_url(@user) %>"> + <div class="avatar"> + <img src="<%= User.avatar_url(@user) |> MediaProxy.url %>" width="48" height="48" alt=""> + </div> + <span class="display-name"> + <bdi><%= raw (@user.name |> Formatter.emojify(emoji_for_user(@user))) %></bdi> + <span class="nickname"><%= @user.nickname %></span> + </span> + </a> +</div> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex @@ -0,0 +1,11 @@ +<header> + <h1><%= link instance_name(), to: "/" %></h1> +</header> + +<main> + <div class="conversation"> + <%= for activity <- @activities do %> + <%= render("_notice.html", activity) %> + <% end %> + </div> +</main> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/error.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/error.html.eex @@ -0,0 +1,7 @@ +<header> + <h1><%= gettext("Oops") %></h1> +</header> + +<main> + <p><%= @message %></p> +</main> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex @@ -0,0 +1,31 @@ +<header> + <h1><%= link instance_name(), to: "/" %></h1> + + <h3> + <form class="pull-right collapse" method="POST" action="<%= Helpers.util_path(@conn, :remote_subscribe) %>"> + <input type="hidden" name="nickname" value="<%= @user.nickname %>"> + <input type="hidden" name="profile" value=""> + <button type="submit" class="collapse">Remote follow</button> + </form> + <%= raw Formatter.emojify(@user.name, emoji_for_user(@user)) %> | + <%= link "@#{@user.nickname}@#{Endpoint.host()}", to: User.profile_url(@user) %> + </h3> + <p><%= raw @user.bio %></p> +</header> + +<main> + <div class="activity-stream"> + <%= for activity <- @timeline do %> + <%= render("_notice.html", Map.put(activity, :selected, false)) %> + <% end %> + <p id="pagination"> + <%= if @prev_page_id do %> + <%= link "«", to: "?min_id=" <> @prev_page_id %> + <% end %> + <%= if @prev_page_id && @next_page_id, do: " | " %> + <%= if @next_page_id do %> + <%= link "»", to: "?max_id=" <> @next_page_id %> + <% end %> + </p> + </div> +</main> diff --git a/priv/static/static/static-fe.css b/priv/static/static/static-fe.css @@ -0,0 +1,176 @@ +body { + background-color: #282c37; + font-family: sans-serif; + color: white; +} + +main { + margin: 50px auto; + max-width: 960px; + padding: 40px; + background-color: #313543; + border-radius: 4px; +} + +header { + margin: 50px auto; + max-width: 960px; + padding: 40px; + background-color: #313543; + border-radius: 4px; +} + +.activity { + border-radius: 4px; + padding: 1em; + padding-bottom: 2em; + margin-bottom: 1em; +} + +.avatar { + cursor: pointer; +} + +.avatar img { + float: left; + border-radius: 4px; + margin-right: 4px; +} + +.activity-content img, video, audio { + padding: 1em; + max-width: 800px; + max-height: 800px; +} + +#selected { + background-color: #1b2735; +} + +.counts dt, .counts dd { + float: left; + margin-left: 1em; +} + +a { + color: white; +} + +.h-card { + min-height: 48px; + margin-bottom: 8px; +} + +header a, .h-card a { + text-decoration: none; +} + +header a:hover, .h-card a:hover { + text-decoration: underline; +} + +.display-name { + padding-top: 4px; + display: block; + text-overflow: ellipsis; + overflow: hidden; + color: white; +} + +/* keep emoji from being hilariously huge */ +.display-name img { + max-height: 1em; +} + +.display-name .nickname { + padding-top: 4px; + display: block; +} + +.nickname:hover { + text-decoration: none; +} + +.pull-right { + float: right; +} + +.collapse { + margin: 0; + width: auto; +} + +h1 { + margin: 0; +} + +h2 { + color: #9baec8; + font-weight: normal; + font-size: 20px; + margin-bottom: 40px; +} + +form { + width: 100%; +} + +input { + box-sizing: border-box; + width: 100%; + padding: 10px; + margin-top: 20px; + background-color: rgba(0,0,0,.1); + color: white; + border: 0; + border-bottom: 2px solid #9baec8; + font-size: 14px; +} + +input:focus { + border-bottom: 2px solid #4b8ed8; +} + +input[type="checkbox"] { + width: auto; +} + +button { + box-sizing: border-box; + width: 100%; + color: white; + background-color: #419bdd; + border-radius: 4px; + border: none; + padding: 10px; + margin-top: 30px; + text-transform: uppercase; + font-weight: 500; + font-size: 16px; +} + +.alert-danger { + box-sizing: border-box; + width: 100%; + color: #D8000C; + background-color: #FFD2D2; + border-radius: 4px; + border: none; + padding: 10px; + margin-top: 20px; + font-weight: 500; + font-size: 16px; +} + +.alert-info { + box-sizing: border-box; + width: 100%; + color: #00529B; + background-color: #BDE5F8; + border-radius: 4px; + border: none; + padding: 10px; + margin-top: 20px; + font-weight: 500; + font-size: 16px; +} diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs @@ -0,0 +1,181 @@ +defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do + use Pleroma.Web.ConnCase + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + clear_config_all([:static_fe, :enabled]) do + Pleroma.Config.put([:static_fe, :enabled], true) + end + + describe "user profile page" do + test "just the profile as HTML", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/users/#{user.nickname}") + + assert html_response(conn, 200) =~ user.nickname + end + + test "renders json unless there's an html accept header", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> get("/users/#{user.nickname}") + + assert json_response(conn, 200) + end + + test "404 when user not found", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/users/limpopo") + + assert html_response(conn, 404) =~ "not found" + end + + test "profile does not include private messages", %{conn: conn} do + user = insert(:user) + CommonAPI.post(user, %{"status" => "public"}) + CommonAPI.post(user, %{"status" => "private", "visibility" => "private"}) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/users/#{user.nickname}") + + html = html_response(conn, 200) + + assert html =~ ">public<" + refute html =~ ">private<" + end + + test "pagination", %{conn: conn} do + user = insert(:user) + Enum.map(1..30, fn i -> CommonAPI.post(user, %{"status" => "test#{i}"}) end) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/users/#{user.nickname}") + + html = html_response(conn, 200) + + assert html =~ ">test30<" + assert html =~ ">test11<" + refute html =~ ">test10<" + refute html =~ ">test1<" + end + + test "pagination, page 2", %{conn: conn} do + user = insert(:user) + activities = Enum.map(1..30, fn i -> CommonAPI.post(user, %{"status" => "test#{i}"}) end) + {:ok, a11} = Enum.at(activities, 11) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/users/#{user.nickname}?max_id=#{a11.id}") + + html = html_response(conn, 200) + + assert html =~ ">test1<" + assert html =~ ">test10<" + refute html =~ ">test20<" + refute html =~ ">test29<" + end + end + + describe "notice rendering" do + test "single notice page", %{conn: conn} do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "testing a thing!"}) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/notice/#{activity.id}") + + html = html_response(conn, 200) + assert html =~ "<header>" + assert html =~ user.nickname + assert html =~ "testing a thing!" + end + + test "shows the whole thread", %{conn: conn} do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "space: the final frontier"}) + + CommonAPI.post(user, %{ + "status" => "these are the voyages or something", + "in_reply_to_status_id" => activity.id + }) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/notice/#{activity.id}") + + html = html_response(conn, 200) + assert html =~ "the final frontier" + assert html =~ "voyages" + end + + test "404 when notice not found", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/notice/88c9c317") + + assert html_response(conn, 404) =~ "not found" + end + + test "404 for private status", %{conn: conn} do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{"status" => "don't show me!", "visibility" => "private"}) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/notice/#{activity.id}") + + assert html_response(conn, 404) =~ "not found" + end + + test "404 for remote cached status", %{conn: conn} do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => user.follower_address, + "cc" => "https://www.w3.org/ns/activitystreams#Public", + "type" => "Create", + "object" => %{ + "content" => "blah blah blah", + "type" => "Note", + "attributedTo" => user.ap_id, + "inReplyTo" => nil + }, + "actor" => user.ap_id + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/notice/#{activity.id}") + + assert html_response(conn, 404) =~ "not found" + end + end +end