Merge remote-tracking branch 'origin/develop' into shigusegubu

* origin/develop: (92 commits)
  Web.MastodonApi.MastodonSocketTest: Add test for unauthenticated websocket
  Web.Streamer: Get unauthenticated statuses representation
  Web.MastodonAPI.MastodonSocket: Put access_token at function-level
  Web.MastodonAPI.MastodonSocket: Add unauthentified websocket endpoints
  Improved version string
  mediaproxy: fix empty url & add some tests
  RetryQueue: tiny refractor, add tests
  Various runtime configuration fixes
  update pleroma frontend
  Federator: add retry queue.
  activitypub: object view: avoid leaking private details
  ostatus controller: respond with AS2 objects instead of activities to notice URIs
  tests: federator: fix formatting
  activitypub: transmogrifier: make deletes secure
  Web.AdminAPI.AdminAPIControllerTest: New Test
  Web.AdminAPI.AdminAPIController: Fixes bugs found with ExUnit
  test/plugs/user_is_admin_plug_test: New test
  lib/mix/tasks/relay*: Use a with block
  Change Relay from `status` to `{status, message}`
  Web.Router: Change right to permission group (except for function names)
  ...
This commit is contained in:
Henry Jameson 2018-11-26 17:52:07 +03:00
commit f077d41b12
75 changed files with 1678 additions and 228 deletions

View file

@ -8,7 +8,7 @@ Pleroma is written in Elixir, high-performance and can run on small devices like
For clients it supports both the [GNU Social API with Qvitter extensions](https://twitter-api.readthedocs.io/en/latest/index.html) and the [Mastodon client API](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md). For clients it supports both the [GNU Social API with Qvitter extensions](https://twitter-api.readthedocs.io/en/latest/index.html) and the [Mastodon client API](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md).
Mobile clients that are known to work well: Client applications that are known to work well:
* Twidere * Twidere
* Tusky * Tusky
@ -17,6 +17,7 @@ Mobile clients that are known to work well:
* Amaroq (iOS) * Amaroq (iOS)
* Tootdon (Android + iOS) * Tootdon (Android + iOS)
* Tootle (iOS) * Tootle (iOS)
* Whalebird (Windows + Mac + Linux)
No release has been made yet, but several servers have been online for months already. If you want to run your own server, feel free to contact us at @lain@pleroma.soykaf.com or in our dev chat at #pleroma on freenode or via matrix at https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org. No release has been made yet, but several servers have been online for months already. If you want to run your own server, feel free to contact us at @lain@pleroma.soykaf.com or in our dev chat at #pleroma on freenode or via matrix at https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org.

View file

@ -23,6 +23,10 @@ config :pleroma, Pleroma.Uploaders.S3,
public_endpoint: "https://s3.amazonaws.com", public_endpoint: "https://s3.amazonaws.com",
force_media_proxy: false force_media_proxy: false
config :pleroma, Pleroma.Uploaders.MDII,
cgi: "https://mdii.sakura.ne.jp/mdii-post.cgi",
files: "https://mdii.sakura.ne.jp"
config :pleroma, :emoji, shortcode_globs: ["/emoji/custom/**/*.png"] config :pleroma, :emoji, shortcode_globs: ["/emoji/custom/**/*.png"]
config :pleroma, :uri_schemes, config :pleroma, :uri_schemes,
@ -48,6 +52,7 @@ config :pleroma, Pleroma.Web.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],
protocol: "https", protocol: "https",
secret_key_base: "aK4Abxf29xU9TTDKre9coZPUgevcVCFQJe/5xP/7Lt4BEif6idBIbjupVbOrbKxl", secret_key_base: "aK4Abxf29xU9TTDKre9coZPUgevcVCFQJe/5xP/7Lt4BEif6idBIbjupVbOrbKxl",
signing_salt: "CqaoopA2",
render_errors: [view: Pleroma.Web.ErrorView, accepts: ~w(json)], render_errors: [view: Pleroma.Web.ErrorView, accepts: ~w(json)],
pubsub: [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2], pubsub: [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2],
secure_cookie_flag: true secure_cookie_flag: true
@ -68,18 +73,10 @@ config :pleroma, :websub, Pleroma.Web.Websub
config :pleroma, :ostatus, Pleroma.Web.OStatus config :pleroma, :ostatus, Pleroma.Web.OStatus
config :pleroma, :httpoison, Pleroma.HTTP config :pleroma, :httpoison, Pleroma.HTTP
version =
with {version, 0} <- System.cmd("git", ["rev-parse", "HEAD"]) do
"Pleroma #{Mix.Project.config()[:version]} #{String.trim(version)}"
else
_ -> "Pleroma #{Mix.Project.config()[:version]} dev"
end
# Configures http settings, upstream proxy etc. # Configures http settings, upstream proxy etc.
config :pleroma, :http, proxy_url: nil config :pleroma, :http, proxy_url: nil
config :pleroma, :instance, config :pleroma, :instance,
version: version,
name: "Shigusegubu", name: "Shigusegubu",
email: "pleroma@hjkos.com", email: "pleroma@hjkos.com",
description: "SigSegV, a pleroma instance", description: "SigSegV, a pleroma instance",
@ -176,6 +173,13 @@ config :pleroma, :suggestions,
limit: 23, limit: 23,
web: "https://vinayaka.distsn.org/?{{host}}+{{user}}" web: "https://vinayaka.distsn.org/?{{host}}+{{user}}"
config :pleroma, :http_security,
enabled: true,
sts: false,
sts_max_age: 31_536_000,
ct_max_age: 2_592_000,
referrer_policy: "same-origin"
config :cors_plug, config :cors_plug,
max_age: 86_400, max_age: 86_400,
methods: ["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"], methods: ["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"],

View file

@ -80,3 +80,10 @@ This section is used to configure Pleroma-FE, unless ``:managed_config`` in ``:i
* ``unfollow_blocked``: Whether blocks result in people getting unfollowed * ``unfollow_blocked``: Whether blocks result in people getting unfollowed
* ``outgoing_blocks``: Whether to federate blocks to other instances * ``outgoing_blocks``: Whether to federate blocks to other instances
* ``deny_follow_blocked``: Whether to disallow following an account that has blocked the user in question * ``deny_follow_blocked``: Whether to disallow following an account that has blocked the user in question
## :http_security
* ``enabled``: Whether the managed content security policy is enabled
* ``sts``: Whether to additionally send a `Strict-Transport-Security` header
* ``sts_max_age``: The maximum age for the `Strict-Transport-Security` header if sent
* ``ct_max_age``: The maximum age for the `Expect-CT` header if sent
* ``referrer_policy``: The referrer policy to use, either `"same-origin"` or `"no-referrer"`.

View file

@ -14,6 +14,7 @@ use Mix.Config
# manifest is generated by the mix phoenix.digest task # manifest is generated by the mix phoenix.digest task
# which you typically run after static files are built. # which you typically run after static files are built.
config :pleroma, Pleroma.Web.Endpoint, config :pleroma, Pleroma.Web.Endpoint,
server: true,
http: [port: 4000], http: [port: 4000],
protocol: "http" protocol: "http"

View file

@ -21,28 +21,6 @@ example.tld {
ciphers ECDHE-ECDSA-WITH-CHACHA20-POLY1305 ECDHE-RSA-WITH-CHACHA20-POLY1305 ECDHE-ECDSA-AES256-GCM-SHA384 ECDHE-RSA-AES256-GCM-SHA384 ECDHE-ECDSA-AES128-GCM-SHA256 ECDHE-RSA-AES128-GCM-SHA256 ciphers ECDHE-ECDSA-WITH-CHACHA20-POLY1305 ECDHE-RSA-WITH-CHACHA20-POLY1305 ECDHE-ECDSA-AES256-GCM-SHA384 ECDHE-RSA-AES256-GCM-SHA384 ECDHE-ECDSA-AES128-GCM-SHA256 ECDHE-RSA-AES128-GCM-SHA256
} }
header / {
X-XSS-Protection "1; mode=block"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains;"
Expect-CT "enforce, max-age=2592000"
Content-Security-Policy "default-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self' wss://{host}; upgrade-insecure-requests;"
}
# If you do not want remote frontends to be able to access your Pleroma backend server, remove these lines.
# If you want to allow all origins access, remove the origin lines.
# To use this directive, you need the http.cors plugin for Caddy.
cors / {
origin https://halcyon.example.tld
origin https://pinafore.example.tld
methods POST,PUT,DELETE,GET,PATCH,OPTIONS
allowed_headers Authorization,Content-Type,Idempotency-Key
exposed_headers Link,X-RateLimit-Reset,X-RateLimit-Limit,X-RateLimit-Remaining,X-Request-Id
}
# Stop removing lines here.
# If you do not want to use the mediaproxy function, remove these lines. # If you do not want to use the mediaproxy function, remove these lines.
# To use this directive, you need the http.cache plugin for Caddy. # To use this directive, you need the http.cache plugin for Caddy.
cache { cache {

View file

@ -34,15 +34,6 @@ CustomLog ${APACHE_LOG_DIR}/access.log combined
SSLCompression off SSLCompression off
SSLSessionTickets off SSLSessionTickets off
Header always set X-Xss-Protection "1; mode=block"
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy same-origin
Header always set Content-Security-Policy "default-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self' wss://${servername}; upgrade-insecure-requests;"
# Uncomment this only after you get HTTPS working.
# Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
RewriteEngine On RewriteEngine On
RewriteCond %{HTTP:Connection} Upgrade [NC] RewriteCond %{HTTP:Connection} Upgrade [NC]
RewriteCond %{HTTP:Upgrade} websocket [NC] RewriteCond %{HTTP:Upgrade} websocket [NC]

View file

@ -60,17 +60,6 @@ server {
client_max_body_size 16m; client_max_body_size 16m;
location / { location / {
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Permitted-Cross-Domain-Policies "none" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "same-origin" always;
add_header X-Download-Options "noopen" always;
add_header Content-Security-Policy "default-src 'none'; base-uri 'self'; form-action *; frame-ancestors 'none'; img-src 'self' data: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self' wss://$server_name; upgrade-insecure-requests;" always;
# Uncomment this only after you get HTTPS working.
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";

View file

@ -119,13 +119,3 @@ sub vcl_pipe {
set bereq.http.connection = req.http.connection; set bereq.http.connection = req.http.connection;
} }
} }
sub vcl_deliver {
set resp.http.X-Frame-Options = "DENY";
set resp.http.X-XSS-Protection = "1; mode=block";
set resp.http.X-Content-Type-Options = "nosniff";
set resp.http.Referrer-Policy = "same-origin";
set resp.http.Content-Security-Policy = "default-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self' wss://" + req.http.host + "; upgrade-insecure-requests;";
# Uncomment this only after you get HTTPS working.
# set resp.http.Strict-Transport-Security= "max-age=31536000; includeSubDomains";
}

View file

@ -14,9 +14,11 @@ defmodule Mix.Tasks.RelayFollow do
def run([target]) do def run([target]) do
Mix.Task.run("app.start") Mix.Task.run("app.start")
:ok = Relay.follow(target) with {:ok, activity} <- Relay.follow(target) do
# put this task to sleep to allow the genserver to push out the messages
# put this task to sleep to allow the genserver to push out the messages :timer.sleep(500)
:timer.sleep(500) else
{:error, e} -> Mix.shell().error("Error while following #{target}: #{inspect(e)}")
end
end end
end end

View file

@ -13,9 +13,11 @@ defmodule Mix.Tasks.RelayUnfollow do
def run([target]) do def run([target]) do
Mix.Task.run("app.start") Mix.Task.run("app.start")
:ok = Relay.unfollow(target) with {:ok, activity} <- Relay.follow(target) do
# put this task to sleep to allow the genserver to push out the messages
# put this task to sleep to allow the genserver to push out the messages :timer.sleep(500)
:timer.sleep(500) else
{:error, e} -> Mix.shell().error("Error while following #{target}: #{inspect(e)}")
end
end end
end end

View file

@ -25,6 +25,10 @@ config :pleroma, Pleroma.Repo,
hostname: "localhost", hostname: "localhost",
pool_size: 10 pool_size: 10
# Enable Strict-Transport-Security once SSL is working:
# config :pleroma, :http_security,
# sts: true
# Configure S3 support if desired. # Configure S3 support if desired.
# The public S3 endpoint is different depending on region and provider, # The public S3 endpoint is different depending on region and provider,
# consult your S3 provider's documentation for details on what to use. # consult your S3 provider's documentation for details on what to use.

View file

@ -0,0 +1,32 @@
defmodule Mix.Tasks.SetAdmin do
use Mix.Task
alias Pleroma.User
@doc """
Sets admin status
Usage: set_admin nickname [true|false]
"""
def run([nickname | rest]) do
Application.ensure_all_started(:pleroma)
status =
case rest do
[status] -> status == "true"
_ -> true
end
with %User{local: true} = user <- User.get_by_nickname(nickname) do
info =
user.info
|> Map.put("is_admin", !!status)
cng = User.info_changeset(user, %{info: info})
{:ok, user} = User.update_and_set_cache(cng)
IO.puts("Admin status of #{nickname}: #{user.info["is_admin"]}")
else
_ ->
IO.puts("No local user #{nickname}")
end
end
end

View file

@ -1,8 +1,15 @@
defmodule Pleroma.Application do defmodule Pleroma.Application do
use Application use Application
@name "Pleroma"
@version Mix.Project.config()[:version]
def name, do: @name
def version, do: @version
def named_version(), do: @name <> " " <> @version
# See http://elixir-lang.org/docs/stable/elixir/Application.html # See http://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications # for more information on OTP Applications
@env Mix.env()
def start(_type, _args) do def start(_type, _args) do
import Supervisor.Spec import Supervisor.Spec
import Cachex.Spec import Cachex.Spec
@ -57,10 +64,11 @@ defmodule Pleroma.Application do
id: :cachex_idem id: :cachex_idem
), ),
worker(Pleroma.Web.Federator, []), worker(Pleroma.Web.Federator, []),
worker(Pleroma.Stats, []), worker(Pleroma.Web.Federator.RetryQueue, []),
worker(Pleroma.Gopher.Server, []) worker(Pleroma.Gopher.Server, []),
worker(Pleroma.Stats, [])
] ++ ] ++
if Mix.env() == :test, if @env == :test,
do: [], do: [],
else: else:
[worker(Pleroma.Web.Streamer, [])] ++ [worker(Pleroma.Web.Streamer, [])] ++

