Rename MobilizonWeb to Mobilizon.Web

This commit is contained in:
rustra
2020-01-26 21:36:50 +01:00
parent b3f8d52bc9
commit 8856cc2f55
143 changed files with 490 additions and 490 deletions

29
lib/web/auth/context.ex Normal file
View File

@@ -0,0 +1,29 @@
defmodule Mobilizon.Web.Auth.Context do
@moduledoc """
Guardian context for Mobilizon.Web
"""
@behaviour Plug
import Plug.Conn
alias Mobilizon.Users.User
def init(opts) do
opts
end
def call(conn, _) do
context = %{ip: to_string(:inet_parse.ntoa(conn.remote_ip))}
context =
case Guardian.Plug.current_resource(conn) do
%User{} = user ->
Map.put(context, :current_user, user)
nil ->
context
end
put_private(conn, :absinthe, %{context: context})
end
end

View File

@@ -0,0 +1,11 @@
defmodule Mobilizon.Web.Auth.ErrorHandler do
@moduledoc """
In case we have an auth error
"""
import Plug.Conn
def auth_error(conn, {type, _reason}, _opts) do
body = Jason.encode!(%{message: to_string(type)})
send_resp(conn, 401, body)
end
end

79
lib/web/auth/guardian.ex Normal file
View File

@@ -0,0 +1,79 @@
defmodule Mobilizon.Web.Auth.Guardian do
@moduledoc """
Handles the JWT tokens encoding and decoding
"""
use Guardian,
otp_app: :mobilizon,
permissions: %{
superuser: [:moderate, :super],
user: [:base]
}
alias Mobilizon.Users
alias Mobilizon.Users.User
require Logger
def subject_for_token(%User{} = user, _claims) do
{:ok, "User:" <> to_string(user.id)}
end
def subject_for_token(_, _) do
{:error, :unknown_resource}
end
def resource_from_claims(%{"sub" => "User:" <> uid_str}) do
Logger.debug(fn -> "Receiving claim for user #{uid_str}" end)
try do
case Integer.parse(uid_str) do
{uid, ""} ->
{:ok, Users.get_user_with_actors!(uid)}
_ ->
{:error, :invalid_id}
end
rescue
Ecto.NoResultsError -> {:error, :no_result}
end
end
def resource_from_claims(_) do
{:error, :reason_for_error}
end
def after_encode_and_sign(resource, claims, token, _options) do
Logger.debug(fn -> "after_encode_and_sign #{inspect(claims)}" end)
with {:ok, _} <- Guardian.DB.after_encode_and_sign(resource, claims["typ"], claims, token) do
{:ok, token}
end
end
def on_verify(claims, token, _options) do
with {:ok, _} <- Guardian.DB.on_verify(claims, token) do
{:ok, claims}
end
end
def on_revoke(claims, token, _options) do
with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do
{:ok, claims}
end
end
def on_refresh({old_token, old_claims}, {new_token, new_claims}, _options) do
with {:ok, _, _} <- Guardian.DB.on_refresh({old_token, old_claims}, {new_token, new_claims}) do
{:ok, {old_token, old_claims}, {new_token, new_claims}}
end
end
def on_exchange(old_stuff, new_stuff, options), do: on_refresh(old_stuff, new_stuff, options)
# def build_claims(claims, _resource, opts) do
# claims = claims
# |> encode_permissions_into_claims!(Keyword.get(opts, :permissions))
# {:ok, claims}
# end
end

14
lib/web/auth/pipeline.ex Normal file
View File

@@ -0,0 +1,14 @@
defmodule Mobilizon.Web.Auth.Pipeline do
@moduledoc """
Handles the app sessions
"""
use Guardian.Plug.Pipeline,
otp_app: :mobilizon,
module: Mobilizon.Web.Auth.Guardian,
error_handler: Mobilizon.Web.Auth.ErrorHandler
plug(Guardian.Plug.VerifyHeader, realm: "Bearer")
plug(Guardian.Plug.LoadResource, allow_blank: true)
plug(Mobilizon.Web.Auth.Context)
end

80
lib/web/cache/activity_pub.ex vendored Normal file
View File

