Refactor media upload

Use Upload Media logic from Pleroma

Backend changes for picture upload

Move AS <-> Model conversion to separate module

Front changes

Downgrade apollo-client: https://github.com/Akryum/vue-apollo/issues/577

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2019-05-22 14:12:11 +02:00
parent 9724bc8e9f
commit f90089e1bf
113 changed files with 4718 additions and 1328 deletions

View File

@@ -26,8 +26,9 @@ defmodule MobilizonWeb.API.Events do
title <- String.trim(title),
mentions <- Formatter.parse_mentions(description),
visibility <- Map.get(args, :visibility, "public"),
{to, cc} <- to_for_actor_and_mentions(actor, mentions, nil, visibility),
{to, cc} <- to_for_actor_and_mentions(actor, mentions, nil, Atom.to_string(visibility)),
tags <- Formatter.parse_tags(description),
picture <- Map.get(args, :picture, nil),
content_html <-
make_content_html(
description,
@@ -41,6 +42,7 @@ defmodule MobilizonWeb.API.Events do
to,
title,
content_html,
picture,
tags,
cc,
%{begins_on: begins_on},

View File

@@ -5,11 +5,9 @@
defmodule MobilizonWeb.ActivityPubController do
use MobilizonWeb, :controller
alias Mobilizon.{Actors, Actors.Actor, Events}
alias Mobilizon.Events.{Event, Comment}
alias MobilizonWeb.ActivityPub.{ObjectView, ActorView}
alias Mobilizon.{Actors, Actors.Actor}
alias MobilizonWeb.ActivityPub.ActorView
alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.Utils
alias Mobilizon.Service.Federator
require Logger

View File

@@ -0,0 +1,45 @@
# 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 MobilizonWeb.MediaProxyController do
use MobilizonWeb, :controller
alias MobilizonWeb.ReverseProxy
alias MobilizonWeb.MediaProxy
@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 <- Mobilizon.CommonConfig.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, Plug.Conn.Status.reason_phrase(404))
{:error, :invalid_signature} ->
send_resp(conn, 403, Plug.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

@@ -4,9 +4,7 @@ defmodule MobilizonWeb.PageController do
"""
use MobilizonWeb, :controller
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Comment}
action_fallback(MobilizonWeb.FallbackController)

View File

@@ -9,12 +9,7 @@ defmodule MobilizonWeb.Endpoint do
# You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production.
plug(
Plug.Static,
at: "/uploads",
from: "./uploads",
gzip: false
)
plug(MobilizonWeb.Plugs.UploadedMedia)
plug(
Plug.Static,
@@ -38,7 +33,7 @@ defmodule MobilizonWeb.Endpoint do
plug(
Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser],
pass: ["*/*"],
json_decoder: Jason
)
@@ -57,22 +52,4 @@ defmodule MobilizonWeb.Endpoint do
)
plug(MobilizonWeb.Router)
@doc """
Callback invoked for dynamically configuring the endpoint.
It receives the endpoint configuration and checks if
configuration should be loaded from the system environment.
"""
def init(_key, config) do
if config[:load_from_system_env] do
port =
System.get_env("MOBILIZON_INSTANCE_PORT") ||
raise "expected the MOBILIZON_INSTANCE_PORT environment variable to be set"
{:ok, Keyword.put(config, :http, [:inet6, port: port])}
else
{:ok, config}
end
end
end

View File

@@ -0,0 +1,86 @@
# 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 MobilizonWeb.MediaProxy do
@moduledoc """
Handles proxifying media files
"""
@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, MobilizonWeb.Endpoint.url()) do
url
else
encode_url(url)
end
end
def encode_url(url) do
secret = Application.get_env(:mobilizon, MobilizonWeb.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, MobilizonWeb.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
[
Mobilizon.CommonConfig.get([:media_proxy, :base_url], MobilizonWeb.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,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 MobilizonWeb.Plugs.UploadedMedia do
@moduledoc """
Serves uploaded media files
"""
import Plug.Conn
require Logger
@behaviour Plug
# 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, "\"", "\\\"")
conn
|> put_resp_header("content-disposition", "filename=\"#{name}\"")
conn ->
conn
end
config = Mobilizon.CommonConfig.get([MobilizonWeb.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 =
Map.get(opts, :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
conn
|> MobilizonWeb.ReverseProxy.call(
url,
Mobilizon.CommonConfig.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

@@ -5,6 +5,7 @@ defmodule MobilizonWeb.Resolvers.Event do
alias Mobilizon.Activity
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Media.Picture
alias Mobilizon.Actors.Actor
alias Mobilizon.Users.User
@@ -185,16 +186,27 @@ defmodule MobilizonWeb.Resolvers.Event do
@doc """
Create an event
"""
def create_event(_parent, args, %{context: %{current_user: _user}}) do
with {:ok, %Activity{data: %{"object" => %{"type" => "Event"} = object}}} <-
def create_event(_parent, args, %{context: %{current_user: _user}} = _resolution) do
with {:ok, args} <- save_attached_picture(args),
{:ok, %Activity{data: %{"object" => %{"type" => "Event"} = object}}} <-
MobilizonWeb.API.Events.create_event(args) do
{:ok,
%Event{
title: object["name"],
description: object["content"],
uuid: object["uuid"],
url: object["id"]
}}
res = %{
title: object["name"],
description: object["content"],
uuid: object["uuid"],
url: object["id"]
}
res =
if Map.has_key?(object, "attachment"),
do:
Map.put(res, :picture, %{
name: object["attachment"] |> hd() |> Map.get("name"),
url: object["attachment"] |> hd() |> Map.get("url") |> hd() |> Map.get("href")
}),
else: res
{:ok, res}
end
end
@@ -202,6 +214,22 @@ defmodule MobilizonWeb.Resolvers.Event do
{:error, "You need to be logged-in to create events"}
end
# If we have an attached picture, just transmit it. It will be handled by
# Mobilizon.Service.ActivityPub.Utils.make_picture_data/1
@spec save_attached_picture(map()) :: {:ok, map()}
defp save_attached_picture(%{picture: %{picture: %Plug.Upload{} = _picture}} = args), do: args
# Otherwise if we use a previously uploaded picture we need to fetch it from database
@spec save_attached_picture(map()) :: {:ok, map()}
defp save_attached_picture(%{picture: %{picture_id: picture_id}} = args) do
with %Picture{} = picture <- Mobilizon.Media.get_picture(picture_id) do
{:ok, Map.put(args, :picture, picture)}
end
end
@spec save_attached_picture(map()) :: {:ok, map()}
defp save_attached_picture(args), do: {:ok, args}
@doc """
Delete an event
"""

View File

@@ -47,12 +47,17 @@ defmodule MobilizonWeb.Resolvers.Person do
@doc """
This function is used to create more identities from an existing user
"""
def create_person(_parent, %{preferred_username: _preferred_username} = args, %{
context: %{current_user: user}
}) do
def create_person(
_parent,
%{preferred_username: _preferred_username} = args,
%{
context: %{current_user: user}
} = _resolution
) do
args = Map.put(args, :user_id, user.id)
with {:ok, %Actor{} = new_person} <- Actors.new_person(args) do
with args <- save_attached_pictures(args),
{:ok, %Actor{} = new_person} <- Actors.new_person(args) do
{:ok, new_person}
end
end
@@ -64,6 +69,21 @@ defmodule MobilizonWeb.Resolvers.Person do
{:error, "You need to be logged-in to create a new identity"}
end
defp save_attached_pictures(args) do
Enum.reduce([:avatar, :banner], args, fn key, args ->
if Map.has_key?(args, key) do
pic = args[key][:picture]
with {:ok, %{"name" => name, "url" => [%{"href" => url, "mediaType" => content_type}]}} <-
MobilizonWeb.Upload.store(pic.file, type: key, description: pic.alt) do
Map.put(args, key, %{"name" => name, "url" => url, "mediaType" => content_type})
end
else
args
end
end)
end
@doc """
This function is used to register a person afterwards the user has been created (but not activated)
"""
@@ -71,6 +91,7 @@ defmodule MobilizonWeb.Resolvers.Person do
with {:ok, %User{} = user} <- Users.get_user_by_email(args.email),
{:no_actor, nil} <- {:no_actor, Users.get_actor_for_user(user)},
args <- Map.put(args, :user_id, user.id),
args <- save_attached_pictures(args),
{:ok, %Actor{} = new_person} <- Actors.new_person(args) do
{:ok, new_person}
else

View File

@@ -0,0 +1,64 @@
defmodule MobilizonWeb.Resolvers.Picture do
@moduledoc """
Handles the picture-related GraphQL calls
"""
alias Mobilizon.Media
alias Mobilizon.Media.Picture
@doc """
Get picture for an event's pic
"""
def picture(%{picture_id: picture_id} = _parent, _args, _resolution) do
with {:ok, picture} <- do_fetch_picture(picture_id) do
{:ok, picture}
end
end
@doc """
Get picture for an event that has an attached
See MobilizonWeb.Resolvers.Event.create_event/3
"""
def picture(%{picture: picture} = _parent, _args, _resolution) do
{:ok, picture}
end
def picture(_parent, %{id: picture_id}, _resolution), do: do_fetch_picture(picture_id)
def picture(_parent, _args, _resolution) do
{:ok, nil}
end
@spec do_fetch_picture(nil) :: {:error, nil}
defp do_fetch_picture(nil), do: {:error, nil}
@spec do_fetch_picture(String.t()) :: {:ok, Picture.t()} | {:error, :not_found}
defp do_fetch_picture(picture_id) do
with %Picture{id: id, file: file} = _pic <- Media.get_picture(picture_id) do
{:ok, %{name: file.name, url: file.url, id: id}}
else
_err ->
{:error, "Picture with ID #{picture_id} was not found"}
end
end
@spec upload_picture(map(), map(), map()) :: {:ok, Picture.t()} | {:error, any()}
def upload_picture(_parent, %{file: %Plug.Upload{} = file} = args, %{
context: %{
current_user: _user
}
}) do
with {:ok, %{"url" => [%{"href" => url}]}} <- MobilizonWeb.Upload.store(file),
args <- Map.put(args, :url, url),
{:ok, picture = %Picture{}} <- Media.create_picture(%{"file" => args}) do
{:ok, %{name: picture.file.name, url: picture.file.url, id: picture.id}}
else
err ->
{:error, err}
end
end
def upload_picture(_parent, _args, _resolution) do
{:error, "You need to login to upload a picture"}
end
end

View File

@@ -0,0 +1,382 @@
# 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 MobilizonWeb.ReverseProxy do
@keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++
~w(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) ++
~w(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.
MobilizonWeb.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).
"""
@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"
]
require Logger
import Plug.Conn
@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()}
@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 = MobilizonWeb.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} ->
head_response(conn, 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: " <> Plug.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, Plug.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.Application.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

View File

@@ -5,7 +5,7 @@ defmodule MobilizonWeb.Router do
use MobilizonWeb, :router
pipeline :graphql do
plug(:accepts, ["json"])
# plug(:accepts, ["json"])
plug(MobilizonWeb.AuthPipeline)
end
@@ -102,7 +102,6 @@ defmodule MobilizonWeb.Router do
scope "/", MobilizonWeb do
pipe_through(:browser)
forward("/uploads", UploadPlug)
get("/*path", PageController, :index)
end
end

View File

@@ -4,7 +4,7 @@ defmodule MobilizonWeb.Schema do
"""
use Absinthe.Schema
alias Mobilizon.{Actors, Events, Users, Addresses}
alias Mobilizon.{Actors, Events, Users, Addresses, Media}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.{Event, Comment, Participant}
@@ -14,6 +14,7 @@ defmodule MobilizonWeb.Schema do
import_types(Absinthe.Plug.Types)
import_types(MobilizonWeb.Schema.UserType)
import_types(MobilizonWeb.Schema.PictureType)
import_types(MobilizonWeb.Schema.ActorInterface)
import_types(MobilizonWeb.Schema.Actors.PersonType)
import_types(MobilizonWeb.Schema.Actors.GroupType)
@@ -32,12 +33,6 @@ defmodule MobilizonWeb.Schema do
field(:user, non_null(:user), description: "The user associated to this session")
end
@desc "A picture"
object :picture do
field(:url, :string, description: "The URL for this picture")
field(:url_thumbnail, :string, description: "The URL for this picture's thumbnail")
end
@desc """
Represents a notification for an user
"""
@@ -91,6 +86,7 @@ defmodule MobilizonWeb.Schema do
|> Dataloader.add_source(Users, Users.data())
|> Dataloader.add_source(Events, Events.data())
|> Dataloader.add_source(Addresses, Addresses.data())
|> Dataloader.add_source(Media, Media.data())
Map.put(ctx, :loader, loader)
end
@@ -112,6 +108,7 @@ defmodule MobilizonWeb.Schema do
import_fields(:tag_queries)
import_fields(:address_queries)
import_fields(:config_queries)
import_fields(:picture_queries)
end
@desc """
@@ -126,11 +123,6 @@ defmodule MobilizonWeb.Schema do
import_fields(:participant_mutations)
import_fields(:member_mutations)
import_fields(:feed_token_mutations)
# @desc "Upload a picture"
# field :upload_picture, :picture do
# arg(:file, non_null(:upload))
# resolve(&Resolvers.Upload.upload_picture/3)
# end
import_fields(:picture_mutations)
end
end

View File

@@ -5,10 +5,11 @@ defmodule MobilizonWeb.Schema.ActorInterface do
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.{Events}
import_types(MobilizonWeb.Schema.Actors.FollowerType)
import_types(MobilizonWeb.Schema.EventType)
# import_types(MobilizonWeb.Schema.PictureType)
@desc "An ActivityPub actor"
interface :actor do
@@ -27,8 +28,9 @@ defmodule MobilizonWeb.Schema.ActorInterface do
)
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar_url, :string, description: "The actor's avatar url")
field(:banner_url, :string, description: "The actor's banner url")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
# These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings")