View file

@ -31,10 +31,12 @@ defmodule Pleroma.Object do
def normalize(ap_id) when is_binary(ap_id), do: Object.get_by_ap_id(ap_id) def normalize(ap_id) when is_binary(ap_id), do: Object.get_by_ap_id(ap_id)
def normalize(_), do: nil def normalize(_), do: nil
def get_cached_by_ap_id(ap_id) do if Mix.env() == :test do
if Mix.env() == :test do def get_cached_by_ap_id(ap_id) do
get_by_ap_id(ap_id) get_by_ap_id(ap_id)
else end
else
def get_cached_by_ap_id(ap_id) do
key = "object:#{ap_id}" key = "object:#{ap_id}"
Cachex.fetch!(:object_cache, key, fn _ -> Cachex.fetch!(:object_cache, key, fn _ ->

View file

@ -0,0 +1,58 @@
defmodule Pleroma.Plugs.HTTPSecurityPlug do
alias Pleroma.Config
import Plug.Conn
def init(opts), do: opts
def call(conn, options) do
if Config.get([:http_security, :enabled]) do
conn =
merge_resp_headers(conn, headers())
|> maybe_send_sts_header(Config.get([:http_security, :sts]))
else
conn
end
end
defp headers do
referrer_policy = Config.get([:http_security, :referrer_policy])
[
{"x-xss-protection", "1; mode=block"},
{"x-permitted-cross-domain-policies", "none"},
{"x-frame-options", "DENY"},
{"x-content-type-options", "nosniff"},
{"referrer-policy", referrer_policy},
{"x-download-options", "noopen"},
{"content-security-policy", csp_string() <> ";"}
]
end
defp csp_string do
[
"default-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
"img-src 'self' data: https:",
"media-src 'self' https:",
"style-src 'self' 'unsafe-inline'",
"font-src 'self'",
"script-src 'self'",
"connect-src 'self' " <> String.replace(Pleroma.Web.Endpoint.static_url(), "http", "ws"),
"upgrade-insecure-requests"
]
|> Enum.join("; ")
end
defp maybe_send_sts_header(conn, true) do
max_age_sts = Config.get([:http_security, :sts_max_age])
max_age_ct = Config.get([:http_security, :ct_max_age])
merge_resp_headers(conn, [
{"strict-transport-security", "max-age=#{max_age_sts}; includeSubDomains"},
{"expect-ct", "enforce, max-age=#{max_age_ct}"}
])
end
defp maybe_send_sts_header(conn, _), do: conn
end

View file

@ -0,0 +1,19 @@
defmodule Pleroma.Plugs.UserIsAdminPlug do
import Plug.Conn
alias Pleroma.User
def init(options) do
options
end
def call(%{assigns: %{user: %User{info: %{"is_admin" => true}}}} = conn, _) do
conn
end
def call(conn, _) do
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{error: "User is not admin."}))
|> halt
end
end

View file

@ -0,0 +1,26 @@
defmodule Pleroma.Uploaders.MDII do
alias Pleroma.Config
@behaviour Pleroma.Uploaders.Uploader
@httpoison Application.get_env(:pleroma, :httpoison)
def put_file(name, uuid, path, content_type, should_dedupe) do
cgi = Pleroma.Config.get([Pleroma.Uploaders.MDII, :cgi])
files = Pleroma.Config.get([Pleroma.Uploaders.MDII, :files])
{:ok, file_data} = File.read(path)
extension = String.split(name, ".") |> List.last()
query = "#{cgi}?#{extension}"
with {:ok, %{status_code: 200, body: body}} <- @httpoison.post(query, file_data) do
File.rm!(path)
remote_file_name = String.split(body) |> List.first()
public_url = "#{files}/#{remote_file_name}.#{extension}"
{:ok, public_url}
else
_ -> Pleroma.Uploaders.Local.put_file(name, uuid, path, content_type, should_dedupe)
end
end
end

View file

@ -498,7 +498,7 @@ defmodule Pleroma.User do
Repo.all(query) Repo.all(query)
end end
def search(query, resolve) do def search(query, resolve \\ false) do
# strip the beginning @ off if there is a query # strip the beginning @ off if there is a query
query = String.trim_leading(query, "@") query = String.trim_leading(query, "@")

View file

@ -628,9 +628,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
end end
def fetch_and_prepare_user_from_ap_id(ap_id) do def fetch_and_prepare_user_from_ap_id(ap_id) do
with {:ok, %{status_code: 200, body: body}} <- with {:ok, data} <- fetch_and_contain_remote_object_from_id(ap_id) do
@httpoison.get(ap_id, [Accept: "application/activity+json"], follow_redirect: true),
{:ok, data} <- Jason.decode(body) do
user_data_from_user_object(data) user_data_from_user_object(data)
else else
e -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") e -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}")
@ -732,22 +730,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
else else
Logger.info("Fetching #{id} via AP") Logger.info("Fetching #{id} via AP")
with true <- String.starts_with?(id, "http"), with {:ok, data} <- fetch_and_contain_remote_object_from_id(id),
{:ok, %{body: body, status_code: code}} when code in 200..299 <-
@httpoison.get(
id,
[Accept: "application/activity+json"],
follow_redirect: true,
timeout: 10000,
recv_timeout: 20000
),
{:ok, data} <- Jason.decode(body),
nil <- Object.normalize(data), nil <- Object.normalize(data),
params <- %{ params <- %{
"type" => "Create", "type" => "Create",
"to" => data["to"], "to" => data["to"],
"cc" => data["cc"], "cc" => data["cc"],
"actor" => data["attributedTo"], "actor" => data["actor"] || data["attributedTo"],
"object" => data "object" => data
}, },
:ok <- Transmogrifier.contain_origin(id, params), :ok <- Transmogrifier.contain_origin(id, params),
@ -771,6 +760,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
end end
end end
def fetch_and_contain_remote_object_from_id(id) do
Logger.info("Fetching #{id} via AP")
with true <- String.starts_with?(id, "http"),
{:ok, %{body: body, status_code: code}} when code in 200..299 <-
@httpoison.get(
id,
[Accept: "application/activity+json"],
follow_redirect: true,
timeout: 10000,
recv_timeout: 20000
),
{:ok, data} <- Jason.decode(body),
:ok <- Transmogrifier.contain_origin_from_id(id, data) do
{:ok, data}
else
e ->
{:error, e}
end
end
def is_public?(activity) do def is_public?(activity) do
"https://www.w3.org/ns/activitystreams#Public" in (activity.data["to"] ++ "https://www.w3.org/ns/activitystreams#Public" in (activity.data["to"] ++
(activity.data["cc"] || [])) (activity.data["cc"] || []))

View file

@ -12,11 +12,12 @@ defmodule Pleroma.Web.ActivityPub.Relay do
%User{} = target_user <- User.get_or_fetch_by_ap_id(target_instance), %User{} = target_user <- User.get_or_fetch_by_ap_id(target_instance),
{:ok, activity} <- ActivityPub.follow(local_user, target_user) do {:ok, activity} <- ActivityPub.follow(local_user, target_user) do
Logger.info("relay: followed instance: #{target_instance}; id=#{activity.data["id"]}") Logger.info("relay: followed instance: #{target_instance}; id=#{activity.data["id"]}")
{:ok, activity}
else else
e -> Logger.error("error: #{inspect(e)}") e ->
Logger.error("error: #{inspect(e)}")
{:error, e}
end end
:ok
end end
def unfollow(target_instance) do def unfollow(target_instance) do
@ -24,11 +25,12 @@ defmodule Pleroma.Web.ActivityPub.Relay do
%User{} = target_user <- User.get_or_fetch_by_ap_id(target_instance), %User{} = target_user <- User.get_or_fetch_by_ap_id(target_instance),
{:ok, activity} <- ActivityPub.unfollow(local_user, target_user) do {:ok, activity} <- ActivityPub.unfollow(local_user, target_user) do
Logger.info("relay: unfollowed instance: #{target_instance}: id=#{activity.data["id"]}") Logger.info("relay: unfollowed instance: #{target_instance}: id=#{activity.data["id"]}")
{:ok, activity}
else else
e -> Logger.error("error: #{inspect(e)}") e ->
Logger.error("error: #{inspect(e)}")
{:error, e}
end end
:ok
end end
def publish(%Activity{data: %{"type" => "Create"}} = activity) do def publish(%Activity{data: %{"type" => "Create"}} = activity) do

