Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
213 KB
Referenced Files
None
Subscribers
None
diff --git a/.gitignore b/.gitignore
index 9aad700ee..b71dfa9fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,31 +1,34 @@
# App artifacts
/_build
/db
/deps
/*.ez
/uploads
/test/uploads
/.elixir_ls
+/test/fixtures/test_tmp.txt
+/test/fixtures/image_tmp.jpg
+/doc
# Prevent committing custom emojis
/priv/static/emoji/custom/*
# Generated on crash by the VM
erl_crash.dump
# Files matching config/*.secret.exs pattern contain sensitive
# data and you should not commit them into version control.
#
# Alternatively, you may comment the line below and commit the
# secrets files as long as you replace their contents by environment
# variables.
/config/*.secret.exs
# Database setup file, some may forget to delete it
/config/setup_db.psql
.DS_Store
.env
# Editor config
-/.vscode
\ No newline at end of file
+/.vscode
diff --git a/config/config.exs b/config/config.exs
index 848c9d6cf..12f47389c 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -1,199 +1,201 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Mix.Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
use Mix.Config
# General application configuration
config :pleroma, ecto_repos: [Pleroma.Repo]
config :pleroma, Pleroma.Repo, types: Pleroma.PostgresTypes
+# Upload configuration
config :pleroma, Pleroma.Upload,
uploader: Pleroma.Uploaders.Local,
- strip_exif: false
+ filters: [],
+ proxy_remote: false,
+ proxy_opts: []
-config :pleroma, Pleroma.Uploaders.Local,
- uploads: "uploads",
- uploads_url: "{{base_url}}/media/{{file}}"
+config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads"
config :pleroma, Pleroma.Uploaders.S3,
bucket: nil,
- public_endpoint: "https://s3.amazonaws.com",
- force_media_proxy: false
+ public_endpoint: "https://s3.amazonaws.com"
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, :uri_schemes,
valid_schemes: [
"https",
"http",
"dat",
"dweb",
"gopher",
"ipfs",
"ipns",
"irc",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
"xmpp"
]
# Configures the endpoint
config :pleroma, Pleroma.Web.Endpoint,
url: [host: "localhost"],
protocol: "https",
secret_key_base: "aK4Abxf29xU9TTDKre9coZPUgevcVCFQJe/5xP/7Lt4BEif6idBIbjupVbOrbKxl",
signing_salt: "CqaoopA2",
render_errors: [view: Pleroma.Web.ErrorView, accepts: ~w(json)],
pubsub: [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2],
secure_cookie_flag: true
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
config :mime, :types, %{
"application/xml" => ["xml"],
"application/xrd+xml" => ["xrd+xml"],
"application/activity+json" => ["activity+json"],
"application/ld+json" => ["activity+json"]
}
config :pleroma, :websub, Pleroma.Web.Websub
config :pleroma, :ostatus, Pleroma.Web.OStatus
config :pleroma, :httpoison, Pleroma.HTTP
# Configures http settings, upstream proxy etc.
config :pleroma, :http, proxy_url: nil
config :pleroma, :instance,
name: "Pleroma",
email: "example@example.com",
description: "A Pleroma instance, an alternative fediverse server",
limit: 5000,
upload_limit: 16_000_000,
avatar_upload_limit: 2_000_000,
background_upload_limit: 4_000_000,
banner_upload_limit: 4_000_000,
registrations_open: true,
federating: true,
allow_relay: true,
rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy,
public: true,
quarantined_instances: [],
managed_config: true,
allowed_post_formats: [
"text/plain",
"text/html",
"text/markdown"
],
finmoji_enabled: true,
mrf_transparency: true
config :pleroma, :markup,
# XXX - unfortunately, inline images must be enabled by default right now, because
# of custom emoji. Issue #275 discusses defanging that somehow.
allow_inline_images: true,
allow_headings: false,
allow_tables: false,
allow_fonts: false,
scrub_policy: [
Pleroma.HTML.Transform.MediaProxy,
Pleroma.HTML.Scrubber.Default
]
config :pleroma, :fe,
theme: "pleroma-dark",
logo: "/static/logo.png",
logo_mask: true,
logo_margin: "0.1em",
background: "/static/aurora_borealis.jpg",
redirect_root_no_login: "/main/all",
redirect_root_login: "/main/friends",
show_instance_panel: true,
scope_options_enabled: false,
formatting_options_enabled: false,
collapse_message_with_subject: false,
hide_post_stats: false,
hide_user_stats: false
config :pleroma, :activitypub,
accept_blocks: true,
unfollow_blocked: true,
outgoing_blocks: true,
follow_handshake_timeout: 500
config :pleroma, :user, deny_follow_blocked: true
config :pleroma, :mrf_normalize_markup, scrub_policy: Pleroma.HTML.Scrubber.Default
config :pleroma, :mrf_rejectnonpublic,
allow_followersonly: false,
allow_direct: false
config :pleroma, :mrf_simple,
media_removal: [],
media_nsfw: [],
federated_timeline_removal: [],
reject: [],
accept: []
config :pleroma, :media_proxy,
enabled: false,
- redirect_on_failure: true
-
-# base_url: "https://cache.pleroma.social"
+ # base_url: "https://cache.pleroma.social",
+ proxy_opts: [
+ # inline_content_types: [] | false | true,
+ # http: [:insecure]
+ ]
config :pleroma, :chat, enabled: true
config :ecto, json_library: Jason
config :phoenix, :format_encoders, json: Jason
config :pleroma, :gopher,
enabled: false,
ip: {0, 0, 0, 0},
port: 9999
config :pleroma, :suggestions,
enabled: false,
third_party_engine:
"http://vinayaka.distsn.org/cgi-bin/vinayaka-user-match-suggestions-api.cgi?{{host}}+{{user}}",
timeout: 300_000,
limit: 23,
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,
max_age: 86_400,
methods: ["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"],
expose: [
"Link",
"X-RateLimit-Reset",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-Request-Id",
"Idempotency-Key"
],
credentials: true,
headers: ["Authorization", "Content-Type", "Idempotency-Key"]
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"
diff --git a/config/config.md b/config/config.md
index c843bca5d..47e838dd6 100644
--- a/config/config.md
+++ b/config/config.md
@@ -1,102 +1,111 @@
# Configuration
This file describe the configuration, it is recommended to edit the relevant *.secret.exs file instead of the others founds in the ``config`` directory.
If you run Pleroma with ``MIX_ENV=prod`` the file is ``prod.secret.exs``, otherwise it is ``dev.secret.exs``.
## Pleroma.Upload
* `uploader`: Select which `Pleroma.Uploaders` to use
-* `strip_exif`: boolean, uses ImageMagick(!) to strip exif.
+* `filters`: List of `Pleroma.Upload.Filter` to use.
+* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host.
+* `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it.
+* `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation.
+
+Note: `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`.
## Pleroma.Uploaders.Local
* `uploads`: Which directory to store the user-uploads in, relative to pleroma’s working directory
-* `uploads_url`: The URL to access a user-uploaded file, ``{{base_url}}`` is replaced to the instance URL and ``{{file}}`` to the filename. Useful when you want to proxy the media files via another host.
+
+## Pleroma.Upload.Filter.Mogrify
+
+* `args`: List of actions for the `mogrify` command like `"strip"` or `["strip", {"impode", "1"}]`.
## :uri_schemes
* `valid_schemes`: List of the scheme part that is considered valid to be an URL
## :instance
* `name`: The instance’s name
* `email`: Email used to reach an Administrator/Moderator of the instance
* `description`: The instance’s description, can be seen in nodeinfo and ``/api/v1/instance``
* `limit`: Posts character limit (CW/Subject included in the counter)
* `upload_limit`: File size limit of uploads (except for avatar, background, banner)
* `avatar_upload_limit`: File size limit of user’s profile avatars
* `background_upload_limit`: File size limit of user’s profile backgrounds
* `banner_upload_limit`: File size limit of user’s profile backgrounds
* `registerations_open`: Enable registerations for anyone, invitations can be used when false.
* `federating`
* `allow_relay`: Enable Pleroma’s Relay, which makes it possible to follow a whole instance
* `rewrite_policy`: Message Rewrite Policy, either one or a list. Here are the ones available by default:
* `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default)
* `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production
* `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See ``:mrf_simple`` section)
* `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See ``:mrf_rejectnonpublic`` section)
* `public`: Makes the client API in authentificated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network.
* `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send.
* `managed_config`: Whenether the config for pleroma-fe is configured in this config or in ``static/config.json``
* `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML)
* `finmoji_enabled`: Whenether to enable the finmojis in the custom emojis.
* `mrf_transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo).
## :fe
This section is used to configure Pleroma-FE, unless ``:managed_config`` in ``:instance`` is set to false.
* `theme`: Which theme to use, they are defined in ``styles.json``
* `logo`: URL of the logo, defaults to Pleroma’s logo
* `logo_mask`: Whenether to mask the logo
* `logo_margin`: What margin to use around the logo
* `background`: URL of the background, unless viewing a user profile with a background that is set
* `redirect_root_no_login`: relative URL which indicates where to redirect when a user isn’t logged in.
* `redirect_root_login`: relative URL which indicates where to redirect when a user is logged in.
* `show_instance_panel`: Whenether to show the instance’s specific panel.
* `scope_options_enabled`: Enable setting an notice visibility and subject/CW when posting
* `formatting_options_enabled`: Enable setting a formatting different than plain-text (ie. HTML, Markdown) when posting, relates to ``:instance, allowed_post_formats``
* `collapse_message_with_subjects`: When a message has a subject(aka Content Warning), collapse it by default
* `hide_post_stats`: Hide notices statistics(repeats, favorites, …)
* `hide_user_stats`: Hide profile statistics(posts, posts per day, followers, followings, …)
## :mrf_simple
* `media_removal`: List of instances to remove medias from
* `media_nsfw`: List of instances to put medias as NSFW(sensitive) from
* `federated_timeline_removal`: List of instances to remove from Federated (aka The Whole Known Network) Timeline
* `reject`: List of instances to reject any activities from
* `accept`: List of instances to accept any activities from
## :mrf_rejectnonpublic
* `allow_followersonly`: whether to allow followers-only posts
* `allow_direct`: whether to allow direct messages
## :media_proxy
* `enabled`: Enables proxying of remote media to the instance’s proxy
-* `redirect_on_failure`: Use the original URL when Media Proxy fails to get it
+* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.
+* `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`.
## :gopher
* `enabled`: Enables the gopher interface
* `ip`: IP address to bind to
* `port`: Port to bind to
## :activitypub
* ``accept_blocks``: Whether to accept incoming block activities from other instances
* ``unfollow_blocked``: Whether blocks result in people getting unfollowed
* ``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
## :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"`.
## :mrf_user_allowlist
The keys in this section are the domain names that the policy should apply to.
Each key should be assigned a list of users that should be allowed through by
their ActivityPub ID.
An example:
```
config :pleroma, :mrf_user_allowlist,
"example.org": ["https://example.org/users/admin"]
```
diff --git a/config/test.exs b/config/test.exs
index 1bd11dee4..3aaed1b2c 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,37 +1,37 @@
use Mix.Config
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :pleroma, Pleroma.Web.Endpoint,
http: [port: 4001],
server: false
# Print only warnings and errors during test
config :logger, level: :warn
-config :pleroma, Pleroma.Upload, uploads: "test/uploads"
+config :pleroma, Pleroma.Uploaders.Local, uploads: "test/uploads"
# Configure your database
config :pleroma, Pleroma.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "pleroma_test",
hostname: System.get_env("DB_HOST") || "localhost",
pool: Ecto.Adapters.SQL.Sandbox
# Reduce hash rounds for testing
config :pbkdf2_elixir, rounds: 1
config :pleroma, :websub, Pleroma.Web.WebsubMock
config :pleroma, :ostatus, Pleroma.Web.OStatusMock
config :pleroma, :httpoison, HTTPoisonMock
try do
import_config "test.secret.exs"
rescue
_ ->
IO.puts(
"You may want to create test.secret.exs to declare custom database connection parameters."
)
end
diff --git a/installation/pleroma.nginx b/installation/pleroma.nginx
index f0e684f2c..e1184fe77 100644
--- a/installation/pleroma.nginx
+++ b/installation/pleroma.nginx
@@ -1,79 +1,81 @@
# default nginx site config for Pleroma
#
# Simple installation instructions:
# 1. Install your TLS certificate, possibly using Let's Encrypt.
# 2. Replace 'example.tld' with your instance's domain wherever it appears.
# 3. Copy this file to /etc/nginx/sites-available/ and then add a symlink to it
# in /etc/nginx/sites-enabled/ and run 'nginx -s reload' or restart nginx.
proxy_cache_path /tmp/pleroma-media-cache levels=1:2 keys_zone=pleroma_media_cache:10m max_size=10g
inactive=720m use_temp_path=off;
server {
server_name example.tld;
listen 80;
return 301 https://$server_name$request_uri;
# Uncomment this if you need to use the 'webroot' method with certbot. Make sure
# that you also create the .well-known/acme-challenge directory structure in pleroma/priv/static and
# that is is accessible by the webserver. You may need to load this file with the ssl
# server block commented out, run certbot to get the certificate, and then uncomment it.
#
# location ~ /\.well-known/acme-challenge {
# root <path to install>/pleroma/priv/static/;
# }
}
# Enable SSL session caching for improved performance
ssl_session_cache shared:ssl_session_cache:10m;
server {
listen 443 ssl http2;
ssl_session_timeout 5m;
ssl_trusted_certificate /etc/letsencrypt/live/example.tld/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/example.tld/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem;
# Add TLSv1.0 to support older devices
ssl_protocols TLSv1.2;
# Uncomment line below if you want to support older devices (Before Android 4.4.2, IE 8, etc.)
# ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES";
ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
ssl_prefer_server_ciphers on;
# In case of an old server with an OpenSSL version of 1.0.2 or below,
# leave only prime256v1 or comment out the following line.
ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1;
ssl_stapling on;
ssl_stapling_verify on;
server_name example.tld;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml;
# the nginx default is 1m, not enough for large media uploads
client_max_body_size 16m;
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_pass http://localhost:4000;
client_max_body_size 16m;
}
- location /proxy {
+ location ~ ^/(media|proxy) {
proxy_cache pleroma_media_cache;
proxy_cache_lock on;
proxy_ignore_client_abort on;
+ proxy_buffering off;
+ chunked_transfer_encoding on;
proxy_pass http://localhost:4000;
}
}
diff --git a/lib/mix/tasks/migrate_local_uploads.ex b/lib/mix/tasks/migrate_local_uploads.ex
new file mode 100644
index 000000000..8f9e210c0
--- /dev/null
+++ b/lib/mix/tasks/migrate_local_uploads.ex
@@ -0,0 +1,97 @@
+defmodule Mix.Tasks.MigrateLocalUploads do
+ use Mix.Task
+ import Mix.Ecto
+ alias Pleroma.{Upload, Uploaders.Local, Uploaders.S3}
+ require Logger
+
+ @log_every 50
+ @shortdoc "Migrate uploads from local to remote storage"
+
+ def run([target_uploader | args]) do
+ delete? = Enum.member?(args, "--delete")
+ Application.ensure_all_started(:pleroma)
+
+ local_path = Pleroma.Config.get!([Local, :uploads])
+ uploader = Module.concat(Pleroma.Uploaders, target_uploader)
+
+ unless Code.ensure_loaded?(uploader) do
+ raise("The uploader #{inspect(uploader)} is not an existing/loaded module.")
+ end
+
+ target_enabled? = Pleroma.Config.get([Upload, :uploader]) == uploader
+
+ unless target_enabled? do
+ Pleroma.Config.put([Upload, :uploader], uploader)
+ end
+
+ Logger.info("Migrating files from local #{local_path} to #{to_string(uploader)}")
+
+ if delete? do
+ Logger.warn(
+ "Attention: uploaded files will be deleted, hope you have backups! (--delete ; cancel with ^C)"
+ )
+
+ :timer.sleep(:timer.seconds(5))
+ end
+
+ uploads =
+ File.ls!(local_path)
+ |> Enum.map(fn id ->
+ root_path = Path.join(local_path, id)
+
+ cond do
+ File.dir?(root_path) ->
+ files = for file <- File.ls!(root_path), do: {id, file, Path.join([root_path, file])}
+
+ case List.first(files) do
+ {id, file, path} ->
+ {%Pleroma.Upload{id: id, name: file, path: id <> "/" <> file, tempfile: path},
+ root_path}
+
+ _ ->
+ nil
+ end
+
+ File.exists?(root_path) ->
+ file = Path.basename(id)
+ [hash, ext] = String.split(id, ".")
+ {%Pleroma.Upload{id: hash, name: file, path: file, tempfile: root_path}, root_path}
+
+ true ->
+ nil
+ end
+ end)
+ |> Enum.filter(& &1)
+
+ total_count = length(uploads)
+ Logger.info("Found #{total_count} uploads")
+
+ uploads
+ |> Task.async_stream(
+ fn {upload, root_path} ->
+ case Upload.store(upload, uploader: uploader, filters: [], size_limit: nil) do
+ {:ok, _} ->
+ if delete?, do: File.rm_rf!(root_path)
+ Logger.debug("uploaded: #{inspect(upload.path)} #{inspect(upload)}")
+ :ok
+
+ error ->
+ Logger.error("failed to upload #{inspect(upload.path)}: #{inspect(error)}")
+ end
+ end,
+ timeout: 150_000
+ )
+ |> Stream.chunk_every(@log_every)
+ |> Enum.reduce(0, fn done, count ->
+ count = count + length(done)
+ Logger.info("Uploaded #{count}/#{total_count} files")
+ count
+ end)
+
+ Logger.info("Done!")
+ end
+
+ def run(_) do
+ Logger.error("Usage: migrate_local_uploads S3|Swift [--delete]")
+ end
+end
diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex
index 2d86efae5..cc68d9669 100644
--- a/lib/pleroma/application.ex
+++ b/lib/pleroma/application.ex
@@ -1,90 +1,101 @@
defmodule Pleroma.Application do
use Application
+ import Supervisor.Spec
@name "Pleroma"
@version Mix.Project.config()[:version]
def name, do: @name
def version, do: @version
def named_version(), do: @name <> " " <> @version
+ def user_agent() do
+ info = "#{Pleroma.Web.base_url()} <#{Pleroma.Config.get([:instance, :email], "")}>"
+ named_version() <> "; " <> info
+ end
+
# See http://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications
@env Mix.env()
def start(_type, _args) do
- import Supervisor.Spec
import Cachex.Spec
# Define workers and child supervisors to be supervised
children =
[
# Start the Ecto repository
supervisor(Pleroma.Repo, []),
worker(Pleroma.Emoji, []),
- # Start the endpoint when the application starts
- supervisor(Pleroma.Web.Endpoint, []),
- # Start your own worker by calling: Pleroma.Worker.start_link(arg1, arg2, arg3)
- # worker(Pleroma.Worker, [arg1, arg2, arg3]),
worker(
Cachex,
[
:user_cache,
[
default_ttl: 25000,
ttl_interval: 1000,
limit: 2500
]
],
id: :cachex_user
),
worker(
Cachex,
[
:object_cache,
[
default_ttl: 25000,
ttl_interval: 1000,
limit: 2500
]
],
id: :cachex_object
),
worker(
Cachex,
[
:idempotency_cache,
[
expiration:
expiration(
default: :timer.seconds(6 * 60 * 60),
interval: :timer.seconds(60)
),
limit: 2500
]
],
id: :cachex_idem
),
- worker(Pleroma.Web.Federator, []),
worker(Pleroma.Web.Federator.RetryQueue, []),
- worker(Pleroma.Gopher.Server, []),
+ worker(Pleroma.Web.Federator, []),
worker(Pleroma.Stats, [])
] ++
- if @env == :test,
- do: [],
- else:
- [worker(Pleroma.Web.Streamer, [])] ++
- if(
- !chat_enabled(),
- do: [],
- else: [worker(Pleroma.Web.ChatChannel.ChatChannelState, [])]
- )
+ streamer_child() ++
+ chat_child() ++
+ [
+ # Start the endpoint when the application starts
+ supervisor(Pleroma.Web.Endpoint, []),
+ worker(Pleroma.Gopher.Server, [])
+ ]
# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Pleroma.Supervisor]
Supervisor.start_link(children, opts)
end
- defp chat_enabled do
- Application.get_env(:pleroma, :chat, []) |> Keyword.get(:enabled)
+ if Mix.env() == :test do
+ defp streamer_child(), do: []
+ defp chat_child(), do: []
+ else
+ defp streamer_child() do
+ [worker(Pleroma.Web.Streamer, [])]
+ end
+
+ defp chat_child() do
+ if Pleroma.Config.get([:chat, :enabled]) do
+ [worker(Pleroma.Web.ChatChannel.ChatChannelState, [])]
+ else
+ []
+ end
+ end
end
end
diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex
index 15f771b6e..3876ddf1f 100644
--- a/lib/pleroma/config.ex
+++ b/lib/pleroma/config.ex
@@ -1,42 +1,56 @@
defmodule Pleroma.Config do
defmodule Error do
defexception [:message]
end
def get(key), do: get(key, nil)
def get([key], default), do: get(key, default)
def get([parent_key | keys], default) do
Application.get_env(:pleroma, parent_key)
|> get_in(keys) || default
end
def get(key, default) do
Application.get_env(:pleroma, key, default)
end
def get!(key) do
value = get(key, nil)
if value == nil do
raise(Error, message: "Missing configuration value: #{inspect(key)}")
else
value
end
end
def put([key], value), do: put(key, value)
def put([parent_key | keys], value) do
parent =
Application.get_env(:pleroma, parent_key)
|> put_in(keys, value)
Application.put_env(:pleroma, parent_key, parent)
end
def put(key, value) do
Application.put_env(:pleroma, key, value)
end
+
+ def delete([key]), do: delete(key)
+
+ def delete([parent_key | keys]) do
+ {_, parent} =
+ Application.get_env(:pleroma, parent_key)
+ |> get_and_update_in(keys, fn _ -> :pop end)
+
+ Application.put_env(:pleroma, parent_key, parent)
+ end
+
+ def delete(key) do
+ Application.delete_env(:pleroma, key)
+ end
end
diff --git a/lib/pleroma/gopher/server.ex b/lib/pleroma/gopher/server.ex
index e6361a82c..3b0569a99 100644
--- a/lib/pleroma/gopher/server.ex
+++ b/lib/pleroma/gopher/server.ex
@@ -1,152 +1,153 @@
defmodule Pleroma.Gopher.Server do
use GenServer
require Logger
def start_link() do
config = Pleroma.Config.get(:gopher, [])
ip = Keyword.get(config, :ip, {0, 0, 0, 0})
port = Keyword.get(config, :port, 1234)
- GenServer.start_link(__MODULE__, [ip, port], [])
- end
- def init([ip, port]) do
- if Pleroma.Config.get([:gopher, :enabled], false) do
- Logger.info("Starting gopher server on #{port}")
-
- :ranch.start_listener(
- :gopher,
- 100,
- :ranch_tcp,
- [port: port],
- __MODULE__.ProtocolHandler,
- []
- )
-
- {:ok, %{ip: ip, port: port}}
+ if Keyword.get(config, :enabled, false) do
+ GenServer.start_link(__MODULE__, [ip, port], [])
else
Logger.info("Gopher server disabled")
- {:ok, nil}
+ :ignore
end
end
+
+ def init([ip, port]) do
+ Logger.info("Starting gopher server on #{port}")
+
+ :ranch.start_listener(
+ :gopher,
+ 100,
+ :ranch_tcp,
+ [port: port],
+ __MODULE__.ProtocolHandler,
+ []
+ )
+
+ {:ok, %{ip: ip, port: port}}
+ end
end
defmodule Pleroma.Gopher.Server.ProtocolHandler do
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.User
alias Pleroma.Activity
alias Pleroma.Repo
alias Pleroma.HTML
def start_link(ref, socket, transport, opts) do
pid = spawn_link(__MODULE__, :init, [ref, socket, transport, opts])
{:ok, pid}
end
def init(ref, socket, transport, _Opts = []) do
:ok = :ranch.accept_ack(ref)
loop(socket, transport)
end
def info(text) do
text = String.replace(text, ~r/[\t\n]/, "")
String.split(text, "\r")
|> Enum.map(fn text ->
"i#{text}\tfake\t(NULL)\t0\r\n"
end)
|> Enum.join("")
end
def link(name, selector, type \\ 1) do
address = Pleroma.Web.Endpoint.host()
port = Pleroma.Config.get([:gopher, :port], 1234)
"#{type}#{name}\t#{selector}\t#{address}\t#{port}\r\n"
end
def render_activities(activities) do
activities
|> Enum.reverse()
|> Enum.map(fn activity ->
user = User.get_cached_by_ap_id(activity.data["actor"])
object = activity.data["object"]
like_count = object["like_count"] || 0
announcement_count = object["announcement_count"] || 0
link("Post ##{activity.id} by #{user.nickname}", "/notices/#{activity.id}") <>
info("#{like_count} likes, #{announcement_count} repeats") <>
"i\tfake\t(NULL)\t0\r\n" <>
info(HTML.strip_tags(String.replace(activity.data["object"]["content"], "<br>", "\r")))
end)
|> Enum.join("i\tfake\t(NULL)\t0\r\n")
end
def response("") do
info("Welcome to #{Pleroma.Config.get([:instance, :name], "Pleroma")}!") <>
link("Public Timeline", "/main/public") <>
link("Federated Timeline", "/main/all") <> ".\r\n"
end
def response("/main/public") do
posts =
ActivityPub.fetch_public_activities(%{"type" => ["Create"], "local_only" => true})
|> render_activities
info("Welcome to the Public Timeline!") <> posts <> ".\r\n"
end
def response("/main/all") do
posts =
ActivityPub.fetch_public_activities(%{"type" => ["Create"]})
|> render_activities
info("Welcome to the Federated Timeline!") <> posts <> ".\r\n"
end
def response("/notices/" <> id) do
with %Activity{} = activity <- Repo.get(Activity, id),
true <- ActivityPub.is_public?(activity) do
activities =
ActivityPub.fetch_activities_for_context(activity.data["context"])
|> render_activities
user = User.get_cached_by_ap_id(activity.data["actor"])
info("Post #{activity.id} by #{user.nickname}") <>
link("More posts by #{user.nickname}", "/users/#{user.nickname}") <> activities <> ".\r\n"
else
_e ->
info("Not public") <> ".\r\n"
end
end
def response("/users/" <> nickname) do
with %User{} = user <- User.get_cached_by_nickname(nickname) do
params = %{
"type" => ["Create"],
"actor_id" => user.ap_id
}
activities =
ActivityPub.fetch_public_activities(params)
|> render_activities
info("Posts by #{user.nickname}") <> activities <> ".\r\n"
else
_e ->
info("No such user") <> ".\r\n"
end
end
def loop(socket, transport) do
case transport.recv(socket, 0, 5000) do
{:ok, data} ->
data = String.trim_trailing(data, "\r\n")
transport.send(socket, response(data))
:ok = transport.close(socket)
_ ->
:ok = transport.close(socket)
end
end
end
diff --git a/lib/pleroma/mime.ex b/lib/pleroma/mime.ex
new file mode 100644
index 000000000..db8b7c742
--- /dev/null
+++ b/lib/pleroma/mime.ex
@@ -0,0 +1,108 @@
+defmodule Pleroma.MIME do
+ @moduledoc """
+ Returns the mime-type of a binary and optionally a normalized file-name.
+ """
+ @default "application/octet-stream"
+ @read_bytes 31
+
+ @spec file_mime_type(String.t()) ::
+ {:ok, content_type :: String.t(), filename :: String.t()} | {:error, any()} | :error
+ def file_mime_type(path, filename) do
+ with {:ok, content_type} <- file_mime_type(path),
+ filename <- fix_extension(filename, content_type) do
+ {:ok, content_type, filename}
+ end
+ end
+
+ @spec file_mime_type(String.t()) :: {:ok, String.t()} | {:error, any()} | :error
+ def file_mime_type(filename) do
+ File.open(filename, [:read], fn f ->
+ check_mime_type(IO.binread(f, @read_bytes))
+ end)
+ end
+
+ def bin_mime_type(binary, filename) do
+ with {:ok, content_type} <- bin_mime_type(binary),
+ filename <- fix_extension(filename, content_type) do
+ {:ok, content_type, filename}
+ end
+ end
+
+ @spec bin_mime_type(binary()) :: {:ok, String.t()} | :error
+ def bin_mime_type(<<head::binary-size(@read_bytes), _::binary>>) do
+ {:ok, check_mime_type(head)}
+ end
+
+ def mime_type(<<_::binary>>), do: {:ok, @default}
+
+ def bin_mime_type(_), do: :error
+
+ defp fix_extension(filename, content_type) do
+ parts = String.split(filename, ".")
+
+ new_filename =
+ if length(parts) > 1 do
+ Enum.drop(parts, -1) |> Enum.join(".")
+ else
+ Enum.join(parts)
+ end
+
+ cond do
+ content_type == "application/octet-stream" ->
+ filename
+
+ ext = List.first(MIME.extensions(content_type)) ->
+ new_filename <> "." <> ext
+
+ true ->
+ Enum.join([new_filename, String.split(content_type, "/") |> List.last()], ".")
+ end
+ end
+
+ defp check_mime_type(<<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, _::binary>>) do
+ "image/png"
+ end
+
+ defp check_mime_type(<<0x47, 0x49, 0x46, 0x38, _, 0x61, _::binary>>) do
+ "image/gif"
+ end
+
+ defp check_mime_type(<<0xFF, 0xD8, 0xFF, _::binary>>) do
+ "image/jpeg"
+ end
+
+ defp check_mime_type(<<0x1A, 0x45, 0xDF, 0xA3, _::binary>>) do
+ "video/webm"
+ end
+
+ defp check_mime_type(<<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::binary>>) do
+ "video/mp4"
+ end
+
+ defp check_mime_type(<<0x49, 0x44, 0x33, _::binary>>) do
+ "audio/mpeg"
+ end
+
+ defp check_mime_type(<<255, 251, _, 68, 0, 0, 0, 0, _::binary>>) do
+ "audio/mpeg"
+ end
+
+ defp check_mime_type(
+ <<0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, _::size(160), 0x80, 0x74, 0x68, 0x65,
+ 0x6F, 0x72, 0x61, _::binary>>
+ ) do
+ "video/ogg"
+ end
+
+ defp check_mime_type(<<0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, _::binary>>) do
+ "audio/ogg"
+ end
+
+ defp check_mime_type(<<0x52, 0x49, 0x46, 0x46, _::binary>>) do
+ "audio/wav"
+ end
+
+ defp check_mime_type(_) do
+ @default
+ end
+end
diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex
new file mode 100644
index 000000000..994cc8bf6
--- /dev/null
+++ b/lib/pleroma/plugs/uploaded_media.ex
@@ -0,0 +1,78 @@
+defmodule Pleroma.Plugs.UploadedMedia do
+ @moduledoc """
+ """
+
+ import Plug.Conn
+ require Logger
+
+ @behaviour Plug
+ # no slashes
+ @path "media"
+ @cache_control %{
+ default: "public, max-age=1209600",
+ error: "public, must-revalidate, max-age=160"
+ }
+
+ 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(conn = %{request_path: <<"/", @path, "/", file::binary>>}, opts) do
+ config = Pleroma.Config.get([Pleroma.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
+ |> Pleroma.ReverseProxy.call(url, Pleroma.Config.get([Pleroma.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
diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex
new file mode 100644
index 000000000..ad9dc82fe
--- /dev/null
+++ b/lib/pleroma/reverse_proxy.ex
@@ -0,0 +1,343 @@
+defmodule Pleroma.ReverseProxy do
+ @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since if-unmodified-since if-none-match if-range range)
+ @resp_cache_headers ~w(etag date last-modified cache-control)
+ @keep_resp_headers @resp_cache_headers ++
+ ~w(content-type content-disposition content-encoding content-range accept-ranges vary)
+ @default_cache_control_header "public, max-age=1209600"
+ @valid_resp_codes [200, 206, 304]
+ @max_read_duration :timer.seconds(30)
+ @max_body_length :infinity
+ @methods ~w(GET HEAD)
+
+ @moduledoc """
+ A reverse proxy.
+
+ Pleroma.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(:pleroma, :hackney, :hackney)
+ @httpoison Application.get_env(:pleroma, :httpoison, HTTPoison)
+
+ @default_hackney_options [{:follow_redirect, true}]
+
+ @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 = %{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 = Pleroma.Web.MediaProxy.filename(url) do
+ Keyword.put_new(opts, :attachment_name, filename)
+ else
+ opts
+ end
+
+ with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
+ :ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do
+ response(conn, client, url, code, headers, opts)
+ else
+ {:ok, code, headers} ->
+ 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 =
+ 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", Pleroma.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)
+
+ if has_cache? do
+ headers
+ else
+ 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
+ disposition = "attachment; filename=" <> Keyword.get(opts, :attachment_name, "attachment")
+ 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
diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex
index 238630bf3..bf2c60102 100644
--- a/lib/pleroma/upload.ex
+++ b/lib/pleroma/upload.ex
@@ -1,190 +1,222 @@
defmodule Pleroma.Upload do
- alias Ecto.UUID
+ @moduledoc """
+ # Upload
- def check_file_size(path, nil), do: true
+ 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
- def check_file_size(path, size_limit) do
- {:ok, %{size: size}} = File.stat(path)
- size <= size_limit
- end
+ The `%Pleroma.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:
- def store(file, should_dedupe, size_limit \\ nil)
-
- def store(%Plug.Upload{} = file, should_dedupe, size_limit) do
- content_type = get_content_type(file.path)
-
- with uuid <- get_uuid(file, should_dedupe),
- name <- get_name(file, uuid, content_type, should_dedupe),
- true <- check_file_size(file.path, size_limit) do
- strip_exif_data(content_type, file.path)
-
- {:ok, url_path} = uploader().put_file(name, uuid, file.path, content_type, should_dedupe)
-
- %{
- "type" => "Document",
- "url" => [
- %{
- "type" => "Link",
- "mediaType" => content_type,
- "href" => url_path
- }
- ],
- "name" => name
- }
+ * `Pleroma.Uploaders.Uploader`
+ * `Pleroma.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} <- Pleroma.Upload.Filter.filter(opts.filters, upload),
+ {:ok, url_spec} <- Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload) do
+ {:ok,
+ %{
+ "type" => opts.activity_type,
+ "url" => [
+ %{
+ "type" => "Link",
+ "mediaType" => upload.content_type,
+ "href" => url_from_spec(opts.base_url, url_spec)
+ }
+ ],
+ "name" => Map.get(opts, :description) || upload.name
+ }}
else
- _e -> nil
+ {:error, error} ->
+ Logger.error(
+ "#{__MODULE__} store (using #{inspect(opts.uploader)}) failed: #{inspect(error)}"
+ )
+
+ {:error, error}
end
end
- def store(%{"img" => "data:image/" <> image_data}, should_dedupe, size_limit) do
- parsed = Regex.named_captures(~r/(?<filetype>jpeg|png|gif);base64,(?<data>.*)/, image_data)
- data = Base.decode64!(parsed["data"], ignore: :whitespace)
+ defp get_opts(opts) do
+ {size_limit, activity_type} =
+ case Keyword.get(opts, :type) do
+ :banner ->
+ {Pleroma.Config.get!([:instance, :banner_upload_limit]), "Image"}
+
+ :avatar ->
+ {Pleroma.Config.get!([:instance, :avatar_upload_limit]), "Image"}
+
+ :background ->
+ {Pleroma.Config.get!([:instance, :background_upload_limit]), "Image"}
- with tmp_path <- tempfile_for_image(data),
- uuid <- UUID.generate(),
- true <- check_file_size(tmp_path, size_limit) do
- content_type = get_content_type(tmp_path)
- strip_exif_data(content_type, tmp_path)
-
- name =
- create_name(
- String.downcase(Base.encode16(:crypto.hash(:sha256, data))),
- parsed["filetype"],
- content_type
+ _ ->
+ {Pleroma.Config.get!([:instance, :upload_limit]), "Document"}
+ end
+
+ opts = %{
+ activity_type: Keyword.get(opts, :activity_type, activity_type),
+ size_limit: Keyword.get(opts, :size_limit, size_limit),
+ uploader: Keyword.get(opts, :uploader, Pleroma.Config.get([__MODULE__, :uploader])),
+ filters: Keyword.get(opts, :filters, Pleroma.Config.get([__MODULE__, :filters])),
+ description: Keyword.get(opts, :description),
+ base_url:
+ Keyword.get(
+ opts,
+ :base_url,
+ Pleroma.Config.get([__MODULE__, :base_url], Pleroma.Web.base_url())
)
+ }
- {:ok, url_path} = uploader().put_file(name, uuid, tmp_path, content_type, should_dedupe)
-
- %{
- "type" => "Image",
- "url" => [
- %{
- "type" => "Link",
- "mediaType" => content_type,
- "href" => url_path
- }
- ],
- "name" => name
- }
- else
- _e -> nil
- end
- end
+ # TODO: 1.0+ : remove old config compatibility
+ opts =
+ if Pleroma.Config.get([__MODULE__, :strip_exif]) == true &&
+ !Enum.member?(opts.filters, Pleroma.Upload.Filter.Mogrify) do
+ Logger.warn("""
+ Pleroma: configuration `:instance, :strip_exif` is deprecated, please instead set:
- @doc """
- Creates a tempfile using the Plug.Upload Genserver which cleans them up
- automatically.
- """
- def tempfile_for_image(data) do
- {:ok, tmp_path} = Plug.Upload.random_file("profile_pics")
- {:ok, tmp_file} = File.open(tmp_path, [:write, :raw, :binary])
- IO.binwrite(tmp_file, data)
+ :pleroma, Pleroma.Upload, [filters: [Pleroma.Upload.Filter.Mogrify]]
- tmp_path
- end
+ :pleroma, Pleroma.Upload.Filter.Mogrify, args: "strip"
+ """)
- def strip_exif_data(content_type, file) do
- settings = Application.get_env(:pleroma, Pleroma.Upload)
- do_strip = Keyword.fetch!(settings, :strip_exif)
- [filetype, _ext] = String.split(content_type, "/")
+ Pleroma.Config.put([Pleroma.Upload.Filter.Mogrify], args: "strip")
+ Map.put(opts, :filters, opts.filters ++ [Pleroma.Upload.Filter.Mogrify])
+ else
+ opts
+ end
- if filetype == "image" and do_strip == true do
- Mogrify.open(file) |> Mogrify.custom("strip") |> Mogrify.save(in_place: true)
- end
- end
+ opts =
+ if Pleroma.Config.get([:instance, :dedupe_media]) == true &&
+ !Enum.member?(opts.filters, Pleroma.Upload.Filter.Dedupe) do
+ Logger.warn("""
+ Pleroma: configuration `:instance, :dedupe_media` is deprecated, please instead set:
- defp create_name(uuid, ext, type) do
- case type do
- "application/octet-stream" ->
- String.downcase(Enum.join([uuid, ext], "."))
+ :pleroma, Pleroma.Upload, [filters: [Pleroma.Upload.Filter.Dedupe]]
+ """)
- "audio/mpeg" ->
- String.downcase(Enum.join([uuid, "mp3"], "."))
+ Map.put(opts, :filters, opts.filters ++ [Pleroma.Upload.Filter.Dedupe])
+ else
+ opts
+ end
+ end
- _ ->
- String.downcase(Enum.join([uuid, List.last(String.split(type, "/"))], "."))
+ defp prepare_upload(%Plug.Upload{} = file, opts) do
+ with :ok <- check_file_size(file.path, opts.size_limit),
+ {:ok, content_type, name} <- Pleroma.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 get_uuid(file, should_dedupe) do
- if should_dedupe do
- Base.encode16(:crypto.hash(:sha256, File.read!(file.path)))
- else
- UUID.generate()
+ defp prepare_upload(%{"img" => "data:image/" <> image_data}, opts) do
+ parsed = Regex.named_captures(~r/(?<filetype>jpeg|png|gif);base64,(?<data>.*)/, image_data)
+ data = Base.decode64!(parsed["data"], ignore: :whitespace)
+ hash = String.downcase(Base.encode16(:crypto.hash(:sha256, data)))
+
+ with :ok <- check_binary_size(data, opts.size_limit),
+ tmp_path <- tempfile_for_image(data),
+ {:ok, content_type, name} <-
+ Pleroma.MIME.bin_mime_type(data, hash <> "." <> parsed["filetype"]) do
+ {:ok,
+ %__MODULE__{
+ id: UUID.generate(),
+ name: name,
+ tempfile: tmp_path,
+ content_type: content_type
+ }}
end
end
- defp get_name(file, uuid, type, should_dedupe) do
- if should_dedupe do
- create_name(uuid, List.last(String.split(file.filename, ".")), type)
- else
- parts = String.split(file.filename, ".")
-
- new_filename =
- if length(parts) > 1 do
- Enum.drop(parts, -1) |> Enum.join(".")
- else
- Enum.join(parts)
- end
-
- case type do
- "application/octet-stream" -> file.filename
- "audio/mpeg" -> new_filename <> ".mp3"
- "image/jpeg" -> new_filename <> ".jpg"
- _ -> Enum.join([new_filename, String.split(type, "/") |> List.last()], ".")
- end
+ # For Mix.Tasks.MigrateLocalUploads
+ defp prepare_upload(upload = %__MODULE__{tempfile: path}, _opts) do
+ with {:ok, content_type} <- Pleroma.MIME.file_mime_type(path) do
+ {:ok, %__MODULE__{upload | content_type: content_type}}
end
end
- def get_content_type(file) do
- match =
- File.open(file, [:read], fn f ->
- case IO.binread(f, 8) do
- <<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A>> ->
- "image/png"
-
- <<0x47, 0x49, 0x46, 0x38, _, 0x61, _, _>> ->
- "image/gif"
-
- <<0xFF, 0xD8, 0xFF, _, _, _, _, _>> ->
- "image/jpeg"
-
- <<0x1A, 0x45, 0xDF, 0xA3, _, _, _, _>> ->
- "video/webm"
-
- <<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70>> ->
- "video/mp4"
-
- <<0x49, 0x44, 0x33, _, _, _, _, _>> ->
- "audio/mpeg"
+ defp check_binary_size(binary, size_limit)
+ when is_integer(size_limit) and size_limit > 0 and byte_size(binary) >= size_limit do
+ {:error, :file_too_large}
+ end
- <<255, 251, _, 68, 0, 0, 0, 0>> ->
- "audio/mpeg"
+ defp check_binary_size(_, _), do: :ok
- <<0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00>> ->
- case IO.binread(f, 27) do
- <<_::size(160), 0x80, 0x74, 0x68, 0x65, 0x6F, 0x72, 0x61>> ->
- "video/ogg"
+ 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
- _ ->
- "audio/ogg"
- end
+ defp check_file_size(_, _), do: :ok
- <<0x52, 0x49, 0x46, 0x46, _, _, _, _>> ->
- "audio/wav"
+ # Creates a tempfile using the Plug.Upload Genserver which cleans them up
+ # automatically.
+ defp tempfile_for_image(data) do
+ {:ok, tmp_path} = Plug.Upload.random_file("profile_pics")
+ {:ok, tmp_file} = File.open(tmp_path, [:write, :raw, :binary])
+ IO.binwrite(tmp_file, data)
- _ ->
- "application/octet-stream"
- end
- end)
+ tmp_path
+ end
- case match do
- {:ok, type} -> type
- _e -> "application/octet-stream"
- end
+ defp url_from_spec(base_url, {:file, path}) do
+ [base_url, "media", path]
+ |> Path.join()
end
- defp uploader() do
- Pleroma.Config.get!([Pleroma.Upload, :uploader])
+ defp url_from_spec({:url, url}) do
+ url
end
end
diff --git a/lib/pleroma/upload/filter.ex b/lib/pleroma/upload/filter.ex
new file mode 100644
index 000000000..d1384ddad
--- /dev/null
+++ b/lib/pleroma/upload/filter.ex
@@ -0,0 +1,35 @@
+defmodule Pleroma.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 `Pleroma.Upload` struct
+ * cancel/stop the upload
+ """
+
+ require Logger
+
+ @callback filter(Pleroma.Upload.t()) :: :ok | {:ok, Pleroma.Upload.t()} | {:error, any()}
+
+ @spec filter([module()], Pleroma.Upload.t()) :: {:ok, Pleroma.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
diff --git a/lib/pleroma/upload/filter/anonymize_filename.ex b/lib/pleroma/upload/filter/anonymize_filename.ex
new file mode 100644
index 000000000..a83e764e5
--- /dev/null
+++ b/lib/pleroma/upload/filter/anonymize_filename.ex
@@ -0,0 +1,10 @@
+defmodule Pleroma.Upload.Filter.AnonymizeFilename do
+ @moduledoc "Replaces the original filename with a randomly generated string."
+ @behaviour Pleroma.Upload.Filter
+
+ def filter(upload) do
+ extension = List.last(String.split(upload.name, "."))
+ string = Base.url_encode64(:crypto.strong_rand_bytes(10), padding: false)
+ {:ok, %Pleroma.Upload{upload | name: string <> "." <> extension}}
+ end
+end
diff --git a/lib/pleroma/upload/filter/dedupe.ex b/lib/pleroma/upload/filter/dedupe.ex
new file mode 100644
index 000000000..28091a627
--- /dev/null
+++ b/lib/pleroma/upload/filter/dedupe.ex
@@ -0,0 +1,10 @@
+defmodule Pleroma.Upload.Filter.Dedupe do
+ @behaviour Pleroma.Upload.Filter
+
+ def filter(upload = %Pleroma.Upload{name: name, tempfile: path}) do
+ extension = String.split(name, ".") |> List.last()
+ shasum = :crypto.hash(:sha256, File.read!(upload.tempfile)) |> Base.encode16(case: :lower)
+ filename = shasum <> "." <> extension
+ {:ok, %Pleroma.Upload{upload | id: shasum, path: filename}}
+ end
+end
diff --git a/lib/pleroma/upload/filter/mogrifun.ex b/lib/pleroma/upload/filter/mogrifun.ex
new file mode 100644
index 000000000..4d4f0b401
--- /dev/null
+++ b/lib/pleroma/upload/filter/mogrifun.ex
@@ -0,0 +1,60 @@
+defmodule Pleroma.Upload.Filter.Mogrifun do
+ @behaviour Pleroma.Upload.Filter
+
+ @filters [
+ {"implode", "1"},
+ {"-raise", "20"},
+ {"+raise", "20"},
+ [{"-interpolate", "nearest"}, {"-virtual-pixel", "mirror"}, {"-spread", "5"}],
+ "+polaroid",
+ {"-statistic", "Mode 10"},
+ {"-emboss", "0x1.1"},
+ {"-emboss", "0x2"},
+ {"-colorspace", "Gray"},
+ "-negate",
+ [{"-channel", "green"}, "-negate"],
+ [{"-channel", "red"}, "-negate"],
+ [{"-channel", "blue"}, "-negate"],
+ {"+level-colors", "green,gold"},
+ {"+level-colors", ",DodgerBlue"},
+ {"+level-colors", ",Gold"},
+ {"+level-colors", ",Lime"},
+ {"+level-colors", ",Red"},
+ {"+level-colors", ",DarkGreen"},
+ {"+level-colors", "firebrick,yellow"},
+ {"+level-colors", "'rgb(102,75,25)',lemonchiffon"},
+ [{"fill", "red"}, {"tint", "40"}],
+ [{"fill", "green"}, {"tint", "40"}],
+ [{"fill", "blue"}, {"tint", "40"}],
+ [{"fill", "yellow"}, {"tint", "40"}]
+ ]
+
+ def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do
+ filter = Enum.random(@filters)
+
+ file
+ |> Mogrify.open()
+ |> mogrify_filter(filter)
+ |> Mogrify.save(in_place: true)
+
+ :ok
+ end
+
+ def filter(_), do: :ok
+
+ 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, string) when is_binary(string) do
+ Mogrify.custom(mogrify, string)
+ end
+end
diff --git a/lib/pleroma/upload/filter/mogrify.ex b/lib/pleroma/upload/filter/mogrify.ex
new file mode 100644
index 000000000..d6ed471ed
--- /dev/null
+++ b/lib/pleroma/upload/filter/mogrify.ex
@@ -0,0 +1,37 @@
+defmodule Pleroma.Upload.Filter.Mogrify do
+ @behaviour Pleroma.Uploader.Filter
+
+ @type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()}
+ @type conversions :: conversion() | [conversion()]
+
+ def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do
+ filters = Pleroma.Config.get!([__MODULE__, :args])
+
+ file
+ |> Mogrify.open()
+ |> mogrify_filter(filters)
+ |> Mogrify.save(in_place: true)
+
+ :ok
+ end
+
+ def filter(_), do: :ok
+
+ defp mogrify_filter(mogrify, nil), do: mogrify
+
+ defp mogrify_filter(mogrify, [filter | rest]) do
+ mogrify
+ |> mogrify_filter(filter)
+ |> mogrify_filter(rest)
+ end
+
+ defp mogrify_filter(mogrify, []), do: mogrify
+
+ defp mogrify_filter(mogrify, {action, options}) do
+ Mogrify.custom(mogrify, action, options)
+ end
+
+ defp mogrify_filter(mogrify, action) when is_binary(action) do
+ Mogrify.custom(mogrify, action)
+ end
+end
diff --git a/lib/pleroma/uploaders/local.ex b/lib/pleroma/uploaders/local.ex
index d96481c8d..434a6b515 100644
--- a/lib/pleroma/uploaders/local.ex
+++ b/lib/pleroma/uploaders/local.ex
@@ -1,51 +1,34 @@
defmodule Pleroma.Uploaders.Local do
@behaviour Pleroma.Uploaders.Uploader
alias Pleroma.Web
- def put_file(name, uuid, tmpfile, _content_type, should_dedupe) do
- upload_folder = get_upload_path(uuid, should_dedupe)
- url_path = get_url(name, uuid, should_dedupe)
-
- File.mkdir_p!(upload_folder)
+ def get_file(_) do
+ {:ok, {:static_dir, upload_path()}}
+ end
- result_file = Path.join(upload_folder, name)
+ def put_file(upload) do
+ {local_path, file} =
+ case Enum.reverse(String.split(upload.path, "/", trim: true)) do
+ [file] ->
+ {upload_path(), file}
- if File.exists?(result_file) do
- File.rm!(tmpfile)
- else
- File.cp!(tmpfile, result_file)
- end
+ [file | folders] ->
+ path = Path.join([upload_path()] ++ Enum.reverse(folders))
+ File.mkdir_p!(path)
+ {path, file}
+ end
- {:ok, url_path}
- end
+ result_file = Path.join(local_path, file)
- def upload_path do
- settings = Application.get_env(:pleroma, Pleroma.Uploaders.Local)
- Keyword.fetch!(settings, :uploads)
- end
-
- defp get_upload_path(uuid, should_dedupe) do
- if should_dedupe do
- upload_path()
- else
- Path.join(upload_path(), uuid)
+ unless File.exists?(result_file) do
+ File.cp!(upload.tempfile, result_file)
end
- end
- defp get_url(name, uuid, should_dedupe) do
- if should_dedupe do
- url_for(:cow_uri.urlencode(name))
- else
- url_for(Path.join(uuid, :cow_uri.urlencode(name)))
- end
+ :ok
end
- defp url_for(file) do
- settings = Application.get_env(:pleroma, Pleroma.Uploaders.Local)
-
- Keyword.get(settings, :uploads_url)
- |> String.replace("{{file}}", file)
- |> String.replace("{{base_url}}", Web.base_url())
+ def upload_path do
+ Pleroma.Config.get!([__MODULE__, :uploads])
end
end
diff --git a/lib/pleroma/uploaders/mdii.ex b/lib/pleroma/uploaders/mdii.ex
index a9d52b0dc..35d36d3e4 100644
--- a/lib/pleroma/uploaders/mdii.ex
+++ b/lib/pleroma/uploaders/mdii.ex
@@ -1,26 +1,31 @@
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
+ # MDII-hosted images are never passed through the MediaPlug; only local media.
+ # Delegate to Pleroma.Uploaders.Local
+ def get_file(file) do
+ Pleroma.Uploaders.Local.get_file(file)
+ end
+
+ def put_file(upload) do
cgi = Pleroma.Config.get([Pleroma.Uploaders.MDII, :cgi])
files = Pleroma.Config.get([Pleroma.Uploaders.MDII, :files])
- {:ok, file_data} = File.read(path)
+ {:ok, file_data} = File.read(upload.tempfile)
- extension = String.split(name, ".") |> List.last()
+ extension = String.split(upload.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}
+ {:ok, {:url, public_url}}
else
- _ -> Pleroma.Uploaders.Local.put_file(name, uuid, path, content_type, should_dedupe)
+ _ -> Pleroma.Uploaders.Local.put_file(upload)
end
end
end
diff --git a/lib/pleroma/uploaders/s3.ex b/lib/pleroma/uploaders/s3.ex
index 40a836460..19832a7ec 100644
--- a/lib/pleroma/uploaders/s3.ex
+++ b/lib/pleroma/uploaders/s3.ex
@@ -1,40 +1,46 @@
defmodule Pleroma.Uploaders.S3 do
- alias Pleroma.Web.MediaProxy
-
@behaviour Pleroma.Uploaders.Uploader
+ require Logger
+
+ # The file name is re-encoded with S3's constraints here to comply with previous links with less strict filenames
+ def get_file(file) do
+ config = Pleroma.Config.get([__MODULE__])
+
+ {:ok,
+ {:url,
+ Path.join([
+ Keyword.fetch!(config, :public_endpoint),
+ Keyword.fetch!(config, :bucket),
+ strict_encode(URI.decode(file))
+ ])}}
+ end
- def put_file(name, uuid, path, content_type, _should_dedupe) do
- settings = Application.get_env(:pleroma, Pleroma.Uploaders.S3)
- bucket = Keyword.fetch!(settings, :bucket)
- public_endpoint = Keyword.fetch!(settings, :public_endpoint)
- force_media_proxy = Keyword.fetch!(settings, :force_media_proxy)
-
- {:ok, file_data} = File.read(path)
+ def put_file(upload = %Pleroma.Upload{}) do
+ config = Pleroma.Config.get([__MODULE__])
+ bucket = Keyword.get(config, :bucket)
- File.rm!(path)
+ {:ok, file_data} = File.read(upload.tempfile)
- s3_name = "#{uuid}/#{encode(name)}"
+ s3_name = strict_encode(upload.path)
- {:ok, _} =
+ op =
ExAws.S3.put_object(bucket, s3_name, file_data, [
{:acl, :public_read},
- {:content_type, content_type}
+ {:content_type, upload.content_type}
])
- |> ExAws.request()
-
- url_base = "#{public_endpoint}/#{bucket}/#{s3_name}"
- public_url =
- if force_media_proxy do
- MediaProxy.url(url_base)
- else
- url_base
- end
+ case ExAws.request(op) do
+ {:ok, _} ->
+ {:ok, {:file, s3_name}}
- {:ok, public_url}
+ error ->
+ Logger.error("#{__MODULE__}: #{inspect(error)}")
+ {:error, "S3 Upload failed"}
+ end
end
- defp encode(name) do
- String.replace(name, ~r/[^0-9a-zA-Z!.*'()_-]/, "-")
+ @regex Regex.compile!("[^0-9a-zA-Z!.*/'()_-]")
+ def strict_encode(name) do
+ String.replace(name, @regex, "-")
end
end
diff --git a/lib/pleroma/uploaders/swift/swift.ex b/lib/pleroma/uploaders/swift/swift.ex
index fa08ca966..1e865f101 100644
--- a/lib/pleroma/uploaders/swift/swift.ex
+++ b/lib/pleroma/uploaders/swift/swift.ex
@@ -1,26 +1,26 @@
defmodule Pleroma.Uploaders.Swift.Client do
use HTTPoison.Base
def process_url(url) do
Enum.join(
[Pleroma.Config.get!([Pleroma.Uploaders.Swift, :storage_url]), url],
"/"
)
end
def upload_file(filename, body, content_type) do
object_url = Pleroma.Config.get!([Pleroma.Uploaders.Swift, :object_url])
token = Pleroma.Uploaders.Swift.Keystone.get_token()
case put("#{filename}", body, "X-Auth-Token": token, "Content-Type": content_type) do
{:ok, %HTTPoison.Response{status_code: 201}} ->
- {:ok, "#{object_url}/#{filename}"}
+ {:ok, {:file, filename}}
{:ok, %HTTPoison.Response{status_code: 401}} ->
{:error, "Unauthorized, Bad Token"}
{:error, _} ->
{:error, "Swift Upload Error"}
end
end
end
diff --git a/lib/pleroma/uploaders/swift/uploader.ex b/lib/pleroma/uploaders/swift/uploader.ex
index 794f76cb0..b35b9807b 100644
--- a/lib/pleroma/uploaders/swift/uploader.ex
+++ b/lib/pleroma/uploaders/swift/uploader.ex
@@ -1,10 +1,15 @@
defmodule Pleroma.Uploaders.Swift do
@behaviour Pleroma.Uploaders.Uploader
- def put_file(name, uuid, tmp_path, content_type, _should_dedupe) do
- {:ok, file_data} = File.read(tmp_path)
- remote_name = "#{uuid}/#{name}"
+ def get_file(name) do
+ {:ok, {:url, Path.join([Pleroma.Config.get!([__MODULE__, :object_url]), name])}}
+ end
- Pleroma.Uploaders.Swift.Client.upload_file(remote_name, file_data, content_type)
+ def put_file(upload) do
+ Pleroma.Uploaders.Swift.Client.upload_file(
+ upload.path,
+ File.read!(upload.tmpfile),
+ upload.content_type
+ )
end
end
diff --git a/lib/pleroma/uploaders/uploader.ex b/lib/pleroma/uploaders/uploader.ex
index b58fc6d71..afda5609e 100644
--- a/lib/pleroma/uploaders/uploader.ex
+++ b/lib/pleroma/uploaders/uploader.ex
@@ -1,20 +1,40 @@
defmodule Pleroma.Uploaders.Uploader do
@moduledoc """
- Defines the contract to put an uploaded file to any backend.
+ 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 `Pleroma.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, String.t } | {:error, String.t} containing the path of the
- uploaded file, or error information if the file failed to be saved to the
- respective 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.
+
+
"""
- @callback put_file(
- name :: String.t(),
- uuid :: String.t(),
- file :: File.t(),
- content_type :: String.t(),
- should_dedupe :: Boolean.t()
- ) :: {:ok, String.t()} | {:error, String.t()}
+ @callback put_file(Pleroma.Upload.t()) ::
+ :ok | {:ok, {:file | :url, String.t()}} | {:error, String.t()}
+
+ @spec put_file(module(), Pleroma.Upload.t()) ::
+ {:ok, {:file | :url, String.t()}} | {:error, String.t()}
+ def put_file(uploader, upload) do
+ case uploader.put_file(upload) do
+ :ok -> {:ok, {:file, upload.path}}
+ other -> other
+ end
+ end
end
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
index 5cb350c8d..7e207c620 100644
--- a/lib/pleroma/web/activity_pub/activity_pub.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -1,832 +1,830 @@
defmodule Pleroma.Web.ActivityPub.ActivityPub do
alias Pleroma.{Activity, Repo, Object, Upload, User, Notification}
alias Pleroma.Web.ActivityPub.{Transmogrifier, MRF}
alias Pleroma.Web.WebFinger
alias Pleroma.Web.Federator
alias Pleroma.Web.OStatus
import Ecto.Query
import Pleroma.Web.ActivityPub.Utils
require Logger
@httpoison Application.get_env(:pleroma, :httpoison)
# For Announce activities, we filter the recipients based on following status for any actors
# that match actual users. See issue #164 for more information about why this is necessary.
defp get_recipients(%{"type" => "Announce"} = data) do
to = data["to"] || []
cc = data["cc"] || []
recipients = to ++ cc
actor = User.get_cached_by_ap_id(data["actor"])
recipients
|> Enum.filter(fn recipient ->
case User.get_cached_by_ap_id(recipient) do
nil ->
true
user ->
User.following?(user, actor)
end
end)
{recipients, to, cc}
end
defp get_recipients(data) do
to = data["to"] || []
cc = data["cc"] || []
recipients = to ++ cc
{recipients, to, cc}
end
defp check_actor_is_active(actor) do
if not is_nil(actor) do
with user <- User.get_cached_by_ap_id(actor),
false <- user.info.deactivated do
:ok
else
_e -> :reject
end
else
:ok
end
end
def insert(map, local \\ true) when is_map(map) do
with nil <- Activity.normalize(map),
map <- lazy_put_activity_defaults(map),
:ok <- check_actor_is_active(map["actor"]),
{:ok, map} <- MRF.filter(map),
:ok <- insert_full_object(map) do
{recipients, _, _} = get_recipients(map)
{:ok, activity} =
Repo.insert(%Activity{
data: map,
local: local,
actor: map["actor"],
recipients: recipients
})
Notification.create_notifications(activity)
stream_out(activity)
{:ok, activity}
else
%Activity{} = activity -> {:ok, activity}
error -> {:error, error}
end
end
def stream_out(activity) do
public = "https://www.w3.org/ns/activitystreams#Public"
if activity.data["type"] in ["Create", "Announce"] do
Pleroma.Web.Streamer.stream("user", activity)
Pleroma.Web.Streamer.stream("list", activity)
if Enum.member?(activity.data["to"], public) do
Pleroma.Web.Streamer.stream("public", activity)
if activity.local do
Pleroma.Web.Streamer.stream("public:local", activity)
end
activity.data["object"]
|> Map.get("tag", [])
|> Enum.filter(fn tag -> is_bitstring(tag) end)
|> Enum.map(fn tag -> Pleroma.Web.Streamer.stream("hashtag:" <> tag, activity) end)
if activity.data["object"]["attachment"] != [] do
Pleroma.Web.Streamer.stream("public:media", activity)
if activity.local do
Pleroma.Web.Streamer.stream("public:local:media", activity)
end
end
else
if !Enum.member?(activity.data["cc"] || [], public) &&
!Enum.member?(
activity.data["to"],
User.get_by_ap_id(activity.data["actor"]).follower_address
),
do: Pleroma.Web.Streamer.stream("direct", activity)
end
end
end
def create(%{to: to, actor: actor, context: context, object: object} = params) do
additional = params[:additional] || %{}
# only accept false as false value
local = !(params[:local] == false)
published = params[:published]
with create_data <-
make_create_data(
%{to: to, actor: actor, published: published, context: context, object: object},
additional
),
{:ok, activity} <- insert(create_data, local),
:ok <- maybe_federate(activity),
{:ok, _actor} <- User.increase_note_count(actor) do
{:ok, activity}
end
end
def accept(%{to: to, actor: actor, object: object} = params) do
# only accept false as false value
local = !(params[:local] == false)
with data <- %{"to" => to, "type" => "Accept", "actor" => actor, "object" => object},
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
def reject(%{to: to, actor: actor, object: object} = params) do
# only accept false as false value
local = !(params[:local] == false)
with data <- %{"to" => to, "type" => "Reject", "actor" => actor, "object" => object},
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
def update(%{to: to, cc: cc, actor: actor, object: object} = params) do
# only accept false as false value
local = !(params[:local] == false)
with data <- %{
"to" => to,
"cc" => cc,
"type" => "Update",
"actor" => actor,
"object" => object
},
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
# TODO: This is weird, maybe we shouldn't check here if we can make the activity.
def like(
%User{ap_id: ap_id} = user,
%Object{data: %{"id" => _}} = object,
activity_id \\ nil,
local \\ true
) do
with nil <- get_existing_like(ap_id, object),
like_data <- make_like_data(user, object, activity_id),
{:ok, activity} <- insert(like_data, local),
{:ok, object} <- add_like_to_object(activity, object),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
%Activity{} = activity -> {:ok, activity, object}
error -> {:error, error}
end
end
def unlike(
%User{} = actor,
%Object{} = object,
activity_id \\ nil,
local \\ true
) do
with %Activity{} = like_activity <- get_existing_like(actor.ap_id, object),
unlike_data <- make_unlike_data(actor, like_activity, activity_id),
{:ok, unlike_activity} <- insert(unlike_data, local),
{:ok, _activity} <- Repo.delete(like_activity),
{:ok, object} <- remove_like_from_object(like_activity, object),
:ok <- maybe_federate(unlike_activity) do
{:ok, unlike_activity, like_activity, object}
else
_e -> {:ok, object}
end
end
def announce(
%User{ap_id: _} = user,
%Object{data: %{"id" => _}} = object,
activity_id \\ nil,
local \\ true
) do
with true <- is_public?(object),
announce_data <- make_announce_data(user, object, activity_id),
{:ok, activity} <- insert(announce_data, local),
{:ok, object} <- add_announce_to_object(activity, object),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
error -> {:error, error}
end
end
def unannounce(
%User{} = actor,
%Object{} = object,
activity_id \\ nil,
local \\ true
) do
with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object),
unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id),
{:ok, unannounce_activity} <- insert(unannounce_data, local),
:ok <- maybe_federate(unannounce_activity),
{:ok, _activity} <- Repo.delete(announce_activity),
{:ok, object} <- remove_announce_from_object(announce_activity, object) do
{:ok, unannounce_activity, object}
else
_e -> {:ok, object}
end
end
def follow(follower, followed, activity_id \\ nil, local \\ true) do
with data <- make_follow_data(follower, followed, activity_id),
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
def unfollow(follower, followed, activity_id \\ nil, local \\ true) do
with %Activity{} = follow_activity <- fetch_latest_follow(follower, followed),
{:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
{:ok, activity} <- insert(unfollow_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
def delete(%Object{data: %{"id" => id, "actor" => actor}} = object, local \\ true) do
user = User.get_cached_by_ap_id(actor)
data = %{
"type" => "Delete",
"actor" => actor,
"object" => id,
"to" => [user.follower_address, "https://www.w3.org/ns/activitystreams#Public"]
}
with {:ok, _} <- Object.delete(object),
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity),
{:ok, _actor} <- User.decrease_note_count(user) do
{:ok, activity}
end
end
def block(blocker, blocked, activity_id \\ nil, local \\ true) do
ap_config = Application.get_env(:pleroma, :activitypub)
unfollow_blocked = Keyword.get(ap_config, :unfollow_blocked)
outgoing_blocks = Keyword.get(ap_config, :outgoing_blocks)
with true <- unfollow_blocked do
follow_activity = fetch_latest_follow(blocker, blocked)
if follow_activity do
unfollow(blocker, blocked, nil, local)
end
end
with true <- outgoing_blocks,
block_data <- make_block_data(blocker, blocked, activity_id),
{:ok, activity} <- insert(block_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
_e -> {:ok, nil}
end
end
def unblock(blocker, blocked, activity_id \\ nil, local \\ true) do
with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked),
unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id),
{:ok, activity} <- insert(unblock_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
def fetch_activities_for_context(context, opts \\ %{}) do
public = ["https://www.w3.org/ns/activitystreams#Public"]
recipients =
if opts["user"], do: [opts["user"].ap_id | opts["user"].following] ++ public, else: public
query = from(activity in Activity)
query =
query
|> restrict_blocked(opts)
|> restrict_recipients(recipients, opts["user"])
query =
from(
activity in query,
where:
fragment(
"?->>'type' = ? and ?->>'context' = ?",
activity.data,
"Create",
activity.data,
^context
),
order_by: [desc: :id]
)
Repo.all(query)
end
def fetch_public_activities(opts \\ %{}) do
q = fetch_activities_query(["https://www.w3.org/ns/activitystreams#Public"], opts)
q
|> restrict_unlisted()
|> Repo.all()
|> Enum.reverse()
end
@valid_visibilities ~w[direct unlisted public private]
defp restrict_visibility(query, %{visibility: "direct"}) do
public = "https://www.w3.org/ns/activitystreams#Public"
from(
activity in query,
join: sender in User,
on: sender.ap_id == activity.actor,
# Are non-direct statuses with no to/cc possible?
where:
fragment(
"not (? && ?)",
[^public, sender.follower_address],
activity.recipients
)
)
end
defp restrict_visibility(_query, %{visibility: visibility})
when visibility not in @valid_visibilities do
Logger.error("Could not restrict visibility to #{visibility}")
end
defp restrict_visibility(query, _visibility), do: query
def fetch_user_activities(user, reading_user, params \\ %{}) do
params =
params
|> Map.put("type", ["Create", "Announce"])
|> Map.put("actor_id", user.ap_id)
|> Map.put("whole_db", true)
recipients =
if reading_user do
["https://www.w3.org/ns/activitystreams#Public"] ++
[reading_user.ap_id | reading_user.following]
else
["https://www.w3.org/ns/activitystreams#Public"]
end
fetch_activities(recipients, params)
|> Enum.reverse()
end
defp restrict_since(query, %{"since_id" => since_id}) do
from(activity in query, where: activity.id > ^since_id)
end
defp restrict_since(query, _), do: query
defp restrict_tag(query, %{"tag" => tag}) do
from(
activity in query,
where: fragment("? <@ (? #> '{\"object\",\"tag\"}')", ^tag, activity.data)
)
end
defp restrict_tag(query, _), do: query
defp restrict_to_cc(query, recipients_to, recipients_cc) do
from(
activity in query,
where:
fragment(
"(?->'to' \\?| ?) or (?->'cc' \\?| ?)",
activity.data,
^recipients_to,
activity.data,
^recipients_cc
)
)
end
defp restrict_recipients(query, [], _user), do: query
defp restrict_recipients(query, recipients, nil) do
from(activity in query, where: fragment("? && ?", ^recipients, activity.recipients))
end
defp restrict_recipients(query, recipients, user) do
from(
activity in query,
where: fragment("? && ?", ^recipients, activity.recipients),
or_where: activity.actor == ^user.ap_id
)
end
defp restrict_limit(query, %{"limit" => limit}) do
from(activity in query, limit: ^limit)
end
defp restrict_limit(query, _), do: query
defp restrict_local(query, %{"local_only" => true}) do
from(activity in query, where: activity.local == true)
end
defp restrict_local(query, _), do: query
defp restrict_max(query, %{"max_id" => max_id}) do
from(activity in query, where: activity.id < ^max_id)
end
defp restrict_max(query, _), do: query
defp restrict_actor(query, %{"actor_id" => actor_id}) do
from(activity in query, where: activity.actor == ^actor_id)
end
defp restrict_actor(query, _), do: query
defp restrict_type(query, %{"type" => type}) when is_binary(type) do
restrict_type(query, %{"type" => [type]})
end
defp restrict_type(query, %{"type" => type}) do
from(activity in query, where: fragment("?->>'type' = ANY(?)", activity.data, ^type))
end
defp restrict_type(query, _), do: query
defp restrict_favorited_by(query, %{"favorited_by" => ap_id}) do
from(
activity in query,
where: fragment("? <@ (? #> '{\"object\",\"likes\"}')", ^ap_id, activity.data)
)
end
defp restrict_favorited_by(query, _), do: query
defp restrict_media(query, %{"only_media" => val}) when val == "true" or val == "1" do
from(
activity in query,
where: fragment("not (? #> '{\"object\",\"attachment\"}' = ?)", activity.data, ^[])
)
end
defp restrict_media(query, _), do: query
defp restrict_replies(query, %{"exclude_replies" => val}) when val == "true" or val == "1" do
from(
activity in query,
where: fragment("?->'object'->>'inReplyTo' is null", activity.data)
)
end
defp restrict_replies(query, _), do: query
# Only search through last 100_000 activities by default
defp restrict_recent(query, %{"whole_db" => true}), do: query
defp restrict_recent(query, _) do
since = (Repo.aggregate(Activity, :max, :id) || 0) - 100_000
from(activity in query, where: activity.id > ^since)
end
defp restrict_blocked(query, %{"blocking_user" => %User{info: info}}) do
blocks = info.blocks || []
domain_blocks = info.domain_blocks || []
from(
activity in query,
where: fragment("not (? = ANY(?))", activity.actor, ^blocks),
where: fragment("not (?->'to' \\?| ?)", activity.data, ^blocks),
where: fragment("not (split_part(?, '/', 3) = ANY(?))", activity.actor, ^domain_blocks)
)
end
defp restrict_blocked(query, _), do: query
defp restrict_unlisted(query) do
from(
activity in query,
where:
fragment(
"not (coalesce(?->'cc', '{}'::jsonb) \\?| ?)",
activity.data,
^["https://www.w3.org/ns/activitystreams#Public"]
)
)
end
def fetch_activities_query(recipients, opts \\ %{}) do
base_query =
from(
activity in Activity,
limit: 20,
order_by: [fragment("? desc nulls last", activity.id)]
)
base_query
|> restrict_recipients(recipients, opts["user"])
|> restrict_tag(opts)
|> restrict_since(opts)
|> restrict_local(opts)
|> restrict_limit(opts)
|> restrict_max(opts)
|> restrict_actor(opts)
|> restrict_type(opts)
|> restrict_favorited_by(opts)
|> restrict_recent(opts)
|> restrict_blocked(opts)
|> restrict_media(opts)
|> restrict_visibility(opts)
|> restrict_replies(opts)
end
def fetch_activities(recipients, opts \\ %{}) do
fetch_activities_query(recipients, opts)
|> Repo.all()
|> Enum.reverse()
end
def fetch_activities_bounded(recipients_to, recipients_cc, opts \\ %{}) do
fetch_activities_query([], opts)
|> restrict_to_cc(recipients_to, recipients_cc)
|> Repo.all()
|> Enum.reverse()
end
- def upload(file, size_limit \\ nil) do
- with data <-
- Upload.store(file, Application.get_env(:pleroma, :instance)[:dedupe_media], size_limit),
- false <- is_nil(data) do
+ def upload(file, opts \\ []) do
+ with {:ok, data} <- Upload.store(file, opts) do
Repo.insert(%Object{data: data})
end
end
def user_data_from_user_object(data) do
avatar =
data["icon"]["url"] &&
%{
"type" => "Image",
"url" => [%{"href" => data["icon"]["url"]}]
}
banner =
data["image"]["url"] &&
%{
"type" => "Image",
"url" => [%{"href" => data["image"]["url"]}]
}
locked = data["manuallyApprovesFollowers"] || false
data = Transmogrifier.maybe_fix_user_object(data)
user_data = %{
ap_id: data["id"],
info: %{
"ap_enabled" => true,
"source_data" => data,
"banner" => banner,
"locked" => locked
},
avatar: avatar,
name: data["name"],
follower_address: data["followers"],
bio: data["summary"]
}
# nickname can be nil because of virtual actors
user_data =
if data["preferredUsername"] do
Map.put(
user_data,
:nickname,
"#{data["preferredUsername"]}@#{URI.parse(data["id"]).host}"
)
else
Map.put(user_data, :nickname, nil)
end
{:ok, user_data}
end
def fetch_and_prepare_user_from_ap_id(ap_id) do
with {:ok, data} <- fetch_and_contain_remote_object_from_id(ap_id) do
user_data_from_user_object(data)
else
e -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}")
end
end
def make_user_from_ap_id(ap_id) do
if _user = User.get_by_ap_id(ap_id) do
Transmogrifier.upgrade_user_from_ap_id(ap_id)
else
with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id) do
User.insert_or_update_user(data)
else
e -> {:error, e}
end
end
end
def make_user_from_nickname(nickname) do
with {:ok, %{"ap_id" => ap_id}} when not is_nil(ap_id) <- WebFinger.finger(nickname) do
make_user_from_ap_id(ap_id)
else
_e -> {:error, "No AP id in WebFinger"}
end
end
def should_federate?(inbox, public) do
if public do
true
else
inbox_info = URI.parse(inbox)
!Enum.member?(Pleroma.Config.get([:instance, :quarantined_instances], []), inbox_info.host)
end
end
def publish(actor, activity) do
followers =
if actor.follower_address in activity.recipients do
{:ok, followers} = User.get_followers(actor)
followers |> Enum.filter(&(!&1.local))
else
[]
end
public = is_public?(activity)
remote_inboxes =
(Pleroma.Web.Salmon.remote_users(activity) ++ followers)
|> Enum.filter(fn user -> User.ap_enabled?(user) end)
|> Enum.map(fn %{info: %{source_data: data}} ->
(is_map(data["endpoints"]) && Map.get(data["endpoints"], "sharedInbox")) || data["inbox"]
end)
|> Enum.uniq()
|> Enum.filter(fn inbox -> should_federate?(inbox, public) end)
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
json = Jason.encode!(data)
Enum.each(remote_inboxes, fn inbox ->
Federator.enqueue(:publish_single_ap, %{
inbox: inbox,
json: json,
actor: actor,
id: activity.data["id"]
})
end)
end
def publish_one(%{inbox: inbox, json: json, actor: actor, id: id}) do
Logger.info("Federating #{id} to #{inbox}")
host = URI.parse(inbox).host
digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64())
signature =
Pleroma.Web.HTTPSignatures.sign(actor, %{
host: host,
"content-length": byte_size(json),
digest: digest
})
@httpoison.post(
inbox,
json,
[
{"Content-Type", "application/activity+json"},
{"signature", signature},
{"digest", digest}
],
hackney: [pool: :default]
)
end
# TODO:
# This will create a Create activity, which we need internally at the moment.
def fetch_object_from_id(id) do
if object = Object.get_cached_by_ap_id(id) do
{:ok, object}
else
Logger.info("Fetching #{id} via AP")
with {:ok, data} <- fetch_and_contain_remote_object_from_id(id),
nil <- Object.normalize(data),
params <- %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["actor"] || data["attributedTo"],
"object" => data
},
:ok <- Transmogrifier.contain_origin(id, params),
{:ok, activity} <- Transmogrifier.handle_incoming(params) do
{:ok, Object.normalize(activity.data["object"])}
else
{:error, {:reject, nil}} ->
{:reject, nil}
object = %Object{} ->
{:ok, object}
_e ->
Logger.info("Couldn't get object via AP, trying out OStatus fetching...")
case OStatus.fetch_activity_from_url(id) do
{:ok, [activity | _]} -> {:ok, Object.normalize(activity.data["object"])}
e -> e
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
"https://www.w3.org/ns/activitystreams#Public" in (activity.data["to"] ++
(activity.data["cc"] || []))
end
def visible_for_user?(activity, nil) do
is_public?(activity)
end
def visible_for_user?(activity, user) do
x = [user.ap_id | user.following]
y = activity.data["to"] ++ (activity.data["cc"] || [])
visible_for_user?(activity, nil) || Enum.any?(x, &(&1 in y))
end
# guard
def entire_thread_visible_for_user?(nil, user), do: false
# child
def entire_thread_visible_for_user?(
%Activity{data: %{"object" => %{"inReplyTo" => parent_id}}} = tail,
user
)
when is_binary(parent_id) do
parent = Activity.get_in_reply_to_activity(tail)
visible_for_user?(tail, user) && entire_thread_visible_for_user?(parent, user)
end
# root
def entire_thread_visible_for_user?(tail, user), do: visible_for_user?(tail, user)
# filter out broken threads
def contain_broken_threads(%Activity{} = activity, %User{} = user) do
entire_thread_visible_for_user?(activity, user)
end
# do post-processing on a specific activity
def contain_activity(%Activity{} = activity, %User{} = user) do
contain_broken_threads(activity, user)
end
# do post-processing on a timeline
def contain_timeline(timeline, user) do
timeline
|> Enum.filter(fn activity ->
contain_activity(activity, user)
end)
end
end
diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex
index 8728c908b..c5f9d51d9 100644
--- a/lib/pleroma/web/endpoint.ex
+++ b/lib/pleroma/web/endpoint.ex
@@ -1,79 +1,79 @@
defmodule Pleroma.Web.Endpoint do
use Phoenix.Endpoint, otp_app: :pleroma
socket("/socket", Pleroma.Web.UserSocket)
socket("/api/v1", Pleroma.Web.MastodonAPI.MastodonSocket)
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production.
plug(CORSPlug)
plug(Pleroma.Plugs.HTTPSecurityPlug)
- plug(Plug.Static, at: "/media", from: Pleroma.Uploaders.Local.upload_path(), gzip: false)
+ plug(Pleroma.Plugs.UploadedMedia)
plug(
Plug.Static,
at: "/",
from: :pleroma,
only:
~w(index.html static finmoji emoji packs sounds images instance sw.js favicon.png schemas)
)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
plug(Phoenix.CodeReloader)
end
plug(TrailingFormatPlug)
plug(Plug.RequestId)
plug(Plug.Logger)
plug(
Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Jason,
length: Application.get_env(:pleroma, :instance) |> Keyword.get(:upload_limit),
body_reader: {Pleroma.Web.Plugs.DigestPlug, :read_body, []}
)
plug(Plug.MethodOverride)
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,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
plug(
Plug.Session,
store: :cookie,
key: cookie_name,
signing_salt: {Pleroma.Config, :get, [[__MODULE__, :signing_salt], "CqaoopA2"]},
http_only: true,
secure:
Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag),
extra: "SameSite=Strict"
)
plug(Pleroma.Web.Router)
@doc """
Dynamically loads configuration from the system environment
on startup.
It receives the endpoint configuration from the config files
and must return the updated configuration.
"""
def load_from_system_env(config) do
port = System.get_env("PORT") || raise "expected the PORT environment variable to be set"
{:ok, Keyword.put(config, :http, [:inet6, port: port])}
end
end
diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
index 3a49a96a1..f209bb43c 100644
--- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
+++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
@@ -1,1248 +1,1233 @@
defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
use Pleroma.Web, :controller
alias Pleroma.{Repo, Object, Activity, User, Notification, Stats}
alias Pleroma.Web
alias Pleroma.Web.MastodonAPI.{StatusView, AccountView, MastodonView, ListView, FilterView}
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OAuth.{Authorization, Token, App}
alias Pleroma.Web.MediaProxy
alias Comeonin.Pbkdf2
import Ecto.Query
require Logger
@httpoison Application.get_env(:pleroma, :httpoison)
action_fallback(:errors)
def create_app(conn, params) do
with cs <- App.register_changeset(%App{}, params) |> IO.inspect(),
{:ok, app} <- Repo.insert(cs) |> IO.inspect() do
res = %{
id: app.id |> to_string,
name: app.client_name,
client_id: app.client_id,
client_secret: app.client_secret,
redirect_uri: app.redirect_uris,
website: app.website
}
json(conn, res)
end
end
def update_credentials(%{assigns: %{user: user}} = conn, params) do
original_user = user
- avatar_upload_limit =
- Application.get_env(:pleroma, :instance)
- |> Keyword.fetch(:avatar_upload_limit)
-
- banner_upload_limit =
- Application.get_env(:pleroma, :instance)
- |> Keyword.fetch(:banner_upload_limit)
-
params =
if bio = params["note"] do
Map.put(params, "bio", bio)
else
params
end
params =
if name = params["display_name"] do
Map.put(params, "name", name)
else
params
end
user =
if avatar = params["avatar"] do
with %Plug.Upload{} <- avatar,
- {:ok, object} <- ActivityPub.upload(avatar, avatar_upload_limit),
+ {:ok, object} <- ActivityPub.upload(avatar, type: :avatar),
change = Ecto.Changeset.change(user, %{avatar: object.data}),
{:ok, user} = User.update_and_set_cache(change) do
user
else
_e -> user
end
else
user
end
# user =
# if banner = params["header"] do
# with %Plug.Upload{} <- banner,
- # {:ok, object} <- ActivityPub.upload(banner, banner_upload_limit),
+ # {:ok, object} <- ActivityPub.upload(banner, type: :banner),
# new_info <- Map.put(user.info, "banner", object.data),
# change <- User.info_changeset(user, %{info: new_info}),
# {:ok, user} <- User.update_and_set_cache(change) do
# user
# else
# _e -> user
# end
# else
# user
# end
# user =
# if locked = params["locked"] do
# with locked <- locked == "true",
# new_info <- Map.put(user.info, "locked", locked),
# change <- User.info_changeset(user, %{info: new_info}),
# {:ok, user} <- User.update_and_set_cache(change) do
# user
# else
# _e -> user
# end
# else
# user
# end
with changeset <- User.update_changeset(user, params),
{:ok, user} <- User.update_and_set_cache(changeset) do
if original_user != user do
CommonAPI.update(user)
end
json(conn, AccountView.render("account.json", %{user: user, for: user}))
else
_e ->
conn
|> put_status(403)
|> json(%{error: "Invalid request"})
end
end
def verify_credentials(%{assigns: %{user: user}} = conn, _) do
account = AccountView.render("account.json", %{user: user, for: user})
json(conn, account)
end
def user(%{assigns: %{user: for_user}} = conn, %{"id" => id}) do
with %User{} = user <- Repo.get(User, id) do
account = AccountView.render("account.json", %{user: user, for: for_user})
json(conn, account)
else
_e ->
conn
|> put_status(404)
|> json(%{error: "Can't find user"})
end
end
@mastodon_api_level "2.5.0"
def masto_instance(conn, _params) do
instance = Pleroma.Config.get(:instance)
response = %{
uri: Web.base_url(),
title: Keyword.get(instance, :name),
description: Keyword.get(instance, :description),
version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})",
email: Keyword.get(instance, :email),
urls: %{
streaming_api: String.replace(Pleroma.Web.Endpoint.static_url(), "http", "ws")
},
stats: Stats.get_stats(),
thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg",
max_toot_chars: Keyword.get(instance, :limit)
}
json(conn, response)
end
def peers(conn, _params) do
json(conn, Stats.get_peers())
end
defp mastodonized_emoji do
Pleroma.Emoji.get_all()
|> Enum.map(fn {shortcode, relative_url} ->
url = to_string(URI.merge(Web.base_url(), relative_url))
%{
"shortcode" => shortcode,
"static_url" => url,
"visible_in_picker" => true,
"url" => url
}
end)
end
def custom_emojis(conn, _params) do
mastodon_emoji = mastodonized_emoji()
json(conn, mastodon_emoji)
end
defp add_link_headers(conn, method, activities, param \\ nil, params \\ %{}) do
last = List.last(activities)
first = List.first(activities)
if last do
min = last.id
max = first.id
{next_url, prev_url} =
if param do
{
mastodon_api_url(
Pleroma.Web.Endpoint,
method,
param,
Map.merge(params, %{max_id: min})
),
mastodon_api_url(
Pleroma.Web.Endpoint,
method,
param,
Map.merge(params, %{since_id: max})
)
}
else
{
mastodon_api_url(
Pleroma.Web.Endpoint,
method,
Map.merge(params, %{max_id: min})
),
mastodon_api_url(
Pleroma.Web.Endpoint,
method,
Map.merge(params, %{since_id: max})
)
}
end
conn
|> put_resp_header("link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"")
else
conn
end
end
def home_timeline(%{assigns: %{user: user}} = conn, params) do
params =
params
|> Map.put("type", ["Create", "Announce"])
|> Map.put("blocking_user", user)
|> Map.put("user", user)
activities =
ActivityPub.fetch_activities([user.ap_id | user.following], params)
|> ActivityPub.contain_timeline(user)
|> Enum.reverse()
conn
|> add_link_headers(:home_timeline, activities)
|> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity})
end
def public_timeline(%{assigns: %{user: user}} = conn, params) do
local_only = params["local"] in [true, "True", "true", "1"]
params =
params
|> Map.put("type", ["Create", "Announce"])
|> Map.put("local_only", local_only)
|> Map.put("blocking_user", user)
activities =
ActivityPub.fetch_public_activities(params)
|> Enum.reverse()
conn
|> add_link_headers(:public_timeline, activities, false, %{"local" => local_only})
|> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity})
end
def user_statuses(%{assigns: %{user: reading_user}} = conn, params) do
with %User{} = user <- Repo.get(User, params["id"]) do
# Since Pleroma has no "pinned" posts feature, we'll just set an empty list here
activities =
if params["pinned"] == "true" do
[]
else
ActivityPub.fetch_user_activities(user, reading_user, params)
end
conn
|> add_link_headers(:user_statuses, activities, params["id"])
|> render(StatusView, "index.json", %{
activities: activities,
for: reading_user,
as: :activity
})
end
end
def dm_timeline(%{assigns: %{user: user}} = conn, params) do
query =
ActivityPub.fetch_activities_query(
[user.ap_id],
Map.merge(params, %{"type" => "Create", visibility: "direct"})
)
activities = Repo.all(query)
conn
|> add_link_headers(:dm_timeline, activities)
|> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity})
end
def get_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %Activity{} = activity <- Repo.get(Activity, id),
true <- ActivityPub.visible_for_user?(activity, user) do
try_render(conn, StatusView, "status.json", %{activity: activity, for: user})
end
end
def get_context(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %Activity{} = activity <- Repo.get(Activity, id),
activities <-
ActivityPub.fetch_activities_for_context(activity.data["context"], %{
"blocking_user" => user,
"user" => user
}),
activities <-
activities |> Enum.filter(fn %{id: aid} -> to_string(aid) != to_string(id) end),
activities <-
activities |> Enum.filter(fn %{data: %{"type" => type}} -> type == "Create" end),
grouped_activities <- Enum.group_by(activities, fn %{id: id} -> id < activity.id end) do
result = %{
ancestors:
StatusView.render(
"index.json",
for: user,
activities: grouped_activities[true] || [],
as: :activity
)
|> Enum.reverse(),
descendants:
StatusView.render(
"index.json",
for: user,
activities: grouped_activities[false] || [],
as: :activity
)
|> Enum.reverse()
}
json(conn, result)
end
end
def post_status(conn, %{"status" => "", "media_ids" => media_ids} = params)
when length(media_ids) > 0 do
params =
params
|> Map.put("status", ".")
post_status(conn, params)
end
def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do
params =
params
|> Map.put("in_reply_to_status_id", params["in_reply_to_id"])
|> Map.put("no_attachment_links", true)
idempotency_key =
case get_req_header(conn, "idempotency-key") do
[key] -> key
_ -> Ecto.UUID.generate()
end
{:ok, activity} =
Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> CommonAPI.post(user, params) end)
try_render(conn, StatusView, "status.json", %{activity: activity, for: user, as: :activity})
end
def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do
json(conn, %{})
else
_e ->
conn
|> put_status(403)
|> json(%{error: "Can't delete this post"})
end
end
def reblog_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do
with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user) do
try_render(conn, StatusView, "status.json", %{activity: announce, for: user, as: :activity})
end
end
def unreblog_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do
with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user),
%Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
try_render(conn, StatusView, "status.json", %{activity: activity, for: user, as: :activity})
end
end
def fav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do
with {:ok, _fav, %{data: %{"id" => id}}} <- CommonAPI.favorite(ap_id_or_id, user),
%Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
try_render(conn, StatusView, "status.json", %{activity: activity, for: user, as: :activity})
end
end
def unfav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do
with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user),
%Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
try_render(conn, StatusView, "status.json", %{activity: activity, for: user, as: :activity})
end
end
def notifications(%{assigns: %{user: user}} = conn, params) do
notifications = Notification.for_user(user, params)
result =
Enum.map(notifications, fn x ->
render_notification(user, x)
end)
|> Enum.filter(& &1)
conn
|> add_link_headers(:notifications, notifications)
|> json(result)
end
def get_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do
with {:ok, notification} <- Notification.get(user, id) do
json(conn, render_notification(user, notification))
else
{:error, reason} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => reason}))
end
end
def clear_notifications(%{assigns: %{user: user}} = conn, _params) do
Notification.clear(user)
json(conn, %{})
end
def dismiss_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do
with {:ok, _notif} <- Notification.dismiss(user, id) do
json(conn, %{})
else
{:error, reason} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => reason}))
end
end
def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do
id = List.wrap(id)
q = from(u in User, where: u.id in ^id)
targets = Repo.all(q)
render(conn, AccountView, "relationships.json", %{user: user, targets: targets})
end
# Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array.
def relationships(%{assigns: %{user: user}} = conn, _) do
conn
|> json([])
end
def update_media(%{assigns: %{user: _}} = conn, data) do
with %Object{} = object <- Repo.get(Object, data["id"]),
true <- is_binary(data["description"]),
description <- data["description"] do
new_data = %{object.data | "name" => description}
change = Object.change(object, %{data: new_data})
{:ok, _} = Repo.update(change)
data =
new_data
|> Map.put("id", object.id)
render(conn, StatusView, "attachment.json", %{attachment: data})
end
end
def upload(%{assigns: %{user: _}} = conn, %{"file" => file} = data) do
- with {:ok, object} <- ActivityPub.upload(file) do
- objdata =
- if Map.has_key?(data, "description") do
- Map.put(object.data, "name", data["description"])
- else
- object.data
- end
-
- change = Object.change(object, %{data: objdata})
+ with {:ok, object} <- ActivityPub.upload(file, description: Map.get(data, "description")) do
+ change = Object.change(object, %{data: object.data})
{:ok, object} = Repo.update(change)
objdata =
- objdata
+ object.data
|> Map.put("id", object.id)
render(conn, StatusView, "attachment.json", %{attachment: objdata})
end
end
def favourited_by(conn, %{"id" => id}) do
with %Activity{data: %{"object" => %{"likes" => likes}}} <- Repo.get(Activity, id) do
q = from(u in User, where: u.ap_id in ^likes)
users = Repo.all(q)
render(conn, AccountView, "accounts.json", %{users: users, as: :user})
else
_ -> json(conn, [])
end
end
def reblogged_by(conn, %{"id" => id}) do
with %Activity{data: %{"object" => %{"announcements" => announces}}} <- Repo.get(Activity, id) do
q = from(u in User, where: u.ap_id in ^announces)
users = Repo.all(q)
render(conn, AccountView, "accounts.json", %{users: users, as: :user})
else
_ -> json(conn, [])
end
end
def hashtag_timeline(%{assigns: %{user: user}} = conn, params) do
local_only = params["local"] in [true, "True", "true", "1"]
params =
params
|> Map.put("type", "Create")
|> Map.put("local_only", local_only)
|> Map.put("blocking_user", user)
|> Map.put("tag", String.downcase(params["tag"]))
activities =
ActivityPub.fetch_public_activities(params)
|> Enum.reverse()
conn
|> add_link_headers(:hashtag_timeline, activities, params["tag"], %{"local" => local_only})
|> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity})
end
# TODO: Pagination
def followers(conn, %{"id" => id}) do
with %User{} = user <- Repo.get(User, id),
{:ok, followers} <- User.get_followers(user) do
render(conn, AccountView, "accounts.json", %{users: followers, as: :user})
end
end
def following(conn, %{"id" => id}) do
with %User{} = user <- Repo.get(User, id),
{:ok, followers} <- User.get_friends(user) do
render(conn, AccountView, "accounts.json", %{users: followers, as: :user})
end
end
def follow_requests(%{assigns: %{user: followed}} = conn, _params) do
with {:ok, follow_requests} <- User.get_follow_requests(followed) do
render(conn, AccountView, "accounts.json", %{users: follow_requests, as: :user})
end
end
def authorize_follow_request(%{assigns: %{user: followed}} = conn, %{"id" => id}) do
with %User{} = follower <- Repo.get(User, id),
{:ok, follower} <- User.maybe_follow(follower, followed),
%Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed),
{:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
{:ok, _activity} <-
ActivityPub.accept(%{
to: [follower.ap_id],
actor: followed.ap_id,
object: follow_activity.data["id"],
type: "Accept"
}) do
render(conn, AccountView, "relationship.json", %{user: followed, target: follower})
else
{:error, message} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => message}))
end
end
def reject_follow_request(%{assigns: %{user: followed}} = conn, %{"id" => id}) do
with %User{} = follower <- Repo.get(User, id),
%Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed),
{:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
{:ok, _activity} <-
ActivityPub.reject(%{
to: [follower.ap_id],
actor: followed.ap_id,
object: follow_activity.data["id"],
type: "Reject"
}) do
render(conn, AccountView, "relationship.json", %{user: followed, target: follower})
else
{:error, message} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => message}))
end
end
def follow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do
with %User{} = followed <- Repo.get(User, id),
{:ok, follower} <- User.maybe_direct_follow(follower, followed),
{:ok, _activity} <- ActivityPub.follow(follower, followed),
{:ok, follower, followed} <-
User.wait_and_refresh(
Pleroma.Config.get([:activitypub, :follow_handshake_timeout]),
follower,
followed
) do
render(conn, AccountView, "relationship.json", %{user: follower, target: followed})
else
{:error, message} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => message}))
end
end
def follow(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
with %User{} = followed <- Repo.get_by(User, nickname: uri),
{:ok, follower} <- User.maybe_direct_follow(follower, followed),
{:ok, _activity} <- ActivityPub.follow(follower, followed) do
render(conn, AccountView, "account.json", %{user: followed, for: follower})
else
{:error, message} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => message}))
end
end
def unfollow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do
with %User{} = followed <- Repo.get(User, id),
{:ok, _activity} <- ActivityPub.unfollow(follower, followed),
{:ok, follower, _} <- User.unfollow(follower, followed) do
render(conn, AccountView, "relationship.json", %{user: follower, target: followed})
end
end
def block(%{assigns: %{user: blocker}} = conn, %{"id" => id}) do
with %User{} = blocked <- Repo.get(User, id),
{:ok, blocker} <- User.block(blocker, blocked),
{:ok, _activity} <- ActivityPub.block(blocker, blocked) do
render(conn, AccountView, "relationship.json", %{user: blocker, target: blocked})
else
{:error, message} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => message}))
end
end
def unblock(%{assigns: %{user: blocker}} = conn, %{"id" => id}) do
with %User{} = blocked <- Repo.get(User, id),
{:ok, blocker} <- User.unblock(blocker, blocked),
{:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do
render(conn, AccountView, "relationship.json", %{user: blocker, target: blocked})
else
{:error, message} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => message}))
end
end
# TODO: Use proper query
def blocks(%{assigns: %{user: user}} = conn, _) do
with blocked_users <- user.info.blocks || [],
accounts <- Enum.map(blocked_users, fn ap_id -> User.get_cached_by_ap_id(ap_id) end) do
res = AccountView.render("accounts.json", users: accounts, for: user, as: :user)
json(conn, res)
end
end
def domain_blocks(%{assigns: %{user: %{info: info}}} = conn, _) do
json(conn, info.domain_blocks || [])
end
def block_domain(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do
User.block_domain(blocker, domain)
json(conn, %{})
end
def unblock_domain(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do
User.unblock_domain(blocker, domain)
json(conn, %{})
end
def status_search(query) do
fetched =
if Regex.match?(~r/https?:/, query) do
with {:ok, object} <- ActivityPub.fetch_object_from_id(query) do
[Activity.get_create_activity_by_object_ap_id(object.data["id"])]
else
_e -> []
end
end || []
q =
from(
a in Activity,
where: fragment("?->>'type' = 'Create'", a.data),
where: "https://www.w3.org/ns/activitystreams#Public" in a.recipients,
where:
fragment(
"to_tsvector('english', ?->'object'->>'content') @@ plainto_tsquery('english', ?)",
a.data,
^query
),
limit: 20,
order_by: [desc: :id]
)
Repo.all(q) ++ fetched
end
def search2(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do
accounts = User.search(query, params["resolve"] == "true")
statuses = status_search(query)
tags_path = Web.base_url() <> "/tag/"
tags =
String.split(query)
|> Enum.uniq()
|> Enum.filter(fn tag -> String.starts_with?(tag, "#") end)
|> Enum.map(fn tag -> String.slice(tag, 1..-1) end)
|> Enum.map(fn tag -> %{name: tag, url: tags_path <> tag} end)
res = %{
"accounts" => AccountView.render("accounts.json", users: accounts, for: user, as: :user),
"statuses" =>
StatusView.render("index.json", activities: statuses, for: user, as: :activity),
"hashtags" => tags
}
json(conn, res)
end
def search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do
accounts = User.search(query, params["resolve"] == "true")
statuses = status_search(query)
tags =
String.split(query)
|> Enum.uniq()
|> Enum.filter(fn tag -> String.starts_with?(tag, "#") end)
|> Enum.map(fn tag -> String.slice(tag, 1..-1) end)
res = %{
"accounts" => AccountView.render("accounts.json", users: accounts, for: user, as: :user),
"statuses" =>
StatusView.render("index.json", activities: statuses, for: user, as: :activity),
"hashtags" => tags
}
json(conn, res)
end
def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do
accounts = User.search(query, params["resolve"] == "true")
res = AccountView.render("accounts.json", users: accounts, for: user, as: :user)
json(conn, res)
end
def favourites(%{assigns: %{user: user}} = conn, _) do
params =
%{}
|> Map.put("type", "Create")
|> Map.put("favorited_by", user.ap_id)
|> Map.put("blocking_user", user)
activities =
ActivityPub.fetch_public_activities(params)
|> Enum.reverse()
conn
|> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity})
end
def get_lists(%{assigns: %{user: user}} = conn, opts) do
lists = Pleroma.List.for_user(user, opts)
res = ListView.render("lists.json", lists: lists)
json(conn, res)
end
def get_list(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %Pleroma.List{} = list <- Pleroma.List.get(id, user) do
res = ListView.render("list.json", list: list)
json(conn, res)
else
_e -> json(conn, "error")
end
end
def account_lists(%{assigns: %{user: user}} = conn, %{"id" => account_id}) do
lists = Pleroma.List.get_lists_account_belongs(user, account_id)
res = ListView.render("lists.json", lists: lists)
json(conn, res)
end
def delete_list(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %Pleroma.List{} = list <- Pleroma.List.get(id, user),
{:ok, _list} <- Pleroma.List.delete(list) do
json(conn, %{})
else
_e ->
json(conn, "error")
end
end
def create_list(%{assigns: %{user: user}} = conn, %{"title" => title}) do
with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(title, user) do
res = ListView.render("list.json", list: list)
json(conn, res)
end
end
def add_to_list(%{assigns: %{user: user}} = conn, %{"id" => id, "account_ids" => accounts}) do
accounts
|> Enum.each(fn account_id ->
with %Pleroma.List{} = list <- Pleroma.List.get(id, user),
%User{} = followed <- Repo.get(User, account_id) do
Pleroma.List.follow(list, followed)
end
end)
json(conn, %{})
end
def remove_from_list(%{assigns: %{user: user}} = conn, %{"id" => id, "account_ids" => accounts}) do
accounts
|> Enum.each(fn account_id ->
with %Pleroma.List{} = list <- Pleroma.List.get(id, user),
%User{} = followed <- Repo.get(Pleroma.User, account_id) do
Pleroma.List.unfollow(list, followed)
end
end)
json(conn, %{})
end
def list_accounts(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %Pleroma.List{} = list <- Pleroma.List.get(id, user),
{:ok, users} = Pleroma.List.get_following(list) do
render(conn, AccountView, "accounts.json", %{users: users, as: :user})
end
end
def rename_list(%{assigns: %{user: user}} = conn, %{"id" => id, "title" => title}) do
with %Pleroma.List{} = list <- Pleroma.List.get(id, user),
{:ok, list} <- Pleroma.List.rename(list, title) do
res = ListView.render("list.json", list: list)
json(conn, res)
else
_e ->
json(conn, "error")
end
end
def list_timeline(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do
with %Pleroma.List{title: title, following: following} <- Pleroma.List.get(id, user) do
params =
params
|> Map.put("type", "Create")
|> Map.put("blocking_user", user)
# we must filter the following list for the user to avoid leaking statuses the user
# does not actually have permission to see (for more info, peruse security issue #270).
following_to =
following
|> Enum.filter(fn x -> x in user.following end)
activities =
ActivityPub.fetch_activities_bounded(following_to, following, params)
|> Enum.reverse()
conn
|> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity})
else
_e ->
conn
|> put_status(403)
|> json(%{error: "Error."})
end
end
def index(%{assigns: %{user: user}} = conn, _params) do
token =
conn
|> get_session(:oauth_token)
if user && token do
mastodon_emoji = mastodonized_emoji()
limit = Pleroma.Config.get([:instance, :limit])
accounts =
Map.put(%{}, user.id, AccountView.render("account.json", %{user: user, for: user}))
initial_state =
%{
meta: %{
streaming_api_base_url:
String.replace(Pleroma.Web.Endpoint.static_url(), "http", "ws"),
access_token: token,
locale: "en",
domain: Pleroma.Web.Endpoint.host(),
admin: "1",
me: "#{user.id}",
unfollow_modal: false,
boost_modal: false,
delete_modal: true,
auto_play_gif: false,
display_sensitive_media: false,
reduce_motion: false,
max_toot_chars: limit
},
rights: %{
delete_others_notice: !!user.info.is_moderator
},
compose: %{
me: "#{user.id}",
default_privacy: user.info.default_scope,
default_sensitive: false
},
media_attachments: %{
accept_content_types: [
".jpg",
".jpeg",
".png",
".gif",
".webm",
".mp4",
".m4v",
"image\/jpeg",
"image\/png",
"image\/gif",
"video\/webm",
"video\/mp4"
]
},
settings:
Map.get(user.info, :settings) ||
%{
onboarded: true,
home: %{
shows: %{
reblog: true,
reply: true
}
},
notifications: %{
alerts: %{
follow: true,
favourite: true,
reblog: true,
mention: true
},
shows: %{
follow: true,
favourite: true,
reblog: true,
mention: true
},
sounds: %{
follow: true,
favourite: true,
reblog: true,
mention: true
}
}
},
push_subscription: nil,
accounts: accounts,
custom_emojis: mastodon_emoji,
char_limit: limit
}
|> Jason.encode!()
conn
|> put_layout(false)
|> render(MastodonView, "index.html", %{initial_state: initial_state})
else
conn
|> redirect(to: "/web/login")
end
end
def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
with new_info <- Map.put(user.info, "settings", settings),
change <- User.info_changeset(user, %{info: new_info}),
{:ok, _user} <- User.update_and_set_cache(change) do
conn
|> json(%{})
else
e ->
conn
|> json(%{error: inspect(e)})
end
end
def login(conn, %{"code" => code}) do
with {:ok, app} <- get_or_make_app(),
%Authorization{} = auth <- Repo.get_by(Authorization, token: code, app_id: app.id),
{:ok, token} <- Token.exchange_token(app, auth) do
conn
|> put_session(:oauth_token, token.token)
|> redirect(to: "/web/getting-started")
end
end
def login(conn, _) do
with {:ok, app} <- get_or_make_app() do
path =
o_auth_path(conn, :authorize,
response_type: "code",
client_id: app.client_id,
redirect_uri: ".",
scope: app.scopes
)
conn
|> redirect(to: path)
end
end
defp get_or_make_app() do
with %App{} = app <- Repo.get_by(App, client_name: "Mastodon-Local") do
{:ok, app}
else
_e ->
cs =
App.register_changeset(%App{}, %{
client_name: "Mastodon-Local",
redirect_uris: ".",
scopes: "read,write,follow"
})
Repo.insert(cs)
end
end
def logout(conn, _) do
conn
|> clear_session
|> redirect(to: "/")
end
def relationship_noop(%{assigns: %{user: user}} = conn, %{"id" => id}) do
Logger.debug("Unimplemented, returning unmodified relationship")
with %User{} = target <- Repo.get(User, id) do
render(conn, AccountView, "relationship.json", %{user: user, target: target})
end
end
def empty_array(conn, _) do
Logger.debug("Unimplemented, returning an empty array")
json(conn, [])
end
def empty_object(conn, _) do
Logger.debug("Unimplemented, returning an empty object")
json(conn, %{})
end
def render_notification(user, %{id: id, activity: activity, inserted_at: created_at} = _params) do
actor = User.get_cached_by_ap_id(activity.data["actor"])
created_at =
NaiveDateTime.to_iso8601(created_at)
|> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
id = id |> to_string
case activity.data["type"] do
"Create" ->
%{
id: id,
type: "mention",
created_at: created_at,
account: AccountView.render("account.json", %{user: actor, for: user}),
status: StatusView.render("status.json", %{activity: activity, for: user})
}
"Like" ->
liked_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"])
%{
id: id,
type: "favourite",
created_at: created_at,
account: AccountView.render("account.json", %{user: actor, for: user}),
status: StatusView.render("status.json", %{activity: liked_activity, for: user})
}
"Announce" ->
announced_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"])
%{
id: id,
type: "reblog",
created_at: created_at,
account: AccountView.render("account.json", %{user: actor, for: user}),
status: StatusView.render("status.json", %{activity: announced_activity, for: user})
}
"Follow" ->
%{
id: id,
type: "follow",
created_at: created_at,
account: AccountView.render("account.json", %{user: actor, for: user})
}
_ ->
nil
end
end
def get_filters(%{assigns: %{user: user}} = conn, _) do
filters = Pleroma.Filter.get_filters(user)
res = FilterView.render("filters.json", filters: filters)
json(conn, res)
end
def create_filter(
%{assigns: %{user: user}} = conn,
%{"phrase" => phrase, "context" => context} = params
) do
query = %Pleroma.Filter{
user_id: user.id,
phrase: phrase,
context: context,
hide: Map.get(params, "irreversible", nil),
whole_word: Map.get(params, "boolean", true)
# expires_at
}
{:ok, response} = Pleroma.Filter.create(query)
res = FilterView.render("filter.json", filter: response)
json(conn, res)
end
def get_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do
filter = Pleroma.Filter.get(filter_id, user)
res = FilterView.render("filter.json", filter: filter)
json(conn, res)
end
def update_filter(
%{assigns: %{user: user}} = conn,
%{"phrase" => phrase, "context" => context, "id" => filter_id} = params
) do
query = %Pleroma.Filter{
user_id: user.id,
filter_id: filter_id,
phrase: phrase,
context: context,
hide: Map.get(params, "irreversible", nil),
whole_word: Map.get(params, "boolean", true)
# expires_at
}
{:ok, response} = Pleroma.Filter.update(query)
res = FilterView.render("filter.json", filter: response)
json(conn, res)
end
def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do
query = %Pleroma.Filter{
user_id: user.id,
filter_id: filter_id
}
{:ok, _} = Pleroma.Filter.delete(query)
json(conn, %{})
end
def errors(conn, _) do
conn
|> put_status(500)
|> json("Something went wrong")
end
def suggestions(%{assigns: %{user: user}} = conn, _) do
suggestions = Pleroma.Config.get(:suggestions)
if Keyword.get(suggestions, :enabled, false) do
api = Keyword.get(suggestions, :third_party_engine, "")
timeout = Keyword.get(suggestions, :timeout, 5000)
limit = Keyword.get(suggestions, :limit, 23)
host = Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host])
user = user.nickname
url = String.replace(api, "{{host}}", host) |> String.replace("{{user}}", user)
with {:ok, %{status_code: 200, body: body}} <-
@httpoison.get(url, [], timeout: timeout, recv_timeout: timeout),
{:ok, data} <- Jason.decode(body) do
data2 =
Enum.slice(data, 0, limit)
|> Enum.map(fn x ->
Map.put(
x,
"id",
case User.get_or_fetch(x["acct"]) do
%{id: id} -> id
_ -> 0
end
)
end)
|> Enum.map(fn x ->
Map.put(x, "avatar", MediaProxy.url(x["avatar"]))
end)
|> Enum.map(fn x ->
Map.put(x, "avatar_static", MediaProxy.url(x["avatar_static"]))
end)
conn
|> json(data2)
else
e -> Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
end
else
json(conn, [])
end
end
def try_render(conn, renderer, target, params)
when is_binary(target) do
res = render(conn, renderer, target, params)
if res == nil do
conn
|> put_status(501)
|> json(%{error: "Can't display this activity"})
else
res
end
end
def try_render(conn, _, _, _) do
conn
|> put_status(501)
|> json(%{error: "Can't display this activity"})
end
end
diff --git a/lib/pleroma/web/media_proxy/controller.ex b/lib/pleroma/web/media_proxy/controller.ex
index bb257c262..81ea5d510 100644
--- a/lib/pleroma/web/media_proxy/controller.ex
+++ b/lib/pleroma/web/media_proxy/controller.ex
@@ -1,135 +1,34 @@
defmodule Pleroma.Web.MediaProxy.MediaProxyController do
use Pleroma.Web, :controller
- require Logger
+ alias Pleroma.{Web.MediaProxy, ReverseProxy}
- @httpoison Application.get_env(:pleroma, :httpoison)
+ @default_proxy_opts [max_body_length: 25 * 1_048_576]
- @max_body_length 25 * 1_048_576
-
- @cache_control %{
- default: "public, max-age=1209600",
- error: "public, must-revalidate, max-age=160"
- }
-
- # 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, [])
-
- with true <- Keyword.get(config, :enabled, false),
- {:ok, url} <- Pleroma.Web.MediaProxy.decode_url(sig, url),
+ def remote(conn, params = %{"sig" => sig64, "url" => url64}) do
+ with config <- Pleroma.Config.get([:media_proxy]),
+ true <- Keyword.get(config, :enabled, false),
+ {:ok, url} <- MediaProxy.decode_url(sig64, url64),
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
- |> put_resp_content_type(content_type)
- |> 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)
+ :ok <- filename_matches(Map.has_key?(params, "filename"), conn.request_path, url) do
+ ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_length))
else
false ->
- send_error(conn, 404)
+ send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404))
{:error, :invalid_signature} ->
- send_error(conn, 403)
+ send_resp(conn, 403, Plug.Conn.Status.reason_phrase(403))
- {:error, {:http, _, url}} ->
- redirect_or_error(conn, url, Keyword.get(config, :redirect_on_failure, true))
+ {:wrong_filename, filename} ->
+ redirect(conn, external: MediaProxy.build_url(sig64, url64, filename))
end
end
- defp proxy_request(link) do
- headers = [
- {"user-agent",
- "Pleroma/MediaProxy; #{Pleroma.Web.base_url()} <#{
- Application.get_env(:pleroma, :instance)[:email]
- }>"}
- ]
-
- options =
- @httpoison.process_request_options([:insecure, {:follow_redirect, true}]) ++
- [{:pool, :default}]
-
- with {:ok, 200, headers, client} <- :hackney.request(:get, link, headers, "", options),
- headers = Enum.into(headers, Map.new()),
- {:ok, body} <- proxy_request_body(client),
- content_type <- proxy_request_content_type(headers, body) do
- {:ok, content_type, body}
- else
- {:ok, status, _, _} ->
- Logger.warn("MediaProxy: request failed, status #{status}, link: #{link}")
- {:error, {:http, :bad_status, link}}
+ def filename_matches(has_filename, path, url) do
+ filename = MediaProxy.filename(url)
- {:error, error} ->
- Logger.warn("MediaProxy: request failed, error #{inspect(error)}, link: #{link}")
- {:error, {:http, error, link}}
+ cond do
+ has_filename && filename && Path.basename(path) != filename -> {:wrong_filename, filename}
+ true -> :ok
end
end
-
- defp set_cache_header(conn, key) do
- Plug.Conn.put_resp_header(conn, "cache-control", @cache_control[key])
- end
-
- defp redirect_or_error(conn, url, true), do: redirect(conn, external: url)
- defp redirect_or_error(conn, url, _), do: send_error(conn, 502, "Media proxy error: " <> url)
-
- defp send_error(conn, code, body \\ "") do
- conn
- |> set_cache_header(:error)
- |> send_resp(code, body)
- end
-
- defp proxy_request_body(client), do: proxy_request_body(client, <<>>)
-
- defp proxy_request_body(client, body) when byte_size(body) < @max_body_length do
- case :hackney.stream_body(client) do
- {:ok, data} -> proxy_request_body(client, <<body::binary, data::binary>>)
- :done -> {:ok, body}
- {:error, reason} -> {:error, reason}
- end
- end
-
- defp proxy_request_body(client, _) do
- :hackney.close(client)
- {:error, :body_too_large}
- end
-
- # 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.
- defp proxy_request_content_type(headers, _body) do
- 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
diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex
index 0fc0a07b2..28aacb0b1 100644
--- a/lib/pleroma/web/media_proxy/media_proxy.ex
+++ b/lib/pleroma/web/media_proxy/media_proxy.ex
@@ -1,38 +1,52 @@
defmodule Pleroma.Web.MediaProxy do
@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(:pleroma, :media_proxy, [])
if !Keyword.get(config, :enabled, false) or String.starts_with?(url, Pleroma.Web.base_url()) do
url
else
secret = Application.get_env(:pleroma, Pleroma.Web.Endpoint)[:secret_key_base]
base64 = Base.url_encode64(url, @base64_opts)
sig = :crypto.hmac(:sha, secret, base64)
sig64 = sig |> Base.url_encode64(@base64_opts)
- 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}"
+ build_url(sig64, base64, filename(url))
end
end
def decode_url(sig, url) do
secret = Application.get_env(:pleroma, Pleroma.Web.Endpoint)[:secret_key_base]
sig = Base.url_decode64!(sig, @base64_opts)
local_sig = :crypto.hmac(:sha, secret, url)
if local_sig == sig do
{:ok, Base.url_decode64!(url, @base64_opts)}
else
{:error, :invalid_signature}
end
end
+
+ def filename(url_or_path) do
+ if path = URI.parse(url_or_path).path, do: Path.basename(path)
+ end
+
+ def build_url(sig_base64, url_base64, filename \\ nil) do
+ [
+ Pleroma.Config.get([:media_proxy, :base_url], Pleroma.Web.base_url()),
+ "proxy",
+ sig_base64,
+ url_base64,
+ filename
+ ]
+ |> Enum.filter(fn value -> value end)
+ |> Path.join()
+ end
end
diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex
index d98deeb03..39a2974bb 100644
--- a/lib/pleroma/web/twitter_api/twitter_api.ex
+++ b/lib/pleroma/web/twitter_api/twitter_api.ex
@@ -1,288 +1,288 @@
defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
alias Pleroma.{UserInviteToken, User, Activity, Repo, Object}
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.TwitterAPI.UserView
alias Pleroma.Web.{OStatus, CommonAPI}
alias Pleroma.Web.MediaProxy
import Ecto.Query
@httpoison Application.get_env(:pleroma, :httpoison)
def create_status(%User{} = user, %{"status" => _} = data) do
CommonAPI.post(user, data)
end
def delete(%User{} = user, id) do
with %Activity{data: %{"type" => type}} <- Repo.get(Activity, id),
{:ok, activity} <- CommonAPI.delete(id, user) do
{:ok, activity}
end
end
def follow(%User{} = follower, params) do
with {:ok, %User{} = followed} <- get_user(params),
{:ok, follower} <- User.maybe_direct_follow(follower, followed),
{:ok, activity} <- ActivityPub.follow(follower, followed),
{:ok, follower, followed} <-
User.wait_and_refresh(
Pleroma.Config.get([:activitypub, :follow_handshake_timeout]),
follower,
followed
) do
{:ok, follower, followed, activity}
else
err -> err
end
end
def unfollow(%User{} = follower, params) do
with {:ok, %User{} = unfollowed} <- get_user(params),
{:ok, follower, follow_activity} <- User.unfollow(follower, unfollowed),
{:ok, _activity} <- ActivityPub.unfollow(follower, unfollowed) do
{:ok, follower, unfollowed}
else
err -> err
end
end
def block(%User{} = blocker, params) do
with {:ok, %User{} = blocked} <- get_user(params),
{:ok, blocker} <- User.block(blocker, blocked),
{:ok, _activity} <- ActivityPub.block(blocker, blocked) do
{:ok, blocker, blocked}
else
err -> err
end
end
def unblock(%User{} = blocker, params) do
with {:ok, %User{} = blocked} <- get_user(params),
{:ok, blocker} <- User.unblock(blocker, blocked),
{:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do
{:ok, blocker, blocked}
else
err -> err
end
end
def repeat(%User{} = user, ap_id_or_id) do
with {:ok, _announce, %{data: %{"id" => id}}} <- CommonAPI.repeat(ap_id_or_id, user),
%Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
{:ok, activity}
end
end
def unrepeat(%User{} = user, ap_id_or_id) do
with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user),
%Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
{:ok, activity}
end
end
def fav(%User{} = user, ap_id_or_id) do
with {:ok, _fav, %{data: %{"id" => id}}} <- CommonAPI.favorite(ap_id_or_id, user),
%Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
{:ok, activity}
end
end
def unfav(%User{} = user, ap_id_or_id) do
with {:ok, _unfav, _fav, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user),
%Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
{:ok, activity}
end
end
def upload(%Plug.Upload{} = file, format \\ "xml") do
{:ok, object} = ActivityPub.upload(file)
url = List.first(object.data["url"])
- href = url["href"] |> MediaProxy.url()
+ href = url["href"]
type = url["mediaType"]
case format do
"xml" ->
# Fake this as good as possible...
"""
<?xml version="1.0" encoding="UTF-8"?>
<rsp stat="ok" xmlns:atom="http://www.w3.org/2005/Atom">
<mediaid>#{object.id}</mediaid>
<media_id>#{object.id}</media_id>
<media_id_string>#{object.id}</media_id_string>
<media_url>#{href}</media_url>
<mediaurl>#{href}</mediaurl>
<atom:link rel="enclosure" href="#{href}" type="#{type}"></atom:link>
</rsp>
"""
"json" ->
%{
media_id: object.id,
media_id_string: "#{object.id}}",
media_url: href,
size: 0
}
|> Jason.encode!()
end
end
def register_user(params) do
tokenString = params["token"]
params = %{
nickname: params["nickname"],
name: params["fullname"],
bio: params["bio"],
email: params["email"],
password: params["password"],
password_confirmation: params["confirm"]
}
registrations_open = Pleroma.Config.get([:instance, :registrations_open])
# no need to query DB if registration is open
token =
unless registrations_open || is_nil(tokenString) do
Repo.get_by(UserInviteToken, %{token: tokenString})
end
cond do
registrations_open || (!is_nil(token) && !token.used) ->
changeset = User.register_changeset(%User{info: %{}}, params)
with {:ok, user} <- Repo.insert(changeset) do
!registrations_open && UserInviteToken.mark_as_used(token.token)
{:ok, user}
else
{:error, changeset} ->
errors =
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|> Jason.encode!()
{:error, %{error: errors}}
end
!registrations_open && is_nil(token) ->
{:error, "Invalid token"}
!registrations_open && token.used ->
{:error, "Expired token"}
end
end
def get_by_id_or_nickname(id_or_nickname) do
if !is_integer(id_or_nickname) && :error == Integer.parse(id_or_nickname) do
Repo.get_by(User, nickname: id_or_nickname)
else
Repo.get(User, id_or_nickname)
end
end
def get_user(user \\ nil, params) do
case params do
%{"user_id" => user_id} ->
case target = get_by_id_or_nickname(user_id) do
nil ->
{:error, "No user with such user_id"}
_ ->
{:ok, target}
end
%{"screen_name" => nickname} ->
case target = Repo.get_by(User, nickname: nickname) do
nil ->
{:error, "No user with such screen_name"}
_ ->
{:ok, target}
end
_ ->
if user do
{:ok, user}
else
{:error, "You need to specify screen_name or user_id"}
end
end
end
defp parse_int(string, default)
defp parse_int(string, default) when is_binary(string) do
with {n, _} <- Integer.parse(string) do
n
else
_e -> default
end
end
defp parse_int(_, default), do: default
def search(_user, %{"q" => query} = params) do
limit = parse_int(params["rpp"], 20)
page = parse_int(params["page"], 1)
offset = (page - 1) * limit
q =
from(
a in Activity,
where: fragment("?->>'type' = 'Create'", a.data),
where: "https://www.w3.org/ns/activitystreams#Public" in a.recipients,
where:
fragment(
"to_tsvector('english', ?->'object'->>'content') @@ plainto_tsquery('english', ?)",
a.data,
^query
),
limit: ^limit,
offset: ^offset,
# this one isn't indexed so psql won't take the wrong index.
order_by: [desc: :inserted_at]
)
_activities = Repo.all(q)
end
defp make_date do
DateTime.utc_now() |> DateTime.to_iso8601()
end
# DEPRECATED mostly, context objects are now created at insertion time.
def context_to_conversation_id(context) do
with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
id
else
_e ->
changeset = Object.context_mapping(context)
case Repo.insert(changeset) do
{:ok, %{id: id}} ->
id
# This should be solved by an upsert, but it seems ecto
# has problems accessing the constraint inside the jsonb.
{:error, _} ->
Object.get_cached_by_ap_id(context).id
end
end
end
def conversation_id_to_context(id) do
with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
context
else
_e ->
{:error, "No such conversation"}
end
end
def get_external_profile(for_user, uri) do
with %User{} = user <- User.get_or_fetch(uri) do
{:ok, UserView.render("show.json", %{user: user, for: for_user})}
else
_e ->
{:error, "Couldn't find user"}
end
end
end
diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex
index b0bd98dbc..8f9d6c905 100644
--- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex
+++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex
@@ -1,553 +1,541 @@
defmodule Pleroma.Web.TwitterAPI.Controller do
use Pleroma.Web, :controller
alias Pleroma.Formatter
alias Pleroma.Web.TwitterAPI.{TwitterAPI, UserView, ActivityView, NotificationView}
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
alias Pleroma.{Repo, Activity, User, Notification}
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils
alias Ecto.Changeset
require Logger
plug(:only_if_public_instance when action in [:public_timeline, :public_and_external_timeline])
action_fallback(:errors)
def verify_credentials(%{assigns: %{user: user}} = conn, _params) do
token = Phoenix.Token.sign(conn, "user socket", user.id)
render(conn, UserView, "show.json", %{user: user, token: token})
end
def status_update(%{assigns: %{user: user}} = conn, %{"status" => _} = status_data) do
with media_ids <- extract_media_ids(status_data),
{:ok, activity} <-
TwitterAPI.create_status(user, Map.put(status_data, "media_ids", media_ids)) do
conn
|> json(ActivityView.render("activity.json", activity: activity, for: user))
else
_ -> empty_status_reply(conn)
end
end
def status_update(conn, _status_data) do
empty_status_reply(conn)
end
defp empty_status_reply(conn) do
bad_request_reply(conn, "Client must provide a 'status' parameter with a value.")
end
defp extract_media_ids(status_data) do
with media_ids when not is_nil(media_ids) <- status_data["media_ids"],
split_ids <- String.split(media_ids, ","),
clean_ids <- Enum.reject(split_ids, fn id -> String.length(id) == 0 end) do
clean_ids
else
_e -> []
end
end
def public_and_external_timeline(%{assigns: %{user: user}} = conn, params) do
params =
params
|> Map.put("type", ["Create", "Announce"])
|> Map.put("blocking_user", user)
activities = ActivityPub.fetch_public_activities(params)
conn
|> render(ActivityView, "index.json", %{activities: activities, for: user})
end
def public_timeline(%{assigns: %{user: user}} = conn, params) do
params =
params
|> Map.put("type", ["Create", "Announce"])
|> Map.put("local_only", true)
|> Map.put("blocking_user", user)
activities = ActivityPub.fetch_public_activities(params)
conn
|> render(ActivityView, "index.json", %{activities: activities, for: user})
end
def friends_timeline(%{assigns: %{user: user}} = conn, params) do
params =
params
|> Map.put("type", ["Create", "Announce", "Follow", "Like"])
|> Map.put("blocking_user", user)
|> Map.put("user", user)
activities =
ActivityPub.fetch_activities([user.ap_id | user.following], params)
|> ActivityPub.contain_timeline(user)
conn
|> render(ActivityView, "index.json", %{activities: activities, for: user})
end
def show_user(conn, params) do
with {:ok, shown} <- TwitterAPI.get_user(params) do
if user = conn.assigns.user do
render(conn, UserView, "show.json", %{user: shown, for: user})
else
render(conn, UserView, "show.json", %{user: shown})
end
else
{:error, msg} ->
bad_request_reply(conn, msg)
end
end
def user_timeline(%{assigns: %{user: user}} = conn, params) do
case TwitterAPI.get_user(user, params) do
{:ok, target_user} ->
activities = ActivityPub.fetch_user_activities(target_user, user, params)
conn
|> render(ActivityView, "index.json", %{activities: activities, for: user})
{:error, msg} ->
bad_request_reply(conn, msg)
end
end
def mentions_timeline(%{assigns: %{user: user}} = conn, params) do
params =
params
|> Map.put("type", ["Create", "Announce", "Follow", "Like"])
|> Map.put("blocking_user", user)
activities = ActivityPub.fetch_activities([user.ap_id], params)
conn
|> render(ActivityView, "index.json", %{activities: activities, for: user})
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
notifications = Notification.for_user(user, params)
conn
|> render(NotificationView, "notification.json", %{notifications: notifications, for: user})
end
def notifications_read(%{assigns: %{user: user}} = conn, %{"latest_id" => latest_id} = params) do
Notification.set_read_up_to(user, latest_id)
notifications = Notification.for_user(user, params)
conn
|> render(NotificationView, "notification.json", %{notifications: notifications, for: user})
end
def notifications_read(%{assigns: %{user: user}} = conn, _) do
bad_request_reply(conn, "You need to specify latest_id")
end
def follow(%{assigns: %{user: user}} = conn, params) do
case TwitterAPI.follow(user, params) do
{:ok, user, followed, _activity} ->
render(conn, UserView, "show.json", %{user: followed, for: user})
{:error, msg} ->
forbidden_json_reply(conn, msg)
end
end
def block(%{assigns: %{user: user}} = conn, params) do
case TwitterAPI.block(user, params) do
{:ok, user, blocked} ->
render(conn, UserView, "show.json", %{user: blocked, for: user})
{:error, msg} ->
forbidden_json_reply(conn, msg)
end
end
def unblock(%{assigns: %{user: user}} = conn, params) do
case TwitterAPI.unblock(user, params) do
{:ok, user, blocked} ->
render(conn, UserView, "show.json", %{user: blocked, for: user})
{:error, msg} ->
forbidden_json_reply(conn, msg)
end
end
def delete_post(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with {:ok, activity} <- TwitterAPI.delete(user, id) do
render(conn, ActivityView, "activity.json", %{activity: activity, for: user})
end
end
def unfollow(%{assigns: %{user: user}} = conn, params) do
case TwitterAPI.unfollow(user, params) do
{:ok, user, unfollowed} ->
render(conn, UserView, "show.json", %{user: unfollowed, for: user})
{:error, msg} ->
forbidden_json_reply(conn, msg)
end
end
def fetch_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %Activity{} = activity <- Repo.get(Activity, id),
true <- ActivityPub.visible_for_user?(activity, user) do
render(conn, ActivityView, "activity.json", %{activity: activity, for: user})
end
end
def fetch_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do
id = String.to_integer(id)
with context when is_binary(context) <- TwitterAPI.conversation_id_to_context(id),
activities <-
ActivityPub.fetch_activities_for_context(context, %{
"blocking_user" => user,
"user" => user
}) do
conn
|> render(ActivityView, "index.json", %{activities: activities, for: user})
end
end
def upload(conn, %{"media" => media}) do
response = TwitterAPI.upload(media)
conn
|> put_resp_content_type("application/atom+xml")
|> send_resp(200, response)
end
def upload_json(conn, %{"media" => media}) do
response = TwitterAPI.upload(media, "json")
conn
|> json_reply(200, response)
end
def get_by_id_or_ap_id(id) do
activity = Repo.get(Activity, id) || Activity.get_create_activity_by_object_ap_id(id)
if activity.data["type"] == "Create" do
activity
else
Activity.get_create_activity_by_object_ap_id(activity.data["object"])
end
end
def favorite(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)},
{:ok, activity} <- TwitterAPI.fav(user, id) do
render(conn, ActivityView, "activity.json", %{activity: activity, for: user})
end
end
def unfavorite(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)},
{:ok, activity} <- TwitterAPI.unfav(user, id) do
render(conn, ActivityView, "activity.json", %{activity: activity, for: user})
end
end
def retweet(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)},
{:ok, activity} <- TwitterAPI.repeat(user, id) do
render(conn, ActivityView, "activity.json", %{activity: activity, for: user})
end
end
def unretweet(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)},
{:ok, activity} <- TwitterAPI.unrepeat(user, id) do
render(conn, ActivityView, "activity.json", %{activity: activity, for: user})
end
end
def register(conn, params) do
with {:ok, user} <- TwitterAPI.register_user(params) do
render(conn, UserView, "show.json", %{user: user})
else
{:error, errors} ->
conn
|> json_reply(400, Jason.encode!(errors))
end
end
def update_avatar(%{assigns: %{user: user}} = conn, params) do
- upload_limit =
- Application.get_env(:pleroma, :instance)
- |> Keyword.fetch(:avatar_upload_limit)
-
- {:ok, object} = ActivityPub.upload(params, upload_limit)
+ {:ok, object} = ActivityPub.upload(params, type: :avatar)
change = Changeset.change(user, %{avatar: object.data})
{:ok, user} = User.update_and_set_cache(change)
CommonAPI.update(user)
render(conn, UserView, "show.json", %{user: user, for: user})
end
def update_banner(%{assigns: %{user: user}} = conn, params) do
- upload_limit =
- Application.get_env(:pleroma, :instance)
- |> Keyword.fetch(:banner_upload_limit)
-
- with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, upload_limit),
+ with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, type: :banner),
new_info <- Map.put(user.info, "banner", object.data),
change <- User.info_changeset(user, %{info: new_info}),
{:ok, user} <- User.update_and_set_cache(change) do
CommonAPI.update(user)
%{"url" => [%{"href" => href} | _]} = object.data
response = %{url: href} |> Jason.encode!()
conn
|> json_reply(200, response)
end
end
def update_background(%{assigns: %{user: user}} = conn, params) do
- upload_limit =
- Application.get_env(:pleroma, :instance)
- |> Keyword.fetch(:background_upload_limit)
-
- with {:ok, object} <- ActivityPub.upload(params, upload_limit),
+ with {:ok, object} <- ActivityPub.upload(params, type: :background),
new_info <- Map.put(user.info, "background", object.data),
change <- User.info_changeset(user, %{info: new_info}),
{:ok, _user} <- User.update_and_set_cache(change) do
%{"url" => [%{"href" => href} | _]} = object.data
response = %{url: href} |> Jason.encode!()
conn
|> json_reply(200, response)
end
end
def external_profile(%{assigns: %{user: current_user}} = conn, %{"profileurl" => uri}) do
with {:ok, user_map} <- TwitterAPI.get_external_profile(current_user, uri),
response <- Jason.encode!(user_map) do
conn
|> json_reply(200, response)
else
_e ->
conn
|> put_status(404)
|> json(%{error: "Can't find user"})
end
end
def followers(conn, params) do
with {:ok, user} <- TwitterAPI.get_user(conn.assigns[:user], params),
{:ok, followers} <- User.get_followers(user) do
render(conn, UserView, "index.json", %{users: followers, for: conn.assigns[:user]})
else
_e -> bad_request_reply(conn, "Can't get followers")
end
end
def friends(conn, params) do
with {:ok, user} <- TwitterAPI.get_user(conn.assigns[:user], params),
{:ok, friends} <- User.get_friends(user) do
render(conn, UserView, "index.json", %{users: friends, for: conn.assigns[:user]})
else
_e -> bad_request_reply(conn, "Can't get friends")
end
end
def friend_requests(conn, params) do
with {:ok, user} <- TwitterAPI.get_user(conn.assigns[:user], params),
{:ok, friend_requests} <- User.get_follow_requests(user) do
render(conn, UserView, "index.json", %{users: friend_requests, for: conn.assigns[:user]})
else
_e -> bad_request_reply(conn, "Can't get friend requests")
end
end
def approve_friend_request(conn, %{"user_id" => uid} = params) do
with followed <- conn.assigns[:user],
uid when is_number(uid) <- String.to_integer(uid),
%User{} = follower <- Repo.get(User, uid),
{:ok, follower} <- User.maybe_follow(follower, followed),
%Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed),
{:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
{:ok, _activity} <-
ActivityPub.accept(%{
to: [follower.ap_id],
actor: followed.ap_id,
object: follow_activity.data["id"],
type: "Accept"
}) do
render(conn, UserView, "show.json", %{user: follower, for: followed})
else
e -> bad_request_reply(conn, "Can't approve user: #{inspect(e)}")
end
end
def deny_friend_request(conn, %{"user_id" => uid} = params) do
with followed <- conn.assigns[:user],
uid when is_number(uid) <- String.to_integer(uid),
%User{} = follower <- Repo.get(User, uid),
%Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed),
{:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
{:ok, _activity} <-
ActivityPub.reject(%{
to: [follower.ap_id],
actor: followed.ap_id,
object: follow_activity.data["id"],
type: "Reject"
}) do
render(conn, UserView, "show.json", %{user: follower, for: followed})
else
e -> bad_request_reply(conn, "Can't deny user: #{inspect(e)}")
end
end
def friends_ids(%{assigns: %{user: user}} = conn, _params) do
with {:ok, friends} <- User.get_friends(user) do
ids =
friends
|> Enum.map(fn x -> x.id end)
|> Jason.encode!()
json(conn, ids)
else
_e -> bad_request_reply(conn, "Can't get friends")
end
end
def empty_array(conn, _params) do
json(conn, Jason.encode!([]))
end
def raw_empty_array(conn, _params) do
json(conn, [])
end
defp build_info_cng(user, params) do
info_params =
["no_rich_text", "locked"]
|> Enum.reduce(%{}, fn key, res ->
if value = params[key] do
Map.put(res, key, value == "true")
else
res
end
end)
info_params =
if value = params["default_scope"] do
Map.put(info_params, "default_scope", value)
else
info_params
end
User.Info.profile_update(user.info, info_params)
end
defp add_profile_emoji(user, params) do
if bio = params["description"] do
mentions = Formatter.parse_mentions(bio)
tags = Formatter.parse_tags(bio)
emoji =
(user.info.source_data["tag"] || [])
|> Enum.filter(fn %{"type" => t} -> t == "Emoji" end)
|> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} ->
{String.trim(name, ":"), url}
end)
bio_html = CommonUtils.format_input(bio, mentions, tags, "text/plain")
Map.put(params, "bio", bio_html |> Formatter.emojify(emoji))
else
params
end
end
def update_profile(%{assigns: %{user: user}} = conn, params) do
params = add_profile_emoji(user, params)
info_cng = build_info_cng(user, params)
with changeset <- User.update_changeset(user, params),
changeset <- Ecto.Changeset.put_embed(changeset, :info, info_cng),
{:ok, user} <- User.update_and_set_cache(changeset) do
CommonAPI.update(user)
render(conn, UserView, "user.json", %{user: user, for: user})
else
error ->
Logger.debug("Can't update user: #{inspect(error)}")
bad_request_reply(conn, "Can't update user")
end
end
def search(%{assigns: %{user: user}} = conn, %{"q" => _query} = params) do
activities = TwitterAPI.search(user, params)
conn
|> render(ActivityView, "index.json", %{activities: activities, for: user})
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
json = error_json(conn, error_message)
json_reply(conn, 400, json)
end
defp json_reply(conn, status, json) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, json)
end
defp forbidden_json_reply(conn, error_message) do
json = error_json(conn, error_message)
json_reply(conn, 403, json)
end
def only_if_public_instance(conn = %{conn: %{assigns: %{user: _user}}}, _), do: conn
def only_if_public_instance(conn, _) do
if Keyword.get(Application.get_env(:pleroma, :instance), :public) do
conn
else
conn
|> forbidden_json_reply("Invalid credentials.")
|> halt()
end
end
defp error_json(conn, error_message) do
%{"error" => error_message, "request" => conn.request_path} |> Jason.encode!()
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
diff --git a/test/config_test.exs b/test/config_test.exs
index 0124544c8..837cbb30c 100644
--- a/test/config_test.exs
+++ b/test/config_test.exs
@@ -1,56 +1,71 @@
defmodule Pleroma.ConfigTest do
use ExUnit.Case
test "get/1 with an atom" do
assert Pleroma.Config.get(:instance) == Application.get_env(:pleroma, :instance)
assert Pleroma.Config.get(:azertyuiop) == nil
assert Pleroma.Config.get(:azertyuiop, true) == true
end
test "get/1 with a list of keys" do
assert Pleroma.Config.get([:instance, :public]) ==
Keyword.get(Application.get_env(:pleroma, :instance), :public)
assert Pleroma.Config.get([Pleroma.Web.Endpoint, :render_errors, :view]) ==
get_in(
Application.get_env(
:pleroma,
Pleroma.Web.Endpoint
),
[:render_errors, :view]
)
assert Pleroma.Config.get([:azerty, :uiop]) == nil
assert Pleroma.Config.get([:azerty, :uiop], true) == true
end
test "get!/1" do
assert Pleroma.Config.get!(:instance) == Application.get_env(:pleroma, :instance)
assert Pleroma.Config.get!([:instance, :public]) ==
Keyword.get(Application.get_env(:pleroma, :instance), :public)
assert_raise(Pleroma.Config.Error, fn ->
Pleroma.Config.get!(:azertyuiop)
end)
assert_raise(Pleroma.Config.Error, fn ->
Pleroma.Config.get!([:azerty, :uiop])
end)
end
test "put/2 with a key" do
Pleroma.Config.put(:config_test, true)
assert Pleroma.Config.get(:config_test) == true
end
test "put/2 with a list of keys" do
Pleroma.Config.put([:instance, :config_test], true)
Pleroma.Config.put([:instance, :config_nested_test], [])
Pleroma.Config.put([:instance, :config_nested_test, :x], true)
assert Pleroma.Config.get([:instance, :config_test]) == true
assert Pleroma.Config.get([:instance, :config_nested_test, :x]) == true
end
+
+ test "delete/1 with a key" do
+ Pleroma.Config.put([:delete_me], :delete_me)
+ Pleroma.Config.delete([:delete_me])
+ assert Pleroma.Config.get([:delete_me]) == nil
+ end
+
+ test "delete/2 with a list of keys" do
+ Pleroma.Config.put([:delete_me], hello: "world", world: "Hello")
+ Pleroma.Config.delete([:delete_me, :world])
+ assert Pleroma.Config.get([:delete_me]) == [hello: "world"]
+ Pleroma.Config.put([:delete_me, :delete_me], hello: "world", world: "Hello")
+ Pleroma.Config.delete([:delete_me, :delete_me, :world])
+ assert Pleroma.Config.get([:delete_me, :delete_me]) == [hello: "world"]
+ end
end
diff --git a/test/media_proxy_test.exs b/test/media_proxy_test.exs
index c69ed7ea4..d71f9f13a 100644
--- a/test/media_proxy_test.exs
+++ b/test/media_proxy_test.exs
@@ -1,113 +1,130 @@
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
+
+ test "uses the configured base_url" do
+ base_url = Pleroma.Config.get([:media_proxy, :base_url])
+
+ if base_url do
+ on_exit(fn ->
+ Pleroma.Config.put([:media_proxy, :base_url], base_url)
+ end)
+ end
+
+ Pleroma.Config.put([:media_proxy, :base_url], "https://cache.pleroma.social")
+
+ url = "https://pleroma.soykaf.com/static/logo.png"
+ encoded = url(url)
+
+ assert String.starts_with?(encoded, Pleroma.Config.get([:media_proxy, :base_url]))
+ 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
diff --git a/test/support/httpoison_mock.ex b/test/support/httpoison_mock.ex
index 0be09b6ce..e7344500f 100644
--- a/test/support/httpoison_mock.ex
+++ b/test/support/httpoison_mock.ex
@@ -1,881 +1,883 @@
defmodule HTTPoisonMock do
alias HTTPoison.Response
+ def process_request_options(options), do: options
+
def get(url, body \\ [], headers \\ [])
def get("https://prismo.news/@mxb", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___prismo.news__mxb.json")
}}
end
def get("https://osada.macgirvin.com/channel/mike", _, _) do
{:ok,
%Response{
status_code: 200,
body:
File.read!("test/fixtures/httpoison_mock/https___osada.macgirvin.com_channel_mike.json")
}}
end
def get(
"https://osada.macgirvin.com/.well-known/webfinger?resource=acct:mike@osada.macgirvin.com",
_,
_
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/mike@osada.macgirvin.com.json")
}}
end
def get("https://info.pleroma.site/activity.json", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https__info.pleroma.site_activity.json")
}}
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
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/puckipedia.com.json")
}}
end
def get(
"https://gerzilla.de/.well-known/webfinger?resource=acct:kaniini@gerzilla.de",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/kaniini@gerzilla.de.json")
}}
end
def get(
"https://framatube.org/.well-known/webfinger?resource=acct:framasoft@framatube.org",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/framasoft@framatube.org.json")
}}
end
def get(
"https://gnusocial.de/.well-known/webfinger?resource=acct:winterdienst@gnusocial.de",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/winterdienst_webfinger.json")
}}
end
def get(
"https://social.heldscal.la/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "nonexistant@social.heldscal.la"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 500,
body: File.read!("test/fixtures/httpoison_mock/nonexistant@social.heldscal.la.xml")
}}
end
def get(
"https://social.heldscal.la/.well-known/webfinger?resource=shp@social.heldscal.la",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/shp@social.heldscal.la.xml")
}}
end
def get(
"https://social.heldscal.la/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "shp@social.heldscal.la"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/shp@social.heldscal.la.xml")
}}
end
def get(
"https://social.heldscal.la/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "https://social.heldscal.la/user/23211"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___social.heldscal.la_user_23211.xml")
}}
end
def get(
"https://social.heldscal.la/.well-known/webfinger?resource=https://social.heldscal.la/user/23211",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___social.heldscal.la_user_23211.xml")
}}
end
def get(
"https://social.heldscal.la/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "https://social.heldscal.la/user/29191"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___social.heldscal.la_user_29191.xml")
}}
end
def get(
"https://social.heldscal.la/.well-known/webfinger?resource=https://social.heldscal.la/user/29191",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___social.heldscal.la_user_29191.xml")
}}
end
def get(
"https://mastodon.social/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "https://mastodon.social/users/lambadalambda"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/https___mastodon.social_users_lambadalambda.xml"
)
}}
end
def get(
"https://mastodon.social/.well-known/webfinger?resource=https://mastodon.social/users/lambadalambda",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/https___mastodon.social_users_lambadalambda.xml"
)
}}
end
def get(
"https://shitposter.club/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "https://shitposter.club/user/1"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___shitposter.club_user_1.xml")
}}
end
def get(
"https://shitposter.club/.well-known/webfinger?resource=https://shitposter.club/user/1",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___shitposter.club_user_1.xml")
}}
end
def get(
"https://shitposter.club/.well-known/webfinger?resource=https://shitposter.club/user/5381",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/spc_5381_xrd.xml")
}}
end
def get(
"http://gs.example.org/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "http://gs.example.org:4040/index.php/user/1"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/http___gs.example.org_4040_index.php_user_1.xml"
)
}}
end
def get(
"http://gs.example.org/.well-known/webfinger?resource=http://gs.example.org:4040/index.php/user/1",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/http___gs.example.org_4040_index.php_user_1.xml"
)
}}
end
def get(
"https://social.stopwatchingus-heidelberg.de/.well-known/webfinger?resource=https://social.stopwatchingus-heidelberg.de/user/18330",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/atarifrosch_webfinger.xml")
}}
end
def get(
"https://pleroma.soykaf.com/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "https://pleroma.soykaf.com/users/lain"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___pleroma.soykaf.com_users_lain.xml")
}}
end
def get(
"https://pleroma.soykaf.com/.well-known/webfinger?resource=https://pleroma.soykaf.com/users/lain",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___pleroma.soykaf.com_users_lain.xml")
}}
end
def get("https://social.heldscal.la/api/statuses/user_timeline/29191.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/https___social.heldscal.la_api_statuses_user_timeline_29191.atom.xml"
)
}}
end
def get("https://shitposter.club/api/statuses/user_timeline/5381.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/spc_5381.atom")
}}
end
def get("https://social.heldscal.la/api/statuses/user_timeline/23211.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/https___social.heldscal.la_api_statuses_user_timeline_23211.atom.xml"
)
}}
end
def get("https://mastodon.social/users/lambadalambda.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/https___mastodon.social_users_lambadalambda.atom"
)
}}
end
def get(
"https://social.stopwatchingus-heidelberg.de/api/statuses/user_timeline/18330.atom",
_body,
_headers
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/atarifrosch_feed.xml")
}}
end
def get("https://pleroma.soykaf.com/users/lain/feed.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/https___pleroma.soykaf.com_users_lain_feed.atom.xml"
)
}}
end
def get("https://social.sakamoto.gq/users/eal/feed.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/sakamoto_eal_feed.atom")
}}
end
def get("http://gs.example.org/index.php/api/statuses/user_timeline/1.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/http__gs.example.org_index.php_api_statuses_user_timeline_1.atom.xml"
)
}}
end
def get("https://shitposter.club/notice/2827873", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body:
File.read!("test/fixtures/httpoison_mock/https___shitposter.club_notice_2827873.html")
}}
end
def get("https://shitposter.club/api/statuses/show/2827873.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/https___shitposter.club_api_statuses_show_2827873.atom.xml"
)
}}
end
def get("https://shitposter.club/api/statuses/user_timeline/1.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/https___shitposter.club_api_statuses_user_timeline_1.atom.xml"
)
}}
end
def post(
"https://social.heldscal.la/main/push/hub",
{:form, _data},
"Content-type": "application/x-www-form-urlencoded"
) do
{:ok,
%Response{
status_code: 202
}}
end
def get("http://mastodon.example.org/users/admin/statuses/100787282858396771", _, _) do
{:ok,
%Response{
status_code: 200,
body:
File.read!(
"test/fixtures/httpoison_mock/http___mastodon.example.org_users_admin_status_1234.json"
)
}}
end
def get(
"https://pawoo.net/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "https://pawoo.net/users/pekorino"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___pawoo.net_users_pekorino.xml")
}}
end
def get(
"https://pawoo.net/.well-known/webfinger?resource=https://pawoo.net/users/pekorino",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___pawoo.net_users_pekorino.xml")
}}
end
def get("https://pawoo.net/users/pekorino.atom", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___pawoo.net_users_pekorino.atom")
}}
end
def get(
"https://mamot.fr/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "https://mamot.fr/users/Skruyb"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/skruyb@mamot.fr.atom")
}}
end
def get(
"https://mamot.fr/.well-known/webfinger?resource=https://mamot.fr/users/Skruyb",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/skruyb@mamot.fr.atom")
}}
end
def get(
"https://social.sakamoto.gq/.well-known/webfinger",
[Accept: "application/xrd+xml,application/jrd+json"],
params: [resource: "https://social.sakamoto.gq/users/eal"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/eal_sakamoto.xml")
}}
end
def get(
"https://social.sakamoto.gq/.well-known/webfinger?resource=https://social.sakamoto.gq/users/eal",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/eal_sakamoto.xml")
}}
end
def get(
"https://pleroma.soykaf.com/.well-known/webfinger?resource=https://pleroma.soykaf.com/users/shp",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/shp@pleroma.soykaf.com.webfigner")
}}
end
def get(
"https://squeet.me/xrd/?uri=lain@squeet.me",
[Accept: "application/xrd+xml,application/jrd+json"],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/lain_squeet.me_webfinger.xml")
}}
end
def get("https://mamot.fr/users/Skruyb.atom", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/https___mamot.fr_users_Skruyb.atom")
}}
end
def get(
"https://social.sakamoto.gq/objects/0ccc1a2c-66b0-4305-b23a-7f7f2b040056",
[Accept: "application/atom+xml"],
_
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/sakamoto.atom")
}}
end
def get("https://pleroma.soykaf.com/users/shp/feed.atom", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/shp@pleroma.soykaf.com.feed")
}}
end
def get("http://social.heldscal.la/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/social.heldscal.la_host_meta")
}}
end
def get("http://status.alpicola.com/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/status.alpicola.com_host_meta")
}}
end
def get("http://macgirvin.com/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/macgirvin.com_host_meta")
}}
end
def get("http://mastodon.social/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/mastodon.social_host_meta")
}}
end
def get("http://shitposter.club/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/shitposter.club_host_meta")
}}
end
def get("http://pleroma.soykaf.com/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/pleroma.soykaf.com_host_meta")
}}
end
def get("http://social.sakamoto.gq/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/social.sakamoto.gq_host_meta")
}}
end
def get("http://gs.example.org/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/gs.example.org_host_meta")
}}
end
def get("http://pawoo.net/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/pawoo.net_host_meta")
}}
end
def get("http://mamot.fr/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/mamot.fr_host_meta")
}}
end
def get("http://mastodon.xyz/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/mastodon.xyz_host_meta")
}}
end
def get("http://social.wxcafe.net/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/social.wxcafe.net_host_meta")
}}
end
def get("http://squeet.me/.well-known/host-meta", [], follow_redirect: true) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/squeet.me_host_meta")
}}
end
def get(
"http://social.stopwatchingus-heidelberg.de/.well-known/host-meta",
[],
follow_redirect: true
) do
{:ok,
%Response{
status_code: 200,
body:
File.read!("test/fixtures/httpoison_mock/social.stopwatchingus-heidelberg.de_host_meta")
}}
end
def get("http://mastodon.example.org/users/admin", [Accept: "application/activity+json"], _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/admin@mastdon.example.org.json")
}}
end
def get(
"https://hubzilla.example.org/channel/kaniini",
[Accept: "application/activity+json"],
_
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/kaniini@hubzilla.example.org.json")
}}
end
def get("https://masto.quad.moe/users/_HellPie", [Accept: "application/activity+json"], _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/hellpie.json")
}}
end
def get("https://niu.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("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(
"https://mst3k.interlinked.me/users/luciferMysticus",
[Accept: "application/activity+json"],
_
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/lucifermysticus.json")
}}
end
def get("https://mstdn.io/users/mayuutann", [Accept: "application/activity+json"], _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/mayumayu.json")
}}
end
def get(
"http://mastodon.example.org/@admin/99541947525187367",
[Accept: "application/activity+json"],
_
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/mastodon-note-object.json")
}}
end
def get(
"https://mstdn.io/users/mayuutann/statuses/99568293732299394",
[Accept: "application/activity+json"],
_
) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/mayumayupost.json")
}}
end
def get("https://shitposter.club/notice/7369654", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/7369654.html")
}}
end
def get("https://shitposter.club/api/statuses/show/7369654.atom", _body, _headers) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/7369654.atom")
}}
end
def get("https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june-2018/", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/baptiste.gelex.xyz-article.json")
}}
end
def get("https://baptiste.gelez.xyz/@/BaptisteGelez", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/baptiste.gelex.xyz-user.json")
}}
end
def get("https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/peertube.moe-vid.json")
}}
end
def get("https://peertube.moe/accounts/7even", _, _) do
{:ok,
%Response{
status_code: 200,
body: File.read!("test/fixtures/httpoison_mock/7even.json")
}}
end
def get(url, body, headers) do
{:error,
"Not implemented the mock response for get #{inspect(url)}, #{inspect(body)}, #{
inspect(headers)
}"}
end
def post(url, _body, _headers) do
{:error, "Not implemented the mock response for post #{inspect(url)}"}
end
def post(url, _body, _headers, _options) do
{:error, "Not implemented the mock response for post #{inspect(url)}"}
end
end
diff --git a/test/upload_test.exs b/test/upload_test.exs
index d273ea5f6..b2ce755d2 100644
--- a/test/upload_test.exs
+++ b/test/upload_test.exs
@@ -1,86 +1,152 @@
defmodule Pleroma.UploadTest do
alias Pleroma.Upload
use Pleroma.DataCase
- describe "Storing a file" do
+ describe "Storing a file with the Local uploader" do
+ setup do
+ uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
+ filters = Pleroma.Config.get([Pleroma.Upload, :filters])
+
+ unless uploader == Pleroma.Uploaders.Local || filters != [] do
+ Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
+ Pleroma.Config.put([Pleroma.Upload, :filters], [])
+
+ on_exit(fn ->
+ Pleroma.Config.put([Pleroma.Upload, :uploader], uploader)
+ Pleroma.Config.put([Pleroma.Upload, :filters], filters)
+ end)
+ end
+
+ :ok
+ end
+
+ test "returns a media url" do
+ File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
+
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image_tmp.jpg"),
+ filename: "image.jpg"
+ }
+
+ {:ok, data} = Upload.store(file)
+
+ assert %{"url" => [%{"href" => url}]} = data
+
+ assert String.starts_with?(url, Pleroma.Web.base_url() <> "/media/")
+ end
+
+ test "returns a media url with configured base_url" do
+ base_url = "https://cache.pleroma.social"
+
+ File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
+
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image_tmp.jpg"),
+ filename: "image.jpg"
+ }
+
+ {:ok, data} = Upload.store(file, base_url: base_url)
+
+ assert %{"url" => [%{"href" => url}]} = data
+
+ assert String.starts_with?(url, base_url <> "/media/")
+ end
+
test "copies the file to the configured folder with deduping" do
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
file = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image_tmp.jpg"),
filename: "an [image.jpg"
}
- data = Upload.store(file, true)
+ {:ok, data} = Upload.store(file, filters: [Pleroma.Upload.Filter.Dedupe])
- assert data["name"] ==
- "e7a6d0cf595bff76f14c9a98b6c199539559e8b844e02e51e5efcfd1f614a2df.jpeg"
+ assert List.first(data["url"])["href"] ==
+ Pleroma.Web.base_url() <>
+ "/media/e7a6d0cf595bff76f14c9a98b6c199539559e8b844e02e51e5efcfd1f614a2df.jpg"
end
test "copies the file to the configured folder without deduping" do
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
file = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image_tmp.jpg"),
filename: "an [image.jpg"
}
- data = Upload.store(file, false)
+ {:ok, data} = Upload.store(file)
assert data["name"] == "an [image.jpg"
end
test "fixes incorrect content type" do
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
file = %Plug.Upload{
content_type: "application/octet-stream",
path: Path.absname("test/fixtures/image_tmp.jpg"),
filename: "an [image.jpg"
}
- data = Upload.store(file, true)
+ {:ok, data} = Upload.store(file, filters: [Pleroma.Upload.Filter.Dedupe])
assert hd(data["url"])["mediaType"] == "image/jpeg"
end
test "adds missing extension" do
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
file = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image_tmp.jpg"),
filename: "an [image"
}
- data = Upload.store(file, false)
+ {:ok, data} = Upload.store(file)
assert data["name"] == "an [image.jpg"
end
test "fixes incorrect file extension" do
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
file = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname("test/fixtures/image_tmp.jpg"),
filename: "an [image.blah"
}
- data = Upload.store(file, false)
+ {:ok, data} = Upload.store(file)
assert data["name"] == "an [image.jpg"
end
test "don't modify filename of an unknown type" do
File.cp("test/fixtures/test.txt", "test/fixtures/test_tmp.txt")
file = %Plug.Upload{
content_type: "text/plain",
path: Path.absname("test/fixtures/test_tmp.txt"),
filename: "test.txt"
}
- data = Upload.store(file, false)
+ {:ok, data} = Upload.store(file)
assert data["name"] == "test.txt"
end
+
+ test "copies the file to the configured folder with anonymizing filename" do
+ File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
+
+ file = %Plug.Upload{
+ content_type: "image/jpg",
+ path: Path.absname("test/fixtures/image_tmp.jpg"),
+ filename: "an [image.jpg"
+ }
+
+ {:ok, data} = Upload.store(file, filters: [Pleroma.Upload.Filter.AnonymizeFilename])
+
+ refute data["name"] == "an [image.jpg"
+ end
end
end

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 6:07 AM (17 h, 45 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801105
Default Alt Text
(213 KB)

Event Timeline