@@ -0,0 +1,80 @@
defmodule Mobilizon.Web.Cache.ActivityPub do
@moduledoc """
ActivityPub related cache.
"""
alias Mobilizon.{Actors, Events, Tombstone}
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@cache :activity_pub
@doc """
Gets a local actor by username.
"""
@spec get_local_actor_by_name(String.t()) ::
{:commit, Actor.t()} | {:ignore, nil}
def get_local_actor_by_name(name) do
Cachex.fetch(@cache, "actor_" <> name, fn "actor_" <> name ->
case Actors.get_local_actor_by_name(name) do
%Actor{} = actor ->
{:commit, actor}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a public event by its UUID, with all associations loaded.
"""
@spec get_public_event_by_uuid_with_preload(String.t()) ::
{:commit, Event.t()} | {:ignore, nil}
def get_public_event_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "event_" <> uuid, fn "event_" <> uuid ->
case Events.get_public_event_by_uuid_with_preload(uuid) do
%Event{} = event ->
{:commit, event}
nil ->
with url <- Routes.page_url(Endpoint, :event, uuid),
%Tombstone{} = tomstone <- Tombstone.find_tombstone(url) do
tomstone
else
_ -> {:ignore, nil}
end
end
end)
end
@doc """
Gets a comment by its UUID, with all associations loaded.
"""
@spec get_comment_by_uuid_with_preload(String.t()) ::
{:commit, Comment.t()} | {:ignore, nil}
def get_comment_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "comment_" <> uuid, fn "comment_" <> uuid ->
case Events.get_comment_from_uuid_with_preload(uuid) do
%Comment{} = comment ->
{:commit, comment}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a relay.
"""
@spec get_relay :: {:commit, Actor.t()} | {:ignore, nil}
def get_relay do
Cachex.fetch(@cache, "relay_actor", &Relay.get_actor/0)
end
end

24
lib/web/cache/cache.ex vendored Normal file
View File

@@ -0,0 +1,24 @@
defmodule Mobilizon.Web.Cache do
@moduledoc """
Facade module which provides access to all cached data.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Web.Cache.ActivityPub
@caches [:activity_pub, :feed, :ics]
@doc """
Clears all caches for an actor.
"""
@spec clear_cache(Actor.t()) :: {:ok, true}
def clear_cache(%Actor{preferred_username: preferred_username, domain: nil}) do
Enum.each(@caches, &Cachex.del(&1, "actor_" <> preferred_username))
end
defdelegate get_local_actor_by_name(name), to: ActivityPub
defdelegate get_public_event_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_comment_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_relay, to: ActivityPub
end

View File

@@ -0,0 +1,28 @@
defmodule Mobilizon.Web.GraphQLSocket do
use Phoenix.Socket
use Absinthe.Phoenix.Socket,
schema: Mobilizon.Web.Schema
alias Mobilizon.Users.User
def connect(%{"token" => token}, socket) do
with {:ok, authed_socket} <-
Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token),
%User{} = user <- Guardian.Phoenix.Socket.current_resource(authed_socket) do
authed_socket =
Absinthe.Phoenix.Socket.put_options(socket,
context: %{
current_user: user
}
)
{:ok, authed_socket}
else
{:error, _} ->
:error
end
end
def id(_socket), do: nil
end

View File

@@ -0,0 +1,140 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/activity_pub/activity_pub_controller.ex
defmodule Mobilizon.Web.ActivityPubController do
use Mobilizon.Web, :controller
alias Mobilizon.{Actors, Config}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Federator
alias Mobilizon.Web.ActivityPub.ActorView
alias Mobilizon.Web.Cache
require Logger
action_fallback(:errors)
plug(Mobilizon.Web.Plugs.Federating when action in [:inbox, :relay])
plug(:relay_active? when action in [:relay])
def relay_active?(conn, _) do
if Config.get([:instance, :allow_relay]) do
conn
else
conn
|> put_status(404)
|> json("Not found")
|> halt()
end
end
def following(conn, %{"name" => name, "page" => page}) do
with {page, ""} <- Integer.parse(page),
%Actor{} = actor <- Actors.get_local_actor_by_name_with_preload(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("following.json", %{actor: actor, page: page}))
end
end
def following(conn, %{"name" => name}) do
with %Actor{} = actor <- Actors.get_local_actor_by_name_with_preload(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("following.json", %{actor: actor}))
end
end
def followers(conn, %{"name" => name, "page" => page}) do
with {page, ""} <- Integer.parse(page),
%Actor{} = actor <- Actors.get_local_actor_by_name_with_preload(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("followers.json", %{actor: actor, page: page}))
end
end
def followers(conn, %{"name" => name}) do
with %Actor{} = actor <- Actors.get_local_actor_by_name_with_preload(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("followers.json", %{actor: actor}))
end
end
def outbox(conn, %{"name" => name, "page" => page}) do
with {page, ""} <- Integer.parse(page),
%Actor{} = actor <- Actors.get_local_actor_by_name(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("outbox.json", %{actor: actor, page: page}))
end
end
def outbox(conn, %{"name" => name}) do
with %Actor{} = actor <- Actors.get_local_actor_by_name(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("outbox.json", %{actor: actor}))
end
end
# TODO: Ensure that this inbox is a recipient of the message
def inbox(%{assigns: %{valid_signature: true}} = conn, params) do
Logger.debug("Got something with valid signature inside inbox")
Federator.enqueue(:incoming_ap_doc, params)
json(conn, "ok")
end
# only accept relayed Creates
def inbox(conn, %{"type" => "Create"} = params) do
Logger.info(
"Signature missing or not from author, relayed Create message, fetching object from source"
)
ActivityPub.fetch_object_from_url(params["object"]["id"])
json(conn, "ok")
end
def inbox(conn, params) do
headers = Enum.into(conn.req_headers, %{})
if String.contains?(headers["signature"], params["actor"]) do
Logger.error(
"Signature validation error for: #{params["actor"]}, make sure you are forwarding the HTTP Host header!"
)
Logger.debug(inspect(conn.req_headers))
end
json(conn, "error")
end
def relay(conn, _params) do
with {status, %Actor{} = actor} when status in [:commit, :ok] <- Cache.get_relay() do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("actor.json", %{actor: actor}))
end
end
def errors(conn, {:error, :not_found}) do
conn
|> put_status(404)
|> json("Not found")
end
def errors(conn, e) do
Logger.debug(inspect(e))
conn
|> put_status(500)
|> json("Unknown Error")
end
end

View File

@@ -0,0 +1,15 @@
defmodule Mobilizon.Web.FallbackController do
@moduledoc """
Translates controller action results into valid `Plug.Conn` responses.
See `Phoenix.Controller.action_fallback/1` for more details.
"""
use Mobilizon.Web, :controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(Mobilizon.Web.ErrorView)
|> render(:"404")
end
end

View File

@@ -0,0 +1,68 @@
defmodule Mobilizon.Web.FeedController do
@moduledoc """
Controller to serve RSS, ATOM and iCal Feeds
"""
use Mobilizon.Web, :controller
plug(:put_layout, false)
action_fallback(Mobilizon.Web.FallbackController)
def actor(conn, %{"name" => name, "format" => "atom"}) do
case Cachex.fetch(:feed, "actor_" <> name) do
{status, data} when status in [:commit, :ok] ->
conn
|> put_resp_content_type("application/atom+xml")
|> send_resp(200, data)
_ ->
{:error, :not_found}
end
end
def actor(conn, %{"name" => name, "format" => "ics"}) do
case Cachex.fetch(:ics, "actor_" <> name) do
{status, data} when status in [:commit, :ok] ->
conn
|> put_resp_content_type("text/calendar")
|> send_resp(200, data)
_ ->
{:error, :not_found}
end
end
def event(conn, %{"uuid" => uuid, "format" => "ics"}) do
case Cachex.fetch(:ics, "event_" <> uuid) do
{status, data} when status in [:commit, :ok] ->
conn
|> put_resp_content_type("text/calendar")
|> send_resp(200, data)
_ ->
{:error, :not_found}
end
end
def going(conn, %{"token" => token, "format" => "ics"}) do
case Cachex.fetch(:ics, "token_" <> token) do
{status, data} when status in [:commit, :ok] ->
conn
|> put_resp_content_type("text/calendar")
|> send_resp(200, data)
_ ->
{:error, :not_found}
end
end
def going(conn, %{"token" => token, "format" => "atom"}) do
case Cachex.fetch(:feed, "token_" <> token) do
{status, data} when status in [:commit, :ok] ->
conn
|> put_resp_content_type("application/atom+xml")
|> send_resp(200, data)
{:ignore, _} ->
{:error, :not_found}
end
end
end

View File

@@ -0,0 +1,50 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/media_proxy/controller.ex
defmodule Mobilizon.Web.MediaProxyController do
use Mobilizon.Web, :controller
alias Plug.Conn
alias Mobilizon.Config
alias Mobilizon.Web.MediaProxy
alias Mobilizon.Web.ReverseProxy
@default_proxy_opts [max_body_length: 25 * 1_048_576, http: [follow_redirect: true]]
def remote(conn, %{"sig" => sig64, "url" => url64} = params) do
with config <- Config.get([:media_proxy], []),
true <- Keyword.get(config, :enabled, false),
{:ok, url} <- MediaProxy.decode_url(sig64, url64),
:ok <- filename_matches(Map.has_key?(params, "filename"), conn.request_path, url) do
ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts))
else
false ->
send_resp(conn, 404, Conn.Status.reason_phrase(404))
{:error, :invalid_signature} ->
send_resp(conn, 403, Conn.Status.reason_phrase(403))
{:wrong_filename, filename} ->
redirect(conn, external: MediaProxy.build_url(sig64, url64, filename))
end
end
def filename_matches(has_filename, path, url) do
filename =
url
|> MediaProxy.filename()
|> URI.decode()
path = URI.decode(path)
if has_filename && filename && Path.basename(path) != filename do
{:wrong_filename, filename}
else
:ok
end
end
end

View File

@@ -0,0 +1,80 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex
defmodule Mobilizon.Web.NodeInfoController do
use Mobilizon.Web, :controller
alias Mobilizon.Config
alias Mobilizon.Service.Statistics
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@node_info_supported_versions ["2.0", "2.1"]
@node_info_schema_uri "http://nodeinfo.diaspora.software/ns/schema/"
def schemas(conn, _params) do
links =
@node_info_supported_versions
|> Enum.map(fn version ->
%{
rel: @node_info_schema_uri <> version,
href: Routes.node_info_url(Endpoint, :nodeinfo, version)
}
end)
json(conn, %{
links: links
})
end
# Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json
def nodeinfo(conn, %{"version" => version}) when version in @node_info_supported_versions do
response = %{
version: version,
software: %{
name: "Mobilizon",
version: Config.instance_version()
},
protocols: ["activitypub"],
services: %{
inbound: [],
outbound: ["atom1.0"]
},
openRegistrations: Config.instance_registrations_open?(),
usage: %{
users: %{
total: Statistics.get_cached_value(:local_users)
},
localPosts: Statistics.get_cached_value(:local_events),
localComments: Statistics.get_cached_value(:local_comments)
},
metadata: %{
nodeName: Config.instance_name(),
nodeDescription: Config.instance_description()
}
}
response =
if version == "2.1" do
put_in(response, [:software, :repository], Config.instance_repository())
else
response
end
conn
|> put_resp_header(
"content-type",
"application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8"
)
|> json(response)
end
def nodeinfo(conn, _) do
conn
|> put_status(404)
|> json(%{error: "Nodeinfo schema version not handled"})
end
end

View File

@@ -0,0 +1,52 @@
defmodule Mobilizon.Web.PageController do
@moduledoc """
Controller to load our webapp
"""
use Mobilizon.Web, :controller
alias Mobilizon.Web.Cache
plug(:put_layout, false)
action_fallback(Mobilizon.Web.FallbackController)
def index(conn, _params), do: render(conn, :index)
def actor(conn, %{"name" => name}) do
{status, actor} = Cache.get_local_actor_by_name(name)
render_or_error(conn, &ok_status?/2, status, :actor, actor)
end
def event(conn, %{"uuid" => uuid}) do
{status, event} = Cache.get_public_event_by_uuid_with_preload(uuid)
render_or_error(conn, &ok_status_and_is_visible?/2, status, :event, event)
end
def comment(conn, %{"uuid" => uuid}) do
{status, comment} = Cache.get_comment_by_uuid_with_preload(uuid)
render_or_error(conn, &ok_status_and_is_visible?/2, status, :comment, comment)
end
defp render_or_error(conn, check_fn, status, object_type, object) do
if check_fn.(status, object) do
case object do
%Mobilizon.Tombstone{} ->
conn
|> put_status(:gone)
|> render(object_type, object: object)
_ ->
render(conn, object_type, object: object)
end
else
{:error, :not_found}
end
end
defp is_visible?(%{visibility: v}), do: v in [:public, :unlisted]
defp is_visible?(%Mobilizon.Tombstone{}), do: true
defp ok_status?(status), do: status in [:ok, :commit]
defp ok_status?(status, _), do: ok_status?(status)
defp ok_status_and_is_visible?(status, o), do: ok_status?(status) and is_visible?(o)
end

View File

@@ -0,0 +1,39 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/web_finger/web_finger_controller.ex
defmodule Mobilizon.Web.WebFingerController do
@moduledoc """
Handles Webfinger requests
"""
use Mobilizon.Web, :controller
alias Mobilizon.Federation.WebFinger
plug(Mobilizon.Web.Plugs.Federating)
@doc """
Provides /.well-known/host-meta
"""
def host_meta(conn, _params) do
xml = WebFinger.host_meta()
conn
|> put_resp_content_type("application/xrd+xml")
|> send_resp(200, xml)
end
@doc """
Provides /.well-known/webfinger
"""
def webfinger(conn, %{"resource" => resource}) do
case WebFinger.webfinger(resource, "JSON") do
{:ok, response} -> json(conn, response)
_e -> send_resp(conn, 404, "Couldn't find user")
end
end
def webfinger(conn, _), do: send_resp(conn, 400, "No query provided")
end

34
lib/web/email/admin.ex Normal file
View File

@@ -0,0 +1,34 @@
defmodule Mobilizon.Web.Email.Admin do
@moduledoc """
Handles emails sent to admins.
"""
use Bamboo.Phoenix, view: Mobilizon.Web.EmailView
import Bamboo.Phoenix
import Mobilizon.Web.Gettext
alias Mobilizon.Config
alias Mobilizon.Reports.Report
alias Mobilizon.Users.User
alias Mobilizon.Web.Email
@spec report(User.t(), Report.t(), String.t()) :: Bamboo.Email.t()
def report(%User{email: email}, %Report{} = report, locale \\ "en") do
Mobilizon.Web.Gettext.put_locale(locale)
subject =
gettext(
"New report on Mobilizon instance %{instance}",
instance: Config.instance_name()
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:subject, subject)
|> assign(:report, report)
|> render(:report)
end
end

14
lib/web/email/checker.ex Normal file
View File

@@ -0,0 +1,14 @@
defmodule Mobilizon.Web.Email.Checker do
@moduledoc """
Provides a function to test emails against a "not so bad" regex.
"""
# TODO: simplify me!
@email_regex ~r/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
@doc """
Returns whether the email is valid.
"""
@spec valid?(String.t()) :: boolean
def valid?(email), do: email =~ @email_regex
end

22
lib/web/email/email.ex Normal file
View File

@@ -0,0 +1,22 @@
defmodule Mobilizon.Web.Email do
@moduledoc """
The Email context.
"""
use Bamboo.Phoenix, view: Mobilizon.Web.EmailView
alias Mobilizon.Config
@spec base_email(keyword()) :: Bamboo.Email.t()
def base_email(args) do
instance = Config.instance_config()
args
|> new_email()
|> from({Config.instance_name(), Config.instance_email_from()})
|> put_header("Reply-To", Config.instance_email_reply_to())
|> assign(:instance, instance)
|> put_html_layout({Mobilizon.Web.EmailView, "email.html"})
|> put_text_layout({Mobilizon.Web.EmailView, "email.text"})
end
end

85
lib/web/email/event.ex Normal file
View File

@@ -0,0 +1,85 @@
defmodule Mobilizon.Web.Email.Event do
@moduledoc """
Handles emails sent about events.
"""
use Bamboo.Phoenix, view: Mobilizon.Web.EmailView
import Bamboo.Phoenix
import Mobilizon.Web.Gettext
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.Event
alias Mobilizon.Storage.Repo
alias Mobilizon.Users.User
alias Mobilizon.Web.Email
@important_changes [:title, :begins_on, :ends_on, :status]
@spec event_updated(User.t(), Actor.t(), Event.t(), Event.t(), list(), String.t()) ::
Bamboo.Email.t()
def event_updated(
%User{} = user,
%Actor{} = actor,
%Event{} = old_event,
%Event{} = event,
changes,
locale \\ "en"
) do
Mobilizon.Web.Gettext.put_locale(locale)
subject =
gettext(
"Event %{title} has been updated",
title: old_event.title
)
Email.base_email(to: {Actor.display_name(actor), user.email}, subject: subject)
|> assign(:locale, locale)
|> assign(:event, event)
|> assign(:old_event, old_event)
|> assign(:changes, changes)
|> assign(:subject, subject)
|> render(:event_updated)
end
def calculate_event_diff_and_send_notifications(
%Event{} = old_event,
%Event{id: event_id} = event,
changes
) do
important = MapSet.new(@important_changes)
diff =
changes
|> Map.keys()
|> MapSet.new()
|> MapSet.intersection(important)
if MapSet.size(diff) > 0 do
Repo.transaction(fn ->
event_id
|> Events.list_local_emails_user_participants_for_event_query()
|> Repo.stream()
|> Enum.to_list()
|> Enum.each(
&send_notification_for_event_update_to_participant(&1, old_event, event, diff)
)
end)
end
end
defp send_notification_for_event_update_to_participant(
{%Actor{} = actor, %User{locale: locale} = user},
%Event{} = old_event,
%Event{} = event,
diff
) do
user
|> Email.Event.event_updated(actor, old_event, event, diff, locale)
|> Email.Mailer.deliver_later()
end
end

6
lib/web/email/mailer.ex Normal file
View File

@@ -0,0 +1,6 @@
defmodule Mobilizon.Web.Email.Mailer do
@moduledoc """
Mobilizon Mailer.
"""
use Bamboo.Mailer, otp_app: :mobilizon
end

View File

@@ -0,0 +1,84 @@
defmodule Mobilizon.Web.Email.Participation do
@moduledoc """
Handles emails sent about participation.
"""
use Bamboo.Phoenix, view: Mobilizon.Web.EmailView
import Bamboo.Phoenix
import Mobilizon.Web.Gettext
alias Mobilizon.Users.User
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Participant
alias Mobilizon.Web.Email
@doc """
Send emails to local user
"""
def send_emails_to_local_user(
%Participant{actor: %Actor{user_id: nil} = _actor} = _participation
),
do: :ok
@doc """
Send emails to local user
"""
def send_emails_to_local_user(
%Participant{actor: %Actor{user_id: user_id} = _actor} = participation
) do
with %User{} = user <- Mobilizon.Users.get_user!(user_id) do
user
|> participation_updated(participation)
|> Email.Mailer.deliver_later()
:ok
end
end
@spec participation_updated(User.t(), Participant.t(), String.t()) :: Bamboo.Email.t()
def participation_updated(user, participant, locale \\ "en")
def participation_updated(
%User{email: email},
%Participant{event: event, role: :rejected},
locale
) do
Mobilizon.Web.Gettext.put_locale(locale)
subject =
gettext(
"Your participation to event %{title} has been rejected",
title: event.title
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:event, event)
|> assign(:subject, subject)
|> render(:event_participation_rejected)
end
@spec participation_updated(User.t(), Participant.t(), String.t()) :: Bamboo.Email.t()
def participation_updated(
%User{email: email},
%Participant{event: event, role: :participant},
locale
) do
Mobilizon.Web.Gettext.put_locale(locale)
subject =
gettext(
"Your participation to event %{title} has been approved",
title: event.title
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:event, event)
|> assign(:subject, subject)
|> render(:event_participation_approved)
end
end

163
lib/web/email/user.ex Normal file
View File

@@ -0,0 +1,163 @@
defmodule Mobilizon.Web.Email.User do
@moduledoc """
Handles emails sent to users.
"""
use Bamboo.Phoenix, view: Mobilizon.Web.EmailView
import Bamboo.Phoenix
import Mobilizon.Web.Gettext
alias Mobilizon.{Config, Crypto, Users}
alias Mobilizon.Storage.Repo
alias Mobilizon.Users.User
alias Mobilizon.Web.Email
require Logger
@spec confirmation_email(User.t(), String.t()) :: Bamboo.Email.t()
def confirmation_email(
%User{email: email, confirmation_token: confirmation_token},
locale \\ "en"
) do
Mobilizon.Web.Gettext.put_locale(locale)
subject =
gettext(
"Instructions to confirm your Mobilizon account on %{instance}",
instance: Config.instance_name()
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:token, confirmation_token)
|> assign(:subject, subject)
|> render(:registration_confirmation)
end
@spec reset_password_email(User.t(), String.t()) :: Bamboo.Email.t()
def reset_password_email(
%User{email: email, reset_password_token: reset_password_token},
locale \\ "en"
) do
Mobilizon.Web.Gettext.put_locale(locale)
subject =
gettext(
"Instructions to reset your password on %{instance}",
instance: Config.instance_name()
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:token, reset_password_token)
|> assign(:subject, subject)
|> render(:password_reset)
end
def check_confirmation_token(token) when is_binary(token) do
with %User{} = user <- Users.get_user_by_activation_token(token),
{:ok, %User{} = user} <-
Users.update_user(user, %{
"confirmed_at" => DateTime.utc_now() |> DateTime.truncate(:second),
"confirmation_sent_at" => nil,
"confirmation_token" => nil
}) do
Logger.info("User #{user.email} has been confirmed")
{:ok, user}
else
_err ->
{:error, :invalid_token}
end
end
def resend_confirmation_email(%User{} = user, locale \\ "en") do
with :ok <- we_can_send_email(user, :confirmation_sent_at),
{:ok, user} <-
Users.update_user(user, %{
"confirmation_sent_at" => DateTime.utc_now() |> DateTime.truncate(:second)
}) do
send_confirmation_email(user, locale)
Logger.info("Sent confirmation email again to #{user.email}")
{:ok, user.email}
end
end
def send_confirmation_email(%User{} = user, locale \\ "en") do
user
|> Email.User.confirmation_email(locale)
|> Email.Mailer.deliver_later()
end
@doc """
Check that the provided token is correct and update provided password
"""
@spec check_reset_password_token(String.t(), String.t()) :: tuple
def check_reset_password_token(password, token) do
with %User{} = user <- Users.get_user_by_reset_password_token(token),
{:ok, %User{} = user} <-
Repo.update(
User.password_reset_changeset(user, %{
"password" => password,
"reset_password_sent_at" => nil,
"reset_password_token" => nil
})
) do
{:ok, user}
else
{:error, %Ecto.Changeset{errors: [password: {"registration.error.password_too_short", _}]}} ->
{:error,
"The password you have choosen is too short. Please make sure your password contains at least 6 charaters."}
_err ->
{:error,
"The token you provided is invalid. Make sure that the URL is exactly the one provided inside the email you got."}
end
end
@doc """
Send the email reset password, if it's not too soon since the last send
"""
@spec send_password_reset_email(User.t(), String.t()) :: tuple
def send_password_reset_email(%User{} = user, locale \\ "en") do
with :ok <- we_can_send_email(user, :reset_password_sent_at),
{:ok, %User{} = user_updated} <-
Repo.update(
User.send_password_reset_changeset(user, %{
"reset_password_token" => Crypto.random_string(30),
"reset_password_sent_at" => DateTime.utc_now() |> DateTime.truncate(:second)
})
) do
mail =
user_updated
|> Email.User.reset_password_email(locale)
|> Email.Mailer.deliver_later()
{:ok, mail}
else
{:error, reason} -> {:error, reason}
end
end
@spec we_can_send_email(User.t(), atom) :: :ok | {:error, :email_too_soon}
defp we_can_send_email(%User{} = user, key) do
case Map.get(user, key) do
nil ->
:ok
_ ->
case Timex.before?(
Timex.shift(Map.get(user, key), hours: 1),
DateTime.utc_now() |> DateTime.truncate(:second)
) do
true ->
:ok
false ->
{:error, :email_too_soon}
end
end
end
end

69
lib/web/endpoint.ex Normal file
View File

@@ -0,0 +1,69 @@
defmodule Mobilizon.Web.Endpoint do
@moduledoc """
Endpoint for Mobilizon app
"""
use Phoenix.Endpoint, otp_app: :mobilizon
use Absinthe.Phoenix.Endpoint
# For e2e tests
if Application.get_env(:mobilizon, :sql_sandbox) do
plug(Phoenix.Ecto.SQL.Sandbox,
at: "/sandbox",
header: "x-session-id",
repo: Mobilizon.Storage.Repo
)
end
socket("/graphql_socket", Mobilizon.Web.GraphQLSocket,
websocket: true,
longpoll: false
)
plug(Mobilizon.Web.Plugs.UploadedMedia)
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production.
plug(
Plug.Static,
at: "/",
from: :mobilizon,
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
plug(Phoenix.LiveReloader)
plug(Phoenix.CodeReloader)
end
plug(CORSPlug)
plug(Plug.RequestId)
plug(Plug.Logger)
plug(
Plug.Parsers,
parsers: [:urlencoded, {:multipart, length: 10_000_000}, :json, Absinthe.Plug.Parser],
pass: ["*/*"],
json_decoder: Jason
)
plug(Plug.MethodOverride)
plug(Plug.Head)
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
plug(
Plug.Session,
store: :cookie,
key: "_mobilizon_key",
signing_salt: "F9CCTF22"
)
plug(Mobilizon.Web.Router)
end

47
lib/web/gettext.ex Normal file
View File

@@ -0,0 +1,47 @@
defmodule Mobilizon.Web.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example:
import Mobilizon.Web.Gettext
# Simple translation
gettext "Here is the string to translate"
# Plural translation
ngettext "Here is the string to translate",
"Here are the strings to translate",
3
# Domain-based translation
dgettext "errors", "Here is the error message to translate"
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext, otp_app: :mobilizon
def put_locale(locale) do
locale = determine_best_locale(locale)
Gettext.put_locale(Mobilizon.Web.Gettext, locale)
end
@spec determine_best_locale(String.t()) :: String.t()
def determine_best_locale(locale) do
locale = String.trim(locale)
locales = Gettext.known_locales(Mobilizon.Web.Gettext)
cond do
# Either it matches directly, eg: "en" => "en", "fr" => "fr", "fr_FR" => "fr_FR"
locale in locales -> locale
# Either the first part matches, "fr_CA" => "fr"
split_locale(locale) in locales -> split_locale(locale)
# Otherwise default to english
true -> "en"
end
end
# Keep only the first part of the locale
defp split_locale(locale), do: locale |> String.split("_", trim: true, parts: 2) |> hd
end

68
lib/web/mobilizon_web.ex Normal file
View File

@@ -0,0 +1,68 @@
defmodule Mobilizon.Web do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, views, channels and so on.
This can be used in your application as:
use Mobilizon.Web, :controller
use Mobilizon.Web, :view
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules
and import those modules here.
"""
def controller do
quote do
use Phoenix.Controller, namespace: Mobilizon.Web
import Plug.Conn
import Mobilizon.Web.Router.Helpers
import Mobilizon.Web.Gettext
end
end
def view do
quote do
use Phoenix.View,
root: "lib/web/templates",
namespace: Mobilizon.Web
# Import convenience functions from controllers
import Phoenix.Controller, only: [get_flash: 2, view_module: 1]
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
import Mobilizon.Web.Router.Helpers
import Mobilizon.Web.ErrorHelpers
import Mobilizon.Web.Gettext
end
end
def router do
quote do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
end
end
def channel do
quote do
use Phoenix.Channel
import Mobilizon.Web.Gettext
end
end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@@ -0,0 +1,28 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Web.Plugs.Federating do
@moduledoc """
Restrict ActivityPub routes when not federating
"""
import Plug.Conn
def init(options) do
options
end
def call(conn, _opts) do
if Mobilizon.Config.get([:instance, :federating]) do
conn
else
conn
|> put_status(404)
|> Phoenix.Controller.put_view(Mobilizon.Web.ErrorView)
|> Phoenix.Controller.render("404.json")
|> halt()
end
end
end

View File

@@ -0,0 +1,56 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/plugs/http_signature.ex
defmodule Mobilizon.Web.Plugs.HTTPSignatures do
@moduledoc """
Plug to check HTTP Signatures on every incoming request
"""
import Plug.Conn
require Logger
def init(options) do
options
end
def call(%{assigns: %{valid_signature: true}} = conn, _opts) do
conn
end
def call(conn, _opts) do
case get_req_header(conn, "signature") do
[signature | _] ->
if signature do
# set (request-target) header to the appropriate value
# we also replace the digest header with the one we computed
conn =
conn
|> put_req_header(
"(request-target)",
String.downcase("#{conn.method}") <> " #{conn.request_path}"
)
conn =
if conn.assigns[:digest] do
conn
|> put_req_header("digest", conn.assigns[:digest])
else
conn
end
signature_valid = HTTPSignatures.validate_conn(conn)
Logger.debug("Is signature valid ? #{inspect(signature_valid)}")
assign(conn, :valid_signature, signature_valid)
else
Logger.debug("No signature header!")
conn
end
_ ->
conn
end
end
end

View File

@@ -0,0 +1,82 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Web.Plugs.MappedSignatureToIdentity do
@moduledoc """
Get actor identity from Signature when handing fetches
"""
import Plug.Conn
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.Federation.HTTPSignatures.Signature
require Logger
def init(options), do: options
@spec key_id_from_conn(Plug.Conn.t()) :: String.t() | nil
defp key_id_from_conn(conn) do
case HTTPSignatures.signature_for_conn(conn) do
%{"keyId" => key_id} ->
Signature.key_id_to_actor_url(key_id)
_ ->
nil
end
end
@spec actor_from_key_id(Plug.Conn.t()) :: Actor.t() | nil
defp actor_from_key_id(conn) do
with key_actor_id when is_binary(key_actor_id) <- key_id_from_conn(conn),
{:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(key_actor_id) do
actor
else
_ ->
nil
end
end
def call(%{assigns: %{actor: _}} = conn, _opts), do: conn
# if this has payload make sure it is signed by the same actor that made it
def call(%{assigns: %{valid_signature: true}, params: %{"actor" => actor}} = conn, _opts) do
with actor_id <- Utils.get_url(actor),
{:actor, %Actor{} = actor} <- {:actor, actor_from_key_id(conn)},
{:actor_match, true} <- {:actor_match, actor.url == actor_id} do
assign(conn, :actor, actor)
else
{:actor_match, false} ->
Logger.debug("Failed to map identity from signature (payload actor mismatch)")
Logger.debug("key_id=#{key_id_from_conn(conn)}, actor=#{actor}")
assign(conn, :valid_signature, false)
# remove me once testsuite uses mapped capabilities instead of what we do now
{:actor, nil} ->
Logger.debug("Failed to map identity from signature (lookup failure)")
Logger.debug("key_id=#{key_id_from_conn(conn)}, actor=#{actor}")
conn
end
end
# no payload, probably a signed fetch
def call(%{assigns: %{valid_signature: true}} = conn, _opts) do
case actor_from_key_id(conn) do
%Actor{} = actor ->
assign(conn, :actor, actor)
_ ->
Logger.debug("Failed to map identity from signature (no payload actor mismatch)")
Logger.debug("key_id=#{key_id_from_conn(conn)}")
assign(conn, :valid_signature, false)
end
end
# no signature at all
def call(conn, _opts), do: conn
end

View File

@@ -0,0 +1,95 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/plugs/uploaded_media.ex
defmodule Mobilizon.Web.Plugs.UploadedMedia do
@moduledoc """
Serves uploaded media files
"""
@behaviour Plug
import Plug.Conn
alias Mobilizon.Config
require Logger
# no slashes
@path "media"
def init(_opts) do
static_plug_opts =
[]
|> Keyword.put(:from, "__unconfigured_media_plug")
|> Keyword.put(:at, "/__unconfigured_media_plug")
|> Plug.Static.init()
%{static_plug_opts: static_plug_opts}
end
def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do
conn =
case fetch_query_params(conn) do
%{query_params: %{"name" => name}} = conn ->
name = String.replace(name, "\"", "\\\"")
put_resp_header(conn, "content-disposition", "filename=\"#{name}\"")
conn ->
conn
end
config = Config.get([Mobilizon.Web.Upload])
with uploader <- Keyword.fetch!(config, :uploader),
proxy_remote = Keyword.get(config, :proxy_remote, false),
{:ok, get_method} <- uploader.get_file(file) do
get_media(conn, get_method, proxy_remote, opts)
else
_ ->
conn
|> send_resp(500, "Failed")
|> halt()
end
end
def call(conn, _opts), do: conn
defp get_media(conn, {:static_dir, directory}, _, opts) do
static_opts =
opts
|> Map.get(:static_plug_opts)
|> Map.put(:at, [@path])
|> Map.put(:from, directory)
conn = Plug.Static.call(conn, static_opts)
if conn.halted do
conn
else
conn
|> send_resp(404, "Not found")
|> halt()
end
end
defp get_media(conn, {:url, url}, true, _) do
Mobilizon.Web.ReverseProxy.call(conn, url, Config.get([Mobilizon.Upload, :proxy_opts], []))
end
defp get_media(conn, {:url, url}, _, _) do
conn
|> Phoenix.Controller.redirect(external: url)
|> halt()
end
defp get_media(conn, unknown, _, _) do
Logger.error("#{__MODULE__}: Unknown get startegy: #{inspect(unknown)}")
conn
|> send_resp(500, "Internal Error")
|> halt()
end
end

View File

@@ -0,0 +1,89 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/media_proxy/media_proxy.ex
defmodule Mobilizon.Web.MediaProxy do
@moduledoc """
Handles proxifying media files
"""
alias Mobilizon.Config
@base64_opts [padding: false]
def url(nil), do: nil
def url(""), do: nil
def url("/" <> _ = url), do: url
def url(url) do
config = Application.get_env(:mobilizon, :media_proxy, [])
if !Keyword.get(config, :enabled, false) or
String.starts_with?(url, Mobilizon.Web.Endpoint.url()) do
url
else
encode_url(url)
end
end
def encode_url(url) do
secret = Application.get_env(:mobilizon, Mobilizon.Web.Endpoint)[:secret_key_base]
# Must preserve `%2F` for compatibility with S3
# https://git.pleroma.social/pleroma/pleroma/issues/580
replacement = get_replacement(url, ":2F:")
# The URL is url-decoded and encoded again to ensure it is correctly encoded and not twice.
base64 =
url
|> String.replace("%2F", replacement)
|> URI.decode()
|> URI.encode()
|> String.replace(replacement, "%2F")
|> Base.url_encode64(@base64_opts)
sig = :crypto.hmac(:sha, secret, base64)
sig64 = sig |> Base.url_encode64(@base64_opts)
build_url(sig64, base64, filename(url))
end
def decode_url(sig, url) do
secret = Application.get_env(:mobilizon, Mobilizon.Web.Endpoint)[:secret_key_base]
sig = Base.url_decode64!(sig, @base64_opts)
local_sig = :crypto.hmac(:sha, secret, url)
if local_sig == sig do
{:ok, Base.url_decode64!(url, @base64_opts)}
else
{:error, :invalid_signature}
end
end
def filename(url_or_path) do
if path = URI.parse(url_or_path).path, do: Path.basename(path)
end
def build_url(sig_base64, url_base64, filename \\ nil) do
[
Config.get([:media_proxy, :base_url], Mobilizon.Web.Endpoint.url()),
"proxy",
sig_base64,
url_base64,
filename
]
|> Enum.filter(fn value -> value end)
|> Path.join()
end
defp get_replacement(url, replacement) do
if String.contains?(url, replacement) do
get_replacement(url, replacement <> replacement)
else
replacement
end
end
end

View File

@@ -0,0 +1,391 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/reverse_proxy.ex
defmodule Mobilizon.Web.ReverseProxy do
@keep_req_headers ~w(accept user-agent accept-encoding cache-control
if-modified-since if-unmodified-since if-none-match if-range range)
@resp_cache_headers ~w(etag date last-modified cache-control)
@keep_resp_headers @resp_cache_headers ++ ~w(content-type content-disposition
content-encoding content-range accept-ranges vary)
@default_cache_control_header "public, max-age=1209600"
@valid_resp_codes [200, 206, 304]
@max_read_duration :timer.seconds(30)
@max_body_length :infinity
@methods ~w(GET HEAD)
@moduledoc """
A reverse proxy.
Mobilizon.Web.ReverseProxy.call(conn, url, options)
It is not meant to be added into a plug pipeline, but to be called from another
plug or controller.
Supports `#{inspect(@methods)}` HTTP methods, and only allows
`#{inspect(@valid_resp_codes)}` status codes.
Responses are chunked to the client while downloading from the upstream.
Some request / responses headers are preserved:
* request: `#{inspect(@keep_req_headers)}`
* response: `#{inspect(@keep_resp_headers)}`
If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by
upstream, `cache-control` will be set to `#{inspect(@default_cache_control_header)}`.
Options:
* `redirect_on_failure` (default `false`). Redirects the client to the real
remote URL if there's any HTTP errors. Any error during body processing will
not be redirected as the response is chunked. This may expose remote URL,
clients IPs, ….
* `max_body_length` (default `#{inspect(@max_body_length)}`): limits the
content length to be approximately the specified length. It is validated with
the `content-length` header and also verified when proxying.
* `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total
time the connection is allowed to read from the remote upstream.
* `inline_content_types`:
* `true` will not alter `content-disposition` (up to the upstream),
* `false` will add `content-disposition: attachment` to any request,
* a list of whitelisted content types
* `keep_user_agent` will forward the client's user-agent to the upstream.
This may be useful if the upstream is doing content transformation
(encoding, …) depending on the request.
* `req_headers`, `resp_headers` additional headers.
* `http`: options for [hackney](https://github.com/benoitc/hackney).
"""
import Plug.Conn
alias Plug.Conn
require Logger
@type option ::
{:keep_user_agent, boolean}
| {:max_read_duration, :timer.time() | :infinity}
| {:max_body_length, non_neg_integer | :infinity}
| {:http, []}
| {:req_headers, [{String.t(), String.t()}]}
| {:resp_headers, [{String.t(), String.t()}]}
| {:inline_content_types, boolean | [String.t()]}
| {:redirect_on_failure, boolean}
@hackney Application.get_env(:mobilizon, :hackney, :hackney)
@httpoison Application.get_env(:mobilizon, :httpoison, HTTPoison)
@default_hackney_options []
@inline_content_types [
"image/gif",
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
"audio/mpeg",
"audio/mp3",
"video/webm",
"video/mp4",
"video/quicktime"
]
@spec call(Plug.Conn.t(), url :: String.t(), [option]) :: Plug.Conn.t()
def call(_conn, _url, _opts \\ [])
def call(conn = %{method: method}, url, opts) when method in @methods do
hackney_opts =
@default_hackney_options
|> Keyword.merge(Keyword.get(opts, :http, []))
|> @httpoison.process_request_options()
req_headers = build_req_headers(conn.req_headers, opts)
opts =
if filename = Mobilizon.Web.MediaProxy.filename(url) do
Keyword.put_new(opts, :attachment_name, filename)
else
opts
end
with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
:ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do
response(conn, client, url, code, headers, opts)
else
{:ok, code, headers} ->
conn
|> head_response(url, code, headers, opts)
|> halt()
{:error, {:invalid_http_response, code}} ->
Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
conn
|> error_or_redirect(
url,
code,
"Request failed: " <> Conn.Status.reason_phrase(code),
opts
)
|> halt()
{:error, error} ->
Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
conn
|> error_or_redirect(url, 500, "Request failed", opts)
|> halt()
end
end
def call(conn, _, _) do
conn
|> send_resp(400, Conn.Status.reason_phrase(400))
|> halt()
end
defp request(method, url, headers, hackney_opts) do
Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
method = method |> String.downcase() |> String.to_existing_atom()
case @hackney.request(method, url, headers, "", hackney_opts) do
{:ok, code, headers, client} when code in @valid_resp_codes ->
{:ok, code, downcase_headers(headers), client}
{:ok, code, headers} when code in @valid_resp_codes ->
{:ok, code, downcase_headers(headers)}
{:ok, code, _, _} ->
{:error, {:invalid_http_response, code}}
{:error, error} ->
{:error, error}
end
end
defp response(conn, client, url, status, headers, opts) do
result =
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> send_chunked(status)
|> chunk_reply(client, opts)
case result do
{:ok, conn} ->
halt(conn)
{:error, :closed, conn} ->
:hackney.close(client)
halt(conn)
{:error, error, conn} ->
Logger.warn(
"#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
)
:hackney.close(client)
halt(conn)
end
end
defp chunk_reply(conn, client, opts) do
chunk_reply(conn, client, opts, 0, 0)
end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do
with {:ok, duration} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data} <- @hackney.stream_body(client),
{:ok, duration} <- increase_read_duration(duration),
sent_so_far = sent_so_far + byte_size(data),
:ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)),
{:ok, conn} <- chunk(conn, data) do
chunk_reply(conn, client, opts, sent_so_far, duration)
else
:done -> {:ok, conn}
{:error, error} -> {:error, error, conn}
end
end
defp head_response(conn, _url, code, headers, opts) do
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> send_resp(code, "")
end
defp error_or_redirect(conn, url, code, body, opts) do
if Keyword.get(opts, :redirect_on_failure, false) do
conn
|> Phoenix.Controller.redirect(external: url)
|> halt()
else
conn
|> send_resp(code, body)
|> halt
end
end
defp downcase_headers(headers) do
Enum.map(headers, fn {k, v} ->
{String.downcase(k), v}
end)
end
defp get_content_type(headers) do
{_, content_type} =
List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
[content_type | _] = String.split(content_type, ";")
content_type
end
defp put_resp_headers(conn, headers) do
Enum.reduce(headers, conn, fn {k, v}, conn ->
put_resp_header(conn, k, v)
end)
end
defp build_req_headers(headers, opts) do
headers
|> downcase_headers()
|> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
|> (fn headers ->
headers = headers ++ Keyword.get(opts, :req_headers, [])
if Keyword.get(opts, :keep_user_agent, false) do
List.keystore(
headers,
"user-agent",
0,
{"user-agent", Mobilizon.user_agent()}
)
else
headers
end
end).()
end
defp build_resp_headers(headers, opts) do
headers
|> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
|> build_resp_cache_headers(opts)
|> build_resp_content_disposition_header(opts)
|> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
end
defp build_resp_cache_headers(headers, _opts) do
has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
has_cache_control? = List.keymember?(headers, "cache-control", 0)
cond do
has_cache? && has_cache_control? ->
headers
has_cache? ->
# There's caching header present but no cache-control -- we need to explicitely override it
# to public as Plug defaults to "max-age=0, private, must-revalidate"
List.keystore(headers, "cache-control", 0, {"cache-control", "public"})
true ->
List.keystore(
headers,
"cache-control",
0,
{"cache-control", @default_cache_control_header}
)
end
end
defp build_resp_content_disposition_header(headers, opts) do
opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
content_type = get_content_type(headers)
attachment? =
cond do
is_list(opt) && !Enum.member?(opt, content_type) -> true
opt == false -> true
true -> false
end
if attachment? do
name =
try do
{{"content-disposition", content_disposition_string}, _} =
List.keytake(headers, "content-disposition", 0)
[name | _] =
Regex.run(
~r/filename="((?:[^"\\]|\\.)*)"/u,
content_disposition_string || "",
capture: :all_but_first
)
name
rescue
MatchError -> Keyword.get(opts, :attachment_name, "attachment")
end
disposition = "attachment; filename=\"#{name}\""
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
else
headers
end
end
defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
with {_, size} <- List.keyfind(headers, "content-length", 0),
{size, _} <- Integer.parse(size),
true <- size <= limit do
:ok
else
false ->
{:error, :body_too_large}
_ ->
:ok
end
end
defp header_length_constraint(_, _), do: :ok
defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
{:error, :body_too_large}
end
defp body_size_constraint(_, _), do: :ok
defp check_read_duration(duration, max)
when is_integer(duration) and is_integer(max) and max > 0 do
if duration > max do
{:error, :read_duration_exceeded}
else
{:ok, {duration, :erlang.system_time(:millisecond)}}
end
end
defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
defp increase_read_duration({previous_duration, started})
when is_integer(previous_duration) and is_integer(started) do
duration = :erlang.system_time(:millisecond) - started
{:ok, previous_duration + duration}
end
defp increase_read_duration(_) do
{:ok, :no_duration_limit, :no_duration_limit}
end
end