View file

@ -50,6 +50,19 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
end end
end end
def contain_origin_from_id(id, %{"id" => nil}), do: :error
def contain_origin_from_id(id, %{"id" => other_id} = params) do
id_uri = URI.parse(id)
other_uri = URI.parse(other_id)
if id_uri.host == other_uri.host do
:ok
else
:error
end
end
@doc """ @doc """
Modifies an incoming AP object (mastodon format) to our internal format. Modifies an incoming AP object (mastodon format) to our internal format.
""" """
@ -454,15 +467,20 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
end end
end end
# TODO: Make secure. # TODO: We presently assume that any actor on the same origin domain as the object being
# deleted has the rights to delete that object. A better way to validate whether or not
# the object should be deleted is to refetch the object URI, which should return either
# an error or a tombstone. This would allow us to verify that a deletion actually took
# place.
def handle_incoming( def handle_incoming(
%{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => _id} = data %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
) do ) do
object_id = Utils.get_ap_id(object_id) object_id = Utils.get_ap_id(object_id)
with actor <- get_actor(data), with actor <- get_actor(data),
%User{} = _actor <- User.get_or_fetch_by_ap_id(actor), %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id), {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id),
:ok <- contain_origin(actor.ap_id, object.data),
{:ok, activity} <- ActivityPub.delete(object, false) do {:ok, activity} <- ActivityPub.delete(object, false) do
{:ok, activity} {:ok, activity}
else else

View file

@ -10,7 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
Map.merge(base, additional) Map.merge(base, additional)
end end
def render("object.json", %{object: %Activity{} = activity}) do def render("object.json", %{object: %Activity{data: %{"type" => "Create"}} = activity}) do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header() base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header()
object = Object.normalize(activity.data["object"]) object = Object.normalize(activity.data["object"])
@ -20,4 +20,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
Map.merge(base, additional) Map.merge(base, additional)
end end
def render("object.json", %{object: %Activity{} = activity}) do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header()
object = Object.normalize(activity.data["object"])
additional =
Transmogrifier.prepare_object(activity.data)
|> Map.put("object", object.data["id"])
Map.merge(base, additional)
end
end end

View file

@ -0,0 +1,158 @@
defmodule Pleroma.Web.AdminAPI.AdminAPIController do
use Pleroma.Web, :controller
alias Pleroma.{User, Repo}
alias Pleroma.Web.ActivityPub.Relay
require Logger
action_fallback(:errors)
def user_delete(conn, %{"nickname" => nickname}) do
user = User.get_by_nickname(nickname)
if user.local == true do
User.delete(user)
else
User.delete(user)
end
conn
|> json(nickname)
end
def user_create(
conn,
%{"nickname" => nickname, "email" => email, "password" => password}
) do
new_user = %{
nickname: nickname,
name: nickname,
email: email,
password: password,
password_confirmation: password,
bio: "."
}
User.register_changeset(%User{}, new_user)
|> Repo.insert!()
conn
|> json(new_user.nickname)
end
def right_add(conn, %{"permission_group" => permission_group, "nickname" => nickname})
when permission_group in ["moderator", "admin"] do
user = User.get_by_nickname(nickname)
info =
user.info
|> Map.put("is_" <> permission_group, true)
cng = User.info_changeset(user, %{info: info})
{:ok, user} = User.update_and_set_cache(cng)
conn
|> json(user.info)
end
def right_get(conn, %{"nickname" => nickname}) do
user = User.get_by_nickname(nickname)
conn
|> json(user.info)
end
def right_add(conn, _) do
conn
|> put_status(404)
|> json(%{error: "No such permission_group"})
end
def right_delete(
%{assigns: %{user: %User{:nickname => admin_nickname}}} = conn,
%{
"permission_group" => permission_group,
"nickname" => nickname
}
)
when permission_group in ["moderator", "admin"] do
if admin_nickname == nickname do
conn
|> put_status(403)
|> json(%{error: "You can't revoke your own admin status."})
else
user = User.get_by_nickname(nickname)
info =
user.info
|> Map.put("is_" <> permission_group, false)
cng = User.info_changeset(user, %{info: info})
{:ok, user} = User.update_and_set_cache(cng)
conn
|> json(user.info)
end
end
def right_delete(conn, _) do
conn
|> put_status(404)
|> json(%{error: "No such permission_group"})
end
def relay_follow(conn, %{"relay_url" => target}) do
{status, message} = Relay.follow(target)
if status == :ok do
conn
|> json(target)
else
conn
|> put_status(500)
|> json(target)
end
end
def relay_unfollow(conn, %{"relay_url" => target}) do
{status, message} = Relay.unfollow(target)
if status == :ok do
conn
|> json(target)
else
conn
|> put_status(500)
|> json(target)
end
end
@shortdoc "Get a account registeration invite token (base64 string)"
def get_invite_token(conn, _params) do
{:ok, token} = Pleroma.UserInviteToken.create_token()
conn
|> json(token.token)
end
@shortdoc "Get a password reset token (base64 string) for given nickname"
def get_password_reset(conn, %{"nickname" => nickname}) do
(%User{local: true} = user) = User.get_by_nickname(nickname)
{:ok, token} = Pleroma.PasswordResetToken.create_token(user)
conn
|> json(token.token)
end
def errors(conn, {:param_cast, _}) do
conn
|> put_status(400)
|> json("Invalid parameters")
end
def errors(conn, _) do
conn
|> put_status(500)
|> json("Something went wrong")
end
end

View file

@ -4,9 +4,7 @@ defmodule Pleroma.Web.UserSocket do
## Channels ## Channels
# channel "room:*", Pleroma.Web.RoomChannel # channel "room:*", Pleroma.Web.RoomChannel
if Application.get_env(:pleroma, :chat) |> Keyword.get(:enabled) do channel("chat:*", Pleroma.Web.ChatChannel)
channel("chat:*", Pleroma.Web.ChatChannel)
end
## Transports ## Transports
transport(:websocket, Phoenix.Transports.WebSocket) transport(:websocket, Phoenix.Transports.WebSocket)
@ -24,7 +22,8 @@ defmodule Pleroma.Web.UserSocket do
# See `Phoenix.Token` documentation for examples in # See `Phoenix.Token` documentation for examples in
# performing token verification on connect. # performing token verification on connect.
def connect(%{"token" => token}, socket) do def connect(%{"token" => token}, socket) do
with {:ok, user_id} <- Phoenix.Token.verify(socket, "user socket", token, max_age: 84600), with true <- Pleroma.Config.get([:chat, :enabled]),
{:ok, user_id} <- Phoenix.Token.verify(socket, "user socket", token, max_age: 84600),
%User{} = user <- Pleroma.Repo.get(User, user_id) do %User{} = user <- Pleroma.Repo.get(User, user_id) do
{:ok, assign(socket, :user_name, user.nickname)} {:ok, assign(socket, :user_name, user.nickname)}
else else

View file

@ -1,9 +1,7 @@
defmodule Pleroma.Web.Endpoint do defmodule Pleroma.Web.Endpoint do
use Phoenix.Endpoint, otp_app: :pleroma use Phoenix.Endpoint, otp_app: :pleroma
if Application.get_env(:pleroma, :chat) |> Keyword.get(:enabled) do socket("/socket", Pleroma.Web.UserSocket)
socket("/socket", Pleroma.Web.UserSocket)
end
socket("/api/v1", Pleroma.Web.MastodonAPI.MastodonSocket) socket("/api/v1", Pleroma.Web.MastodonAPI.MastodonSocket)
@ -12,6 +10,7 @@ defmodule Pleroma.Web.Endpoint do
# You should set gzip to true if you are running phoenix.digest # You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production. # when deploying your static files in production.
plug(CORSPlug) plug(CORSPlug)
plug(Pleroma.Plugs.HTTPSecurityPlug)
plug(Plug.Static, at: "/media", from: Pleroma.Uploaders.Local.upload_path(), gzip: false) plug(Plug.Static, at: "/media", from: Pleroma.Uploaders.Local.upload_path(), gzip: false)
@ -45,14 +44,19 @@ defmodule Pleroma.Web.Endpoint do
plug(Plug.MethodOverride) plug(Plug.MethodOverride)
plug(Plug.Head) plug(Plug.Head)
cookie_name =
if Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag),
do: "__Host-pleroma_key",
else: "pleroma_key"
# The session will be stored in the cookie and signed, # The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with. # this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it. # Set :encryption_salt if you would also like to encrypt it.
plug( plug(
Plug.Session, Plug.Session,
store: :cookie, store: :cookie,
key: "_pleroma_key", key: cookie_name,
signing_salt: "CqaoopA2", signing_salt: {Pleroma.Config, :get, [[__MODULE__, :signing_salt], "CqaoopA2"]},
http_only: true, http_only: true,
secure: secure:
Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag), Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag),

View file

@ -3,6 +3,7 @@ defmodule Pleroma.Web.Federator do
alias Pleroma.User alias Pleroma.User
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Web.{WebFinger, Websub} alias Pleroma.Web.{WebFinger, Websub}
alias Pleroma.Web.Federator.RetryQueue
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Transmogrifier
@ -101,44 +102,46 @@ defmodule Pleroma.Web.Federator do
params = Utils.normalize_params(params) params = Utils.normalize_params(params)
# NOTE: we use the actor ID to do the containment, this is fine because an
# actor shouldn't be acting on objects outside their own AP server.
with {:ok, _user} <- ap_enabled_actor(params["actor"]), with {:ok, _user} <- ap_enabled_actor(params["actor"]),
nil <- Activity.normalize(params["id"]), nil <- Activity.normalize(params["id"]),
{:ok, _activity} <- Transmogrifier.handle_incoming(params) do :ok <- Transmogrifier.contain_origin_from_id(params["actor"], params),
{:ok, activity} <- Transmogrifier.handle_incoming(params) do
{:ok, activity}
else else
%Activity{} -> %Activity{} ->
Logger.info("Already had #{params["id"]}") Logger.info("Already had #{params["id"]}")
:error
_e -> _e ->
# Just drop those for now # Just drop those for now
Logger.info("Unhandled activity") Logger.info("Unhandled activity")
Logger.info(Poison.encode!(params, pretty: 2)) Logger.info(Poison.encode!(params, pretty: 2))
:error
end end
end end
def handle(:publish_single_ap, params) do def handle(:publish_single_ap, params) do
ActivityPub.publish_one(params) case ActivityPub.publish_one(params) do
{:ok, _} ->
:ok
{:error, _} ->
RetryQueue.enqueue(params, ActivityPub)
end
end end
def handle(:publish_single_websub, %{xml: xml, topic: topic, callback: callback, secret: secret}) do def handle(
signature = @websub.sign(secret || "", xml) :publish_single_websub,
Logger.debug(fn -> "Pushing #{topic} to #{callback}" end) %{xml: xml, topic: topic, callback: callback, secret: secret} = params
) do
case Websub.publish_one(params) do
{:ok, _} ->
:ok
with {:ok, %{status_code: code}} <- {:error, _} ->
@httpoison.post( RetryQueue.enqueue(params, Websub)
callback,
xml,
[
{"Content-Type", "application/atom+xml"},
{"X-Hub-Signature", "sha1=#{signature}"}
],
timeout: 10000,
recv_timeout: 20000,
hackney: [pool: :default]
) do
Logger.debug(fn -> "Pushed to #{callback}, code #{code}" end)
else
e ->
Logger.debug(fn -> "Couldn't push to #{callback}, #{inspect(e)}" end)
end end
end end
@ -147,11 +150,15 @@ defmodule Pleroma.Web.Federator do
{:error, "Don't know what to do with this"} {:error, "Don't know what to do with this"}
end end
def enqueue(type, payload, priority \\ 1) do if Mix.env() == :test do
if Pleroma.Config.get([:instance, :federating]) do def enqueue(type, payload, priority \\ 1) do
if Mix.env() == :test do if Pleroma.Config.get([:instance, :federating]) do
handle(type, payload) handle(type, payload)
else end
end
else
def enqueue(type, payload, priority \\ 1) do
if Pleroma.Config.get([:instance, :federating]) do
GenServer.cast(__MODULE__, {:enqueue, type, payload, priority}) GenServer.cast(__MODULE__, {:enqueue, type, payload, priority})
end end
end end

