diff --git a/config/config.exs b/config/config.exs index 9921a17..7361046 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,6 +7,8 @@ # General application configuration use Mix.Config +config :clacks, :frontend, unauthenticated_homepage: :public_timeline + config :clacks, ecto_repos: [Clacks.Repo] diff --git a/lib/clacks/activity.ex b/lib/clacks/activity.ex index 105ded7..27334c8 100644 --- a/lib/clacks/activity.ex +++ b/lib/clacks/activity.ex @@ -25,6 +25,19 @@ defmodule Clacks.Activity do |> validate_required([:data, :local, :actor]) end + def changeset_for_creating(activity, local \\ false) do + changeset(%__MODULE__{}, %{ + data: activity, + local: local, + actor: activity["actor"] + }) + end + + @spec get(id :: String.t()) :: t() | nil + def get(id) do + Repo.get(__MODULE__, id) + end + @spec get_by_ap_id(ap_id :: String.t(), force_refetch :: boolean()) :: t() | nil def get_by_ap_id(ap_id, force_refetch \\ false) do if force_refetch do diff --git a/lib/clacks/activitypub.ex b/lib/clacks/activitypub.ex index 751e339..3e04b73 100644 --- a/lib/clacks/activitypub.ex +++ b/lib/clacks/activitypub.ex @@ -31,7 +31,7 @@ defmodule Clacks.ActivityPub do @spec note( actor :: String.t(), html :: String.t(), - context :: String.t(), + context :: String.t() | nil, id :: String.t() | nil, published :: DateTime.t(), to :: [String.t()], @@ -40,13 +40,14 @@ defmodule Clacks.ActivityPub do def note( actor, html, - context, + context \\ nil, id \\ nil, published \\ DateTime.utc_now(), to \\ [@public], cc \\ [] ) do id = id || object_id(Ecto.UUID.generate()) + context = context || context_id(Ecto.UUID.generate()) %{ "@context" => @context, @@ -131,4 +132,9 @@ defmodule Clacks.ActivityPub do } |> URI.to_string() end + + @spec context_id(id :: String.t()) :: String.t() + def context_id(id) do + "data:,clickityclack" <> id + end end diff --git a/lib/clacks/timeline.ex b/lib/clacks/timeline.ex new file mode 100644 index 0000000..f3daa01 --- /dev/null +++ b/lib/clacks/timeline.ex @@ -0,0 +1,36 @@ +defmodule Clacks.Timeline do + alias Clacks.{Repo, Actor, Activity, Paginator} + import Ecto.Query + + @public "https://www.w3.org/ns/activitystreams#Public" + + @spec actor_timeline(actor :: Actor.t(), only_public :: boolean(), params :: map()) :: [ + Activity.t() + ] + def actor_timeline(actor, only_public \\ true, params) do + Activity + |> restrict_to_actor(actor.ap_id) + |> restrict_to_types(["Create", "Announce"]) + |> restirct_to_public(only_public) + |> Paginator.paginate(params) + |> Repo.all() + end + + defp restrict_to_actor(query, actor_id) do + where(query, [a], fragment("?->>'actor'", a.data) == ^actor_id) + end + + defp restrict_to_types(query, types) do + where(query, [a], fragment("?->>'type'", a.data) in ^types) + end + + defp restirct_to_public(query, true) do + where( + query, + [a], + fragment("?->'to' \\? ?", a.data, @public) or fragment("?->'cc' \\? ?", a.data, @public) + ) + end + + defp restirct_to_public(query, false), do: query +end diff --git a/lib/clacks_web/controllers/activites_controller.ex b/lib/clacks_web/controllers/activites_controller.ex new file mode 100644 index 0000000..ad380b7 --- /dev/null +++ b/lib/clacks_web/controllers/activites_controller.ex @@ -0,0 +1,24 @@ +defmodule ClacksWeb.ActivitiesController do + use ClacksWeb, :controller + alias Clacks.{ActivityPub, Activity} + alias ClacksWeb.Router.Helpers, as: Routes + alias ClacksWeb.Endpoint + + def get(conn, _params) do + ap_id = current_url(conn, %{}) + + case Activity.get_by_ap_id(ap_id) do + %Activity{local: true, id: id, data: data} -> + case conn.assigns[:format] do + "activity+json" -> + json(conn, data) + + "html" -> + redirect(conn, to: Routes.frontend_path(Endpoint, :status, id)) + end + + _ -> + put_status(conn, 404) + end + end +end diff --git a/lib/clacks_web/controllers/actor_controller.ex b/lib/clacks_web/controllers/actor_controller.ex index 3956085..b6cd72e 100644 --- a/lib/clacks_web/controllers/actor_controller.ex +++ b/lib/clacks_web/controllers/actor_controller.ex @@ -1,36 +1,37 @@ defmodule ClacksWeb.ActorController do use ClacksWeb, :controller - alias Clacks.Actor + alias Clacks.{Actor, User} @context "https://www.w3.org/ns/activitystreams" plug :get_actor - defp get_actor(%Plug.Conn{path_params: %{"nickname" => nickname}} = conn, _opts) do - case Actor.get_by_nickname(nickname) do + defp get_actor(%Plug.Conn{path_params: %{"username" => username}} = conn, _opts) do + case User.get_by_username(username) do nil -> conn |> put_status(404) |> halt() - actor -> + %User{actor: actor} -> assign(conn, :actor, actor) end end defp get_actor(conn, _opts), do: conn - def get(conn, _params) do - case conn.assigns[:actor] do - %Actor{local: true, data: data} -> - conn - |> put_resp_header("content-type", "application/activity+json") - |> json(data) + def get(%Plug.Conn{assigns: %{format: "activity+json"}} = conn, _params) do + actor = conn.assigns[:actor] - %Actor{local: false, ap_id: ap_id} -> - conn - |> redirect(external: ap_id) - end + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(actor.data) + end + + def get(%Plug.Conn{assigns: %{format: "html"}} = conn, _params) do + conn + |> put_view(ClacksWeb.FrontendView) + |> ClacksWeb.FrontendController.call(:profile) end def followers(conn, %{"page" => page}) do diff --git a/lib/clacks_web/controllers/frontend_controller.ex b/lib/clacks_web/controllers/frontend_controller.ex new file mode 100644 index 0000000..9b3c794 --- /dev/null +++ b/lib/clacks_web/controllers/frontend_controller.ex @@ -0,0 +1,102 @@ +defmodule ClacksWeb.FrontendController do + use ClacksWeb, :controller + alias Clacks.{Actor, User, Timeline, Repo, ActivityPub, Activity} + alias ClacksWeb.Router.Helpers, as: Routes + alias ClacksWeb.Endpoint + + def index(%Plug.Conn{assigns: %{user: user}} = conn, _params) do + user = Repo.preload(user, :actor) + + render(conn, "home.html", %{ + user: user, + actor: user.actor + }) + end + + def index(conn, params) do + Application.get_env(:clacks, :frontend, %{}) + |> Keyword.get(:unauthenticated_homepage, :public_timeline) + |> index(conn, params) + end + + defp index(:public_timeline, conn, params) do + # tood: show public timeline + end + + defp index({:profile, nickname}, conn, params) do + case Actor.get_by_nickname(nickname) do + %Actor{local: true} = actor -> + # only local profiles are shown + render(conn, "profile.html", %{ + actor: actor, + statuses: actor_statuses(actor, params, only_public: true) + }) + + _ -> + # otherwise show public timeline + index(:public_timeline, conn) + end + end + + defp actor_statuses(actor, params, only_public: only_public) do + Timeline.actor_timeline(actor, only_public, params) + end + + def status(conn, %{"id" => id}) do + current_user = conn.assigns[:user] |> Repo.preload(:actor) + + with %Activity{ + local: true, + data: %{ + "type" => "Create", + "object" => %{"type" => "Note", "attributedTo" => author_id} = note + } + } <- Activity.get(id), + %Actor{} = author <- Actor.get_by_ap_id(author_id) do + render(conn, "status.html", %{ + current_user: current_user, + note: note, + author: author + }) + else + nil -> + put_status(conn, 404) + + %Activity{local: false, data: %{"id" => ap_id}} -> + redirect(conn, external: ap_id) + end + end + + def profile(conn, %{"username" => username} = params) do + current_user = conn.assigns[:user] |> Repo.preload(:actor) + + case User.get_by_username(username) do + nil -> + put_status(conn, 404) + + user -> + user = Repo.preload(user, :actor) + + render(conn, "profile.html", %{ + current_user: current_user, + actor: user.actor, + statuses: actor_statuses(user.actor, params, only_public: true) + }) + end + end + + def post_status(conn, %{"content" => content} = params) do + current_user = conn.assigns[:user] |> Repo.preload(:actor) + + note = ActivityPub.note(current_user.actor.ap_id, content) + create = ActivityPub.create(note) + changeset = Activity.changeset_for_creating(create, true) + + {:ok, activity} = Repo.insert(changeset) + + :ok = ActivityPub.Federator.federate_to_followers(activity.data, current_user.actor) + + path = Map.get(params, "continue", Routes.frontend_path(Endpoint, :status, activity.id)) + redirect(conn, to: path) + end +end diff --git a/lib/clacks_web/plug/format.ex b/lib/clacks_web/plug/format.ex new file mode 100644 index 0000000..ac89044 --- /dev/null +++ b/lib/clacks_web/plug/format.ex @@ -0,0 +1,10 @@ +defmodule ClacksWeb.Plug.Format do + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _opts) do + format = Phoenix.Controller.get_format(conn) + assign(conn, :format, format) + end +end diff --git a/lib/clacks_web/plug/web_authenticate.ex b/lib/clacks_web/plug/web_authenticate.ex index 79e84a3..29c0345 100644 --- a/lib/clacks_web/plug/web_authenticate.ex +++ b/lib/clacks_web/plug/web_authenticate.ex @@ -4,13 +4,15 @@ defmodule ClacksWeb.Plug.WebAuthenticate do alias ClacksWeb.Router.Helpers, as: Routes alias ClacksWeb.Endpoint - def init(%{on_failure: on_failure_action} = opts) + def init([on_failure: on_failure_action] = opts) when on_failure_action in [:redirect_to_login, :pass], do: opts - def init(_), do: %{on_failure: :redirect_to_login} + def init(opts) do + [on_failure: :redirect_to_login] + end - def call(conn, %{on_failure: on_failure_action}) do + def call(%Plug.Conn{assigns: %{format: "html"}} = conn, on_failure: on_failure_action) do user_token = get_session(conn, :user_token) case Phoenix.Token.verify(Endpoint, "user token", user_token, max_age: 7 * 24 * 60 * 60) do @@ -29,6 +31,8 @@ defmodule ClacksWeb.Plug.WebAuthenticate do end end + def call(conn, _opts), do: conn + defp on_failure(conn, :redirect_to_login) do conn |> Phoenix.Controller.redirect(to: Routes.login_path(Endpoint, :login)) diff --git a/lib/clacks_web/router.ex b/lib/clacks_web/router.ex index be08e25..403ed84 100644 --- a/lib/clacks_web/router.ex +++ b/lib/clacks_web/router.ex @@ -3,10 +3,14 @@ defmodule ClacksWeb.Router do pipeline :browser do plug :accepts, ["html"] + plug ClacksWeb.Plug.Format plug :fetch_session plug :fetch_flash plug :protect_from_forgery plug :put_secure_browser_headers + end + + pipeline :browser_maybe_authenticated do plug ClacksWeb.Plug.WebAuthenticate, on_failure: :pass end @@ -18,6 +22,22 @@ defmodule ClacksWeb.Router do plug :accepts, ["activity+json", "html"] end + pipeline :browser_or_activitypub do + plug :accepts, ["html", "activity+json"] + plug ClacksWeb.Plug.Format + plug :browser_if_html + end + + defp browser_if_html(%Plug.Conn{assigns: %{format: "html"}} = conn, _opts) do + conn + |> fetch_session() + |> fetch_flash() + |> protect_from_forgery() + |> put_secure_browser_headers() + end + + defp browser_if_html(conn, _opts), do: conn + scope "/", ClacksWeb do pipe_through :browser @@ -26,6 +46,15 @@ defmodule ClacksWeb.Router do post "/logout", LoginController, :logout_post end + scope "/", ClacksWeb do + pipe_through :browser + pipe_through :browser_maybe_authenticated + + get "/", FrontendController, :index + get "/status/:id", FrontendController, :status + post "/post", FrontendController, :post_status + end + scope "/", ClacksWeb do pipe_through :browser pipe_through :browser_authenticated @@ -36,17 +65,24 @@ defmodule ClacksWeb.Router do get "/objects/:id", ObjectsController, :get - get "/users/:nickname", ActorController, :get - get "/users/:nickname/followers", ActorController, :followers - get "/users/:nickname/following", ActorController, :following - get "/users/:nickname/outbox", OutboxController, :outbox + get "/users/:username/followers", ActorController, :followers + get "/users/:username/following", ActorController, :following + get "/users/:username/outbox", OutboxController, :outbox post "/inbox", InboxController, :shared - post "/users/:nickname/inbox", InboxController, :user_specific + post "/users/:username/inbox", InboxController, :user_specific get "/.well-known/webfinger", WebFingerController, :get end + scope "/", ClacksWeb do + pipe_through :browser_or_activitypub + pipe_through :browser_maybe_authenticated + + get "/users/:username", ActorController, :get + get "/activities/:id", ActivitiesController, :get + end + # Other scopes may use custom stacks. # scope "/api", ClacksWeb do # pipe_through :api diff --git a/lib/clacks_web/templates/frontend/home.html.eex b/lib/clacks_web/templates/frontend/home.html.eex new file mode 100644 index 0000000..7a26641 --- /dev/null +++ b/lib/clacks_web/templates/frontend/home.html.eex @@ -0,0 +1,7 @@ +
Logged in as <%= @user.username %>
+ +<%= form_tag Routes.frontend_path(@conn, :post_status), method: :post do %> + + <%= submit "Post" %> +<% end %> diff --git a/lib/clacks_web/templates/frontend/profile.html.eex b/lib/clacks_web/templates/frontend/profile.html.eex new file mode 100644 index 0000000..ab15525 --- /dev/null +++ b/lib/clacks_web/templates/frontend/profile.html.eex @@ -0,0 +1,15 @@ +<%= @actor.data["summary"] %>
+ +