147
lib/web/router.ex Normal file
View File

@@ -0,0 +1,147 @@
defmodule Mobilizon.Web.Router do
@moduledoc """
Router for mobilizon app
"""
use Mobilizon.Web, :router
pipeline :graphql do
# plug(:accepts, ["json"])
plug(Mobilizon.Web.Auth.Pipeline)
end
pipeline :well_known do
plug(:accepts, ["json", "jrd-json"])
end
pipeline :activity_pub_signature do
plug(Mobilizon.Web.Plugs.HTTPSignatures)
plug(Mobilizon.Web.Plugs.MappedSignatureToIdentity)
end
pipeline :relay do
plug(Mobilizon.Web.Plugs.HTTPSignatures)
plug(Mobilizon.Web.Plugs.MappedSignatureToIdentity)
plug(:accepts, ["activity-json", "json"])
end
pipeline :activity_pub do
plug(:accepts, ["activity-json"])
end
pipeline :activity_pub_and_html do
plug(:accepts, ["html", "activity-json"])
end
pipeline :atom_and_ical do
plug(:accepts, ["atom", "ics", "html"])
end
pipeline :browser do
plug(Plug.Static, at: "/", from: "priv/static")
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_flash)
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
end
pipeline :remote_media do
end
scope "/api" do
pipe_through(:graphql)
forward("/", Absinthe.Plug,
schema: Mobilizon.GraphQL.Schema,
analyze_complexity: true,
max_complexity: 200
)
end
## FEDERATION
scope "/.well-known", Mobilizon.Web do
pipe_through(:well_known)
get("/host-meta", WebFingerController, :host_meta)
get("/webfinger", WebFingerController, :webfinger)
get("/nodeinfo", NodeInfoController, :schemas)
get("/nodeinfo/:version", NodeInfoController, :nodeinfo)
end
scope "/", Mobilizon.Web do
pipe_through(:activity_pub_and_html)
pipe_through(:activity_pub_signature)
get("/@:name", PageController, :actor)
get("/events/:uuid", PageController, :event)
get("/comments/:uuid", PageController, :comment)
end
scope "/", Mobilizon.Web do
pipe_through(:activity_pub)
get("/@:name/outbox", ActivityPubController, :outbox)
get("/@:name/following", ActivityPubController, :following)
get("/@:name/followers", ActivityPubController, :followers)
end
scope "/", Mobilizon.Web do
pipe_through(:activity_pub_signature)
post("/@:name/inbox", ActivityPubController, :inbox)
post("/inbox", ActivityPubController, :inbox)
end
scope "/relay", Mobilizon.Web do
pipe_through(:relay)
get("/", ActivityPubController, :relay)
post("/inbox", ActivityPubController, :inbox)
end
## FEED
scope "/", Mobilizon.Web do
pipe_through(:atom_and_ical)
get("/@:name/feed/:format", FeedController, :actor)
get("/events/:uuid/export/:format", FeedController, :event)
get("/events/going/:token/:format", FeedController, :going)
end
## MOBILIZON
forward("/graphiql", Absinthe.Plug.GraphiQL, schema: Mobilizon.Web.Schema)
scope "/", Mobilizon.Web do
pipe_through(:browser)
# Because the "/events/:uuid" route caches all these, we need to force them
get("/events/create", PageController, :index)
get("/events/list", PageController, :index)
get("/events/me", PageController, :index)
get("/events/explore", PageController, :index)
get("/events/:uuid/edit", PageController, :index)
# This is a hack to ease link generation into emails
get("/moderation/reports/:id", PageController, :index, as: "moderation_report")
end
scope "/proxy/", Mobilizon.Web do
pipe_through(:remote_media)
get("/:sig/:url", MediaProxyController, :remote)
get("/:sig/:url/:filename", MediaProxyController, :remote)
end
if Mix.env() in [:dev, :e2e] do
# If using Phoenix
forward("/sent_emails", Bamboo.SentEmailViewerPlug)
end
scope "/", Mobilizon.Web do
pipe_through(:browser)
get("/*path", PageController, :index)
end
end