View file

@ -0,0 +1,71 @@
defmodule Pleroma.Web.Federator.RetryQueue do
use GenServer
alias Pleroma.Web.{WebFinger, Websub}
alias Pleroma.Web.ActivityPub.ActivityPub
require Logger
@websub Application.get_env(:pleroma, :websub)
@ostatus Application.get_env(:pleroma, :websub)
@httpoison Application.get_env(:pleroma, :websub)
@instance Application.get_env(:pleroma, :websub)
# initial timeout, 5 min
@initial_timeout 30_000
@max_retries 5
def init(args) do
{:ok, args}
end
def start_link() do
GenServer.start_link(__MODULE__, %{delivered: 0, dropped: 0}, name: __MODULE__)
end
def enqueue(data, transport, retries \\ 0) do
GenServer.cast(__MODULE__, {:maybe_enqueue, data, transport, retries + 1})
end
def get_retry_params(retries) do
if retries > @max_retries do
{:drop, "Max retries reached"}
else
{:retry, growth_function(retries)}
end
end
def handle_cast({:maybe_enqueue, data, transport, retries}, %{dropped: drop_count} = state) do
case get_retry_params(retries) do
{:retry, timeout} ->
Process.send_after(
__MODULE__,
{:send, data, transport, retries},
growth_function(retries)
)
{:noreply, state}
{:drop, message} ->
Logger.debug(message)
{:noreply, %{state | dropped: drop_count + 1}}
end
end
def handle_info({:send, data, transport, retries}, %{delivered: delivery_count} = state) do
case transport.publish_one(data) do
{:ok, _} ->
{:noreply, %{state | delivered: delivery_count + 1}}
{:error, reason} ->
enqueue(data, transport, retries)
{:noreply, state}
end
end
def handle_info(unknown, state) do
Logger.debug("RetryQueue: don't know what to do with #{inspect(unknown)}, ignoring")
{:noreply, state}
end
defp growth_function(retries) do
round(@initial_timeout * :math.pow(retries, 3))
end
end

View file

@ -141,7 +141,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
uri: Web.base_url(), uri: Web.base_url(),
title: Keyword.get(instance, :name), title: Keyword.get(instance, :name),
description: Keyword.get(instance, :description), description: Keyword.get(instance, :description),
version: "#{@mastodon_api_level} (compatible; #{Keyword.get(instance, :version)})", version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})",
email: Keyword.get(instance, :email), email: Keyword.get(instance, :email),
urls: %{ urls: %{
streaming_api: String.replace(Pleroma.Web.Endpoint.static_url(), "http", "ws") streaming_api: String.replace(Pleroma.Web.Endpoint.static_url(), "http", "ws")
@ -278,9 +278,12 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
end end
end end
def dm_timeline(%{assigns: %{user: user}} = conn, _params) do def dm_timeline(%{assigns: %{user: user}} = conn, params) do
query = query =
ActivityPub.fetch_activities_query([user.ap_id], %{"type" => "Create", visibility: "direct"}) ActivityPub.fetch_activities_query(
[user.ap_id],
Map.merge(params, %{"type" => "Create", visibility: "direct"})
)
activities = Repo.all(query) activities = Repo.all(query)

View file

@ -11,9 +11,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonSocket do
timeout: :infinity timeout: :infinity
) )
def connect(params, socket) do def connect(%{"access_token" => token} = params, socket) do
with token when not is_nil(token) <- params["access_token"], with %Token{user_id: user_id} <- Repo.get_by(Token, token: token),
%Token{user_id: user_id} <- Repo.get_by(Token, token: token),
%User{} = user <- Repo.get(User, user_id), %User{} = user <- Repo.get(User, user_id),
stream stream
when stream in [ when stream in [
@ -45,6 +44,24 @@ defmodule Pleroma.Web.MastodonAPI.MastodonSocket do
end end
end end
def connect(%{"stream" => stream} = params, socket)
when stream in ["public", "public:local", "hashtag"] do
topic =
case stream do
"hashtag" -> "hashtag:#{params["tag"]}"
_ -> stream
end
with socket =
socket
|> assign(:topic, topic) do
Pleroma.Web.Streamer.add_socket(topic, socket)
{:ok, socket}
else
_e -> :error
end
end
def id(_), do: nil def id(_), do: nil
def handle(:text, message, _state) do def handle(:text, message, _state) do

View file

@ -11,15 +11,47 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do
error: "public, must-revalidate, max-age=160" error: "public, must-revalidate, max-age=160"
} }
def remote(conn, %{"sig" => sig, "url" => url}) do # Content-types that will not be returned as content-disposition attachments
# Override with :media_proxy, :safe_content_types in the configuration
@safe_content_types [
"image/gif",
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
"audio/mpeg",
"audio/mp3",
"video/webm",
"video/mp4"
]
def remote(conn, params = %{"sig" => sig, "url" => url}) do
config = Application.get_env(:pleroma, :media_proxy, []) config = Application.get_env(:pleroma, :media_proxy, [])
with true <- Keyword.get(config, :enabled, false), with true <- Keyword.get(config, :enabled, false),
{:ok, url} <- Pleroma.Web.MediaProxy.decode_url(sig, url), {:ok, url} <- Pleroma.Web.MediaProxy.decode_url(sig, url),
{:ok, content_type, body} <- proxy_request(url) do filename <- Path.basename(URI.parse(url).path),
true <-
if(Map.get(params, "filename"),
do: filename == Path.basename(conn.request_path),
else: true
),
{:ok, content_type, body} <- proxy_request(url),
safe_content_type <-
Enum.member?(
Keyword.get(config, :safe_content_types, @safe_content_types),
content_type
) do
conn conn
|> put_resp_content_type(content_type) |> put_resp_content_type(content_type)
|> set_cache_header(:default) |> set_cache_header(:default)
|> put_resp_header(
"content-security-policy",
"default-src 'none'; style-src 'unsafe-inline'; media-src data:; img-src 'self' data:"
)
|> put_resp_header("x-xss-protection", "1; mode=block")
|> put_resp_header("x-content-type-options", "nosniff")
|> put_attachement_header(safe_content_type, filename)
|> send_resp(200, body) |> send_resp(200, body)
else else
false -> false ->
@ -92,6 +124,12 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do
# TODO: the body is passed here as well because some hosts do not provide a content-type. # TODO: the body is passed here as well because some hosts do not provide a content-type.
# At some point we may want to use magic numbers to discover the content-type and reply a proper one. # At some point we may want to use magic numbers to discover the content-type and reply a proper one.
defp proxy_request_content_type(headers, _body) do defp proxy_request_content_type(headers, _body) do
headers["Content-Type"] || headers["content-type"] || "image/jpeg" headers["Content-Type"] || headers["content-type"] || "application/octet-stream"
end
defp put_attachement_header(conn, true, _), do: conn
defp put_attachement_header(conn, false, filename) do
put_resp_header(conn, "content-disposition", "attachment; filename='#{filename}'")
end end
end end

View file

@ -3,6 +3,8 @@ defmodule Pleroma.Web.MediaProxy do
def url(nil), do: nil def url(nil), do: nil
def url(""), do: nil
def url(url = "/" <> _), do: url def url(url = "/" <> _), do: url
def url(url) do def url(url) do
@ -15,7 +17,10 @@ defmodule Pleroma.Web.MediaProxy do
base64 = Base.url_encode64(url, @base64_opts) base64 = Base.url_encode64(url, @base64_opts)
sig = :crypto.hmac(:sha, secret, base64) sig = :crypto.hmac(:sha, secret, base64)
sig64 = sig |> Base.url_encode64(@base64_opts) sig64 = sig |> Base.url_encode64(@base64_opts)
Keyword.get(config, :base_url, Pleroma.Web.base_url()) <> "/proxy/#{sig64}/#{base64}" filename = if path = URI.parse(url).path, do: "/" <> Path.basename(path), else: ""
Keyword.get(config, :base_url, Pleroma.Web.base_url()) <>
"/proxy/#{sig64}/#{base64}#{filename}"
end end
end end

View file