View File

@@ -5,7 +5,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
import_types(MobilizonWeb.Schema.Actors.MemberType)
alias MobilizonWeb.Resolvers
alias MobilizonWeb.Resolvers.{Member, Group}
alias Mobilizon.Events
@desc """
@@ -29,8 +29,9 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
)
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar_url, :string, description: "The actor's avatar url")
field(:banner_url, :string, description: "The actor's banner url")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
# These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings")
@@ -51,7 +52,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
)
field(:members, non_null(list_of(:member)),
resolve: &Resolvers.Member.find_members_for_group/3,
resolve: &Member.find_members_for_group/3,
description: "List of group members"
)
end
@@ -80,13 +81,13 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
field :groups, list_of(:group) do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Resolvers.Group.list_groups/3)
resolve(&Group.list_groups/3)
end
@desc "Get a group by it's preferred username"
field :group, :group do
arg(:preferred_username, non_null(:string))
resolve(&Resolvers.Group.find_group/3)
resolve(&Group.find_group/3)
end
end
@@ -101,7 +102,17 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
description: "The actor's username which will be the admin (otherwise user's default one)"
)
resolve(&Resolvers.Group.create_group/3)
arg(:avatar, :picture_input,
description:
"The avatar for the group, either as an object or directly the ID of an existing Picture"
)
arg(:banner, :picture_input,
description:
"The banner for the group, either as an object or directly the ID of an existing Picture"
)
resolve(&Group.create_group/3)
end
@desc "Delete a group"
@@ -109,7 +120,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
arg(:group_id, non_null(:integer))
arg(:actor_id, non_null(:integer))
resolve(&Resolvers.Group.delete_group/3)
resolve(&Group.delete_group/3)
end
end
end

View File

@@ -5,7 +5,7 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events
alias MobilizonWeb.Resolvers
alias MobilizonWeb.Resolvers.Person
import MobilizonWeb.Schema.Utils
import_types(MobilizonWeb.Schema.Events.FeedTokenType)
@@ -34,8 +34,9 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
)
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar_url, :string, description: "The actor's avatar url")
field(:banner_url, :string, description: "The actor's banner url")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
# These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings")
@@ -56,25 +57,25 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
@desc "The list of events this person goes to"
field :going_to_events, list_of(:event) do
resolve(&Resolvers.Person.person_going_to_events/3)
resolve(&Person.person_going_to_events/3)
end
end
object :person_queries do
@desc "Get the current actor for the logged-in user"
field :logged_person, :person do
resolve(&Resolvers.Person.get_current_person/3)
resolve(&Person.get_current_person/3)
end
@desc "Get a person by it's preferred username"
field :person, :person do
arg(:preferred_username, non_null(:string))
resolve(&Resolvers.Person.find_person/3)
resolve(&Person.find_person/3)
end
@desc "Get the persons for an user"
field :identities, list_of(:person) do
resolve(&Resolvers.Person.identities/3)
resolve(&Person.identities/3)
end
end
@@ -87,7 +88,17 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for the new profile", default_value: "")
resolve(handle_errors(&Resolvers.Person.create_person/3))
arg(:avatar, :picture_input,
description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture"
)
arg(:banner, :picture_input,
description:
"The banner for the profile, either as an object or directly the ID of an existing Picture"
)
resolve(handle_errors(&Person.create_person/3))
end
@desc "Register a first profile on registration"
@@ -99,7 +110,17 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for the new profile", default_value: "")
arg(:email, non_null(:string), description: "The email from the user previously created")
resolve(handle_errors(&Resolvers.Person.register_person/3))
arg(:avatar, :picture_input,
description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture"
)
arg(:banner, :picture_input,
description:
"The banner for the profile, either as an object or directly the ID of an existing Picture"
)
resolve(handle_errors(&Person.register_person/3))
end
end
end

View File

@@ -8,7 +8,7 @@ defmodule MobilizonWeb.Schema.EventType do
import_types(MobilizonWeb.Schema.AddressType)
import_types(MobilizonWeb.Schema.Events.ParticipantType)
import_types(MobilizonWeb.Schema.TagType)
alias MobilizonWeb.Resolvers
alias MobilizonWeb.Resolvers.{Picture, Event, Tag}
@desc "An event"
object :event do
@@ -23,10 +23,12 @@ defmodule MobilizonWeb.Schema.EventType do
field(:ends_on, :datetime, description: "Datetime for when the event ends")
field(:status, :event_status, description: "Status of the event")
field(:visibility, :event_visibility, description: "The event's visibility")
# TODO replace me with picture object
field(:thumbnail, :string, description: "A thumbnail picture for the event")
# TODO replace me with banner
field(:large_image, :string, description: "A large picture for the event")
field(:picture, :picture,
description: "The event's picture",
resolve: &Picture.picture/3
)
field(:publish_at, :datetime, description: "When the event was published")
field(:physical_address, :address,
@@ -45,19 +47,19 @@ defmodule MobilizonWeb.Schema.EventType do
field(:attributed_to, :actor, description: "Who the event is attributed to (often a group)")
field(:tags, list_of(:tag),
resolve: &MobilizonWeb.Resolvers.Tag.list_tags_for_event/3,
resolve: &Tag.list_tags_for_event/3,
description: "The event's tags"
)
field(:category, :string, description: "The event's category")
field(:participants, list_of(:participant),
resolve: &MobilizonWeb.Resolvers.Event.list_participants_for_event/3,
resolve: &Event.list_participants_for_event/3,
description: "The event's participants"
)
field(:related_events, list_of(:event),
resolve: &MobilizonWeb.Resolvers.Event.list_related_events/3,
resolve: &Event.list_related_events/3,
description: "Events related to this one"
)
@@ -93,13 +95,13 @@ defmodule MobilizonWeb.Schema.EventType do
field :events, list_of(:event) do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Resolvers.Event.list_events/3)
resolve(&Event.list_events/3)
end
@desc "Get an event by uuid"
field :event, :event do
arg(:uuid, non_null(:uuid))
resolve(&Resolvers.Event.find_event/3)
resolve(&Event.find_event/3)
end
end
@@ -113,15 +115,20 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:state, :integer)
arg(:status, :integer)
arg(:public, :boolean)
arg(:thumbnail, :string)
arg(:large_image, :string)
arg(:visibility, :event_visibility, default_value: :private)
arg(:picture, :picture_input,
description:
"The picture for the event, either as an object or directly the ID of an existing Picture"
)
arg(:publish_at, :datetime)
arg(:online_address, :string)
arg(:phone_address, :string)
arg(:organizer_actor_id, non_null(:id))
arg(:category, non_null(:string))
resolve(&Resolvers.Event.create_event/3)
resolve(&Event.create_event/3)
end
@desc "Delete an event"
@@ -129,7 +136,7 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:event_id, non_null(:integer))
arg(:actor_id, non_null(:integer))
resolve(&Resolvers.Event.delete_event/3)
resolve(&Event.delete_event/3)
end
end
end

View File

@@ -0,0 +1,48 @@
defmodule MobilizonWeb.Schema.PictureType do
@moduledoc """
Schema representation for Pictures
"""
use Absinthe.Schema.Notation
alias MobilizonWeb.Resolvers.Picture
@desc "A picture"
object :picture do
field(:id, :id, description: "The picture's ID")
field(:alt, :string, description: "The picture's alternative text")
field(:name, :string, description: "The picture's name")
field(:url, :string, description: "The picture's full URL")
end
@desc "An attached picture or a link to a picture"
input_object :picture_input do
# Either a full picture object
field(:picture, :picture_input_object)
# Or directly the ID of an existing picture
field(:picture_id, :string)
end
@desc "An attached picture"
input_object :picture_input_object do
field(:name, non_null(:string))
field(:alt, :string)
field(:file, non_null(:upload))
end
object :picture_queries do
@desc "Get a picture"
field :picture, :picture do
arg(:id, non_null(:string))
resolve(&Picture.picture/3)
end
end
object :picture_mutations do
@desc "Upload a picture"
field :upload_picture, :picture do
arg(:name, non_null(:string))
arg(:alt, :string)
arg(:file, non_null(:upload))
resolve(&Picture.upload_picture/3)
end
end
end

160
lib/mobilizon_web/upload.ex Normal file
View File

@@ -0,0 +1,160 @@
# 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 MobilizonWeb.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 `%MobilizonWeb.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:
* `MobilizonWeb.Uploaders.Uploader`
* `MobilizonWeb.Upload.Filter`
"""
alias Ecto.UUID
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()
}
defstruct [:id, :name, :tempfile, :content_type, :path]
@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} <- MobilizonWeb.Upload.Filter.filter(opts.filters, upload),
{:ok, url_spec} <- MobilizonWeb.Uploaders.Uploader.put_file(opts.uploader, upload) do
{:ok,
%{
"type" => opts.activity_type,
"url" => [
%{
"type" => "Link",
"mediaType" => upload.content_type,
"href" => url_from_spec(upload, opts.base_url, url_spec)
}
],
"name" => Map.get(opts, :description) || upload.name
}}
else
{:error, error} ->
Logger.error(
"#{__MODULE__} store (using #{inspect(opts.uploader)}) failed: #{inspect(error)}"
)
{:error, error}
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 ->
{Mobilizon.CommonConfig.get!([:instance, :banner_upload_limit]), "Image"}
:avatar ->
{Mobilizon.CommonConfig.get!([:instance, :avatar_upload_limit]), "Image"}
_ ->
{Mobilizon.CommonConfig.get!([:instance, :upload_limit]), "Document"}
end
%{
activity_type: Keyword.get(opts, :activity_type, activity_type),
size_limit: Keyword.get(opts, :size_limit, size_limit),
uploader: Keyword.get(opts, :uploader, Mobilizon.CommonConfig.get([__MODULE__, :uploader])),
filters: Keyword.get(opts, :filters, Mobilizon.CommonConfig.get([__MODULE__, :filters])),
description: Keyword.get(opts, :description),
base_url:
Keyword.get(
opts,
:base_url,
Mobilizon.CommonConfig.get([__MODULE__, :base_url], MobilizonWeb.Endpoint.url())
)
}
end
defp prepare_upload(%Plug.Upload{} = file, opts) do
with :ok <- check_file_size(file.path, opts.size_limit),
{:ok, content_type, name} <- Mobilizon.MIME.file_mime_type(file.path, file.filename) do
{:ok,
%__MODULE__{
id: UUID.generate(),
name: name,
tempfile: file.path,
content_type: content_type
}}
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
else
false -> {:error, :file_too_large}
error -> error
end
end
defp check_file_size(_, _), do: :ok
defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do
path =
URI.encode(path, &char_unescaped?/1) <>
if Mobilizon.CommonConfig.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,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 MobilizonWeb.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(MobilizonWeb.Upload.t()) ::
:ok | {:ok, MobilizonWeb.Upload.t()} | {:error, any()}
@spec filter([module()], MobilizonWeb.Upload.t()) ::
{:ok, MobilizonWeb.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,28 @@
# 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 MobilizonWeb.Upload.Filter.AnonymizeFilename do
@moduledoc """
Replaces the original filename with a pre-defined text or randomly generated string.
Should be used after `MobilizonWeb.Upload.Filter.Dedupe`.
"""
@behaviour MobilizonWeb.Upload.Filter
def filter(upload) do
extension = List.last(String.split(upload.name, "."))
name = Mobilizon.CommonConfig.get([__MODULE__, :text], random(extension))
{:ok, %MobilizonWeb.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,19 @@
# 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 MobilizonWeb.Upload.Filter.Dedupe do
@moduledoc """
Names the file after its hash to avoid dedupes
"""
@behaviour MobilizonWeb.Upload.Filter
alias MobilizonWeb.Upload
def filter(%Upload{name: name} = upload) do
extension = String.split(name, ".") |> 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,45 @@
# 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 MobilizonWeb.Upload.Filter.Mogrify do
@moduledoc """
Handle mogrify transformations
"""
@behaviour MobilizonWeb.Upload.Filter
@type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()}
@type conversions :: conversion() | [conversion()]
def filter(%MobilizonWeb.Upload{tempfile: file, content_type: "image" <> _}) do
filters = Mobilizon.CommonConfig.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

@@ -1,18 +0,0 @@
defmodule MobilizonWeb.UploadPlug do
@moduledoc """
Plug to intercept uploads
"""
use Plug.Builder
plug(Plug.Static,
at: "/",
from: {:mobilizon, "./uploads"}
)
# only: ~w(images robots.txt)
plug(:not_found)
def not_found(conn, _) do
send_resp(conn, 404, "not found")
end
end

View File

@@ -1,53 +0,0 @@
defmodule MobilizonWeb.Uploaders.Avatar do
@moduledoc """
Handles avatar uploads
"""
use Arc.Definition
# Include ecto support (requires package arc_ecto installed):
# use Arc.Ecto.Definition
@versions [:original]
# To add a thumbnail version:
# @versions [:original, :thumb]
# Override the bucket on a per definition basis:
# def bucket do
# :custom_bucket_name
# end
# Whitelist file extensions:
# def validate({file, _}) do
# ~w(.jpg .jpeg .gif .png) |> Enum.member?(Path.extname(file.file_name))
# end
# Define a thumbnail transformation:
# def transform(:thumb, _) do
# {:convert, "-strip -thumbnail 250x250^ -gravity center -extent 250x250 -format png", :png}
# end
# Override the persisted filenames:
# def filename(version, _) do
# version
# end
# Override the storage directory:
# def storage_dir(version, {file, scope}) do
# "uploads/user/avatars/#{scope.id}"
# end
# Provide a default URL if there hasn't been a file uploaded
# def default_url(version, scope) do
# "/images/avatars/default_#{version}.png"
# end
# Specify custom headers for s3 objects
# Available options are [:cache_control, :content_disposition,
# :content_encoding, :content_length, :content_type,
# :expect, :expires, :storage_class, :website_redirect_location]
#
# def s3_object_headers(version, {file, scope}) do
# [content_type: MIME.from_path(file.file_name)]
# end
end

View File

@@ -1,51 +0,0 @@
defmodule MobilizonWeb.Uploaders.Category do
@moduledoc """
Handles file uploads for categories
"""
use Arc.Definition
use Arc.Ecto.Definition
# To add a thumbnail version:
@versions [:original, :thumb]
@extension_whitelist ~w(.jpg .jpeg .gif .png)
# Override the bucket on a per definition basis:
# def bucket do
# :custom_bucket_name
# end
# Whitelist file extensions:
def validate({file, _}) do
file_extension = file.file_name |> Path.extname() |> String.downcase()
Enum.member?(@extension_whitelist, file_extension)
end
# Define a thumbnail transformation:
def transform(:thumb, _) do
{:convert, "-strip -thumbnail 250x250^ -gravity center -extent 250x250 -format png", :png}
end
# Override the persisted filenames:
def filename(version, {_file, %{title: title}}) do
"#{title}_#{version}"
end
# Override the storage directory:
def storage_dir(_, _) do
"uploads/event/"
end
# Provide a default URL if there hasn't been a file uploaded
# def default_url(version, scope) do
# "/images/avatars/default_#{version}.png"
# end
# Specify custom headers for s3 objects
# Available options are [:cache_control, :content_disposition,
# :content_encoding, :content_length, :content_type,
# :expect, :expires, :storage_class, :website_redirect_location]
#
# def s3_object_headers(version, {file, scope}) do
# [content_type: MIME.from_path(file.file_name)]
# end
end

View File

@@ -0,0 +1,40 @@
# 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 MobilizonWeb.Uploaders.Local do
@moduledoc """
Local uploader for files
"""
@behaviour MobilizonWeb.Uploaders.Uploader
def get_file(_) do
{:ok, {:static_dir, upload_path()}}
end
def put_file(upload) do
{local_path, file} =
case Enum.reverse(String.split(upload.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
result_file = Path.join(local_path, file)
unless File.exists?(result_file) do
File.cp!(upload.tempfile, result_file)
end
:ok
end
def upload_path do
Mobilizon.CommonConfig.get!([__MODULE__, :uploads])
end
end

View File

@@ -0,0 +1,73 @@
# 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 MobilizonWeb.Uploaders.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 `MobilizonWeb.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(MobilizonWeb.Upload.t()) ::
:ok | {:ok, file_spec()} | {:error, String.t()} | :wait_callback
@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(), MobilizonWeb.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
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

@@ -8,15 +8,12 @@ defmodule MobilizonWeb.JsonLD.ObjectView do
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,
"image" => [
event.thumbnail,
event.large_image
],
"performer" => %{
"@type" =>
if(event.organizer_actor.type == :Group, do: "PerformingGroup", else: "Person"),
@@ -25,6 +22,15 @@ defmodule MobilizonWeb.JsonLD.ObjectView do
"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
])
else
json_ld
end
json_ld =
if event.begins_on,
do: Map.put(json_ld, "startDate", DateTime.to_iso8601(event.begins_on)),

View File

@@ -44,7 +44,7 @@ defmodule MobilizonWeb.PageView do
end
def render("event.activity-json", %{conn: %{assigns: %{object: event}}}) do
event = Utils.make_event_data(event)
event = Mobilizon.Service.ActivityPub.Converters.Event.model_to_as(event)
{:ok, html, []} = Earmark.as_html(event["summary"])
%{
@@ -66,7 +66,7 @@ defmodule MobilizonWeb.PageView do
end
def render("comment.activity-json", %{conn: %{assigns: %{object: comment}}}) do
comment = Utils.make_comment_data(comment)
comment = Mobilizon.Service.ActivityPub.Converters.Comment.model_to_as(comment)
%{
"actor" => comment["actor"],