View File

@@ -0,0 +1,171 @@
<!-- THIS EMAIL WAS BUILT AND TESTED WITH LITMUS http://litmus.com -->
<!-- IT WAS RELEASED UNDER THE MIT LICENSE https://opensource.org/licenses/MIT -->
<!-- QUESTIONS? TWEET US @LITMUSAPP -->
<!DOCTYPE html>
<html lang="<%= @locale %>">
<head>
<title><%= @subject %></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<style type="text/css">
/* CLIENT-SPECIFIC STYLES */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; }
/* RESET STYLES */
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
table { border-collapse: collapse !important; }
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
/* iOS BLUE LINKS */
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* MOBILE STYLES */
@media screen and (max-width:600px){
h1 {
font-size: 32px !important;
line-height: 32px !important;
}
}
/* ANDROID CENTER FIX */
div[style*="margin: 16px 0;"] { margin: 0 !important; }
</style>
</head>
<body style="background-color: #f4f4f4; margin: 0 !important; padding: 0 !important;">
<!-- HIDDEN PREHEADER TEXT -->
<!--<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; font-family: 'Lato', Helvetica, Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
Looks like you tried signing in a few too many times. Let's see if we can get you back into your account.
</div>-->
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- LOGO -->
<tr>
<td bgcolor="#424056" align="center">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td align="center" valign="top" style="padding: 40px 10px 40px 10px;">
<a href="<%= Mobilizon.Web.Endpoint.url() %>" target="_blank">
<img alt="<%= Mobilizon.Config.instance_name() %>" src="<%= "#{Mobilizon.Web.Endpoint.url()}/img/mobilizon_logo.png" %>" width="366" height="108" style="display: block; width: 366px; max-width: 366px; min-width: 366px; font-family: 'Lato', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;" border="0">
</a>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<%= render @view_module, @view_template, assigns %>
<% if Mobilizon.Config.instance_demo_mode?() do %>
<!-- BETA WARNING -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 30px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- HEADLINE -->
<tr>
<td bgcolor="#ff7061" align="center" style="padding: 30px 30px 30px 30px; border-radius: 4px 4px 4px 4px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<h2 style="font-size: 25px; font-weight: 400; color: #111111; margin: 0;">
<%= gettext "Warning" %>
</h2>
<p style="margin: 0; color: #111111"><%= gettext "This is a demonstration site to test the beta version of Mobilizon." %></p>
<p style="margin: 0; color: #111111;"><%= gettext("%{b_start}Please do not use it in any real way%{b_end}", b_start: "<b>", b_end: "</b>") |> raw() %></p>
<p style="margin: 0; color: #111111;">
<%= gettext("Mobilizon is under development, we will add new features to this site during regular updates, until the release of %{b_start}version 1 of the software in the first half of 2020%{b_end}.", b_start: "<b>", b_end: "</b>") |> raw() %>
<%= gettext("In the meantime, please consider that the software is not (yet) finished. More information %{a_start}on our blog%{a_end}.", a_start: "<a target='_blank' style='color: #424056;' href='https://framablog.org/?p=18299'>", a_end: "</a>") |> raw() %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<% end %>
<!-- SUPPORT CALLOUT -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 30px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- HEADLINE -->
<tr>
<td bgcolor="#C6C2ED" align="center" style="padding: 30px 30px 30px 30px; border-radius: 4px 4px 4px 4px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<h2 style="font-size: 20px; font-weight: 400; color: #111111; margin: 0;">
<%= gettext "Need some help? Something not working properly?" %>
</h2>
<p style="margin: 0;"><a href="https://framacolibri.org/c/mobilizon/test-mobilizon" target="_blank" style="color: #424056;">
<%= gettext "Ask the community on Framacolibri" %>
</a></p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- FOOTER -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- UNSUBSCRIBE -->
<!--<tr>
<td bgcolor="#f4f4f4" align="left" style="padding: 30px 30px 30px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
<p style="margin: 0;">If these emails get annoying, please feel free to <a href="http://litmus.com" target="_blank" style="color: #111111; font-weight: 700;">unsubscribe</a>.</p>
</td>
</tr>-->
<!-- ADDRESS -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 30px 30px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
<p style="margin: 0;">
<%= gettext "%{instance} is a Mobilizon server.", instance: @instance[:name] %>
<a href="https://joinmobilizon.org"><%= gettext "Learn more about Mobilizon." %></a>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<%= render @view_module, @view_template, assigns %>
--
<%= gettext "This is a demonstration site to test the beta version of Mobilizon." %>
<%= gettext "Please do not use it in any real way" %>
<%= gettext "Mobilizon is under development, we will add new features to this site during regular updates, until the release of version 1 of the software in the first half of 2020." %>
<%= gettext "In the meantime, please consider that the software is not (yet) finished. More information on our blog:" %> https://framablog.org/2019/10/15/mobilizon-lifting-the-veil-on-the-beta-release/
<%= gettext "Need some help? Something not working properly?" %>
<%= gettext "Ask the community on Framacolibri" %> https://framacolibri.org/c/mobilizon/test-mobilizon
<%= gettext "%{instance} is a Mobilizon server.", instance: @instance[:name] %> <%= gettext "Learn more about Mobilizon:" %> https://joinmobilizon.org