@ -86,8 +86,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do
response = %{ response = %{
version: "2.0", version: "2.0",
software: %{ software: %{
name: "pleroma", name: Pleroma.Application.name(),
version: Keyword.get(instance, :version) version: Pleroma.Application.version()
}, },
protocols: ["ostatus", "activitypub"], protocols: ["ostatus", "activitypub"],
services: %{ services: %{

View file

@ -1,7 +1,7 @@
defmodule Pleroma.Web.OStatus.OStatusController do defmodule Pleroma.Web.OStatus.OStatusController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.{User, Activity} alias Pleroma.{User, Activity, Object}
alias Pleroma.Web.OStatus.{FeedRepresenter, ActivityRepresenter} alias Pleroma.Web.OStatus.{FeedRepresenter, ActivityRepresenter}
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.Web.{OStatus, Federator} alias Pleroma.Web.{OStatus, Federator}
@ -136,7 +136,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do
"html" -> "html" ->
conn conn
|> put_resp_content_type("text/html") |> put_resp_content_type("text/html")
|> send_file(200, "priv_sid/static/index.html") |> send_file(200, Application.app_dir(:pleroma, "priv_sid/static/index.html"))
_ -> _ ->
represent_activity(conn, format, activity, user) represent_activity(conn, format, activity, user)
@ -153,10 +153,21 @@ defmodule Pleroma.Web.OStatus.OStatusController do
end end
end end
defp represent_activity(conn, "activity+json", activity, user) do defp represent_activity(
conn,
"activity+json",
%Activity{data: %{"type" => "Create"}} = activity,
user
) do
object = Object.normalize(activity.data["object"])
conn conn
|> put_resp_header("content-type", "application/activity+json") |> put_resp_header("content-type", "application/activity+json")
|> json(ObjectView.render("object.json", %{object: activity})) |> json(ObjectView.render("object.json", %{object: object}))
end
defp represent_activity(conn, "activity+json", _, _) do
{:error, :not_found}
end end
defp represent_activity(conn, _, activity, user) do defp represent_activity(conn, _, activity, user) do

View file

@ -31,6 +31,21 @@ defmodule Pleroma.Web.Router do
plug(Pleroma.Plugs.EnsureAuthenticatedPlug) plug(Pleroma.Plugs.EnsureAuthenticatedPlug)
end end
pipeline :admin_api do
plug(:accepts, ["json"])
plug(:fetch_session)
plug(Pleroma.Plugs.OAuthPlug)
plug(Pleroma.Plugs.BasicAuthDecoderPlug)
plug(Pleroma.Plugs.UserFetcherPlug)
plug(Pleroma.Plugs.SessionAuthenticationPlug)
plug(Pleroma.Plugs.LegacyAuthenticationPlug)
plug(Pleroma.Plugs.AuthenticationPlug)
plug(Pleroma.Plugs.UserEnabledPlug)
plug(Pleroma.Plugs.SetUserSessionIdPlug)
plug(Pleroma.Plugs.EnsureAuthenticatedPlug)
plug(Pleroma.Plugs.UserIsAdminPlug)
end
pipeline :mastodon_html do pipeline :mastodon_html do
plug(:accepts, ["html"]) plug(:accepts, ["html"])
plug(:fetch_session) plug(:fetch_session)
@ -79,6 +94,23 @@ defmodule Pleroma.Web.Router do
get("/emoji", UtilController, :emoji) get("/emoji", UtilController, :emoji)
end end
scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:admin_api)
delete("/user", AdminAPIController, :user_delete)
post("/user", AdminAPIController, :user_create)
get("/permission_group/:nickname", AdminAPIController, :right_get)
get("/permission_group/:nickname/:permission_group", AdminAPIController, :right_get)
post("/permission_group/:nickname/:permission_group", AdminAPIController, :right_add)
delete("/permission_group/:nickname/:permission_group", AdminAPIController, :right_delete)
post("/relay", AdminAPIController, :relay_follow)
delete("/relay", AdminAPIController, :relay_unfollow)
get("/invite_token", AdminAPIController, :get_invite_token)
get("/password_reset", AdminAPIController, :get_password_reset)
end
scope "/", Pleroma.Web.TwitterAPI do scope "/", Pleroma.Web.TwitterAPI do
pipe_through(:pleroma_html) pipe_through(:pleroma_html)
get("/ostatus_subscribe", UtilController, :remote_follow) get("/ostatus_subscribe", UtilController, :remote_follow)
@ -250,7 +282,12 @@ defmodule Pleroma.Web.Router do
get("/statuses/networkpublic_timeline", TwitterAPI.Controller, :public_and_external_timeline) get("/statuses/networkpublic_timeline", TwitterAPI.Controller, :public_and_external_timeline)
end end
scope "/api", Pleroma.Web do scope "/api", Pleroma.Web, as: :twitter_api_search do
pipe_through(:api)
get("/pleroma/search_user", TwitterAPI.Controller, :search_user)
end
scope "/api", Pleroma.Web, as: :authenticated_twitter_api do
pipe_through(:authenticated_api) pipe_through(:authenticated_api)
get("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials) get("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials)
@ -270,6 +307,7 @@ defmodule Pleroma.Web.Router do
get("/statuses/friends_timeline", TwitterAPI.Controller, :friends_timeline) get("/statuses/friends_timeline", TwitterAPI.Controller, :friends_timeline)
get("/statuses/mentions", TwitterAPI.Controller, :mentions_timeline) get("/statuses/mentions", TwitterAPI.Controller, :mentions_timeline)
get("/statuses/mentions_timeline", TwitterAPI.Controller, :mentions_timeline) get("/statuses/mentions_timeline", TwitterAPI.Controller, :mentions_timeline)
get("/statuses/dm_timeline", TwitterAPI.Controller, :dm_timeline)
get("/qvitter/statuses/notifications", TwitterAPI.Controller, :notifications) get("/qvitter/statuses/notifications", TwitterAPI.Controller, :notifications)
# XXX: this is really a pleroma API, but we want to keep the pleroma namespace clean # XXX: this is really a pleroma API, but we want to keep the pleroma namespace clean
@ -378,12 +416,12 @@ defmodule Pleroma.Web.Router do
end end
pipeline :remote_media do pipeline :remote_media do
plug(:accepts, ["html"])
end end
scope "/proxy/", Pleroma.Web.MediaProxy do scope "/proxy/", Pleroma.Web.MediaProxy do
pipe_through(:remote_media) pipe_through(:remote_media)
get("/:sig/:url", MediaProxyController, :remote) get("/:sig/:url", MediaProxyController, :remote)
get("/:sig/:url/:filename", MediaProxyController, :remote)
end end
scope "/", Fallback do scope "/", Fallback do
@ -398,11 +436,9 @@ defmodule Fallback.RedirectController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
def redirector(conn, _params) do def redirector(conn, _params) do
if Mix.env() != :test do conn
conn |> put_resp_content_type("text/html")
|> put_resp_content_type("text/html") |> send_file(200, Application.app_dir(:pleroma, "priv_sid/static/index.html"))
|> send_file(200, "priv_sid/static/index.html")
end
end end
def registration_page(conn, params) do def registration_page(conn, params) do

View file

@ -169,16 +169,33 @@ defmodule Pleroma.Web.Streamer do
|> Jason.encode!() |> Jason.encode!()
end end
defp represent_update(%Activity{} = activity) do
%{
event: "update",
payload:
Pleroma.Web.MastodonAPI.StatusView.render(
"status.json",
activity: activity
)
|> Jason.encode!()
}
|> Jason.encode!()
end
def push_to_socket(topics, topic, %Activity{data: %{"type" => "Announce"}} = item) do def push_to_socket(topics, topic, %Activity{data: %{"type" => "Announce"}} = item) do
Enum.each(topics[topic] || [], fn socket -> Enum.each(topics[topic] || [], fn socket ->
# Get the current user so we have up-to-date blocks etc. # Get the current user so we have up-to-date blocks etc.
user = User.get_cached_by_ap_id(socket.assigns[:user].ap_id) if socket.assigns[:user] do
blocks = user.info["blocks"] || [] user = User.get_cached_by_ap_id(socket.assigns[:user].ap_id)
blocks = user.info["blocks"] || []
parent = Object.normalize(item.data["object"]) parent = Object.normalize(item.data["object"])
unless is_nil(parent) or item.actor in blocks or parent.data["actor"] in blocks do unless is_nil(parent) or item.actor in blocks or parent.data["actor"] in blocks do
send(socket.transport_pid, {:text, represent_update(item, user)}) send(socket.transport_pid, {:text, represent_update(item, user)})
end
else
send(socket.transport_pid, {:text, represent_update(item)})
end end
end) end)
end end
@ -186,11 +203,15 @@ defmodule Pleroma.Web.Streamer do
def push_to_socket(topics, topic, item) do def push_to_socket(topics, topic, item) do
Enum.each(topics[topic] || [], fn socket -> Enum.each(topics[topic] || [], fn socket ->
# Get the current user so we have up-to-date blocks etc. # Get the current user so we have up-to-date blocks etc.
user = User.get_cached_by_ap_id(socket.assigns[:user].ap_id) if socket.assigns[:user] do
blocks = user.info["blocks"] || [] user = User.get_cached_by_ap_id(socket.assigns[:user].ap_id)
blocks = user.info["blocks"] || []
unless item.actor in blocks do unless item.actor in blocks do
send(socket.transport_pid, {:text, represent_update(item, user)}) send(socket.transport_pid, {:text, represent_update(item, user)})
end
else
send(socket.transport_pid, {:text, represent_update(item)})
end end
end) end)
end end

View file

@ -197,7 +197,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
end end
def version(conn, _params) do def version(conn, _params) do
version = Pleroma.Config.get([:instance, :version]) version = Pleroma.Application.named_version()
case get_format(conn) do case get_format(conn) do
"xml" -> "xml" ->

View file

@ -126,6 +126,19 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
|> render(ActivityView, "index.json", %{activities: activities, for: user}) |> render(ActivityView, "index.json", %{activities: activities, for: user})
end end
def dm_timeline(%{assigns: %{user: user}} = conn, params) do
query =
ActivityPub.fetch_activities_query(
[user.ap_id],
Map.merge(params, %{"type" => "Create", "user" => user, visibility: "direct"})
)
activities = Repo.all(query)
conn
|> render(ActivityView, "index.json", %{activities: activities, for: user})
end
def notifications(%{assigns: %{user: user}} = conn, params) do def notifications(%{assigns: %{user: user}} = conn, params) do
notifications = Notification.for_user(user, params) notifications = Notification.for_user(user, params)
@ -516,6 +529,13 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
|> render(ActivityView, "index.json", %{activities: activities, for: user}) |> render(ActivityView, "index.json", %{activities: activities, for: user})
end end
def search_user(%{assigns: %{user: user}} = conn, %{"query" => query}) do
users = User.search(query, true)
conn
|> render(UserView, "index.json", %{users: users, for: user})
end
defp bad_request_reply(conn, error_message) do defp bad_request_reply(conn, error_message) do
json = error_json(conn, error_message) json = error_json(conn, error_message)
json_reply(conn, 400, json) json_reply(conn, 400, json)

View file

@ -55,8 +55,12 @@ defmodule Pleroma.Web.TwitterAPI.UserView do
"statusnet_blocking" => statusnet_blocking, "statusnet_blocking" => statusnet_blocking,
"friends_count" => user_info[:following_count], "friends_count" => user_info[:following_count],
"id" => user.id, "id" => user.id,
"name" => user.name, "name" => user.name || user.nickname,
"name_html" => HTML.strip_tags(user.name) |> Formatter.emojify(emoji), "name_html" =>
if(user.name,
do: HTML.strip_tags(user.name) |> Formatter.emojify(emoji),
else: user.nickname
),
"profile_image_url" => image, "profile_image_url" => image,
"profile_image_url_https" => image, "profile_image_url_https" => image,
"profile_image_url_profile_size" => image, "profile_image_url_profile_size" => image,

View file

@ -252,4 +252,29 @@ defmodule Pleroma.Web.Websub do
Pleroma.Web.Federator.enqueue(:request_subscription, sub) Pleroma.Web.Federator.enqueue(:request_subscription, sub)
end) end)
end end
def publish_one(%{xml: xml, topic: topic, callback: callback, secret: secret}) do
signature = sign(secret || "", xml)
Logger.info(fn -> "Pushing #{topic} to #{callback}" end)
with {:ok, %{status_code: code}} <-
@httpoison.post(
callback,
xml,
[
{"Content-Type", "application/atom+xml"},
{"X-Hub-Signature", "sha1=#{signature}"}
],
timeout: 10000,
recv_timeout: 20000,
hackney: [pool: :default]
) do
Logger.info(fn -> "Pushed to #{callback}, code #{code}" end)
{:ok, code}
else
e ->
Logger.debug(fn -> "Couldn't push to #{callback}, #{inspect(e)}" end)
{:error, e}
end
end
end end

49
mix.exs
View file

@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do
def project do def project do
[ [
app: :pleroma, app: :pleroma,
version: "0.9.0", version: version("0.9.0"),
elixir: "~> 1.4", elixir: "~> 1.4",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(), compilers: [:phoenix, :gettext] ++ Mix.compilers(),
@ -84,4 +84,51 @@ defmodule Pleroma.Mixfile do
test: ["ecto.create --quiet", "ecto.migrate", "test"] test: ["ecto.create --quiet", "ecto.migrate", "test"]
] ]
end end
# Builds a version string made of:
# * the application version
# * a pre-release if ahead of the tag: the describe string (-count-commithash)
# * build info:
# * a build name if `PLEROMA_BUILD_NAME` or `:pleroma, :build_name` is defined
# * the mix environment if different than prod
defp version(version) do
{git_tag, git_pre_release} =
with {tag, 0} <- System.cmd("git", ["describe", "--tags", "--abbrev=0"]),
tag = String.trim(tag),
{describe, 0} <- System.cmd("git", ["describe", "--tags"]),
describe = String.trim(describe),
ahead <- String.replace(describe, tag, "") do
{String.replace_prefix(tag, "v", ""), if(ahead != "", do: String.trim(ahead))}
else
_ -> {nil, nil}
end
if git_tag && version != git_tag do
Mix.shell().error(
"Application version #{inspect(version)} does not match git tag #{inspect(git_tag)}"
)
end
build_name =
cond do
name = Application.get_env(:pleroma, :build_name) -> name
name = System.get_env("PLEROMA_BUILD_NAME") -> name
true -> nil
end
env_name = if Mix.env() != :prod, do: to_string(Mix.env())
build =
[build_name, env_name]
|> Enum.filter(fn string -> string && string != "" end)
|> Enum.join("-")
|> (fn
"" -> nil
string -> "+" <> string
end).()
[version, git_pre_release, build]
|> Enum.filter(fn string -> string && string != "" end)
|> Enum.join()
end
end end

View file

@ -1 +1 @@
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Pleroma</title><link rel=icon type=image/png href=/favicon.png><link rel=stylesheet href=/static/font/css/fontello.css><link rel=stylesheet href=/static/font/css/animation.css><link href=/static/css/app.0808aeafc6252b3050ea95b17dcaff1a.css rel=stylesheet></head><body style="display: none"><div id=app></div><script type=text/javascript src=/static/js/manifest.0acc0743ff1587a4dde3.js></script><script type=text/javascript src=/static/js/vendor.26ecdac76eb1b8aebbe9.js></script><script type=text/javascript src=/static/js/app.36f6378df4aa596c1419.js></script></body></html> <!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Pleroma</title><link rel=icon type=image/png href=/favicon.png><link rel=stylesheet href=/static/font/css/fontello.css><link rel=stylesheet href=/static/font/css/animation.css><link href=/static/css/app.0808aeafc6252b3050ea95b17dcaff1a.css rel=stylesheet></head><body style="display: none"><div id=app></div><script type=text/javascript src=/static/js/manifest.34667c2817916147413f.js></script><script type=text/javascript src=/static/js/vendor.32c621c7157f34c20923.js></script><script type=text/javascript src=/static/js/app.065638d22ade92dea420.js></script></body></html>

View file

@ -12,5 +12,6 @@
"formattingOptionsEnabled": false, "formattingOptionsEnabled": false,
"collapseMessageWithSubject": false, "collapseMessageWithSubject": false,
"hidePostStats": false, "hidePostStats": false,
"hideUserStats": false "hideUserStats": false,
"loginMethod": "password"
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
!function(e){function t(a){if(r[a])return r[a].exports;var n=r[a]={exports:{},id:a,loaded:!1};return e[a].call(n.exports,n,n.exports,t),n.loaded=!0,n.exports}var a=window.webpackJsonp;window.webpackJsonp=function(c,o){for(var p,l,s=0,i=[];s<c.length;s++)l=c[s],n[l]&&i.push.apply(i,n[l]),n[l]=0;for(p in o)Object.prototype.hasOwnProperty.call(o,p)&&(e[p]=o[p]);for(a&&a(c,o);i.length;)i.shift().call(null,t);if(o[0])return r[0]=0,t(0)};var r={},n={0:0};t.e=function(e,a){if(0===n[e])return a.call(null,t);if(void 0!==n[e])n[e].push(a);else{n[e]=[a];var r=document.getElementsByTagName("head")[0],c=document.createElement("script");c.type="text/javascript",c.charset="utf-8",c.async=!0,c.src=t.p+"static/js/"+e+"."+{1:"26ecdac76eb1b8aebbe9",2:"36f6378df4aa596c1419"}[e]+".js",r.appendChild(c)}},t.m=e,t.c=r,t.p="/"}([]);
//# sourceMappingURL=manifest.0acc0743ff1587a4dde3.js.map

View file

@ -0,0 +1,2 @@
!function(e){function t(r){if(n[r])return n[r].exports;var a=n[r]={exports:{},id:r,loaded:!1};return e[r].call(a.exports,a,a.exports,t),a.loaded=!0,a.exports}var r=window.webpackJsonp;window.webpackJsonp=function(c,o){for(var p,l,s=0,i=[];s<c.length;s++)l=c[s],a[l]&&i.push.apply(i,a[l]),a[l]=0;for(p in o)Object.prototype.hasOwnProperty.call(o,p)&&(e[p]=o[p]);for(r&&r(c,o);i.length;)i.shift().call(null,t);if(o[0])return n[0]=0,t(0)};var n={},a={0:0};t.e=function(e,r){if(0===a[e])return r.call(null,t);if(void 0!==a[e])a[e].push(r);else{a[e]=[r];var n=document.getElementsByTagName("head")[0],c=document.createElement("script");c.type="text/javascript",c.charset="utf-8",c.async=!0,c.src=t.p+"static/js/"+e+"."+{1:"32c621c7157f34c20923",2:"065638d22ade92dea420"}[e]+".js",n.appendChild(c)}},t.m=e,t.c=n,t.p="/"}([]);
//# sourceMappingURL=manifest.34667c2817916147413f.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,10 @@
[
"Anois",
["%s s", "%s s"],
["%s n", "%s nóimeád"],
["%s u", "%s uair"],
["%s l", "%s lá"],
["%s se", "%s seachtaine"],
["%s m", "%s mí"],
["%s b", "%s bliainta"]
]

View file

@ -3,8 +3,8 @@
["fa %s s", "fa %s s"], ["fa %s s", "fa %s s"],
["fa %s min", "fa %s min"], ["fa %s min", "fa %s min"],
["fa %s h", "fa %s h"], ["fa %s h", "fa %s h"],
["fa %s dia", "fa %s jorns"], ["fa %s jorn", "fa %s jorns"],
["fa %s setm.", "fa %s setm."], ["fa %s setm.", "fa %s setm."],
["fa %s mes", "fa %s meses"], ["fa %s mes", "fa %s meses"],
["fa %s any", "fa %s ans"] ["fa %s an", "fa %s ans"]
] ]

View file

@ -0,0 +1,17 @@
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://info.pleroma.site/actor.json",
"type": "Person",
"following": "https://info.pleroma.site/following.json",
"followers": "https://info.pleroma.site/followers.json",
"inbox": "https://info.pleroma.site/inbox.json",
"outbox": "https://info.pleroma.site/outbox.json",
"preferredUsername": "admin",
"name": null,
"summary": "<p></p>",
"publicKey": {
"id": "https://info.pleroma.site/actor.json#main-key",
"owner": "https://info.pleroma.site/actor.json",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtc4Tir+3ADhSNF6VKrtW\nOU32T01w7V0yshmQei38YyiVwVvFu8XOP6ACchkdxbJ+C9mZud8qWaRJKVbFTMUG\nNX4+6Q+FobyuKrwN7CEwhDALZtaN2IPbaPd6uG1B7QhWorrY+yFa8f2TBM3BxnUy\nI4T+bMIZIEYG7KtljCBoQXuTQmGtuffO0UwJksidg2ffCF5Q+K//JfQagJ3UzrR+\nZXbKMJdAw4bCVJYs4Z5EhHYBwQWiXCyMGTd7BGlmMkY6Av7ZqHKC/owp3/0EWDNz\nNqF09Wcpr3y3e8nA10X40MJqp/wR+1xtxp+YGbq/Cj5hZGBG7etFOmIpVBrDOhry\nBwIDAQAB\n-----END PUBLIC KEY-----\n"
}
}

View file

@ -1,8 +1,8 @@
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://mastodon.example.org/users/admin", "actor": "http://mastodon.example.org/users/admin",
"attachment": [], "attachment": [],
"attributedTo": "https://mastodon.example.org/users/admin", "attributedTo": "http://mastodon.example.org/users/admin",
"content": "<p>this post was not actually written by Haelwenn</p>", "content": "<p>this post was not actually written by Haelwenn</p>",
"id": "https://info.pleroma.site/activity.json", "id": "https://info.pleroma.site/activity.json",
"published": "2018-09-01T22:15:00Z", "published": "2018-09-01T22:15:00Z",

View file

@ -0,0 +1,14 @@
{
"@context": "https://www.w3.org/ns/activitystreams",
"attributedTo": "https://info.pleroma.site/actor.json",
"attachment": [],
"actor": "http://mastodon.example.org/users/admin",
"content": "<p>this post was not actually written by Haelwenn</p>",
"id": "https://info.pleroma.site/activity2.json",
"published": "2018-09-01T22:15:00Z",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note"
}

View file

@ -0,0 +1,13 @@
{
"@context": "https://www.w3.org/ns/activitystreams",
"attributedTo": "http://mastodon.example.org/users/admin",
"attachment": [],
"content": "<p>this post was not actually written by Haelwenn</p>",
"id": "https://info.pleroma.site/activity2.json",
"published": "2018-09-01T22:15:00Z",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note"
}

View file

@ -0,0 +1,13 @@
{
"@context": "https://www.w3.org/ns/activitystreams",
"attributedTo": "http://mastodon.example.org/users/admin",
"attachment": [],
"content": "<p>this post was not actually written by Haelwenn</p>",
"id": "http://mastodon.example.org/users/admin/activities/1234",
"published": "2018-09-01T22:15:00Z",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note"
}

113
test/media_proxy_test.exs Normal file
View file