View File

@@ -0,0 +1,81 @@
<!-- HERO -->
<tr>
<td bgcolor="#424056" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "All good!" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "You requested to participate in event %{title}", title: @event.title %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0">
<%= gettext "An organizer just approved your participation. You're now going to this event!" %>
</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#424056"><a href="<%= page_url(Mobilizon.Web.Endpoint, :event, @event.id) %>" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #424056; display: inline-block;">
<%= gettext "Go to event page" %>
</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext "If you need to cancel your participation, just access the event page through link above and click on the participation button." %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,11 @@
<%= gettext "Participation approved" %>
==
<%= gettext "You requested to participate in event %{title}.", title: @event.title %>
<%= gettext "An organizer just approved your participation. You're now going to this event!" %>
<%= page_url(Mobilizon.Web.Endpoint, :event, @event.id) %>
<%= gettext "If you need to cancel your participation, just access the previous link and click on the participation button." %>

View File

@@ -0,0 +1,56 @@
<!-- HERO -->
<tr>
<td bgcolor="#424056" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Sorry!" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "You requested to participate in event %{title}.", title: @event.title %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0">
<%= gettext "Unfortunately, the organizers rejected your participation." %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,7 @@
<%= gettext "Participation rejected" %>
==
<%= gettext "You requested to participate in event %{title}.", title: @event.title %>
<%= gettext "Unfortunately, the organizers rejected your participation." %>

View File