@ -0,0 +1,113 @@
defmodule Pleroma.MediaProxyTest do
use ExUnit.Case
import Pleroma.Web.MediaProxy
describe "when enabled" do
setup do
enabled = Pleroma.Config.get([:media_proxy, :enabled])
unless enabled do
Pleroma.Config.put([:media_proxy, :enabled], true)
on_exit(fn -> Pleroma.Config.put([:media_proxy, :enabled], enabled) end)
end
:ok
end
test "ignores invalid url" do
assert url(nil) == nil
assert url("") == nil
end
test "ignores relative url" do
assert url("/local") == "/local"
assert url("/") == "/"
end
test "ignores local url" do
local_url = Pleroma.Web.Endpoint.url() <> "/hello"
local_root = Pleroma.Web.Endpoint.url()
assert url(local_url) == local_url
assert url(local_root) == local_root
end
test "encodes and decodes URL" do
url = "https://pleroma.soykaf.com/static/logo.png"
encoded = url(url)
assert String.starts_with?(
encoded,
Pleroma.Config.get([:media_proxy, :base_url], Pleroma.Web.base_url())
)
assert String.ends_with?(encoded, "/logo.png")
assert decode_result(encoded) == url
end
test "encodes and decodes URL without a path" do
url = "https://pleroma.soykaf.com"
encoded = url(url)
assert decode_result(encoded) == url
end
test "encodes and decodes URL without an extension" do
url = "https://pleroma.soykaf.com/path/"
encoded = url(url)
assert String.ends_with?(encoded, "/path")
assert decode_result(encoded) == url
end
test "encodes and decodes URL and ignores query params for the path" do
url = "https://pleroma.soykaf.com/static/logo.png?93939393939&bunny=true"
encoded = url(url)
assert String.ends_with?(encoded, "/logo.png")
assert decode_result(encoded) == url
end
test "validates signature" do
secret_key_base = Pleroma.Config.get([Pleroma.Web.Endpoint, :secret_key_base])
on_exit(fn ->
Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], secret_key_base)
end)
encoded = url("https://pleroma.social")
Pleroma.Config.put(
[Pleroma.Web.Endpoint, :secret_key_base],
"00000000000000000000000000000000000000000000000"
)
[_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/")
assert decode_url(sig, base64) == {:error, :invalid_signature}
end
end
describe "when disabled" do
setup do
enabled = Pleroma.Config.get([:media_proxy, :enabled])
if enabled do
Pleroma.Config.put([:media_proxy, :enabled], false)
on_exit(fn ->
Pleroma.Config.put([:media_proxy, :enabled], enabled)
:ok
end)
end
:ok
end
test "does not encode remote urls" do
assert url("https://google.fr") == "https://google.fr"
end
end
defp decode_result(encoded) do
[_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/")
{:ok, decoded} = decode_url(sig, base64)
decoded
end
end

View file

@ -0,0 +1,79 @@
defmodule Pleroma.Web.Plugs.HTTPSecurityPlugTest do
use Pleroma.Web.ConnCase
alias Pleroma.Config
alias Plug.Conn
test "it sends CSP headers when enabled", %{conn: conn} do
Config.put([:http_security, :enabled], true)
conn =
conn
|> get("/api/v1/instance")
refute Conn.get_resp_header(conn, "x-xss-protection") == []
refute Conn.get_resp_header(conn, "x-permitted-cross-domain-policies") == []
refute Conn.get_resp_header(conn, "x-frame-options") == []
refute Conn.get_resp_header(conn, "x-content-type-options") == []
refute Conn.get_resp_header(conn, "x-download-options") == []
refute Conn.get_resp_header(conn, "referrer-policy") == []
refute Conn.get_resp_header(conn, "content-security-policy") == []
end
test "it does not send CSP headers when disabled", %{conn: conn} do
Config.put([:http_security, :enabled], false)
conn =
conn
|> get("/api/v1/instance")
assert Conn.get_resp_header(conn, "x-xss-protection") == []
assert Conn.get_resp_header(conn, "x-permitted-cross-domain-policies") == []
assert Conn.get_resp_header(conn, "x-frame-options") == []
assert Conn.get_resp_header(conn, "x-content-type-options") == []
assert Conn.get_resp_header(conn, "x-download-options") == []
assert Conn.get_resp_header(conn, "referrer-policy") == []
assert Conn.get_resp_header(conn, "content-security-policy") == []
end
test "it sends STS headers when enabled", %{conn: conn} do
Config.put([:http_security, :enabled], true)
Config.put([:http_security, :sts], true)
conn =
conn
|> get("/api/v1/instance")
refute Conn.get_resp_header(conn, "strict-transport-security") == []
refute Conn.get_resp_header(conn, "expect-ct") == []
end
test "it does not send STS headers when disabled", %{conn: conn} do
Config.put([:http_security, :enabled], true)
Config.put([:http_security, :sts], false)
conn =
conn
|> get("/api/v1/instance")
assert Conn.get_resp_header(conn, "strict-transport-security") == []
assert Conn.get_resp_header(conn, "expect-ct") == []
end
test "referrer-policy header reflects configured value", %{conn: conn} do
Config.put([:http_security, :enabled], true)
conn =
conn
|> get("/api/v1/instance")
assert Conn.get_resp_header(conn, "referrer-policy") == ["same-origin"]
Config.put([:http_security, :referrer_policy], "no-referrer")
conn =
build_conn()
|> get("/api/v1/instance")
assert Conn.get_resp_header(conn, "referrer-policy") == ["no-referrer"]
end
end

View file

@ -0,0 +1,39 @@
defmodule Pleroma.Plugs.UserIsAdminPlugTest do
use Pleroma.Web.ConnCase, async: true
alias Pleroma.Plugs.UserIsAdminPlug
import Pleroma.Factory
test "accepts a user that is admin", %{conn: conn} do
user = insert(:user, info: %{"is_admin" => true})
conn =
build_conn()
|> assign(:user, user)
ret_conn =
conn
|> UserIsAdminPlug.call(%{})
assert conn == ret_conn
end
test "denies a user that isn't admin", %{conn: conn} do
user = insert(:user)
conn =
build_conn()
|> assign(:user, user)
|> UserIsAdminPlug.call(%{})
assert conn.status == 403
end
test "denies when a user isn't set", %{conn: conn} do
conn =
build_conn()
|> UserIsAdminPlug.call(%{})
assert conn.status == 403
end
end

View file

@ -40,6 +40,38 @@ defmodule HTTPoisonMock do
}} }}
end end
def get("https://info.pleroma.site/activity2.json", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https__info.pleroma.site_activity2.json")
}}
end
def get("https://info.pleroma.site/activity3.json", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https__info.pleroma.site_activity3.json")
}}
end
def get("https://info.pleroma.site/activity4.json", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https__info.pleroma.site_activity4.json")
}}
end
def get("https://info.pleroma.site/actor.json", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___info.pleroma.site_actor.json")
}}
end
def get("https://puckipedia.com/", [Accept: "application/activity+json"], _) do def get("https://puckipedia.com/", [Accept: "application/activity+json"], _) do
{:ok, {:ok,
%Response{ %Response{
@ -732,6 +764,14 @@ defmodule HTTPoisonMock do
}} }}
end end
def get("https://n1u.moe/users/rye", [Accept: "application/activity+json"], _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/rye.json")
}}
end
def get( def get(
"https://mst3k.interlinked.me/users/luciferMysticus", "https://mst3k.interlinked.me/users/luciferMysticus",
[Accept: "application/activity+json"], [Accept: "application/activity+json"],

View file

@ -578,4 +578,16 @@ defmodule Pleroma.UserTest do
assert cached_user != user assert cached_user != user
end end
end end
describe "User.search" do
test "finds a user, ranking by similarity" do
user = insert(:user, %{name: "lain"})
user_two = insert(:user, %{name: "ean"})
user_three = insert(:user, %{name: "ebn", nickname: "lain@mastodon.social"})
user_four = insert(:user, %{nickname: "lain@pleroma.soykaf.com"})
assert user_four ==
User.search("lain@ple") |> List.first() |> Map.put(:search_distance, nil)
end
end
end end

View file

@ -361,6 +361,26 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
refute Repo.get(Activity, activity.id) refute Repo.get(Activity, activity.id)
end end
test "it fails for incoming deletes with spoofed origin" do
activity = insert(:note_activity)
data =
File.read!("test/fixtures/mastodon-delete.json")
|> Poison.decode!()
object =
data["object"]
|> Map.put("id", activity.data["object"]["id"])
data =
data
|> Map.put("object", object)
:error = Transmogrifier.handle_incoming(data)
assert Repo.get(Activity, activity.id)
end
test "it works for incoming unannounces with an existing notice" do test "it works for incoming unannounces with an existing notice" do
user = insert(:user) user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
@ -872,12 +892,10 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
end end
test "it rejects activities which reference objects with bogus origins" do test "it rejects activities which reference objects with bogus origins" do
user = insert(:user, %{local: false})
data = %{ data = %{
"@context" => "https://www.w3.org/ns/activitystreams", "@context" => "https://www.w3.org/ns/activitystreams",
"id" => user.ap_id <> "/activities/1234", "id" => "http://mastodon.example.org/users/admin/activities/1234",
"actor" => user.ap_id, "actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"], "to" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => "https://info.pleroma.site/activity.json", "object" => "https://info.pleroma.site/activity.json",
"type" => "Announce" "type" => "Announce"
@ -885,5 +903,96 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
:error = Transmogrifier.handle_incoming(data) :error = Transmogrifier.handle_incoming(data)
end end
test "it rejects objects when attributedTo is wrong (variant 1)" do
{:error, _} = ActivityPub.fetch_object_from_id("https://info.pleroma.site/activity2.json")
end
test "it rejects activities which reference objects that have an incorrect attribution (variant 1)" do
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "http://mastodon.example.org/users/admin/activities/1234",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => "https://info.pleroma.site/activity2.json",
"type" => "Announce"
}
:error = Transmogrifier.handle_incoming(data)
end
test "it rejects objects when attributedTo is wrong (variant 2)" do
{:error, _} = ActivityPub.fetch_object_from_id("https://info.pleroma.site/activity3.json")
end
test "it rejects activities which reference objects that have an incorrect attribution (variant 2)" do
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "http://mastodon.example.org/users/admin/activities/1234",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => "https://info.pleroma.site/activity3.json",
"type" => "Announce"
}
:error = Transmogrifier.handle_incoming(data)
end
end
describe "general origin containment" do
test "contain_origin_from_id() catches obvious spoofing attempts" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json"
}
:error =
Transmogrifier.contain_origin_from_id(
"http://example.org/~alyssa/activities/1234.json",
data
)
end
test "contain_origin_from_id() allows alternate IDs within the same origin domain" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json"
}
:ok =
Transmogrifier.contain_origin_from_id(
"http://example.com/~alyssa/activities/1234",
data
)
end
test "contain_origin_from_id() allows matching IDs" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json"
}
:ok =
Transmogrifier.contain_origin_from_id(
"http://example.com/~alyssa/activities/1234.json",
data
)
end
test "users cannot be collided through fake direction spoofing attempts" do
user =
insert(:user, %{
nickname: "rye@niu.moe",
local: false,
ap_id: "https://niu.moe/users/rye",
follower_address: User.ap_followers(%User{nickname: "rye@niu.moe"})
})
{:error, _} = User.get_or_fetch_by_ap_id("https://n1u.moe/users/rye")
end
test "all objects with fake directions are rejected by the object fetcher" do
{:error, _} =
ActivityPub.fetch_and_contain_remote_object_from_id(
"https://info.pleroma.site/activity4.json"
)
end
end end
end end

View file