@@ -0,0 +1,124 @@
<!-- HERO -->
<tr>
<td bgcolor="#424056" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Event updated!" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "The event %{title} was updated", title: @old_event.title %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<table width="100%">
<%= if MapSet.member?(@changes, :status) do %>
<tr>
<td bgcolor="#ffffff" align="center">
<%= case @event.status do %>
<% :confirmed -> %>
<%= gettext "Event has been confirmed" %>
<% :tentative -> %>
<%= gettext "Event status has been set as tentative" %>
<% :cancelled -> %>
<%= gettext "Event has been cancelled" %>
<% end %>
</td>
</tr>
<% end %>
<%= if MapSet.member?(@changes, :title) do %>
<tr>
<td bgcolor="#ffffff" align="left">
<%= gettext "Title" %>
</td>
<td bgcolor="#ffffff" align="left">
<%= @event.title %>
</td>
</tr>
<% end %>
<%= if MapSet.member?(@changes, :begins_on) do %>
<tr>
<td bgcolor="#ffffff" align="left">
<%= gettext "Start of event" %>
</td>
<td bgcolor="#ffffff" align="left">
<%= datetime_to_string(@event.begins_on, @locale) %>
</td>
</tr>
<% end %>
<%= if MapSet.member?(@changes, :ends_on) && !is_nil(@event.ends_on) do %>
<tr>
<td bgcolor="#ffffff" align="left">
<%= gettext "Ending of event" %>
</td>
<td bgcolor="#ffffff" align="left">
<%= datetime_to_string(@event.ends_on, @locale) %>
</td>
</tr>
<% end %>
</table>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#424056"><a href="<%= page_url(Mobilizon.Web.Endpoint, :event, @event.id) %>" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #424056; display: inline-block;">
<%= gettext "Go to event page" %>
</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext "If you need to cancel your participation, just access the event page through link above and click on the participation button." %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,21 @@
<%= gettext "Event updated!" %>
==
<%= gettext "The event %{title} was just updated", title: @old_event.title %>
<%= if MapSet.member?(@changes, :title) do %>
<%= gettext "New title: %{title}", title: @event.title %>
<% end %>
<%= if MapSet.member?(@changes, :begins_on) do %>
<%= gettext "New date and time for start of event: %{begins_on}", begins_on: datetime_to_string(@event.begins_on, @locale) %>
<% end %>
<%= if MapSet.member?(@changes, :ends_on) && !is_nil(@event.ends_on) do %>
<%= gettext "New date and time for ending of event: %{ends_on}", ends_on: datetime_to_string(@event.ends_on, @locale) %>
<% end %>
<%= gettext "View the updated event on: %{link}", link: page_url(Mobilizon.Web.Endpoint, :event, @event.id) %>
<%= gettext "If you need to cancel your participation, just access the event page through link above and click on the participation button." %>

View File

@@ -0,0 +1,77 @@
<!-- HERO -->
<tr>
<td bgcolor="#424056" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Trouble signing in?" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "You requested a new password for your account on %{instance}.", instance: @instance[:name] %>
</p>
<p style="margin: 0">
<%= gettext "Resetting your password is easy. Just press the button below and follow the instructions. We'll have you up and running in no time." %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext "If you didn't request this, please ignore this email. Your password won't change until you access the link below and create a new one." %>
</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#424056"><a href="<%= "#{Mobilizon.Web.Endpoint.url()}/password-reset/#{@token}" %>" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #424056; display: inline-block;">
<%= gettext "Reset Password" %>
</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,12 @@
<%= gettext "Password reset" %>
==
<%= gettext "You requested a new password for your account on %{instance}.", instance: @instance[:name] %>
<%= gettext "Resetting your password is easy. Just click the link below and follow the instructions. We'll have you up and running in no time." %>
<%= Mobilizon.Web.Endpoint.url() <> "/password-reset/#{@token}" %>
<%= gettext "If you didn't request this, please ignore this email. Your password won't change until you access the link below and create a new one." %>

View File

@@ -0,0 +1,74 @@
<!-- HERO -->
<tr>
<td bgcolor="#424056" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Nearly here!" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "You created an account on %{host} with this email address. You are one click away from activating it.", host: @instance[:name] %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext "If you didn't request this, please ignore this email." %>
</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#424056"><a href="<%= "#{Mobilizon.Web.Endpoint.url()}/validate/#{@token}" %>" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #424056; display: inline-block;">
<%= gettext "Activate my account" %>
</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,7 @@
<%= gettext "Activate your account" %>
==
<%= gettext "You created an account on %{host} with this email address. You are one click away from activating it. If this wasn't you, please ignore this email.", host: @instance[:name] %>
<%= Mobilizon.Web.Endpoint.url() <> "/validate/#{@token}" %>

View File

@@ -0,0 +1,120 @@
<!-- HERO -->
<tr>
<td bgcolor="#424056" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "New report on %{instance}", instance: @instance[:name] %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= if @report.reporter.type == :Application and @report.reporter.preferred_username == "relay" do %>
<%= gettext "Someone on %{instance} reported the following content.", instance: @report.reporter.domain %>
<% else %>
<%= gettext "%{reporter_name} (%{reporter_username}) reported the following content.", reporter_name: @report.reporter.name, reporter_username: Mobilizon.Actors.Actor.preferred_username_and_domain(@report.reporter) %>
<% end %>
</p>
</td>
</tr>
<%= if Map.has_key?(@report, :event) do %>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<h3><%= gettext "Event" %></h3>
<a href="<%= "#{Mobilizon.Web.Endpoint.url()}/events/#{@report.event.uuid}" %>" target="_blank">
<%= gettext "%{title} by %{creator}", title: @report.event.title, creator: Mobilizon.Actors.Actor.preferred_username_and_domain(@report.reported) %>
</a>
</p>
<table cellspacing="0" cellpadding="0" border="0" width="100%" style="width: 100% !important;">
<tr>
<td align="left" valign="top" width="600px" height="1" style="background-color: #f0f0f0; border-collapse:collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; mso-line-height-rule: exactly; line-height: 1px;"><!--[if gte mso 15]>&nbsp;<![endif]--></td>
</tr>
</table>
</td>
</tr>
<% end %>
<%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<h3><%= gettext "Comments" %></h3>
<%= for comment <- @report.comments do %>
<p style="margin: 0;">
<%= HtmlSanitizeEx.strip_tags(comment.text) %>
</p>
<% end %>
<table cellspacing="0" cellpadding="0" border="0" width="100%" style="width: 100% !important;">
<tr>
<td align="left" valign="top" width="600px" height="1" style="background-color: #f0f0f0; border-collapse:collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; mso-line-height-rule: exactly; line-height: 1px;"><!--[if gte mso 15]>&nbsp;<![endif]--></td>
</tr>
</table>
</td>
</tr>
<% end %>
<%= if Map.has_key?(@report, :content) do %>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0">
<h3><%= gettext "Reason" %></h3>
<%= @report.content %>
</p>
<table cellspacing="0" cellpadding="0" border="0" width="100%" style="width: 100% !important;">
<tr>
<td align="left" valign="top" width="600px" height="1" style="background-color: #f0f0f0; border-collapse:collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; mso-line-height-rule: exactly; line-height: 1px;"><!--[if gte mso 15]>&nbsp;<![endif]--></td>
</tr>
</table>
</td>
</tr>
<% end %>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#424056"><a href="<%= moderation_report_url(Mobilizon.Web.Endpoint, :index, @report.id) %>" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #424056; display: inline-block;">
<%= gettext "View the report" %>
</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,29 @@
<%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter.preferred_username, instance: @instance[:name] %>
--
<%= if Map.has_key?(@report, :event) do %>
<%= gettext "Event" %>
<%= @report.event.title %>
<% end %>
<%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %>
<%= gettext "Comments" %>
<%= for comment <- @report.comments do %>
<%= comment.text %>
<% end %>
<% end %>
<%= if @report.content do %>
<%= gettext "Reason" %>
<%= @report.content %>
<% end %>
View the report: <%= moderation_report_url(Mobilizon.Web.Endpoint, :index, @report.id) %>

View File

@@ -0,0 +1,31 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload/filter/anonymize_filename.ex
defmodule Mobilizon.Web.Upload.Filter.AnonymizeFilename do
@moduledoc """
Replaces the original filename with a pre-defined text or randomly generated string.
Should be used after `Mobilizon.Web.Upload.Filter.Dedupe`.
"""
@behaviour Mobilizon.Web.Upload.Filter
alias Mobilizon.Config
def filter(upload) do
extension = List.last(String.split(upload.name, "."))
name = Config.get([__MODULE__, :text], random(extension))
{:ok, %Mobilizon.Web.Upload{upload | name: name}}
end
defp random(extension) do
string =
10
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
string <> "." <> extension
end
end

View File

@@ -0,0 +1,20 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload/filter/dedupe.ex
defmodule Mobilizon.Web.Upload.Filter.Dedupe do
@moduledoc """
Names the file after its hash to avoid dedupes
"""
@behaviour Mobilizon.Web.Upload.Filter
alias Mobilizon.Web.Upload
def filter(%Upload{name: name} = upload) do
extension = name |> String.split(".") |> List.last()
shasum = :crypto.hash(:sha256, File.read!(upload.tempfile)) |> Base.encode16(case: :lower)
filename = shasum <> "." <> extension
{:ok, %Upload{upload | id: shasum, path: filename}}
end
end

View File

@@ -0,0 +1,42 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload/filter.ex
defmodule Mobilizon.Web.Upload.Filter do
@moduledoc """
Upload Filter behaviour
This behaviour allows to run filtering actions just before a file is uploaded. This allows to:
* morph in place the temporary file
* change any field of a `Mobilizon.Upload` struct
* cancel/stop the upload
"""
require Logger
@callback filter(Mobilizon.Web.Upload.t()) ::
:ok | {:ok, Mobilizon.Web.Upload.t()} | {:error, any()}
@spec filter([module()], Mobilizon.Web.Upload.t()) ::
{:ok, Mobilizon.Web.Upload.t()} | {:error, any()}
def filter([], upload) do
{:ok, upload}
end
def filter([filter | rest], upload) do
case filter.filter(upload) do
:ok ->
filter(rest, upload)
{:ok, upload} ->
filter(rest, upload)
error ->
Logger.error("#{__MODULE__}: Filter #{filter} failed: #{inspect(error)}")
error
end
end
end

View File

@@ -0,0 +1,48 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload/filter/mogrify.ex
defmodule Mobilizon.Web.Upload.Filter.Mogrify do
@moduledoc """
Handle mogrify transformations
"""
@behaviour Mobilizon.Web.Upload.Filter
alias Mobilizon.Config
@type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()}
@type conversions :: conversion() | [conversion()]
def filter(%Mobilizon.Web.Upload{tempfile: file, content_type: "image" <> _}) do
filters = Config.get!([__MODULE__, :args])
file
|> Mogrify.open()
|> mogrify_filter(filters)
|> Mogrify.save(in_place: true)
:ok
end
def filter(_), do: :ok
defp mogrify_filter(mogrify, nil), do: mogrify
defp mogrify_filter(mogrify, [filter | rest]) do
mogrify
|> mogrify_filter(filter)
|> mogrify_filter(rest)
end
defp mogrify_filter(mogrify, []), do: mogrify
defp mogrify_filter(mogrify, {action, options}) do
Mogrify.custom(mogrify, action, options)
end
defp mogrify_filter(mogrify, action) when is_binary(action) do
Mogrify.custom(mogrify, action)
end
end

View File

@@ -0,0 +1,41 @@
defmodule Mobilizon.Web.Upload.Filter.Optimize do
@moduledoc """
Handle picture optimizations
"""
@behaviour Mobilizon.Web.Upload.Filter
alias Mobilizon.Config
@default_optimizers [
JpegOptim,
PngQuant,
Optipng,
Svgo,
Gifsicle,
Cwebp
]
def filter(%Mobilizon.Web.Upload{tempfile: file, content_type: "image" <> _}) do
optimizers = Config.get([__MODULE__, :optimizers], @default_optimizers)
case ExOptimizer.optimize(file, deps: optimizers) do
{:ok, _res} ->
:ok
{:error, err} ->
require Logger
Logger.warn(
"Unable to optimize file #{file}. The return from the process was #{inspect(err)}"
)
:ok
err ->
err
end
end
def filter(_), do: :ok
end