@ -2,6 +2,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectViewTest do
use Pleroma.DataCase use Pleroma.DataCase
import Pleroma.Factory import Pleroma.Factory
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.ObjectView
test "renders a note object" do test "renders a note object" do
@ -15,4 +16,43 @@ defmodule Pleroma.Web.ActivityPub.ObjectViewTest do
assert result["type"] == "Note" assert result["type"] == "Note"
assert result["@context"] assert result["@context"]
end end
test "renders a note activity" do
note = insert(:note_activity)
result = ObjectView.render("object.json", %{object: note})
assert result["id"] == note.data["id"]
assert result["to"] == note.data["to"]
assert result["object"]["type"] == "Note"
assert result["object"]["content"] == note.data["object"]["content"]
assert result["type"] == "Create"
assert result["@context"]
end
test "renders a like activity" do
note = insert(:note_activity)
user = insert(:user)
{:ok, like_activity, _} = CommonAPI.favorite(note.id, user)
result = ObjectView.render("object.json", %{object: like_activity})
assert result["id"] == like_activity.data["id"]
assert result["object"] == note.data["object"]["id"]
assert result["type"] == "Like"
end
test "renders an announce activity" do
note = insert(:note_activity)
user = insert(:user)
{:ok, announce_activity, _} = CommonAPI.repeat(note.id, user)
result = ObjectView.render("object.json", %{object: announce_activity})
assert result["id"] == announce_activity.data["id"]
assert result["object"] == note.data["object"]["id"]
assert result["type"] == "Announce"
end
end end

View file

@ -0,0 +1,112 @@
defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.{Repo, User}
import Pleroma.Factory
import ExUnit.CaptureLog
describe "/api/pleroma/admin/user" do
test "Delete" do
admin = insert(:user, info: %{"is_admin" => true})
user = insert(:user)
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> delete("/api/pleroma/admin/user?nickname=#{user.nickname}")
assert json_response(conn, 200) == user.nickname
end
test "Create" do
admin = insert(:user, info: %{"is_admin" => true})
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/user", %{
"nickname" => "lain",
"email" => "lain@example.org",
"password" => "test"
})
assert json_response(conn, 200) == "lain"
end
end
describe "/api/pleroma/admin/permission_group" do
test "GET is giving user_info" do
admin = insert(:user, info: %{"is_admin" => true})
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> get("/api/pleroma/admin/permission_group/#{admin.nickname}")
assert json_response(conn, 200) == admin.info
end
test "/:right POST, can add to a permission group" do
admin = insert(:user, info: %{"is_admin" => true})
user = insert(:user)
user_info =
user.info
|> Map.put("is_admin", true)
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/permission_group/#{user.nickname}/admin")
assert json_response(conn, 200) == user_info
end
test "/:right DELETE, can remove from a permission group" do
admin = insert(:user, info: %{"is_admin" => true})
user = insert(:user, info: %{"is_admin" => true})
user_info =
user.info
|> Map.put("is_admin", false)
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> delete("/api/pleroma/admin/permission_group/#{user.nickname}/admin")
assert json_response(conn, 200) == user_info
end
end
test "/api/pleroma/admin/invite_token" do
admin = insert(:user, info: %{"is_admin" => true})
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> get("/api/pleroma/admin/invite_token")
assert conn.status == 200
end
test "/api/pleroma/admin/password_reset" do
admin = insert(:user, info: %{"is_admin" => true})
user = insert(:user, info: %{"is_admin" => true})
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> get("/api/pleroma/admin/password_reset?nickname=#{user.nickname}")
assert conn.status == 200
end
end

View file

@ -61,4 +61,42 @@ defmodule Pleroma.Web.FederatorTest do
Pleroma.Config.put([:instance, :allow_relay], true) Pleroma.Config.put([:instance, :allow_relay], true)
end end
end end
describe "Receive an activity" do
test "successfully processes incoming AP docs with correct origin" do
params = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"actor" => "http://mastodon.example.org/users/admin",
"type" => "Create",
"id" => "http://mastodon.example.org/users/admin/activities/1",
"object" => %{
"type" => "Note",
"content" => "hi world!",
"id" => "http://mastodon.example.org/users/admin/objects/1",
"attributedTo" => "http://mastodon.example.org/users/admin"
},
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
}
{:ok, _activity} = Federator.handle(:incoming_ap_doc, params)
end
test "rejects incoming AP docs with incorrect origin" do
params = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"actor" => "https://niu.moe/users/rye",
"type" => "Create",
"id" => "http://mastodon.example.org/users/admin/activities/1",
"object" => %{
"type" => "Note",
"content" => "hi world!",
"id" => "http://mastodon.example.org/users/admin/objects/1",
"attributedTo" => "http://mastodon.example.org/users/admin"
},
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
}
:error = Federator.handle(:incoming_ap_doc, params)
end
end
end end

View file

@ -178,6 +178,32 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do
|> get("api/v1/timelines/home") |> get("api/v1/timelines/home")
[_s1, _s2] = json_response(res_conn, 200) [_s1, _s2] = json_response(res_conn, 200)
# Test pagination
Enum.each(1..20, fn _ ->
{:ok, _} =
CommonAPI.post(user_one, %{
"status" => "Hi @#{user_two.nickname}!",
"visibility" => "direct"
})
end)
res_conn =
conn
|> assign(:user, user_two)
|> get("api/v1/timelines/direct")
statuses = json_response(res_conn, 200)
assert length(statuses) == 20
res_conn =
conn
|> assign(:user, user_two)
|> get("api/v1/timelines/direct", %{max_id: List.last(statuses)["id"]})
[status] = json_response(res_conn, 200)
assert status["url"] != direct.data["id"]
end end
test "replying to a status", %{conn: conn} do test "replying to a status", %{conn: conn} do

View file

@ -0,0 +1,33 @@
defmodule Pleroma.Web.MastodonApi.MastodonSocketTest do
use Pleroma.DataCase
alias Pleroma.Web.MastodonApi.MastodonSocket
alias Pleroma.Web.{Streamer, CommonAPI}
alias Pleroma.User
import Pleroma.Factory
test "public is working when non-authenticated" do
user = insert(:user)
task =
Task.async(fn ->
assert_receive {:text, _}, 4_000
end)
fake_socket = %{
transport_pid: task.pid,
assigns: %{}
}
topics = %{
"public" => [fake_socket]
}
{:ok, activity} = CommonAPI.post(user, %{"status" => "Test"})
Streamer.push_to_socket(topics, "public", activity)
Task.await(task)
end
end

View file

@ -2,6 +2,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
use Pleroma.Web.ConnCase use Pleroma.Web.ConnCase
import Pleroma.Factory import Pleroma.Factory
alias Pleroma.{User, Repo} alias Pleroma.{User, Repo}
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OStatus.ActivityRepresenter alias Pleroma.Web.OStatus.ActivityRepresenter
test "decodes a salmon", %{conn: conn} do test "decodes a salmon", %{conn: conn} do
@ -167,6 +168,32 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
assert json_response(conn, 200) assert json_response(conn, 200)
end end
test "only gets a notice in AS2 format for Create messages", %{conn: conn} do
note_activity = insert(:note_activity)
url = "/notice/#{note_activity.id}"
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get(url)
assert json_response(conn, 200)
user = insert(:user)
{:ok, like_activity, _} = CommonAPI.favorite(note_activity.id, user)
url = "/notice/#{like_activity.id}"
assert like_activity.data["type"] == "Like"
conn =
build_conn()
|> put_req_header("accept", "application/activity+json")
|> get(url)
assert response(conn, 404)
end
test "gets an activity in AS2 format", %{conn: conn} do test "gets an activity in AS2 format", %{conn: conn} do
note_activity = insert(:note_activity) note_activity = insert(:note_activity)
[_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"])) [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"]))

View file

@ -0,0 +1,31 @@
defmodule MockActivityPub do
def publish_one(ret) do
{ret, "success"}
end
end
defmodule Pleroma.ActivityTest do
use Pleroma.DataCase
alias Pleroma.Web.Federator.RetryQueue
@small_retry_count 0
@hopeless_retry_count 10
test "failed posts are retried" do
{:retry, _timeout} = RetryQueue.get_retry_params(@small_retry_count)
assert {:noreply, %{delivered: 1}} ==
RetryQueue.handle_info({:send, :ok, MockActivityPub, @small_retry_count}, %{
delivered: 0
})
end
test "posts that have been tried too many times are dropped" do
{:drop, _timeout} = RetryQueue.get_retry_params(@hopeless_retry_count)
assert {:noreply, %{dropped: 1}} ==
RetryQueue.handle_cast({:maybe_enqueue, %{}, nil, @hopeless_retry_count}, %{
dropped: 0
})
end
end

View file

@ -271,6 +271,43 @@ defmodule Pleroma.Web.TwitterAPI.ControllerTest do
end end
end end
describe "GET /statuses/dm_timeline.json" do
test "it show direct messages", %{conn: conn} do
user_one = insert(:user)
user_two = insert(:user)
{:ok, user_two} = User.follow(user_two, user_one)
{:ok, direct} =
CommonAPI.post(user_one, %{
"status" => "Hi @#{user_two.nickname}!",
"visibility" => "direct"
})
{:ok, direct_two} =
CommonAPI.post(user_two, %{
"status" => "Hi @#{user_one.nickname}!",
"visibility" => "direct"
})
{:ok, _follower_only} =
CommonAPI.post(user_one, %{
"status" => "Hi @#{user_two.nickname}!",
"visibility" => "private"
})
# Only direct should be visible here
res_conn =
conn
|> assign(:user, user_two)
|> get("/api/statuses/dm_timeline.json")
[status, status_two] = json_response(res_conn, 200)
assert status["id"] == direct_two.id
assert status_two["id"] == direct.id
end
end
describe "GET /statuses/mentions.json" do describe "GET /statuses/mentions.json" do
setup [:valid_user] setup [:valid_user]
@ -1181,4 +1218,20 @@ defmodule Pleroma.Web.TwitterAPI.ControllerTest do
assert relationship["follows_you"] == false assert relationship["follows_you"] == false
end end
end end
describe "GET /api/pleroma/search_user" do
test "it returns users, ordered by similarity", %{conn: conn} do
user = insert(:user, %{name: "eal"})
user_two = insert(:user, %{name: "ean"})
user_three = insert(:user, %{name: "ebn"})
resp =
conn
|> get(twitter_api_search__path(conn, :search_user), query: "eal")
|> json_response(200)
assert length(resp) == 3
assert [user.id, user_two.id, user_three.id] == Enum.map(resp, fn %{"id" => id} -> id end)
end
end
end end

View file

@ -13,6 +13,13 @@ defmodule Pleroma.Web.TwitterAPI.UserViewTest do
[user: user] [user: user]
end end
test "A user with only a nickname", %{user: user} do
user = %{user | name: nil, nickname: "scarlett@catgirl.science"}
represented = UserView.render("show.json", %{user: user})
assert represented["name"] == user.nickname
assert represented["name_html"] == user.nickname
end
test "A user with an avatar object", %{user: user} do test "A user with an avatar object", %{user: user} do
image = "image" image = "image"
user = %{user | avatar: %{"url" => [%{"href" => image}]}} user = %{user | avatar: %{"url" => [%{"href" => image}]}}