123
lib/web/upload/mime.ex Normal file
View File

@@ -0,0 +1,123 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/mime.ex
defmodule Mobilizon.Web.Upload.MIME do
@moduledoc """
Returns the mime-type of a binary and optionally a normalized file-name.
"""
@default "application/octet-stream"
@read_bytes 35
@spec file_mime_type(String.t()) ::
{:ok, content_type :: String.t(), filename :: String.t()} | {:error, any()} | :error
def file_mime_type(path, filename) do
with {:ok, content_type} <- file_mime_type(path),
filename <- fix_extension(filename, content_type) do
{:ok, content_type, filename}
end
end
@spec file_mime_type(String.t()) :: {:ok, String.t()} | {:error, any()} | :error
def file_mime_type(filename) do
File.open(filename, [:read], fn f ->
check_mime_type(IO.binread(f, @read_bytes))
end)
end
def bin_mime_type(binary, filename) do
with {:ok, content_type} <- bin_mime_type(binary),
filename <- fix_extension(filename, content_type) do
{:ok, content_type, filename}
end
end
@spec bin_mime_type(binary()) :: {:ok, String.t()} | :error
def bin_mime_type(<<head::binary-size(@read_bytes), _::binary>>) do
{:ok, check_mime_type(head)}
end
def bin_mime_type(_), do: :error
def mime_type(<<_::binary>>), do: {:ok, @default}
defp fix_extension(filename, content_type) do
parts = String.split(filename, ".")
new_filename =
if length(parts) > 1 do
parts |> Enum.drop(-1) |> Enum.join(".")
else
Enum.join(parts)
end
cond do
content_type == "application/octet-stream" ->
filename
ext = List.first(MIME.extensions(content_type)) ->
new_filename <> "." <> ext
true ->
extension = content_type |> String.split("/") |> List.last()
Enum.join([new_filename, extension], ".")
end
end
defp check_mime_type(<<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, _::binary>>) do
"image/png"
end
defp check_mime_type(<<0x47, 0x49, 0x46, 0x38, _, 0x61, _::binary>>) do
"image/gif"
end
defp check_mime_type(<<0xFF, 0xD8, 0xFF, _::binary>>) do
"image/jpeg"
end
defp check_mime_type(<<0x1A, 0x45, 0xDF, 0xA3, _::binary>>) do
"video/webm"
end
defp check_mime_type(<<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::binary>>) do
"video/mp4"
end
defp check_mime_type(<<0x49, 0x44, 0x33, _::binary>>) do
"audio/mpeg"
end
defp check_mime_type(<<255, 251, _, 68, 0, 0, 0, 0, _::binary>>) do
"audio/mpeg"
end
defp check_mime_type(
<<0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, _::size(160), 0x80, 0x74, 0x68, 0x65,
0x6F, 0x72, 0x61, _::binary>>
) do
"video/ogg"
end
defp check_mime_type(<<0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, _::binary>>) do
"audio/ogg"
end
defp check_mime_type(<<"RIFF", _::binary-size(4), "WAVE", _::binary>>) do
"audio/wav"
end
defp check_mime_type(<<"RIFF", _::binary-size(4), "WEBP", _::binary>>) do
"image/webp"
end
defp check_mime_type(<<"RIFF", _::binary-size(4), "AVI.", _::binary>>) do
"video/avi"
end
defp check_mime_type(_) do
@default
end
end

209
lib/web/upload/upload.ex Normal file
View File

@@ -0,0 +1,209 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload.ex
defmodule Mobilizon.Web.Upload do
@moduledoc """
Manage user uploads
Options:
* `:type`: presets for activity type (defaults to Document) and size limits from app configuration
* `:description`: upload alternative text
* `:base_url`: override base url
* `:uploader`: override uploader
* `:filters`: override filters
* `:size_limit`: override size limit
* `:activity_type`: override activity type
The `%Mobilizon.Web.Upload{}` struct: all documented fields are meant to be overwritten in filters:
* `:id` - the upload id.
* `:name` - the upload file name.
* `:path` - the upload path: set at first to `id/name` but can be changed. Keep in mind that the path
is once created permanent and changing it (especially in uploaders) is probably a bad idea!
* `:tempfile` - path to the temporary file. Prefer in-place changes on the file rather than changing the
path as the temporary file is also tracked by `Plug.Upload{}` and automatically deleted once the request is over.
Related behaviors:
* `Mobilizon.Web.Upload.Uploader`
* `Mobilizon.Web.Upload.Filter`
"""
alias Ecto.UUID
alias Mobilizon.Config
alias Mobilizon.Web.Upload.{Filter, MIME, Uploader}
require Logger
@type source ::
Plug.Upload.t()
| (data_uri_string :: String.t())
| {:from_local, name :: String.t(), id :: String.t(), path :: String.t()}
@type option ::
{:type, :avatar | :banner | :background}
| {:description, String.t()}
| {:activity_type, String.t()}
| {:size_limit, nil | non_neg_integer()}
| {:uploader, module()}
| {:filters, [module()]}
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
tempfile: String.t(),
content_type: String.t(),
path: String.t(),
size: integer()
}
defstruct [:id, :name, :tempfile, :content_type, :path, :size]
@spec store(source, options :: [option()]) :: {:ok, Map.t()} | {:error, any()}
def store(upload, opts \\ []) do
opts = get_opts(opts)
with {:ok, upload} <- prepare_upload(upload, opts),
upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"},
{:ok, upload} <- Filter.filter(opts.filters, upload),
{:ok, url_spec} <- Uploader.put_file(opts.uploader, upload) do
{:ok,
%{
name: Map.get(opts, :description) || upload.name,
url: url_from_spec(upload, opts.base_url, url_spec),
content_type: upload.content_type,
size: upload.size
}}
else
{:error, error} ->
Logger.error(
"#{__MODULE__} store (using #{inspect(opts.uploader)}) failed: #{inspect(error)}"
)
{:error, error}
end
end
def remove(url, opts \\ []) do
with opts <- get_opts(opts),
%URI{path: "/media/" <> path, host: host} <- URI.parse(url),
{:same_host, true} <- {:same_host, host == Mobilizon.Web.Endpoint.host()} do
Uploader.remove_file(opts.uploader, path)
else
%URI{} = _uri ->
{:error, "URL doesn't match pattern"}
{:same_host, _} ->
Logger.error("Media can't be deleted because its URL doesn't match current host")
end
end
def char_unescaped?(char) do
URI.char_unreserved?(char) or char == ?/
end
defp get_opts(opts) do
{size_limit, activity_type} =
case Keyword.get(opts, :type) do
:banner ->
{Config.get!([:instance, :banner_upload_limit]), "Image"}
:avatar ->
{Config.get!([:instance, :avatar_upload_limit]), "Image"}
_ ->
{Config.get!([:instance, :upload_limit]), nil}
end
%{
activity_type: Keyword.get(opts, :activity_type, activity_type),
size_limit: Keyword.get(opts, :size_limit, size_limit),
uploader: Keyword.get(opts, :uploader, Config.get([__MODULE__, :uploader])),
filters: Keyword.get(opts, :filters, Config.get([__MODULE__, :filters])),
description: Keyword.get(opts, :description),
base_url:
Keyword.get(
opts,
:base_url,
Config.get([__MODULE__, :base_url], Mobilizon.Web.Endpoint.url())
)
}
end
defp prepare_upload(%Plug.Upload{} = file, opts) do
with {:ok, size} <- check_file_size(file.path, opts.size_limit),
{:ok, content_type, name} <- MIME.file_mime_type(file.path, file.filename) do
{:ok,
%__MODULE__{
id: UUID.generate(),
name: name,
tempfile: file.path,
content_type: content_type,
size: size
}}
end
end
defp prepare_upload(%{body: body, name: name} = _file, opts) do
with :ok <- check_binary_size(body, opts.size_limit),
tmp_path <- tempfile_for_image(body),
{:ok, content_type, name} <- MIME.file_mime_type(tmp_path, name) do
{:ok,
%__MODULE__{
id: UUID.generate(),
name: name,
tempfile: tmp_path,
content_type: content_type,
size: byte_size(body)
}}
end
end
defp check_file_size(path, size_limit) when is_integer(size_limit) and size_limit > 0 do
with {:ok, %{size: size}} <- File.stat(path),
true <- size <= size_limit do
{:ok, size}
else
false -> {:error, :file_too_large}
error -> error
end
end
defp check_file_size(_, _), do: :ok
defp check_binary_size(binary, size_limit)
when is_integer(size_limit) and size_limit > 0 and byte_size(binary) >= size_limit do
{:error, :file_too_large}
end
defp check_binary_size(_, _), do: :ok
# Creates a tempfile using the Plug.Upload Genserver which cleans them up
# automatically.
defp tempfile_for_image(data) do
{:ok, tmp_path} = Plug.Upload.random_file("temp_files")
{:ok, tmp_file} = File.open(tmp_path, [:write, :raw, :binary])
IO.binwrite(tmp_file, data)
tmp_path
end
defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do
path =
URI.encode(path, &char_unescaped?/1) <>
if Config.get([__MODULE__, :link_name], false) do
"?name=#{URI.encode(name, &char_unescaped?/1)}"
else
""
end
[base_url, "media", path]
|> Path.join()
end
defp url_from_spec(_upload, _base_url, {:url, url}), do: url
end

View File

@@ -0,0 +1,67 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/uploaders/local.ex
defmodule Mobilizon.Web.Upload.Uploader.Local do
@moduledoc """
Local uploader for files
"""
@behaviour Mobilizon.Web.Upload.Uploader
alias Mobilizon.Config
def get_file(_) do
{:ok, {:static_dir, upload_path()}}
end
def put_file(upload) do
{path, file} = local_path(upload.path)
result_file = Path.join(path, file)
unless File.exists?(result_file) do
File.cp!(upload.tempfile, result_file)
end
:ok
end
def remove_file(path) do
with {path, file} <- local_path(path),
full_path <- Path.join(path, file),
true <- File.exists?(full_path),
:ok <- File.rm(full_path),
:ok <- remove_folder(path) do
{:ok, path}
else
false -> {:error, "File #{path} doesn't exist"}
end
end
defp remove_folder(path) do
with {:subfolder, true} <- {:subfolder, path != upload_path()},
{:empty_folder, {:ok, [] = _files}} <- {:empty_folder, File.ls(path)} do
File.rmdir(path)
else
{:subfolder, _} -> :ok
{:empty_folder, _} -> {:error, "Error: Folder is not empty"}
end
end
defp local_path(path) do
case Enum.reverse(String.split(path, "/", trim: true)) do
[file] ->
{upload_path(), file}
[file | folders] ->
path = Path.join([upload_path()] ++ Enum.reverse(folders))
File.mkdir_p!(path)
{path, file}
end
end
def upload_path do
Config.get!([__MODULE__, :uploads])
end
end

View File

@@ -0,0 +1,78 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/uploaders/uploader.ex
defmodule Mobilizon.Web.Upload.Uploader do
@moduledoc """
Defines the contract to put and get an uploaded file to any backend.
"""
@doc """
Instructs how to get the file from the backend.
Used by `Mobilizon.Web.Plugs.UploadedMedia`.
"""
@type get_method :: {:static_dir, directory :: String.t()} | {:url, url :: String.t()}
@callback get_file(file :: String.t()) :: {:ok, get_method()}
@doc """
Put a file to the backend.
Returns:
* `:ok` which assumes `{:ok, upload.path}`
* `{:ok, spec}` where spec is:
* `{:file, filename :: String.t}` to handle reads with `get_file/1` (recommended)
This allows to correctly proxy or redirect requests to the backend, while allowing to migrate backends without breaking any URL.
* `{url, url :: String.t}` to bypass `get_file/2` and use the `url` directly in the activity.
* `{:error, String.t}` error information if the file failed to be saved to the backend.
* `:wait_callback` will wait for an http post request at `/api/pleroma/upload_callback/:upload_path` and call the uploader's `http_callback/3` method.
"""
@type file_spec :: {:file | :url, String.t()}
@callback put_file(Mobilizon.Web.Upload.t()) ::
:ok | {:ok, file_spec()} | {:error, String.t()} | :wait_callback
@callback remove_file(file_spec()) :: :ok | {:ok, file_spec()} | {:error, String.t()}
@callback http_callback(Plug.Conn.t(), Map.t()) ::
{:ok, Plug.Conn.t()}
| {:ok, Plug.Conn.t(), file_spec()}
| {:error, Plug.Conn.t(), String.t()}
@optional_callbacks http_callback: 2
@spec put_file(module(), Mobilizon.Web.Upload.t()) :: {:ok, file_spec()} | {:error, String.t()}
def put_file(uploader, upload) do
case uploader.put_file(upload) do
:ok -> {:ok, {:file, upload.path}}
:wait_callback -> handle_callback(uploader, upload)
{:ok, _} = ok -> ok
{:error, _} = error -> error
end
end
def remove_file(uploader, path) do
uploader.remove_file(path)
end
defp handle_callback(uploader, upload) do
:global.register_name({__MODULE__, upload.path}, self())
receive do
{__MODULE__, pid, conn, params} ->
case uploader.http_callback(conn, params) do
{:ok, conn, ok} ->
send(pid, {__MODULE__, conn})
{:ok, ok}
{:error, conn, error} ->
send(pid, {__MODULE__, conn})
{:error, error}
end
after
30_000 -> {:error, "Uploader callback timeout"}
end
end
end

View File

@@ -0,0 +1,118 @@
defmodule Mobilizon.Web.ActivityPub.ActorView do
use Mobilizon.Web, :view
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Utils}
alias Mobilizon.Federation.ActivityStream.Convertible
@private_visibility_empty_collection %{elements: [], total: 0}
def render("actor.json", %{actor: actor}) do
actor
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
def render("following.json", %{actor: actor, page: page}) do
%{total: total, elements: following} =
if Actor.is_public_visibility(actor),
do: Actors.build_followings_for_actor(actor, page),
else: @private_visibility_empty_collection
following
|> collection(actor.preferred_username, :following, page, total)
|> Map.merge(Utils.make_json_ld_header())
end
def render("following.json", %{actor: actor}) do
%{total: total, elements: following} =
if Actor.is_public_visibility(actor),
do: Actors.build_followings_for_actor(actor),
else: @private_visibility_empty_collection
%{
"id" => Actor.build_url(actor.preferred_username, :following),
"type" => "OrderedCollection",
"totalItems" => total,
"first" => collection(following, actor.preferred_username, :following, 1, total)
}
|> Map.merge(Utils.make_json_ld_header())
end
def render("followers.json", %{actor: actor, page: page}) do
%{total: total, elements: followers} =
if Actor.is_public_visibility(actor),
do: Actors.build_followers_for_actor(actor, page),
else: @private_visibility_empty_collection
followers
|> collection(actor.preferred_username, :followers, page, total)
|> Map.merge(Utils.make_json_ld_header())
end
def render("followers.json", %{actor: actor}) do
%{total: total, elements: followers} =
if Actor.is_public_visibility(actor),
do: Actors.build_followers_for_actor(actor),
else: @private_visibility_empty_collection
%{
"id" => Actor.build_url(actor.preferred_username, :followers),
"type" => "OrderedCollection",
"totalItems" => total,
"first" => collection(followers, actor.preferred_username, :followers, 1, total)
}
|> Map.merge(Utils.make_json_ld_header())
end
def render("outbox.json", %{actor: actor, page: page}) do
%{total: total, elements: followers} =
if Actor.is_public_visibility(actor),
do: ActivityPub.fetch_public_activities_for_actor(actor, page),
else: @private_visibility_empty_collection
followers
|> collection(actor.preferred_username, :outbox, page, total)
|> Map.merge(Utils.make_json_ld_header())
end
def render("outbox.json", %{actor: actor}) do
%{total: total, elements: followers} =
if Actor.is_public_visibility(actor),
do: ActivityPub.fetch_public_activities_for_actor(actor),
else: @private_visibility_empty_collection
%{
"id" => Actor.build_url(actor.preferred_username, :outbox),
"type" => "OrderedCollection",
"totalItems" => total,
"first" => collection(followers, actor.preferred_username, :outbox, 1, total)
}
|> Map.merge(Utils.make_json_ld_header())
end
@spec collection(list(), String.t(), atom(), integer(), integer()) :: map()
defp collection(collection, preferred_username, endpoint, page, total)
when endpoint in [:followers, :following, :outbox] do
offset = (page - 1) * 10
map = %{
"id" => Actor.build_url(preferred_username, endpoint, page: page),
"type" => "OrderedCollectionPage",
"partOf" => Actor.build_url(preferred_username, endpoint),
"orderedItems" => Enum.map(collection, &item/1)
}
if offset < total do
Map.put(map, "next", Actor.build_url(preferred_username, endpoint, page: page + 1))
end
map
end
def item(%Activity{data: %{"id" => id}}), do: id
def item(%Actor{url: url}), do: url
end

View File

@@ -0,0 +1,30 @@
defmodule Mobilizon.Web.ActivityPub.ObjectView do
use Mobilizon.Web, :view
alias Mobilizon.Federation.ActivityPub.{Activity, Utils}
def render("activity.json", %{activity: %Activity{local: local, data: data} = activity}) do
%{
"id" => data["id"],
"type" =>
if local do
"Create"
else
"Announce"
end,
"actor" => activity.actor,
# Not sure if needed since this is used into outbox
"published" => Timex.now(),
"to" => activity.recipients,
"object" =>
case data["type"] do
"Event" ->
render_one(data, ObjectView, "event.json", as: :event)
"Note" ->
render_one(data, ObjectView, "comment.json", as: :comment)
end
}
|> Map.merge(Utils.make_json_ld_header())
end
end

View File

@@ -0,0 +1,22 @@
defmodule Mobilizon.Web.ChangesetView do
@moduledoc """
View for changesets in case of errors
"""
use Mobilizon.Web, :view
@doc """
Traverses and translates changeset errors.
See `Ecto.Changeset.traverse_errors/2` and
`Mobilizon.Web.ErrorHelpers.translate_error/1` for more details.
"""
def translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
end
def render("error.json", %{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: translate_errors(changeset)}
end
end

View File

@@ -0,0 +1,12 @@
defmodule Mobilizon.Web.EmailView do
use Mobilizon.Web, :view
import Mobilizon.Web.Gettext
def datetime_to_string(%DateTime{} = datetime, locale \\ "en") do
with {:ok, string} <-
Cldr.DateTime.to_string(datetime, Mobilizon.Cldr, format: :medium, locale: locale) do
string
end
end
end

View File

@@ -0,0 +1,40 @@
defmodule Mobilizon.Web.ErrorHelpers do
@moduledoc """
Conveniences for translating and building error messages.
"""
use Phoenix.HTML
@doc """
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error), class: "help-block")
end)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# Because error messages were defined within Ecto, we must
# call the Gettext module passing our Gettext backend. We
# also use the "errors" domain as translations are placed
# in the errors.po file.
# Ecto will pass the :count keyword if the error message is
# meant to be pluralized.
# On your own code and templates, depending on whether you
# need the message to be pluralized or not, this could be
# written simply as:
#
# dngettext "errors", "1 file", "%{count} files", count
# dgettext "errors", "is invalid"
#
if count = opts[:count] do
Gettext.dngettext(Mobilizon.Web.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(Mobilizon.Web.Gettext, "errors", msg, opts)
end
end
end

View File

@@ -0,0 +1,55 @@
defmodule Mobilizon.Web.ErrorView do
@moduledoc """
View for errors
"""
use Mobilizon.Web, :view
def render("404.html", _assigns) do
with {:ok, index_content} <- File.read(index_file_path()) do
{:safe, index_content}
end
end
def render("404.json", _assigns) do
%{msg: "Resource not found"}
end
def render("404.activity-json", _assigns) do
%{msg: "Resource not found"}
end
def render("404.ics", _assigns) do
"Bad feed"
end
def render("404.atom", _assigns) do
"Bad feed"
end
def render("invalid_request.json", _assigns) do
%{errors: "Invalid request"}
end
def render("not_found.json", %{details: details}) do
%{
msg: "Resource not found",
details: details
}
end
def render("500.html", _assigns) do
"Internal server error"
end
# In case no render clause matches or no
# template is found, let's render it as 500
def template_not_found(template, assigns) do
require Logger
Logger.warn("Template #{inspect(template)} not found")
render("500.html", assigns)
end
defp index_file_path() do
Path.join(Application.app_dir(:mobilizon, "priv/static"), "index.html")
end
end

View File

@@ -0,0 +1,65 @@
defmodule Mobilizon.Web.JsonLD.ObjectView do
use Mobilizon.Web, :view
alias Mobilizon.Actors.Actor
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Event
alias Mobilizon.Web.JsonLD.ObjectView
alias Mobilizon.Web.MediaProxy
def render("event.json", %{event: %Event{} = event}) do
# TODO: event.description is actually markdown!
json_ld = %{
"@context" => "https://schema.org",
"@type" => "Event",
"name" => event.title,
"description" => event.description,
"performer" => %{
"@type" =>
if(event.organizer_actor.type == :Group, do: "PerformingGroup", else: "Person"),
"name" => Actor.display_name(event.organizer_actor)
},
"location" => render_one(event.physical_address, ObjectView, "place.json", as: :address)
}
json_ld =
if event.picture do
Map.put(json_ld, "image", [
event.picture.file.url |> MediaProxy.url()
])
else
json_ld
end
json_ld =
if event.begins_on,
do: Map.put(json_ld, "startDate", DateTime.to_iso8601(event.begins_on)),
else: json_ld
json_ld =
if event.ends_on,
do: Map.put(json_ld, "endDate", DateTime.to_iso8601(event.ends_on)),
else: json_ld
json_ld
end
def render("place.json", %{address: %Address{} = address}) do
%{
"@type" => "Place",
"name" => address.description,
"address" => %{
"@type" => "PostalAddress",
"streetAddress" => address.street,
"addressLocality" => address.locality,
"postalCode" => address.postal_code,
"addressRegion" => address.region,
"addressCountry" => address.country
}
}
end
def render("place.json", nil), do: %{}
end

View File

@@ -0,0 +1,3 @@
defmodule Mobilizon.Web.LayoutView do
use Mobilizon.Web, :view
end

View File

@@ -0,0 +1,74 @@
defmodule Mobilizon.Web.PageView do
@moduledoc """
View for our webapp
"""
use Mobilizon.Web, :view
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Tombstone
alias Mobilizon.Service.Metadata
alias Mobilizon.Service.Metadata.Instance
alias Mobilizon.Service.Metadata.Utils, as: MetadataUtils
alias Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.Federation.ActivityStream.Convertible
def render("actor.activity-json", %{conn: %{assigns: %{object: %Actor{} = actor}}}) do
actor
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
def render("event.activity-json", %{conn: %{assigns: %{object: %Event{} = event}}}) do
event
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
def render("event.activity-json", %{conn: %{assigns: %{object: %Tombstone{} = event}}}) do
event
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
def render("comment.activity-json", %{conn: %{assigns: %{object: %Comment{} = comment}}}) do
comment
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
def render(page, %{object: object} = _assigns)
when page in ["actor.html", "event.html", "comment.html"] do
with {:ok, index_content} <- File.read(index_file_path()) do
tags = object |> Metadata.build_tags() |> MetadataUtils.stringify_tags()
index_content = replace_meta(index_content, tags)
{:safe, index_content}
end
end
def render("index.html", _assigns) do
with {:ok, index_content} <- File.read(index_file_path()) do
tags = Instance.build_tags() |> MetadataUtils.stringify_tags()
index_content = replace_meta(index_content, tags)
{:safe, index_content}
end
end
defp index_file_path do
Path.join(Application.app_dir(:mobilizon, "priv/static"), "index.html")
end
# TODO: Find why it's different in dev/prod and during tests
defp replace_meta(index_content, tags) do
index_content
|> String.replace("<meta name=\"server-injected-data\" />", tags)
|> String.replace("<meta name=server-injected-data>", tags)
end
end