diff --git a/.gitignore b/.gitignore index 04c61ede7..774893b35 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ erl_crash.dump # Editor config /.vscode/ + +# Prevent committing docs files +/priv/static/doc/* diff --git a/config/config.exs b/config/config.exs index c143f79fc..3462a37f7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -8,6 +8,10 @@ # General application configuration config :pleroma, ecto_repos: [Pleroma.Repo] +config :pleroma, Pleroma.Repo, + types: Pleroma.PostgresTypes, + telemetry_event: [Pleroma.Repo.Instrumenter] + config :pleroma, Pleroma.Captcha, enabled: false, seconds_valid: 60, @@ -54,7 +58,13 @@ cgi: "https://mdii.sakura.ne.jp/mdii-post.cgi", files: "https://mdii.sakura.ne.jp" -config :pleroma, :emoji, shortcode_globs: ["/emoji/custom/**/*.png"] +config :pleroma, :emoji, + shortcode_globs: ["/emoji/custom/**/*.png"], + groups: [ + # Put groups that have higher priority than defaults here. Example in `docs/config/custom_emoji.md` + Finmoji: "/finmoji/128px/*-128.png", + Custom: ["/emoji/*.png", "/emoji/custom/*.png"] + ] config :pleroma, :uri_schemes, valid_schemes: [ @@ -87,6 +97,7 @@ # Configures the endpoint config :pleroma, Pleroma.Web.Endpoint, + instrumenters: [Pleroma.Web.Endpoint.Instrumenter], url: [host: "localhost"], http: [ dispatch: [ @@ -356,7 +367,10 @@ config :pleroma_job_queue, :queues, federator_incoming: 50, federator_outgoing: 50, - mailer: 10 + web_push: 50, + mailer: 10, + transmogrifier: 20, + scheduled_activities: 10 config :pleroma, :fetch_initial_posts, enabled: false, @@ -383,8 +397,31 @@ base: System.get_env("LDAP_BASE") || "dc=example,dc=com", uid: System.get_env("LDAP_UID") || "cn" +oauth_consumer_strategies = String.split(System.get_env("OAUTH_CONSUMER_STRATEGIES") || "") + +ueberauth_providers = + for strategy <- oauth_consumer_strategies do + strategy_module_name = "Elixir.Ueberauth.Strategy.#{String.capitalize(strategy)}" + strategy_module = String.to_atom(strategy_module_name) + {String.to_atom(strategy), {strategy_module, [callback_params: ["state"]]}} + end + +config :ueberauth, + Ueberauth, + base_path: "/oauth", + providers: ueberauth_providers + +config :pleroma, :auth, oauth_consumer_strategies: oauth_consumer_strategies + config :pleroma, Pleroma.Mailer, adapter: Swoosh.Adapters.Sendmail +config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, path: "/api/pleroma/app_metrics" + +config :pleroma, Pleroma.ScheduledActivity, + daily_user_limit: 25, + total_user_limit: 300, + enabled: true + # 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/dev.exs b/config/dev.exs index f77bb9976..a7eb4b644 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -12,7 +12,6 @@ protocol_options: [max_request_line_length: 8192, max_header_value_length: 8192] ], protocol: "http", - secure_cookie_flag: false, debug_errors: true, code_reloader: true, check_origin: false, diff --git a/config/emoji.txt b/config/emoji.txt index 7afacb09f..79246f239 100644 --- a/config/emoji.txt +++ b/config/emoji.txt @@ -1,5 +1,5 @@ -firefox, /emoji/Firefox.gif -blank, /emoji/blank.png +firefox, /emoji/Firefox.gif, Gif,Fun +blank, /emoji/blank.png, Fun f_00b, /emoji/f_00b.png f_00b11b, /emoji/f_00b11b.png f_00b33b, /emoji/f_00b33b.png @@ -28,4 +28,3 @@ f_33b00b, /emoji/f_33b00b.png f_33b22b, /emoji/f_33b22b.png f_33h, /emoji/f_33h.png f_33t, /emoji/f_33t.png - diff --git a/config/test.exs b/config/test.exs index 6a7b9067e..894fa8d3d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -50,6 +50,11 @@ config :pleroma_job_queue, disabled: true +config :pleroma, Pleroma.ScheduledActivity, + daily_user_limit: 2, + total_user_limit: 3, + enabled: false + try do import_config "test.secret.exs" rescue diff --git a/docs/api/admin_api.md b/docs/api/admin_api.md index 53b68ffd4..86cacebb1 100644 --- a/docs/api/admin_api.md +++ b/docs/api/admin_api.md @@ -58,6 +58,26 @@ Authentication is required and the user must be an admin. - `password` - Response: User’s nickname +## `/api/pleroma/admin/user/follow` +### Make a user follow another user + +- Methods: `POST` +- Params: + - `follower`: The nickname of the follower + - `followed`: The nickname of the followed +- Response: + - "ok" + +## `/api/pleroma/admin/user/unfollow` +### Make a user unfollow another user + +- Methods: `POST` +- Params: + - `follower`: The nickname of the follower + - `followed`: The nickname of the followed +- Response: + - "ok" + ## `/api/pleroma/admin/users/:nickname/toggle_activation` ### Toggle user activation diff --git a/docs/api/differences_in_mastoapi_responses.md b/docs/api/differences_in_mastoapi_responses.md index 215f43155..923d94db2 100644 --- a/docs/api/differences_in_mastoapi_responses.md +++ b/docs/api/differences_in_mastoapi_responses.md @@ -20,6 +20,8 @@ Has these additional fields under the `pleroma` object: - `local`: true if the post was made on the local instance. - `conversation_id`: the ID of the conversation the status is associated with (if any) +- `content`: a map consisting of alternate representations of the `content` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain` +- `spoiler_text`: a map consisting of alternate representations of the `spoiler_text` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain` ## Attachments diff --git a/docs/api/pleroma_api.md b/docs/api/pleroma_api.md index 75569e092..dbe250300 100644 --- a/docs/api/pleroma_api.md +++ b/docs/api/pleroma_api.md @@ -10,7 +10,29 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi * Authentication: not required * Params: none * Response: JSON -* Example response: `{"kalsarikannit_f":"/finmoji/128px/kalsarikannit_f-128.png","perkele":"/finmoji/128px/perkele-128.png","blobdab":"/emoji/blobdab.png","happiness":"/finmoji/128px/happiness-128.png"}` +* Example response: +```json +{ + "girlpower": { + "tags": [ + "Finmoji" + ], + "image_url": "/finmoji/128px/girlpower-128.png" + }, + "education": { + "tags": [ + "Finmoji" + ], + "image_url": "/finmoji/128px/education-128.png" + }, + "finnishlove": { + "tags": [ + "Finmoji" + ], + "image_url": "/finmoji/128px/finnishlove-128.png" + } +} +``` * Note: Same data as Mastodon API’s `/api/v1/custom_emojis` but in a different format ## `/api/pleroma/follow_import` @@ -27,14 +49,14 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi * Method: `GET` * Authentication: not required * Params: none -* Response: Provider specific JSON, the only guaranteed parameter is `type` +* Response: Provider specific JSON, the only guaranteed parameter is `type` * Example response: `{"type": "kocaptcha", "token": "whatever", "url": "https://captcha.kotobank.ch/endpoint"}` ## `/api/pleroma/delete_account` ### Delete an account * Method `POST` * Authentication: required -* Params: +* Params: * `password`: user's password * Response: JSON. Returns `{"status": "success"}` if the deletion was successful, `{"error": "[error message]"}` otherwise * Example response: `{"error": "Invalid password."}` @@ -164,3 +186,14 @@ See [Admin-API](Admin-API.md) "endorsed": false } ``` + +## `/api/pleroma/notification_settings` +### Updates user notification settings +* Method `PUT` +* Authentication: required +* Params: + * `followers`: BOOLEAN field, receives notifications from followers + * `follows`: BOOLEAN field, receives notifications from people the user follows + * `remote`: BOOLEAN field, receives notifications from people on remote instances + * `local`: BOOLEAN field, receives notifications from people on the local instance +* Response: JSON. Returns `{"status": "success"}` if the update was successful, otherwise returns `{"error": "error_msg"}` diff --git a/docs/api/prometheus.md b/docs/api/prometheus.md new file mode 100644 index 000000000..19c564e3c --- /dev/null +++ b/docs/api/prometheus.md @@ -0,0 +1,22 @@ +# Prometheus Metrics + +Pleroma includes support for exporting metrics via the [prometheus_ex](https://github.com/deadtrickster/prometheus.ex) library. + +## `/api/pleroma/app_metrics` +### Exports Prometheus application metrics +* Method: `GET` +* Authentication: not required +* Params: none +* Response: JSON + +## Grafana +### Config example +The following is a config example to use with [Grafana](https://grafana.com) + +``` + - job_name: 'beam' + metrics_path: /api/pleroma/app_metrics + scheme: https + static_configs: + - targets: ['pleroma.soykaf.com'] +``` diff --git a/docs/config.md b/docs/config.md index 06d6fd757..b5ea58746 100644 --- a/docs/config.md +++ b/docs/config.md @@ -218,14 +218,14 @@ This section is used to configure Pleroma-FE, unless ``:managed_config`` in ``:i - `port` * `url` - a list containing the configuration for generating urls, accepts - `host` - the host without the scheme and a post (e.g `example.com`, not `https://example.com:2020`) - - `scheme` - e.g `http`, `https` + - `scheme` - e.g `http`, `https` - `port` - `path` **Important note**: if you modify anything inside these lists, default `config.exs` values will be overwritten, which may result in breakage, to make sure this does not happen please copy the default value for the list from `config.exs` and modify/add only what you need -Example: +Example: ```elixir config :pleroma, Pleroma.Web.Endpoint, url: [host: "example.com", port: 2020, scheme: "https"], @@ -314,9 +314,13 @@ curl "http://localhost:4000/api/pleroma/admin/invite_token?admin_token=somerando [Pleroma Job Queue](https://git.pleroma.social/pleroma/pleroma_job_queue) configuration: a list of queues with maximum concurrent jobs. Pleroma has the following queues: + * `federator_outgoing` - Outgoing federation * `federator_incoming` - Incoming federation * `mailer` - Email sender, see [`Pleroma.Mailer`](#pleroma-mailer) +* `transmogrifier` - Transmogrifier +* `web_push` - Web push notifications +* `scheduled_activities` - Scheduled activities, see [`Pleroma.ScheduledActivities`](#pleromascheduledactivity) Example: @@ -390,6 +394,17 @@ config :auto_linker, ] ``` +## Pleroma.ScheduledActivity + +* `daily_user_limit`: the number of scheduled activities a user is allowed to create in a single day (Default: `25`) +* `total_user_limit`: the number of scheduled activities a user is allowed to create in total (Default: `300`) +* `enabled`: whether scheduled activities are sent to the job queue to be executed + +## Pleroma.Web.Auth.Authenticator + +* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator +* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication + ## :ldap Use LDAP for user authentication. When a user logs in to the Pleroma @@ -408,7 +423,61 @@ Pleroma account will be created with the same name as the LDAP user name. * `base`: LDAP base, e.g. "dc=example,dc=com" * `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base" -## Pleroma.Web.Auth.Authenticator +## :auth -* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator -* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication +Authentication / authorization settings. + +* `auth_template`: authentication form template. By default it's `show.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/show.html.eex`. +* `oauth_consumer_template`: OAuth consumer mode authentication form template. By default it's `consumer.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex`. +* `oauth_consumer_strategies`: the list of enabled OAuth consumer strategies; by default it's set by OAUTH_CONSUMER_STRATEGIES environment variable. + +# OAuth consumer mode + +OAuth consumer mode allows sign in / sign up via external OAuth providers (e.g. Twitter, Facebook, Google, Microsoft, etc.). +Implementation is based on Ueberauth; see the list of [available strategies](https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies). + +Note: each strategy is shipped as a separate dependency; in order to get the strategies, run `OAUTH_CONSUMER_STRATEGIES="..." mix deps.get`, +e.g. `OAUTH_CONSUMER_STRATEGIES="twitter facebook google microsoft" mix deps.get`. +The server should also be started with `OAUTH_CONSUMER_STRATEGIES="..." mix phx.server` in case you enable any strategies. + +Note: each strategy requires separate setup (on external provider side and Pleroma side). Below are the guidelines on setting up most popular strategies. + +* For Twitter, [register an app](https://developer.twitter.com/en/apps), configure callback URL to https:///oauth/twitter/callback + +* For Facebook, [register an app](https://developers.facebook.com/apps), configure callback URL to https:///oauth/facebook/callback, enable Facebook Login service at https://developers.facebook.com/apps//fb-login/settings/ + +* For Google, [register an app](https://console.developers.google.com), configure callback URL to https:///oauth/google/callback + +* For Microsoft, [register an app](https://portal.azure.com), configure callback URL to https:///oauth/microsoft/callback + +Once the app is configured on external OAuth provider side, add app's credentials and strategy-specific settings (if any — e.g. see Microsoft below) to `config/prod.secret.exs`, +per strategy's documentation (e.g. [ueberauth_twitter](https://github.com/ueberauth/ueberauth_twitter)). Example config basing on environment variables: + +``` +# Twitter +config :ueberauth, Ueberauth.Strategy.Twitter.OAuth, + consumer_key: System.get_env("TWITTER_CONSUMER_KEY"), + consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET") + +# Facebook +config :ueberauth, Ueberauth.Strategy.Facebook.OAuth, + client_id: System.get_env("FACEBOOK_APP_ID"), + client_secret: System.get_env("FACEBOOK_APP_SECRET"), + redirect_uri: System.get_env("FACEBOOK_REDIRECT_URI") + +# Google +config :ueberauth, Ueberauth.Strategy.Google.OAuth, + client_id: System.get_env("GOOGLE_CLIENT_ID"), + client_secret: System.get_env("GOOGLE_CLIENT_SECRET"), + redirect_uri: System.get_env("GOOGLE_REDIRECT_URI") + +# Microsoft +config :ueberauth, Ueberauth.Strategy.Microsoft.OAuth, + client_id: System.get_env("MICROSOFT_CLIENT_ID"), + client_secret: System.get_env("MICROSOFT_CLIENT_SECRET") + +config :ueberauth, Ueberauth, + providers: [ + microsoft: {Ueberauth.Strategy.Microsoft, [callback_params: []]} + ] +``` diff --git a/docs/config/custom_emoji.md b/docs/config/custom_emoji.md index e833d2080..419a7d0e2 100644 --- a/docs/config/custom_emoji.md +++ b/docs/config/custom_emoji.md @@ -11,8 +11,43 @@ image files (in `/priv/static/emoji/custom`): `happy.png` and `sad.png` content of `config/custom_emoji.txt`: ``` -happy, /emoji/custom/happy.png -sad, /emoji/custom/sad.png +happy, /emoji/custom/happy.png, Tag1,Tag2 +sad, /emoji/custom/sad.png, Tag1 +foo, /emoji/custom/foo.png ``` The files should be PNG (APNG is okay with `.png` for `image/png` Content-type) and under 50kb for compatibility with mastodon. + +## Emoji tags (groups) + +Default tags are set in `config.exs`. +```elixir +config :pleroma, :emoji, + shortcode_globs: ["/emoji/custom/**/*.png"], + groups: [ + Finmoji: "/finmoji/128px/*-128.png", + Custom: ["/emoji/*.png", "/emoji/custom/*.png"] + ] +``` + +Order of the `groups` matters, so to override default tags just put your group on top of the list. E.g: +```elixir +config :pleroma, :emoji, + shortcode_globs: ["/emoji/custom/**/*.png"], + groups: [ + "Finmoji special": "/finmoji/128px/a_trusted_friend-128.png", # special file + "Cirno": "/emoji/custom/cirno*.png", # png files in /emoji/custom/ which start with `cirno` + "Special group": "/emoji/custom/special_folder/*.png", # png files in /emoji/custom/special_folder/ + "Another group": "/emoji/custom/special_folder/*/.png", # png files in /emoji/custom/special_folder/ subfolders + Finmoji: "/finmoji/128px/*-128.png", + Custom: ["/emoji/*.png", "/emoji/custom/*.png"] + ] +``` + +Priority of tags assigns in emoji.txt and custom.txt: + +`tag in file > special group setting in config.exs > default setting in config.exs` + +Priority for globs: + +`special group setting in config.exs > default setting in config.exs` diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index bc3f8caba..ab8861b27 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -31,7 +31,7 @@ defmodule Pleroma.Activity do field(:data, :map) field(:local, :boolean, default: true) field(:actor, :string) - field(:recipients, {:array, :string}) + field(:recipients, {:array, :string}, default: []) has_many(:notifications, Notification, on_delete: :delete_all) # Attention: this is a fake relation, don't try to preload it blindly and expect it to work! diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 782d1d589..eeb415084 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -25,6 +25,7 @@ def start(_type, _args) do import Cachex.Spec Pleroma.Config.DeprecationWarnings.warn() + setup_instrumenters() # Define workers and child supervisors to be supervised children = @@ -103,14 +104,15 @@ def start(_type, _args) do ], id: :cachex_idem ), - worker(Pleroma.FlakeId, []) + worker(Pleroma.FlakeId, []), + worker(Pleroma.ScheduledActivityWorker, []) ] ++ hackney_pool_children() ++ [ worker(Pleroma.Web.Federator.RetryQueue, []), worker(Pleroma.Stats, []), - worker(Pleroma.Web.Push, []), - worker(Task, [&Pleroma.Web.Federator.init/0], restart: :temporary) + worker(Task, [&Pleroma.Web.Push.init/0], restart: :temporary, id: :web_push_init), + worker(Task, [&Pleroma.Web.Federator.init/0], restart: :temporary, id: :federator_init) ] ++ streamer_child() ++ chat_child() ++ @@ -126,6 +128,24 @@ def start(_type, _args) do Supervisor.start_link(children, opts) end + defp setup_instrumenters do + require Prometheus.Registry + + :ok = + :telemetry.attach( + "prometheus-ecto", + [:pleroma, :repo, :query], + &Pleroma.Repo.Instrumenter.handle_event/4, + %{} + ) + + Prometheus.Registry.register_collector(:prometheus_process_collector) + Pleroma.Web.Endpoint.MetricsExporter.setup() + Pleroma.Web.Endpoint.PipelineInstrumenter.setup() + Pleroma.Web.Endpoint.Instrumenter.setup() + Pleroma.Repo.Instrumenter.setup() + end + def enabled_hackney_pools do [:media] ++ if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index 21507cd38..189faa15f 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -57,4 +57,8 @@ def delete([parent_key | keys]) do def delete(key) do Application.delete_env(:pleroma, key) end + + def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], []) + + def oauth_consumer_enabled?, do: oauth_consumer_strategies() != [] end diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index f3f08cd9d..87c7f2cec 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -8,13 +8,19 @@ defmodule Pleroma.Emoji do * the built-in Finmojis (if enabled in configuration), * the files: `config/emoji.txt` and `config/custom_emoji.txt` - * glob paths + * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder This GenServer stores in an ETS table the list of the loaded emojis, and also allows to reload the list at runtime. """ use GenServer + + @type pattern :: Regex.t() | module() | String.t() + @type patterns :: pattern() | [pattern()] + @type group_patterns :: keyword(patterns()) + @ets __MODULE__.Ets @ets_options [:ordered_set, :protected, :named_table, {:read_concurrency, true}] + @groups Application.get_env(:pleroma, :emoji)[:groups] @doc false def start_link do @@ -73,13 +79,14 @@ def code_change(_old_vsn, state, _extra) do end defp load do + finmoji_enabled = Keyword.get(Application.get_env(:pleroma, :instance), :finmoji_enabled) + shortcode_globs = Application.get_env(:pleroma, :emoji)[:shortcode_globs] || [] + emojis = - (load_finmoji(Keyword.get(Application.get_env(:pleroma, :instance), :finmoji_enabled)) ++ + (load_finmoji(finmoji_enabled) ++ load_from_file("config/emoji.txt") ++ load_from_file("config/custom_emoji.txt") ++ - load_from_globs( - Keyword.get(Application.get_env(:pleroma, :emoji, []), :shortcode_globs, []) - )) + load_from_globs(shortcode_globs)) |> Enum.reject(fn value -> value == nil end) true = :ets.insert(@ets, emojis) @@ -151,9 +158,12 @@ defp load do "white_nights", "woollysocks" ] + defp load_finmoji(true) do Enum.map(@finmoji, fn finmoji -> - {finmoji, "/finmoji/128px/#{finmoji}-128.png"} + file_name = "/finmoji/128px/#{finmoji}-128.png" + group = match_extra(@groups, file_name) + {finmoji, file_name, to_string(group)} end) end @@ -172,8 +182,14 @@ defp load_from_file_stream(stream) do |> Stream.map(&String.trim/1) |> Stream.map(fn line -> case String.split(line, ~r/,\s*/) do - [name, file] -> {name, file} - _ -> nil + [name, file, tags] -> + {name, file, tags} + + [name, file] -> + {name, file, to_string(match_extra(@groups, file))} + + _ -> + nil end end) |> Enum.to_list() @@ -190,9 +206,40 @@ defp load_from_globs(globs) do |> Enum.concat() Enum.map(paths, fn path -> + tag = match_extra(@groups, Path.join("/", Path.relative_to(path, static_path))) shortcode = Path.basename(path, Path.extname(path)) external_path = Path.join("/", Path.relative_to(path, static_path)) - {shortcode, external_path} + {shortcode, external_path, to_string(tag)} + end) + end + + @doc """ + Finds a matching group for the given emoji filename + """ + @spec match_extra(group_patterns(), String.t()) :: atom() | nil + def match_extra(group_patterns, filename) do + match_group_patterns(group_patterns, fn pattern -> + case pattern do + %Regex{} = regex -> Regex.match?(regex, filename) + string when is_binary(string) -> filename == string + end + end) + end + + defp match_group_patterns(group_patterns, matcher) do + Enum.find_value(group_patterns, fn {group, patterns} -> + patterns = + patterns + |> List.wrap() + |> Enum.map(fn pattern -> + if String.contains?(pattern, "*") do + ~r(#{String.replace(pattern, "*", ".*")}) + else + pattern + end + end) + + Enum.any?(patterns, matcher) && group end) end end diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index e3625383b..8ea9dbd38 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -77,9 +77,9 @@ def emojify(text) do def emojify(text, nil), do: text def emojify(text, emoji, strip \\ false) do - Enum.reduce(emoji, text, fn {emoji, file}, text -> - emoji = HTML.strip_tags(emoji) - file = HTML.strip_tags(file) + Enum.reduce(emoji, text, fn emoji_data, text -> + emoji = HTML.strip_tags(elem(emoji_data, 0)) + file = HTML.strip_tags(elem(emoji_data, 1)) html = if not strip do @@ -101,7 +101,7 @@ def demojify(text) do def demojify(text, nil), do: text def get_emoji(text) when is_binary(text) do - Enum.filter(Emoji.get_all(), fn {emoji, _} -> String.contains?(text, ":#{emoji}:") end) + Enum.filter(Emoji.get_all(), fn {emoji, _, _} -> String.contains?(text, ":#{emoji}:") end) end def get_emoji(_), do: [] diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 1e48749a8..7f1dbe28c 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -28,21 +28,20 @@ def filter_tags(html, scrubber), do: Scrubber.scrub(html, scrubber) def filter_tags(html), do: filter_tags(html, nil) def strip_tags(html), do: Scrubber.scrub(html, Scrubber.StripTags) - # TODO: rename object to activity because that's what it is really working with - def get_cached_scrubbed_html_for_object(content, scrubbers, object, module) do - key = "#{module}#{generate_scrubber_signature(scrubbers)}|#{object.id}" + def get_cached_scrubbed_html_for_activity(content, scrubbers, activity, key \\ "") do + key = "#{key}#{generate_scrubber_signature(scrubbers)}|#{activity.id}" Cachex.fetch!(:scrubber_cache, key, fn _key -> - ensure_scrubbed_html(content, scrubbers, object.data["object"]["fake"] || false) + ensure_scrubbed_html(content, scrubbers, activity.data["object"]["fake"] || false) end) end - def get_cached_stripped_html_for_object(content, object, module) do - get_cached_scrubbed_html_for_object( + def get_cached_stripped_html_for_activity(content, activity, key) do + get_cached_scrubbed_html_for_activity( content, HtmlSanitizeEx.Scrubber.StripTags, - object, - module + activity, + key ) end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 7c5856438..15789907a 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -122,13 +122,7 @@ def create_notifications(_), do: {:ok, []} # TODO move to sql, too. def create_notification(%Activity{} = activity, %User{} = user) do - unless User.blocks?(user, %{ap_id: activity.data["actor"]}) or - CommonAPI.thread_muted?(user, activity) or user.ap_id == activity.data["actor"] or - (activity.data["type"] == "Follow" and - Enum.any?(Notification.for_user(user), fn notif -> - notif.activity.data["type"] == "Follow" and - notif.activity.data["actor"] == activity.data["actor"] - end)) do + unless skip?(activity, user) do notification = %Notification{user_id: user.id, activity: activity} {:ok, notification} = Repo.insert(notification) Pleroma.Web.Streamer.stream("user", notification) @@ -155,4 +149,59 @@ def get_notified_from_activity( end def get_notified_from_activity(_, _local_only), do: [] + + def skip?(activity, user) do + [:self, :blocked, :local, :muted, :followers, :follows, :recently_followed] + |> Enum.any?(&skip?(&1, activity, user)) + end + + def skip?(:self, activity, user) do + activity.data["actor"] == user.ap_id + end + + def skip?(:blocked, activity, user) do + actor = activity.data["actor"] + User.blocks?(user, %{ap_id: actor}) + end + + def skip?(:local, %{local: true}, %{info: %{notification_settings: %{"local" => false}}}), + do: true + + def skip?(:local, %{local: false}, %{info: %{notification_settings: %{"remote" => false}}}), + do: true + + def skip?(:muted, activity, user) do + actor = activity.data["actor"] + + User.mutes?(user, %{ap_id: actor}) or + CommonAPI.thread_muted?(user, activity) + end + + def skip?( + :followers, + activity, + %{info: %{notification_settings: %{"followers" => false}}} = user + ) do + actor = activity.data["actor"] + follower = User.get_cached_by_ap_id(actor) + User.following?(follower, user) + end + + def skip?(:follows, activity, %{info: %{notification_settings: %{"follows" => false}}} = user) do + actor = activity.data["actor"] + followed = User.get_by_ap_id(actor) + User.following?(user, followed) + end + + def skip?(:recently_followed, %{data: %{"type" => "Follow"}} = activity, user) do + actor = activity.data["actor"] + + Notification.for_user(user) + |> Enum.any?(fn + %{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true + _ -> false + end) + end + + def skip?(_, _, _), do: false end diff --git a/lib/pleroma/registration.ex b/lib/pleroma/registration.ex new file mode 100644 index 000000000..21fd1fc3f --- /dev/null +++ b/lib/pleroma/registration.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Registration do + use Ecto.Schema + + import Ecto.Changeset + + alias Pleroma.Registration + alias Pleroma.Repo + alias Pleroma.User + + @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + + schema "registrations" do + belongs_to(:user, User, type: Pleroma.FlakeId) + field(:provider, :string) + field(:uid, :string) + field(:info, :map, default: %{}) + + timestamps() + end + + def nickname(registration, default \\ nil), + do: Map.get(registration.info, "nickname", default) + + def email(registration, default \\ nil), + do: Map.get(registration.info, "email", default) + + def name(registration, default \\ nil), + do: Map.get(registration.info, "name", default) + + def description(registration, default \\ nil), + do: Map.get(registration.info, "description", default) + + def changeset(registration, params \\ %{}) do + registration + |> cast(params, [:user_id, :provider, :uid, :info]) + |> validate_required([:provider, :uid]) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:uid, name: :registrations_provider_uid_index) + end + + def bind_to_user(registration, user) do + registration + |> changeset(%{user_id: (user && user.id) || nil}) + |> Repo.update() + end + + def get_by_provider_uid(provider, uid) do + Repo.get_by(Registration, + provider: to_string(provider), + uid: to_string(uid) + ) + end +end diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index 4af1bde56..aa5d427ae 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -8,6 +8,10 @@ defmodule Pleroma.Repo do adapter: Ecto.Adapters.Postgres, migration_timestamps: [type: :naive_datetime_usec] + defmodule Instrumenter do + use Prometheus.EctoInstrumenter + end + @doc """ Dynamically loads the repository url from the DATABASE_URL environment variable. diff --git a/lib/pleroma/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex new file mode 100644 index 000000000..de0e54699 --- /dev/null +++ b/lib/pleroma/scheduled_activity.ex @@ -0,0 +1,161 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ScheduledActivity do + use Ecto.Schema + + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils + + import Ecto.Query + import Ecto.Changeset + + @min_offset :timer.minutes(5) + + schema "scheduled_activities" do + belongs_to(:user, User, type: Pleroma.FlakeId) + field(:scheduled_at, :naive_datetime) + field(:params, :map) + + timestamps() + end + + def changeset(%ScheduledActivity{} = scheduled_activity, attrs) do + scheduled_activity + |> cast(attrs, [:scheduled_at, :params]) + |> validate_required([:scheduled_at, :params]) + |> validate_scheduled_at() + |> with_media_attachments() + end + + defp with_media_attachments( + %{changes: %{params: %{"media_ids" => media_ids} = params}} = changeset + ) + when is_list(media_ids) do + media_attachments = Utils.attachments_from_ids(%{"media_ids" => media_ids}) + + params = + params + |> Map.put("media_attachments", media_attachments) + |> Map.put("media_ids", media_ids) + + put_change(changeset, :params, params) + end + + defp with_media_attachments(changeset), do: changeset + + def update_changeset(%ScheduledActivity{} = scheduled_activity, attrs) do + scheduled_activity + |> cast(attrs, [:scheduled_at]) + |> validate_required([:scheduled_at]) + |> validate_scheduled_at() + end + + def validate_scheduled_at(changeset) do + validate_change(changeset, :scheduled_at, fn _, scheduled_at -> + cond do + not far_enough?(scheduled_at) -> + [scheduled_at: "must be at least 5 minutes from now"] + + exceeds_daily_user_limit?(changeset.data.user_id, scheduled_at) -> + [scheduled_at: "daily limit exceeded"] + + exceeds_total_user_limit?(changeset.data.user_id) -> + [scheduled_at: "total limit exceeded"] + + true -> + [] + end + end) + end + + def exceeds_daily_user_limit?(user_id, scheduled_at) do + ScheduledActivity + |> where(user_id: ^user_id) + |> where([sa], type(sa.scheduled_at, :date) == type(^scheduled_at, :date)) + |> select([sa], count(sa.id)) + |> Repo.one() + |> Kernel.>=(Config.get([ScheduledActivity, :daily_user_limit])) + end + + def exceeds_total_user_limit?(user_id) do + ScheduledActivity + |> where(user_id: ^user_id) + |> select([sa], count(sa.id)) + |> Repo.one() + |> Kernel.>=(Config.get([ScheduledActivity, :total_user_limit])) + end + + def far_enough?(scheduled_at) when is_binary(scheduled_at) do + with {:ok, scheduled_at} <- Ecto.Type.cast(:naive_datetime, scheduled_at) do + far_enough?(scheduled_at) + else + _ -> false + end + end + + def far_enough?(scheduled_at) do + now = NaiveDateTime.utc_now() + diff = NaiveDateTime.diff(scheduled_at, now, :millisecond) + diff > @min_offset + end + + def new(%User{} = user, attrs) do + %ScheduledActivity{user_id: user.id} + |> changeset(attrs) + end + + def create(%User{} = user, attrs) do + user + |> new(attrs) + |> Repo.insert() + end + + def get(%User{} = user, scheduled_activity_id) do + ScheduledActivity + |> where(user_id: ^user.id) + |> where(id: ^scheduled_activity_id) + |> Repo.one() + end + + def update(%ScheduledActivity{} = scheduled_activity, attrs) do + scheduled_activity + |> update_changeset(attrs) + |> Repo.update() + end + + def delete(%ScheduledActivity{} = scheduled_activity) do + scheduled_activity + |> Repo.delete() + end + + def delete(id) when is_binary(id) or is_integer(id) do + ScheduledActivity + |> where(id: ^id) + |> select([sa], sa) + |> Repo.delete_all() + |> case do + {1, [scheduled_activity]} -> {:ok, scheduled_activity} + _ -> :error + end + end + + def for_user_query(%User{} = user) do + ScheduledActivity + |> where(user_id: ^user.id) + end + + def due_activities(offset \\ 0) do + naive_datetime = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(offset, :millisecond) + + ScheduledActivity + |> where([sa], sa.scheduled_at < ^naive_datetime) + |> Repo.all() + end +end diff --git a/lib/pleroma/scheduled_activity_worker.ex b/lib/pleroma/scheduled_activity_worker.ex new file mode 100644 index 000000000..65b38622f --- /dev/null +++ b/lib/pleroma/scheduled_activity_worker.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ScheduledActivityWorker do + @moduledoc """ + Sends scheduled activities to the job queue. + """ + + alias Pleroma.Config + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.CommonAPI + use GenServer + require Logger + + @schedule_interval :timer.minutes(1) + + def start_link do + GenServer.start_link(__MODULE__, nil) + end + + def init(_) do + if Config.get([ScheduledActivity, :enabled]) do + schedule_next() + {:ok, nil} + else + :ignore + end + end + + def perform(:execute, scheduled_activity_id) do + try do + {:ok, scheduled_activity} = ScheduledActivity.delete(scheduled_activity_id) + %User{} = user = User.get_cached_by_id(scheduled_activity.user_id) + {:ok, _result} = CommonAPI.post(user, scheduled_activity.params) + rescue + error -> + Logger.error( + "#{__MODULE__} Couldn't create a status from the scheduled activity: #{inspect(error)}" + ) + end + end + + def handle_info(:perform, state) do + ScheduledActivity.due_activities(@schedule_interval) + |> Enum.each(fn scheduled_activity -> + PleromaJobQueue.enqueue(:scheduled_activities, __MODULE__, [:execute, scheduled_activity.id]) + end) + + schedule_next() + {:noreply, state} + end + + defp schedule_next do + Process.send_after(self(), :perform, @schedule_interval) + end +end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 8a0243604..6e2269aff 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -13,6 +13,7 @@ defmodule Pleroma.User do alias Pleroma.Formatter alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Registration alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web @@ -55,6 +56,7 @@ defmodule Pleroma.User do field(:bookmarks, {:array, :string}, default: []) field(:last_refreshed_at, :naive_datetime_usec) has_many(:notifications, Notification) + has_many(:registrations, Registration) embeds_one(:info, Pleroma.User.Info) timestamps() @@ -216,7 +218,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do changeset = struct |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) - |> validate_required([:email, :name, :nickname, :password, :password_confirmation]) + |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) |> unique_constraint(:email) |> unique_constraint(:nickname) @@ -227,6 +229,13 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do |> validate_length(:name, min: 1, max: 100) |> put_change(:info, info_change) + changeset = + if opts[:external] do + changeset + else + validate_required(changeset, [:email]) + end + if changeset.valid? do hashed = Pbkdf2.hashpwsalt(changeset.changes[:password]) ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]}) @@ -505,11 +514,10 @@ def get_by_nickname(nickname) do end end + def get_by_email(email), do: Repo.get_by(User, email: email) + def get_by_nickname_or_email(nickname_or_email) do - case user = Repo.get_by(User, nickname: nickname_or_email) do - %User{} -> user - nil -> Repo.get_by(User, email: nickname_or_email) - end + get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email) end def get_cached_user_info(user) do @@ -977,6 +985,8 @@ def block(blocker, %User{ap_id: ap_id} = blocked) do unfollow(blocked, blocker) end + {:ok, blocker} = update_follower_count(blocker) + info_cng = blocker.info |> User.Info.add_to_block(ap_id) @@ -1131,6 +1141,14 @@ def deactivate(%User{} = user, status \\ true) do update_and_set_cache(cng) end + def update_notification_settings(%User{} = user, settings \\ %{}) do + info_changeset = User.Info.update_notification_settings(user.info, settings) + + change(user) + |> put_embed(:info, info_changeset) + |> update_and_set_cache() + end + def delete(%User{} = user) do {:ok, user} = User.deactivate(user) diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index 1cf46feb3..5afa7988c 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -41,6 +41,10 @@ defmodule Pleroma.User.Info do field(:pinned_activities, {:array, :string}, default: []) field(:flavour, :string, default: nil) + field(:notification_settings, :map, + default: %{"remote" => true, "local" => true, "followers" => true, "follows" => true} + ) + # Found in the wild # ap_id -> Where is this used? # bio -> Where is this used? @@ -58,6 +62,19 @@ def set_activation_status(info, deactivated) do |> validate_required([:deactivated]) end + def update_notification_settings(info, settings) do + notification_settings = + info.notification_settings + |> Map.merge(settings) + |> Map.take(["remote", "local", "followers", "follows"]) + + params = %{notification_settings: notification_settings} + + info + |> cast(params, [:notification_settings]) + |> validate_required([:notification_settings]) + end + def add_to_note_count(info, number) do set_note_count(info, info.note_count + number) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index f733ae7e1..49ea73204 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -83,6 +83,22 @@ def fix_object(object) do |> fix_content_map |> fix_likes |> fix_addressing + |> fix_summary + end + + def fix_summary(%{"summary" => nil} = object) do + object + |> Map.put("summary", "") + end + + def fix_summary(%{"summary" => _} = object) do + # summary is present, nothing to do + object + end + + def fix_summary(object) do + object + |> Map.put("summary", "") end def fix_addressing_list(map, field) do @@ -954,7 +970,7 @@ defp strip_internal_tags(%{"tag" => tags} = object) do defp strip_internal_tags(object), do: object - defp user_upgrade_task(user) do + def perform(:user_upgrade, user) do # we pass a fake user so that the followers collection is stripped away old_follower_address = User.ap_followers(%User{nickname: user.nickname}) @@ -999,28 +1015,18 @@ defp user_upgrade_task(user) do Repo.update_all(q, []) end - def upgrade_user_from_ap_id(ap_id, async \\ true) do + def upgrade_user_from_ap_id(ap_id) do with %User{local: false} = user <- User.get_by_ap_id(ap_id), - {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id) do - already_ap = User.ap_enabled?(user) - - {:ok, user} = - User.upgrade_changeset(user, data) - |> Repo.update() - - if !already_ap do - # This could potentially take a long time, do it in the background - if async do - Task.start(fn -> - user_upgrade_task(user) - end) - else - user_upgrade_task(user) - end + {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), + already_ap <- User.ap_enabled?(user), + {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do + unless already_ap do + PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user]) end {:ok, user} else + %User{} = user -> {:ok, user} e -> e end end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 32545937e..0b53f71c3 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -99,7 +99,10 @@ def make_json_ld_header do %{ "@context" => [ "https://www.w3.org/ns/activitystreams", - "#{Web.base_url()}/schemas/litepub-0.1.jsonld" + "#{Web.base_url()}/schemas/litepub-0.1.jsonld", + %{ + "@language" => "und" + } ] } end diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index b3a09e49e..78bf31893 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -25,6 +25,26 @@ def user_delete(conn, %{"nickname" => nickname}) do |> json(nickname) end + def user_follow(conn, %{"follower" => follower_nick, "followed" => followed_nick}) do + with %User{} = follower <- User.get_by_nickname(follower_nick), + %User{} = followed <- User.get_by_nickname(followed_nick) do + User.follow(follower, followed) + end + + conn + |> json("ok") + end + + def user_unfollow(conn, %{"follower" => follower_nick, "followed" => followed_nick}) do + with %User{} = follower <- User.get_by_nickname(follower_nick), + %User{} = followed <- User.get_by_nickname(followed_nick) do + User.unfollow(follower, followed) + end + + conn + |> json("ok") + end + def user_create( conn, %{"nickname" => nickname, "email" => email, "password" => password} diff --git a/lib/pleroma/web/auth/authenticator.ex b/lib/pleroma/web/auth/authenticator.ex index 82267c595..89d88af32 100644 --- a/lib/pleroma/web/auth/authenticator.ex +++ b/lib/pleroma/web/auth/authenticator.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.Authenticator do + alias Pleroma.Registration alias Pleroma.User def implementation do @@ -12,14 +13,33 @@ def implementation do ) end - @callback get_user(Plug.Conn.t()) :: {:ok, User.t()} | {:error, any()} - def get_user(plug), do: implementation().get_user(plug) + @callback get_user(Plug.Conn.t(), Map.t()) :: {:ok, User.t()} | {:error, any()} + def get_user(plug, params), do: implementation().get_user(plug, params) + + @callback create_from_registration(Plug.Conn.t(), Map.t(), Registration.t()) :: + {:ok, User.t()} | {:error, any()} + def create_from_registration(plug, params, registration), + do: implementation().create_from_registration(plug, params, registration) + + @callback get_registration(Plug.Conn.t(), Map.t()) :: + {:ok, Registration.t()} | {:error, any()} + def get_registration(plug, params), + do: implementation().get_registration(plug, params) @callback handle_error(Plug.Conn.t(), any()) :: any() def handle_error(plug, error), do: implementation().handle_error(plug, error) @callback auth_template() :: String.t() | nil def auth_template do - implementation().auth_template() || Pleroma.Config.get(:auth_template, "show.html") + # Note: `config :pleroma, :auth_template, "..."` support is deprecated + implementation().auth_template() || + Pleroma.Config.get([:auth, :auth_template], Pleroma.Config.get(:auth_template)) || + "show.html" + end + + @callback oauth_consumer_template() :: String.t() | nil + def oauth_consumer_template do + implementation().oauth_consumer_template() || + Pleroma.Config.get([:auth, :oauth_consumer_template], "consumer.html") end end diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index 88217aab8..8b6d5a77f 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -8,14 +8,19 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do require Logger @behaviour Pleroma.Web.Auth.Authenticator + @base Pleroma.Web.Auth.PleromaAuthenticator @connection_timeout 10_000 @search_timeout 10_000 - def get_user(%Plug.Conn{} = conn) do + defdelegate get_registration(conn, params), to: @base + + defdelegate create_from_registration(conn, params, registration), to: @base + + def get_user(%Plug.Conn{} = conn, params) do if Pleroma.Config.get([:ldap, :enabled]) do {name, password} = - case conn.params do + case params do %{"authorization" => %{"name" => name, "password" => password}} -> {name, password} @@ -29,14 +34,14 @@ def get_user(%Plug.Conn{} = conn) do {:error, {:ldap_connection_error, _}} -> # When LDAP is unavailable, try default authenticator - Pleroma.Web.Auth.PleromaAuthenticator.get_user(conn) + @base.get_user(conn, params) error -> error end else # Fall back to default authenticator - Pleroma.Web.Auth.PleromaAuthenticator.get_user(conn) + @base.get_user(conn, params) end end @@ -46,6 +51,8 @@ def handle_error(%Plug.Conn{} = _conn, error) do def auth_template, do: nil + def oauth_consumer_template, do: nil + defp ldap_user(name, password) do ldap = Pleroma.Config.get(:ldap, []) host = Keyword.get(ldap, :host, "localhost") diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index 94a19ad49..c826adb4c 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -4,13 +4,15 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do alias Comeonin.Pbkdf2 + alias Pleroma.Registration + alias Pleroma.Repo alias Pleroma.User @behaviour Pleroma.Web.Auth.Authenticator - def get_user(%Plug.Conn{} = conn) do + def get_user(%Plug.Conn{} = _conn, params) do {name, password} = - case conn.params do + case params do %{"authorization" => %{"name" => name, "password" => password}} -> {name, password} @@ -27,9 +29,69 @@ def get_user(%Plug.Conn{} = conn) do end end + def get_registration( + %Plug.Conn{assigns: %{ueberauth_auth: %{provider: provider, uid: uid} = auth}}, + _params + ) do + registration = Registration.get_by_provider_uid(provider, uid) + + if registration do + {:ok, registration} + else + info = auth.info + + Registration.changeset(%Registration{}, %{ + provider: to_string(provider), + uid: to_string(uid), + info: %{ + "nickname" => info.nickname, + "email" => info.email, + "name" => info.name, + "description" => info.description + } + }) + |> Repo.insert() + end + end + + def get_registration(%Plug.Conn{} = _conn, _params), do: {:error, :missing_credentials} + + def create_from_registration(_conn, params, registration) do + nickname = value([params["nickname"], Registration.nickname(registration)]) + email = value([params["email"], Registration.email(registration)]) + name = value([params["name"], Registration.name(registration)]) || nickname + bio = value([params["bio"], Registration.description(registration)]) + + random_password = :crypto.strong_rand_bytes(64) |> Base.encode64() + + with {:ok, new_user} <- + User.register_changeset( + %User{}, + %{ + email: email, + nickname: nickname, + name: name, + bio: bio, + password: random_password, + password_confirmation: random_password + }, + external: true, + confirmed: true + ) + |> Repo.insert(), + {:ok, _} <- + Registration.changeset(registration, %{user_id: new_user.id}) |> Repo.update() do + {:ok, new_user} + end + end + + defp value(list), do: Enum.find(list, &(to_string(&1) != "")) + def handle_error(%Plug.Conn{} = _conn, error) do error end def auth_template, do: nil + + def oauth_consumer_template, do: nil end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 745d1839b..74babdf14 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -167,7 +167,7 @@ def post(user, %{"status" => status} = data) do object, "emoji", (Formatter.get_emoji(status) ++ Formatter.get_emoji(data["spoiler_text"])) - |> Enum.reduce(%{}, fn {name, file}, acc -> + |> Enum.reduce(%{}, fn {name, file, _}, acc -> Map.put(acc, name, "#{Pleroma.Web.Endpoint.static_url()}#{file}") end) ) do diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index a830ea5d2..7b9f0ea06 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -294,7 +294,7 @@ def confirm_current_password(user, password) do def emoji_from_profile(%{info: _info} = user) do (Formatter.get_emoji(user.bio) ++ Formatter.get_emoji(user.name)) - |> Enum.map(fn {shortcode, url} -> + |> Enum.map(fn {shortcode, url, _} -> %{ "type" => "Emoji", "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{url}"}, diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index fa2d1cbe7..1633477c3 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -51,11 +51,22 @@ defmodule Pleroma.Web.Endpoint do plug(Plug.MethodOverride) plug(Plug.Head) + secure_cookies = Pleroma.Config.get([__MODULE__, :secure_cookie_flag]) + cookie_name = - if Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag), + if secure_cookies, do: "__Host-pleroma_key", else: "pleroma_key" + same_site = + if Pleroma.Config.oauth_consumer_enabled?() do + # Note: "SameSite=Strict" prevents sign in with external OAuth provider + # (there would be no cookies during callback request from OAuth provider) + "SameSite=Lax" + else + "SameSite=Strict" + end + # 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. @@ -65,11 +76,30 @@ defmodule Pleroma.Web.Endpoint do 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" + secure: secure_cookies, + extra: same_site ) + # Note: the plug and its configuration is compile-time this can't be upstreamed yet + if proxies = Pleroma.Config.get([__MODULE__, :reverse_proxies]) do + plug(RemoteIp, proxies: proxies) + end + + defmodule Instrumenter do + use Prometheus.PhoenixInstrumenter + end + + defmodule PipelineInstrumenter do + use Prometheus.PlugPipelineInstrumenter + end + + defmodule MetricsExporter do + use Prometheus.PlugExporter + end + + plug(PipelineInstrumenter) + plug(MetricsExporter) + plug(Pleroma.Web.Router) @doc """ diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index 08ea5f967..382f07e6b 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.Pagination + alias Pleroma.ScheduledActivity alias Pleroma.User def get_followers(user, params \\ %{}) do @@ -28,6 +29,12 @@ def get_notifications(user, params \\ %{}) do |> Pagination.fetch_paginated(params) end + def get_scheduled_activities(user, params \\ %{}) do + user + |> ScheduledActivity.for_user_query() + |> Pagination.fetch_paginated(params) + end + defp cast_params(params) do param_types = %{ exclude_types: {:array, :string} diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index d9c0cd55e..ed082abdf 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -5,12 +5,14 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do use Pleroma.Web, :controller + alias Ecto.Changeset alias Pleroma.Activity alias Pleroma.Config alias Pleroma.Filter alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.ScheduledActivity alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web @@ -25,6 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.MastodonAPI.MastodonView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.ReportView + alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy alias Pleroma.Web.OAuth.App @@ -178,14 +181,15 @@ def peers(conn, _params) do defp mastodonized_emoji do Pleroma.Emoji.get_all() - |> Enum.map(fn {shortcode, relative_url} -> + |> Enum.map(fn {shortcode, relative_url, tags} -> url = to_string(URI.merge(Web.base_url(), relative_url)) %{ "shortcode" => shortcode, "static_url" => url, "visible_in_picker" => true, - "url" => url + "url" => url, + "tags" => String.split(tags, ",") } end) end @@ -364,6 +368,55 @@ def get_context(%{assigns: %{user: user}} = conn, %{"id" => id}) do end end + def scheduled_statuses(%{assigns: %{user: user}} = conn, params) do + with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do + conn + |> add_link_headers(:scheduled_statuses, scheduled_activities) + |> put_view(ScheduledActivityView) + |> render("index.json", %{scheduled_activities: scheduled_activities}) + end + end + + def show_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do + with %ScheduledActivity{} = scheduled_activity <- + ScheduledActivity.get(user, scheduled_activity_id) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", %{scheduled_activity: scheduled_activity}) + else + _ -> {:error, :not_found} + end + end + + def update_scheduled_status( + %{assigns: %{user: user}} = conn, + %{"id" => scheduled_activity_id} = params + ) do + with %ScheduledActivity{} = scheduled_activity <- + ScheduledActivity.get(user, scheduled_activity_id), + {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", %{scheduled_activity: scheduled_activity}) + else + nil -> {:error, :not_found} + error -> error + end + end + + def delete_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do + with %ScheduledActivity{} = scheduled_activity <- + ScheduledActivity.get(user, scheduled_activity_id), + {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", %{scheduled_activity: scheduled_activity}) + else + nil -> {:error, :not_found} + error -> error + end + end + def post_status(conn, %{"status" => "", "media_ids" => media_ids} = params) when length(media_ids) > 0 do params = @@ -384,12 +437,27 @@ def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do _ -> Ecto.UUID.generate() end - {:ok, activity} = - Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> CommonAPI.post(user, params) end) + scheduled_at = params["scheduled_at"] - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do + with {:ok, scheduled_activity} <- + ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", %{scheduled_activity: scheduled_activity}) + end + else + params = Map.drop(params, ["scheduled_at"]) + + {:ok, activity} = + Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> + CommonAPI.post(user, params) + end) + + conn + |> put_view(StatusView) + |> try_render("status.json", %{activity: activity, for: user, as: :activity}) + end end def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do @@ -1119,9 +1187,7 @@ def list_timeline(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) end def index(%{assigns: %{user: user}} = conn, _params) do - token = - conn - |> get_session(:oauth_token) + token = get_session(conn, :oauth_token) if user && token do mastodon_emoji = mastodonized_emoji() @@ -1222,6 +1288,7 @@ def index(%{assigns: %{user: user}} = conn, _params) do |> render("index.html", %{initial_state: initial_state, flavour: flavour}) else conn + |> put_session(:return_to, conn.request_path) |> redirect(to: "/web/login") end end @@ -1306,12 +1373,20 @@ def login(conn, _) do scope: Enum.join(app.scopes, " ") ) - conn - |> redirect(to: path) + redirect(conn, to: path) end end - defp local_mastodon_root_path(conn), do: mastodon_api_path(conn, :index, ["getting-started"]) + defp local_mastodon_root_path(conn) do + case get_session(conn, :return_to) do + nil -> + mastodon_api_path(conn, :index, ["getting-started"]) + + return_to -> + delete_session(conn, :return_to) + return_to + end + end defp get_or_make_app do find_attrs = %{client_name: @local_mastodon_name, redirect_uris: "."} @@ -1427,6 +1502,23 @@ def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do # fallback action # + def errors(conn, {:error, %Changeset{} = changeset}) do + error_message = + changeset + |> Changeset.traverse_errors(fn {message, _opt} -> message end) + |> Enum.map_join(", ", fn {_k, v} -> v end) + + conn + |> put_status(422) + |> json(%{error: error_message}) + end + + def errors(conn, {:error, :not_found}) do + conn + |> put_status(404) + |> json(%{error: "Record not found"}) + end + def errors(conn, _) do conn |> put_status(500) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 42595b0b5..af56c4149 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -118,13 +118,15 @@ defp do_render("account.json", %{user: user} = opts) do }, # Pleroma extension - pleroma: %{ - confirmation_pending: user_info.confirmation_pending, - tags: user.tags, - is_moderator: user.info.is_moderator, - is_admin: user.info.is_admin, - relationship: relationship - } + pleroma: + %{ + confirmation_pending: user_info.confirmation_pending, + tags: user.tags, + is_moderator: user.info.is_moderator, + is_admin: user.info.is_admin, + relationship: relationship + } + |> with_notification_settings(user, opts[:for]) } end @@ -133,4 +135,10 @@ defp username_from_nickname(string) when is_binary(string) do end defp username_from_nickname(_), do: nil + + defp with_notification_settings(data, %User{id: user_id} = user, %User{id: user_id}) do + Map.put(data, :notification_settings, user.info.notification_settings) + end + + defp with_notification_settings(data, _, _), do: data end diff --git a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex new file mode 100644 index 000000000..0aae15ab9 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do + use Pleroma.Web, :view + + alias Pleroma.ScheduledActivity + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.ScheduledActivityView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("index.json", %{scheduled_activities: scheduled_activities}) do + render_many(scheduled_activities, ScheduledActivityView, "show.json") + end + + def render("show.json", %{scheduled_activity: %ScheduledActivity{} = scheduled_activity}) do + %{ + id: to_string(scheduled_activity.id), + scheduled_at: CommonAPI.Utils.to_masto_date(scheduled_activity.scheduled_at), + params: status_params(scheduled_activity.params) + } + |> with_media_attachments(scheduled_activity) + end + + defp with_media_attachments(data, %{params: %{"media_attachments" => media_attachments}}) do + try do + attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment) + Map.put(data, :media_attachments, attachments) + rescue + _ -> data + end + end + + defp with_media_attachments(data, _), do: data + + defp status_params(params) do + data = %{ + text: params["status"], + sensitive: params["sensitive"], + spoiler_text: params["spoiler_text"], + visibility: params["visibility"], + scheduled_at: params["scheduled_at"], + poll: params["poll"], + in_reply_to_id: params["in_reply_to_id"] + } + + data = + if media_ids = params["media_ids"] do + Map.put(data, :media_ids, media_ids) + else + data + end + + data + end +end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 200bb453d..d4a8e4fff 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -147,10 +147,37 @@ def render("status.json", %{activity: %{data: %{"object" => object}} = activity} content = object |> render_content() - |> HTML.get_cached_scrubbed_html_for_object( + + content_html = + content + |> HTML.get_cached_scrubbed_html_for_activity( User.html_filter_policy(opts[:for]), activity, - __MODULE__ + "mastoapi:content" + ) + + content_plaintext = + content + |> HTML.get_cached_stripped_html_for_activity( + activity, + "mastoapi:content" + ) + + summary = object["summary"] || "" + + summary_html = + summary + |> HTML.get_cached_scrubbed_html_for_activity( + User.html_filter_policy(opts[:for]), + activity, + "mastoapi:summary" + ) + + summary_plaintext = + summary + |> HTML.get_cached_stripped_html_for_activity( + activity, + "mastoapi:summary" ) card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)) @@ -171,7 +198,7 @@ def render("status.json", %{activity: %{data: %{"object" => object}} = activity} in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), reblog: nil, card: card, - content: content, + content: content_html, created_at: created_at, reblogs_count: announcement_count, replies_count: object["repliesCount"] || 0, @@ -182,7 +209,7 @@ def render("status.json", %{activity: %{data: %{"object" => object}} = activity} muted: CommonAPI.thread_muted?(user, activity) || User.mutes?(opts[:for], user), pinned: pinned?(activity, user), sensitive: sensitive, - spoiler_text: object["summary"] || "", + spoiler_text: summary_html, visibility: get_visibility(object), media_attachments: attachments, mentions: mentions, @@ -195,7 +222,9 @@ def render("status.json", %{activity: %{data: %{"object" => object}} = activity} emojis: build_emojis(activity.data["object"]["emoji"]), pleroma: %{ local: activity.local, - conversation_id: get_context_id(activity) + conversation_id: get_context_id(activity), + content: %{"text/plain" => content_plaintext}, + spoiler_text: %{"text/plain" => summary_plaintext} } } end diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index 23bbde1a6..58385a3d1 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -12,7 +12,7 @@ def scrub_html_and_truncate(%{data: %{"content" => content}} = object) do # html content comes from DB already encoded, decode first and scrub after |> HtmlEntities.decode() |> String.replace(~r//, " ") - |> HTML.get_cached_stripped_html_for_object(object, __MODULE__) + |> HTML.get_cached_stripped_html_for_activity(object, "metadata") |> Formatter.demojify() |> Formatter.truncate() end diff --git a/lib/pleroma/web/oauth/fallback_controller.ex b/lib/pleroma/web/oauth/fallback_controller.ex index f0fe3b578..afaa00242 100644 --- a/lib/pleroma/web/oauth/fallback_controller.ex +++ b/lib/pleroma/web/oauth/fallback_controller.ex @@ -6,8 +6,21 @@ defmodule Pleroma.Web.OAuth.FallbackController do use Pleroma.Web, :controller alias Pleroma.Web.OAuth.OAuthController - # No user/password - def call(conn, _) do + def call(conn, {:register, :generic_error}) do + conn + |> put_status(:internal_server_error) + |> put_flash(:error, "Unknown error, please check the details and try again.") + |> OAuthController.registration_details(conn.params) + end + + def call(conn, {:register, _error}) do + conn + |> put_status(:unauthorized) + |> put_flash(:error, "Invalid Username/Password") + |> OAuthController.registration_details(conn.params) + end + + def call(conn, _error) do conn |> put_status(:unauthorized) |> put_flash(:error, "Invalid Username/Password") diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 26d53df1a..bee7084ad 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller + alias Pleroma.Registration alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.Auth.Authenticator @@ -15,6 +16,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2] + if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth) + plug(:fetch_session) plug(:fetch_flash) @@ -57,68 +60,67 @@ defp do_authorize(conn, params) do }) end - def create_authorization(conn, %{ - "authorization" => - %{ - "client_id" => client_id, - "redirect_uri" => redirect_uri - } = auth_params - }) do - with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)}, - %App{} = app <- Repo.get_by(App, client_id: client_id), - true <- redirect_uri in String.split(app.redirect_uris), - scopes <- oauth_scopes(auth_params, []), - {:unsupported_scopes, []} <- {:unsupported_scopes, scopes -- app.scopes}, - # Note: `scope` param is intentionally not optional in this context - {:missing_scopes, false} <- {:missing_scopes, scopes == []}, - {:auth_active, true} <- {:auth_active, User.auth_active?(user)}, - {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do - redirect_uri = redirect_uri(conn, redirect_uri) - - cond do - redirect_uri == "urn:ietf:wg:oauth:2.0:oob" -> - render(conn, "results.html", %{ - auth: auth - }) - - true -> - connector = if String.contains?(redirect_uri, "?"), do: "&", else: "?" - url = "#{redirect_uri}#{connector}" - url_params = %{:code => auth.token} - - url_params = - if auth_params["state"] do - Map.put(url_params, :state, auth_params["state"]) - else - url_params - end - - url = "#{url}#{Plug.Conn.Query.encode(url_params)}" - - redirect(conn, external: url) - end + def create_authorization( + conn, + %{"authorization" => auth_params} = params, + opts \\ [] + ) do + with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do + after_create_authorization(conn, auth, auth_params) else - {scopes_issue, _} when scopes_issue in [:unsupported_scopes, :missing_scopes] -> - # Per https://github.com/tootsuite/mastodon/blob/ - # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39 - conn - |> put_flash(:error, "This action is outside the authorized scopes") - |> put_status(:unauthorized) - |> authorize(auth_params) - - {:auth_active, false} -> - # Per https://github.com/tootsuite/mastodon/blob/ - # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76 - conn - |> put_flash(:error, "Your login is missing a confirmed e-mail address") - |> put_status(:forbidden) - |> authorize(auth_params) - error -> - Authenticator.handle_error(conn, error) + handle_create_authorization_error(conn, error, auth_params) end end + def after_create_authorization(conn, auth, %{"redirect_uri" => redirect_uri} = auth_params) do + redirect_uri = redirect_uri(conn, redirect_uri) + + if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" do + render(conn, "results.html", %{ + auth: auth + }) + else + connector = if String.contains?(redirect_uri, "?"), do: "&", else: "?" + url = "#{redirect_uri}#{connector}" + url_params = %{:code => auth.token} + + url_params = + if auth_params["state"] do + Map.put(url_params, :state, auth_params["state"]) + else + url_params + end + + url = "#{url}#{Plug.Conn.Query.encode(url_params)}" + + redirect(conn, external: url) + end + end + + defp handle_create_authorization_error(conn, {scopes_issue, _}, auth_params) + when scopes_issue in [:unsupported_scopes, :missing_scopes] do + # Per https://github.com/tootsuite/mastodon/blob/ + # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39 + conn + |> put_flash(:error, "This action is outside the authorized scopes") + |> put_status(:unauthorized) + |> authorize(auth_params) + end + + defp handle_create_authorization_error(conn, {:auth_active, false}, auth_params) do + # Per https://github.com/tootsuite/mastodon/blob/ + # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76 + conn + |> put_flash(:error, "Your login is missing a confirmed e-mail address") + |> put_status(:forbidden) + |> authorize(auth_params) + end + + defp handle_create_authorization_error(conn, error, _auth_params) do + Authenticator.handle_error(conn, error) + end + def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do with %App{} = app <- get_app_from_request(conn, params), fixed_token = fix_padding(params["code"]), @@ -149,9 +151,10 @@ def token_exchange( conn, %{"grant_type" => "password"} = params ) do - with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)}, + with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn, params)}, %App{} = app <- get_app_from_request(conn, params), {:auth_active, true} <- {:auth_active, User.auth_active?(user)}, + {:user_active, true} <- {:user_active, !user.info.deactivated}, scopes <- oauth_scopes(params, app.scopes), [] <- scopes -- app.scopes, true <- Enum.any?(scopes), @@ -175,6 +178,11 @@ def token_exchange( |> put_status(:forbidden) |> json(%{error: "Your login is missing a confirmed e-mail address"}) + {:user_active, false} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Your account is currently disabled"}) + _error -> put_status(conn, 400) |> json(%{error: "Invalid credentials"}) @@ -205,6 +213,184 @@ def token_revoke(conn, %{"token" => token} = params) do end end + @doc "Prepares OAuth request to provider for Ueberauth" + def prepare_request(conn, %{"provider" => provider} = params) do + scope = + oauth_scopes(params, []) + |> Enum.join(" ") + + state = + params + |> Map.delete("scopes") + |> Map.put("scope", scope) + |> Poison.encode!() + + params = + params + |> Map.drop(~w(scope scopes client_id redirect_uri)) + |> Map.put("state", state) + + # Handing the request to Ueberauth + redirect(conn, to: o_auth_path(conn, :request, provider, params)) + end + + def request(conn, params) do + message = + if params["provider"] do + "Unsupported OAuth provider: #{params["provider"]}." + else + "Bad OAuth request." + end + + conn + |> put_flash(:error, message) + |> redirect(to: "/") + end + + def callback(%{assigns: %{ueberauth_failure: failure}} = conn, params) do + params = callback_params(params) + messages = for e <- Map.get(failure, :errors, []), do: e.message + message = Enum.join(messages, "; ") + + conn + |> put_flash(:error, "Failed to authenticate: #{message}.") + |> redirect(external: redirect_uri(conn, params["redirect_uri"])) + end + + def callback(conn, params) do + params = callback_params(params) + + with {:ok, registration} <- Authenticator.get_registration(conn, params) do + user = Repo.preload(registration, :user).user + auth_params = Map.take(params, ~w(client_id redirect_uri scope scopes state)) + + if user do + create_authorization( + conn, + %{"authorization" => auth_params}, + user: user + ) + else + registration_params = + Map.merge(auth_params, %{ + "nickname" => Registration.nickname(registration), + "email" => Registration.email(registration) + }) + + conn + |> put_session(:registration_id, registration.id) + |> registration_details(registration_params) + end + else + _ -> + conn + |> put_flash(:error, "Failed to set up user account.") + |> redirect(external: redirect_uri(conn, params["redirect_uri"])) + end + end + + defp callback_params(%{"state" => state} = params) do + Map.merge(params, Poison.decode!(state)) + end + + def registration_details(conn, params) do + render(conn, "register.html", %{ + client_id: params["client_id"], + redirect_uri: params["redirect_uri"], + state: params["state"], + scopes: oauth_scopes(params, []), + nickname: params["nickname"], + email: params["email"] + }) + end + + def register(conn, %{"op" => "connect"} = params) do + authorization_params = Map.put(params, "name", params["auth_name"]) + create_authorization_params = %{"authorization" => authorization_params} + + with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn), + %Registration{} = registration <- Repo.get(Registration, registration_id), + {_, {:ok, auth}} <- + {:create_authorization, do_create_authorization(conn, create_authorization_params)}, + %User{} = user <- Repo.preload(auth, :user).user, + {:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do + conn + |> put_session_registration_id(nil) + |> after_create_authorization(auth, authorization_params) + else + {:create_authorization, error} -> + {:register, handle_create_authorization_error(conn, error, create_authorization_params)} + + _ -> + {:register, :generic_error} + end + end + + def register(conn, %{"op" => "register"} = params) do + with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn), + %Registration{} = registration <- Repo.get(Registration, registration_id), + {:ok, user} <- Authenticator.create_from_registration(conn, params, registration) do + conn + |> put_session_registration_id(nil) + |> create_authorization( + %{ + "authorization" => %{ + "client_id" => params["client_id"], + "redirect_uri" => params["redirect_uri"], + "scopes" => oauth_scopes(params, nil) + } + }, + user: user + ) + else + {:error, changeset} -> + message = + Enum.map(changeset.errors, fn {field, {error, _}} -> + "#{field} #{error}" + end) + |> Enum.join("; ") + + message = + String.replace( + message, + "ap_id has already been taken", + "nickname has already been taken" + ) + + conn + |> put_status(:forbidden) + |> put_flash(:error, "Error: #{message}.") + |> registration_details(params) + + _ -> + {:register, :generic_error} + end + end + + defp do_create_authorization( + conn, + %{ + "authorization" => + %{ + "client_id" => client_id, + "redirect_uri" => redirect_uri + } = auth_params + } = params, + user \\ nil + ) do + with {_, {:ok, %User{} = user}} <- + {:get_user, (user && {:ok, user}) || Authenticator.get_user(conn, params)}, + %App{} = app <- Repo.get_by(App, client_id: client_id), + true <- redirect_uri in String.split(app.redirect_uris), + scopes <- oauth_scopes(auth_params, []), + {:unsupported_scopes, []} <- {:unsupported_scopes, scopes -- app.scopes}, + # Note: `scope` param is intentionally not optional in this context + {:missing_scopes, false} <- {:missing_scopes, scopes == []}, + {:auth_active, true} <- {:auth_active, User.auth_active?(user)} do + Authorization.create_authorization(app, user, scopes) + end + end + # XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be # decoding it. Investigate sometime. defp fix_padding(token) do @@ -242,4 +428,9 @@ defp get_app_from_request(conn, params) do defp redirect_uri(conn, "."), do: mastodon_api_url(conn, :login) defp redirect_uri(_conn, redirect_uri), do: redirect_uri + + defp get_session_registration_id(conn), do: get_session(conn, :registration_id) + + defp put_session_registration_id(conn, registration_id), + do: put_session(conn, :registration_id, registration_id) end diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 863573185..2233480c5 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -19,8 +19,8 @@ defmodule Pleroma.Web.Push.Impl do @types ["Create", "Follow", "Announce", "Like"] @doc "Performs sending notifications for user subscriptions" - @spec perform_send(Notification.t()) :: list(any) - def perform_send( + @spec perform(Notification.t()) :: list(any) | :error + def perform( %{activity: %{data: %{"type" => activity_type}, id: activity_id}, user_id: user_id} = notif ) @@ -50,7 +50,7 @@ def perform_send( end end - def perform_send(_) do + def perform(_) do Logger.warn("Unknown notification type") :error end diff --git a/lib/pleroma/web/push/push.ex b/lib/pleroma/web/push/push.ex index 5259e8e33..729dad02a 100644 --- a/lib/pleroma/web/push/push.ex +++ b/lib/pleroma/web/push/push.ex @@ -3,18 +3,20 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Push do - use GenServer - alias Pleroma.Web.Push.Impl require Logger - ############## - # Client API # - ############## + def init do + unless enabled() do + Logger.warn(""" + VAPID key pair is not found. If you wish to enabled web push, please run - def start_link do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + mix web_push.gen.keypair + + and add the resulting output to your configuration file. + """) + end end def vapid_config do @@ -30,35 +32,5 @@ def enabled do end def send(notification), - do: GenServer.cast(__MODULE__, {:send, notification}) - - #################### - # Server Callbacks # - #################### - - @impl true - def init(:ok) do - if enabled() do - {:ok, nil} - else - Logger.warn(""" - VAPID key pair is not found. If you wish to enabled web push, please run - - mix web_push.gen.keypair - - and add the resulting output to your configuration file. - """) - - :ignore - end - end - - @impl true - def handle_cast({:send, notification}, state) do - if enabled() do - Impl.perform_send(notification) - end - - {:noreply, state} - end + do: PleromaJobQueue.enqueue(:web_push, Impl, [notification]) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index da988e5f2..06c8d7a03 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -5,6 +5,11 @@ defmodule Pleroma.Web.Router do use Pleroma.Web, :router + pipeline :browser do + plug(:accepts, ["html"]) + plug(:fetch_session) + end + pipeline :oauth do plug(:fetch_session) plug(Pleroma.Plugs.OAuthPlug) @@ -140,8 +145,12 @@ defmodule Pleroma.Web.Router do scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do pipe_through([:admin_api, :oauth_write]) + post("/user/follow", AdminAPIController, :user_follow) + post("/user/unfollow", AdminAPIController, :user_unfollow) + get("/users", AdminAPIController, :list_users) get("/users/:nickname", AdminAPIController, :user_show) + delete("/user", AdminAPIController, :user_delete) patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation) post("/user", AdminAPIController, :user_create) @@ -184,6 +193,7 @@ defmodule Pleroma.Web.Router do post("/change_password", UtilController, :change_password) post("/delete_account", UtilController, :delete_account) + put("/notification_settings", UtilController, :update_notificaton_settings) end scope [] do @@ -209,6 +219,16 @@ defmodule Pleroma.Web.Router do post("/authorize", OAuthController, :create_authorization) post("/token", OAuthController, :token_exchange) post("/revoke", OAuthController, :token_revoke) + get("/registration_details", OAuthController, :registration_details) + + scope [] do + pipe_through(:browser) + + get("/prepare_request", OAuthController, :prepare_request) + get("/:provider", OAuthController, :request) + get("/:provider/callback", OAuthController, :callback) + post("/register", OAuthController, :register) + end end scope "/api/v1", Pleroma.Web.MastodonAPI do @@ -240,6 +260,9 @@ defmodule Pleroma.Web.Router do get("/notifications", MastodonAPIController, :notifications) get("/notifications/:id", MastodonAPIController, :get_notification) + get("/scheduled_statuses", MastodonAPIController, :scheduled_statuses) + get("/scheduled_statuses/:id", MastodonAPIController, :show_scheduled_status) + get("/lists", MastodonAPIController, :get_lists) get("/lists/:id", MastodonAPIController, :get_list) get("/lists/:id/accounts", MastodonAPIController, :list_accounts) @@ -274,6 +297,9 @@ defmodule Pleroma.Web.Router do post("/statuses/:id/mute", MastodonAPIController, :mute_conversation) post("/statuses/:id/unmute", MastodonAPIController, :unmute_conversation) + put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status) + delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status) + post("/media", MastodonAPIController, :upload) put("/media/:id", MastodonAPIController, :update_media) diff --git a/lib/pleroma/web/templates/o_auth/o_auth/_scopes.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/_scopes.html.eex new file mode 100644 index 000000000..4b8fb5dae --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/o_auth/_scopes.html.eex @@ -0,0 +1,13 @@ +
+ <%= label @form, :scope, "Permissions" %> + +
+ <%= for scope <- @available_scopes do %> + <%# Note: using hidden input with `unchecked_value` in order to distinguish user's empty selection from `scope` param being omitted %> +
+ <%= checkbox @form, :"scope_#{scope}", value: scope in @scopes && scope, checked_value: scope, unchecked_value: "", name: assigns[:scope_param] || "scope[]" %> + <%= label @form, :"scope_#{scope}", String.capitalize(scope) %> +
+ <% end %> +
+
diff --git a/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex new file mode 100644 index 000000000..85f62ca64 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex @@ -0,0 +1,13 @@ +

Sign in with external provider

+ +<%= form_for @conn, o_auth_path(@conn, :prepare_request), [method: "get"], fn f -> %> + <%= render @view_module, "_scopes.html", Map.put(assigns, :form, f) %> + + <%= hidden_input f, :client_id, value: @client_id %> + <%= hidden_input f, :redirect_uri, value: @redirect_uri %> + <%= hidden_input f, :state, value: @state %> + + <%= for strategy <- Pleroma.Config.oauth_consumer_strategies() do %> + <%= submit "Sign in with #{String.capitalize(strategy)}", name: "provider", value: strategy %> + <% end %> +<% end %> diff --git a/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex new file mode 100644 index 000000000..126390391 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex @@ -0,0 +1,43 @@ +<%= if get_flash(@conn, :info) do %> + +<% end %> +<%= if get_flash(@conn, :error) do %> + +<% end %> + +

Registration Details

+ +

If you'd like to register a new account, please provide the details below.

+ +<%= form_for @conn, o_auth_path(@conn, :register), [], fn f -> %> + +
+ <%= label f, :nickname, "Nickname" %> + <%= text_input f, :nickname, value: @nickname %> +
+
+ <%= label f, :email, "Email" %> + <%= text_input f, :email, value: @email %> +
+ +<%= submit "Proceed as new user", name: "op", value: "register" %> + +

Alternatively, sign in to connect to existing account.

+ +
+ <%= label f, :auth_name, "Name or email" %> + <%= text_input f, :auth_name %> +
+
+ <%= label f, :password, "Password" %> + <%= password_input f, :password %> +
+ +<%= submit "Proceed as existing user", name: "op", value: "connect" %> + +<%= hidden_input f, :client_id, value: @client_id %> +<%= hidden_input f, :redirect_uri, value: @redirect_uri %> +<%= hidden_input f, :scope, value: Enum.join(@scopes, " ") %> +<%= hidden_input f, :state, value: @state %> + +<% end %> diff --git a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex index 161333847..87278e636 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex @@ -4,7 +4,9 @@ <%= if get_flash(@conn, :error) do %> <% end %> +

OAuth Authorization

+ <%= form_for @conn, o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %>
<%= label f, :name, "Name or email" %> @@ -14,22 +16,16 @@ <%= label f, :password, "Password" %> <%= password_input f, :password %>
-
-<%= label f, :scope, "Permissions" %> -
- <%= for scope <- @available_scopes do %> - <%# Note: using hidden input with `unchecked_value` in order to distinguish user's empty selection from `scope` param being omitted %> -
- <%= checkbox f, :"scope_#{scope}", value: scope in @scopes && scope, checked_value: scope, unchecked_value: "", name: "authorization[scope][]" %> - <%= label f, :"scope_#{scope}", String.capitalize(scope) %> -
- <% end %> -
-
+ +<%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f, scope_param: "authorization[scope][]"}) %> <%= hidden_input f, :client_id, value: @client_id %> <%= hidden_input f, :response_type, value: @response_type %> <%= hidden_input f, :redirect_uri, value: @redirect_uri %> -<%= hidden_input f, :state, value: @state%> +<%= hidden_input f, :state, value: @state %> <%= submit "Authorize" %> <% end %> + +<%= if Pleroma.Config.oauth_consumer_enabled?() do %> + <%= render @view_module, Pleroma.Web.Auth.Authenticator.oauth_consumer_template(), assigns %> +<% end %> diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 3cdd7a2f2..d066d35f5 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -283,7 +283,20 @@ def version(conn, _params) do end def emoji(conn, _params) do - json(conn, Enum.into(Emoji.get_all(), %{})) + emoji = + Emoji.get_all() + |> Enum.map(fn {short_code, path, tags} -> + {short_code, %{image_url: path, tags: String.split(tags, ",")}} + end) + |> Enum.into(%{}) + + json(conn, emoji) + end + + def update_notificaton_settings(%{assigns: %{user: user}} = conn, params) do + with {:ok, _} <- User.update_notification_settings(user, params) do + json(conn, %{status: "success"}) + end end def follow_import(conn, %{"list" => %Plug.Upload{} = listfile}) do diff --git a/lib/pleroma/web/twitter_api/views/activity_view.ex b/lib/pleroma/web/twitter_api/views/activity_view.ex index aa1d41fa2..433322eb8 100644 --- a/lib/pleroma/web/twitter_api/views/activity_view.ex +++ b/lib/pleroma/web/twitter_api/views/activity_view.ex @@ -254,10 +254,10 @@ def render( html = content - |> HTML.get_cached_scrubbed_html_for_object( + |> HTML.get_cached_scrubbed_html_for_activity( User.html_filter_policy(opts[:for]), activity, - __MODULE__ + "twitterapi:content" ) |> Formatter.emojify(object["emoji"]) @@ -265,7 +265,7 @@ def render( if content do content |> String.replace(~r//, "\n") - |> HTML.get_cached_stripped_html_for_object(activity, __MODULE__) + |> HTML.get_cached_stripped_html_for_activity(activity, "twitterapi:content") else "" end diff --git a/mix.exs b/mix.exs index 3a04e0060..26a03b70b 100644 --- a/mix.exs +++ b/mix.exs @@ -54,6 +54,12 @@ defp elixirc_paths(_), do: ["lib"] # # Type `mix help deps` for examples and options. defp deps do + oauth_strategies = String.split(System.get_env("OAUTH_CONSUMER_STRATEGIES") || "") + + oauth_deps = + for s <- oauth_strategies, + do: {String.to_atom("ueberauth_#{s}"), ">= 0.0.0"} + [ {:phoenix, "~> 1.4.1"}, {:plug_cowboy, "~> 2.0"}, @@ -71,6 +77,7 @@ defp deps do {:calendar, "~> 0.17.4"}, {:cachex, "~> 3.0.2"}, {:httpoison, "~> 1.2.0"}, + {:poison, "~> 3.0", override: true}, {:tesla, "~> 1.2"}, {:jason, "~> 1.0"}, {:mogrify, "~> 0.6.1"}, @@ -91,12 +98,20 @@ defp deps do {:floki, "~> 0.20.0"}, {:ex_syslogger, github: "slashmili/ex_syslogger", tag: "1.4.0"}, {:timex, "~> 3.5"}, + {:ueberauth, "~> 0.4"}, {:auto_linker, git: "https://git.pleroma.social/pleroma/auto_linker.git", ref: "479dd343f4e563ff91215c8275f3b5c67e032850"}, {:pleroma_job_queue, "~> 0.2.0"}, + {:telemetry, "~> 0.3"}, + {:prometheus_ex, "~> 3.0"}, + {:prometheus_plugs, "~> 1.1"}, + {:prometheus_phoenix, "~> 1.2"}, + {:prometheus_ecto, "~> 1.4"}, + {:prometheus_process_collector, "~> 1.4"}, + {:recon, github: "ferd/recon", tag: "2.4.0"}, {:quack, "~> 0.1.1"} - ] + ] ++ oauth_deps end # Aliases are shortcuts or tasks specific to the current project. diff --git a/mix.lock b/mix.lock index d84d11049..bb40ebd48 100644 --- a/mix.lock +++ b/mix.lock @@ -1,10 +1,11 @@ %{ + "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm"}, "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "479dd343f4e563ff91215c8275f3b5c67e032850", [ref: "479dd343f4e563ff91215c8275f3b5c67e032850"]}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, "cachex": {:hex, :cachex, "3.0.2", "1351caa4e26e29f7d7ec1d29b53d6013f0447630bbf382b4fb5d5bad0209f203", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"}, "calendar": {:hex, :calendar, "0.17.4", "22c5e8d98a4db9494396e5727108dffb820ee0d18fed4b0aa8ab76e4f5bc32f1", [:mix], [{:tzdata, "~> 0.5.8 or ~> 0.1.201603", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, - "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, "comeonin": {:hex, :comeonin, "4.1.1", "c7304fc29b45b897b34142a91122bc72757bc0c295e9e824999d5179ffc08416", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, @@ -27,7 +28,7 @@ "floki": {:hex, :floki, "0.20.4", "be42ac911fece24b4c72f3b5846774b6e61b83fe685c2fc9d62093277fb3bc86", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "0.13.0", "11f08504c4bdd831dc520b8f84a1dce5ce624474a797394e7aafd3c29f5dcd25", [:rebar3], [], "hexpm"}, "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, - "hackney": {:hex, :hackney, "1.14.3", "b5f6f5dcc4f1fba340762738759209e21914516df6be440d85772542d4a5e412", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, @@ -39,7 +40,7 @@ "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, "mochiweb": {:hex, :mochiweb, "2.15.0", "e1daac474df07651e5d17cc1e642c4069c7850dc4508d3db7263a0651330aacc", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm"}, @@ -57,8 +58,15 @@ "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, + "prometheus": {:hex, :prometheus, "4.2.2", "a830e77b79dc6d28183f4db050a7cac926a6c58f1872f9ef94a35cd989aceef8", [:mix, :rebar3], [], "hexpm"}, + "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.1", "6c768ea9654de871e5b32fab2eac348467b3021604ebebbcbd8bcbe806a65ed5", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, + "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, + "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.2.1", "964a74dfbc055f781d3a75631e06ce3816a2913976d1df7830283aa3118a797a", [:mix], [{:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, + "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm"}, + "prometheus_process_collector": {:hex, :prometheus_process_collector, "1.4.0", "6dbd39e3165b9ef1c94a7a820e9ffe08479f949dcdd431ed4aaea7b250eebfde", [:rebar3], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, "quack": {:hex, :quack, "0.1.1", "cca7b4da1a233757fdb44b3334fce80c94785b3ad5a602053b7a002b5a8967bf", [:mix], [{:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, + "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, "swoosh": {:hex, :swoosh, "0.20.0", "9a6c13822c9815993c03b6f8fccc370fcffb3c158d9754f67b1fdee6b3a5d928", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.12", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"}, "syslog": {:git, "https://github.com/Vagabond/erlang-syslog.git", "4a6c6f2c996483e86c1320e9553f91d337bcb6aa", [tag: "1.0.5"]}, @@ -67,6 +75,7 @@ "timex": {:hex, :timex, "3.5.0", "b0a23167da02d0fe4f1a4e104d1f929a00d348502b52432c05de875d0b9cffa5", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "tzdata": {:hex, :tzdata, "0.5.17", "50793e3d85af49736701da1a040c415c97dc1caf6464112fd9bd18f425d3053b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "ueberauth": {:hex, :ueberauth, "0.6.1", "9e90d3337dddf38b1ca2753aca9b1e53d8a52b890191cdc55240247c89230412", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, "unsafe": {:hex, :unsafe, "1.0.0", "7c21742cd05380c7875546b023481d3a26f52df8e5dfedcb9f958f322baae305", [:mix], [], "hexpm"}, "web_push_encryption": {:hex, :web_push_encryption, "0.2.1", "d42cecf73420d9dc0053ba3299cc8c8d6ff2be2487d67ca2a57265868e4d9a98", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/priv/repo/migrations/20190315101315_create_registrations.exs b/priv/repo/migrations/20190315101315_create_registrations.exs new file mode 100644 index 000000000..6b28cbdd3 --- /dev/null +++ b/priv/repo/migrations/20190315101315_create_registrations.exs @@ -0,0 +1,18 @@ +defmodule Pleroma.Repo.Migrations.CreateRegistrations do + use Ecto.Migration + + def change do + create table(:registrations, primary_key: false) do + add :id, :uuid, primary_key: true + add :user_id, references(:users, type: :uuid, on_delete: :delete_all) + add :provider, :string + add :uid, :string + add :info, :map, default: %{} + + timestamps() + end + + create unique_index(:registrations, [:provider, :uid]) + create unique_index(:registrations, [:user_id, :provider, :uid]) + end +end diff --git a/priv/repo/migrations/20190328053912_create_scheduled_activities.exs b/priv/repo/migrations/20190328053912_create_scheduled_activities.exs new file mode 100644 index 000000000..dd737e25a --- /dev/null +++ b/priv/repo/migrations/20190328053912_create_scheduled_activities.exs @@ -0,0 +1,16 @@ +defmodule Pleroma.Repo.Migrations.CreateScheduledActivities do + use Ecto.Migration + + def change do + create table(:scheduled_activities) do + add(:user_id, references(:users, type: :uuid, on_delete: :delete_all)) + add(:scheduled_at, :naive_datetime, null: false) + add(:params, :map, null: false) + + timestamps() + end + + create(index(:scheduled_activities, [:scheduled_at])) + create(index(:scheduled_activities, [:user_id])) + end +end diff --git a/test/emoji_test.exs b/test/emoji_test.exs new file mode 100644 index 000000000..cb1d62d00 --- /dev/null +++ b/test/emoji_test.exs @@ -0,0 +1,106 @@ +defmodule Pleroma.EmojiTest do + use ExUnit.Case, async: true + alias Pleroma.Emoji + + describe "get_all/0" do + setup do + emoji_list = Emoji.get_all() + {:ok, emoji_list: emoji_list} + end + + test "first emoji", %{emoji_list: emoji_list} do + [emoji | _others] = emoji_list + {code, path, tags} = emoji + + assert tuple_size(emoji) == 3 + assert is_binary(code) + assert is_binary(path) + assert is_binary(tags) + end + + test "random emoji", %{emoji_list: emoji_list} do + emoji = Enum.random(emoji_list) + {code, path, tags} = emoji + + assert tuple_size(emoji) == 3 + assert is_binary(code) + assert is_binary(path) + assert is_binary(tags) + end + end + + describe "match_extra/2" do + setup do + groups = [ + "list of files": ["/emoji/custom/first_file.png", "/emoji/custom/second_file.png"], + "wildcard folder": "/emoji/custom/*/file.png", + "wildcard files": "/emoji/custom/folder/*.png", + "special file": "/emoji/custom/special.png" + ] + + {:ok, groups: groups} + end + + test "config for list of files", %{groups: groups} do + group = + groups + |> Emoji.match_extra("/emoji/custom/first_file.png") + |> to_string() + + assert group == "list of files" + end + + test "config with wildcard folder", %{groups: groups} do + group = + groups + |> Emoji.match_extra("/emoji/custom/some_folder/file.png") + |> to_string() + + assert group == "wildcard folder" + end + + test "config with wildcard folder and subfolders", %{groups: groups} do + group = + groups + |> Emoji.match_extra("/emoji/custom/some_folder/another_folder/file.png") + |> to_string() + + assert group == "wildcard folder" + end + + test "config with wildcard files", %{groups: groups} do + group = + groups + |> Emoji.match_extra("/emoji/custom/folder/some_file.png") + |> to_string() + + assert group == "wildcard files" + end + + test "config with wildcard files and subfolders", %{groups: groups} do + group = + groups + |> Emoji.match_extra("/emoji/custom/folder/another_folder/some_file.png") + |> to_string() + + assert group == "wildcard files" + end + + test "config for special file", %{groups: groups} do + group = + groups + |> Emoji.match_extra("/emoji/custom/special.png") + |> to_string() + + assert group == "special file" + end + + test "no mathing returns nil", %{groups: groups} do + group = + groups + |> Emoji.match_extra("/emoji/some_undefined.png") + + refute group + end + end +end diff --git a/test/formatter_test.exs b/test/formatter_test.exs index fcdf931b7..e74985c4e 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -271,7 +271,9 @@ test "it does not add XSS emoji" do test "it returns the emoji used in the text" do text = "I love :moominmamma:" - assert Formatter.get_emoji(text) == [{"moominmamma", "/finmoji/128px/moominmamma-128.png"}] + assert Formatter.get_emoji(text) == [ + {"moominmamma", "/finmoji/128px/moominmamma-128.png", "Finmoji"} + ] end test "it returns a nice empty result when no emojis are present" do diff --git a/test/notification_test.exs b/test/notification_test.exs index 5727620c9..c3db77b6c 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -53,6 +53,75 @@ test "it doesn't create a notification for user if the user blocks the activity assert nil == Notification.create_notification(activity, user) end + test "it doesn't create a notificatin for the user if the user mutes the activity author" do + muter = insert(:user) + muted = insert(:user) + {:ok, _} = User.mute(muter, muted) + muter = Repo.get(User, muter.id) + {:ok, activity} = CommonAPI.post(muted, %{"status" => "Hi @#{muter.nickname}"}) + + assert nil == Notification.create_notification(activity, muter) + end + + test "it doesn't create a notification for an activity from a muted thread" do + muter = insert(:user) + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(muter, %{"status" => "hey"}) + CommonAPI.add_mute(muter, activity) + + {:ok, activity} = + CommonAPI.post(other_user, %{ + "status" => "Hi @#{muter.nickname}", + "in_reply_to_status_id" => activity.id + }) + + assert nil == Notification.create_notification(activity, muter) + end + + test "it disables notifications from people on remote instances" do + user = insert(:user, info: %{notification_settings: %{"remote" => false}}) + other_user = insert(:user) + + create_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "actor" => other_user.ap_id, + "object" => %{ + "type" => "Note", + "content" => "Hi @#{user.nickname}", + "attributedTo" => other_user.ap_id + } + } + + {:ok, %{local: false} = activity} = Transmogrifier.handle_incoming(create_activity) + assert nil == Notification.create_notification(activity, user) + end + + test "it disables notifications from people on the local instance" do + user = insert(:user, info: %{notification_settings: %{"local" => false}}) + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"}) + assert nil == Notification.create_notification(activity, user) + end + + test "it disables notifications from followers" do + follower = insert(:user) + followed = insert(:user, info: %{notification_settings: %{"followers" => false}}) + User.follow(follower, followed) + {:ok, activity} = CommonAPI.post(follower, %{"status" => "hey @#{followed.nickname}"}) + assert nil == Notification.create_notification(activity, followed) + end + + test "it disables notifications from people the user follows" do + follower = insert(:user, info: %{notification_settings: %{"follows" => false}}) + followed = insert(:user) + User.follow(follower, followed) + follower = Repo.get(User, follower.id) + {:ok, activity} = CommonAPI.post(followed, %{"status" => "hey @#{follower.nickname}"}) + assert nil == Notification.create_notification(activity, follower) + end + test "it doesn't create a notification for user if he is the activity author" do activity = insert(:note_activity) author = User.get_by_ap_id(activity.data["actor"]) diff --git a/test/plugs/legacy_authentication_plug_test.exs b/test/plugs/legacy_authentication_plug_test.exs index 302662797..8b0b06772 100644 --- a/test/plugs/legacy_authentication_plug_test.exs +++ b/test/plugs/legacy_authentication_plug_test.exs @@ -47,16 +47,18 @@ test "it authenticates the auth_user if present and password is correct and rese |> assign(:auth_user, user) conn = - with_mock User, - reset_password: fn user, %{password: password, password_confirmation: password} -> - send(self(), :reset_password) - {:ok, user} - end do - conn - |> LegacyAuthenticationPlug.call(%{}) + with_mocks([ + {:crypt, [], [crypt: fn _password, password_hash -> password_hash end]}, + {User, [], + [ + reset_password: fn user, %{password: password, password_confirmation: password} -> + {:ok, user} + end + ]} + ]) do + LegacyAuthenticationPlug.call(conn, %{}) end - assert_received :reset_password assert conn.assigns.user == user end diff --git a/test/registration_test.exs b/test/registration_test.exs new file mode 100644 index 000000000..6143b82c7 --- /dev/null +++ b/test/registration_test.exs @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.RegistrationTest do + use Pleroma.DataCase + + import Pleroma.Factory + + alias Pleroma.Registration + alias Pleroma.Repo + + describe "generic changeset" do + test "requires :provider, :uid" do + registration = build(:registration, provider: nil, uid: nil) + + cs = Registration.changeset(registration, %{}) + refute cs.valid? + + assert [ + provider: {"can't be blank", [validation: :required]}, + uid: {"can't be blank", [validation: :required]} + ] == cs.errors + end + + test "ensures uniqueness of [:provider, :uid]" do + registration = insert(:registration) + registration2 = build(:registration, provider: registration.provider, uid: registration.uid) + + cs = Registration.changeset(registration2, %{}) + assert cs.valid? + + assert {:error, + %Ecto.Changeset{ + errors: [ + uid: + {"has already been taken", + [constraint: :unique, constraint_name: "registrations_provider_uid_index"]} + ] + }} = Repo.insert(cs) + + # Note: multiple :uid values per [:user_id, :provider] are intentionally allowed + cs2 = Registration.changeset(registration2, %{uid: "available.uid"}) + assert cs2.valid? + assert {:ok, _} = Repo.insert(cs2) + + cs3 = Registration.changeset(registration2, %{provider: "provider2"}) + assert cs3.valid? + assert {:ok, _} = Repo.insert(cs3) + end + + test "allows `nil` :user_id (user-unbound registration)" do + registration = build(:registration, user_id: nil) + cs = Registration.changeset(registration, %{}) + assert cs.valid? + assert {:ok, _} = Repo.insert(cs) + end + end +end diff --git a/test/scheduled_activity_test.exs b/test/scheduled_activity_test.exs new file mode 100644 index 000000000..edc7cc3f9 --- /dev/null +++ b/test/scheduled_activity_test.exs @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2018 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ScheduledActivityTest do + use Pleroma.DataCase + alias Pleroma.DataCase + alias Pleroma.ScheduledActivity + import Pleroma.Factory + + setup context do + DataCase.ensure_local_uploader(context) + end + + describe "creation" do + test "when daily user limit is exceeded" do + user = insert(:user) + + today = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(6), :millisecond) + |> NaiveDateTime.to_iso8601() + + attrs = %{params: %{}, scheduled_at: today} + {:ok, _} = ScheduledActivity.create(user, attrs) + {:ok, _} = ScheduledActivity.create(user, attrs) + {:error, changeset} = ScheduledActivity.create(user, attrs) + assert changeset.errors == [scheduled_at: {"daily limit exceeded", []}] + end + + test "when total user limit is exceeded" do + user = insert(:user) + + today = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(6), :millisecond) + |> NaiveDateTime.to_iso8601() + + tomorrow = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.hours(36), :millisecond) + |> NaiveDateTime.to_iso8601() + + {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: today}) + {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: today}) + {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow}) + {:error, changeset} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow}) + assert changeset.errors == [scheduled_at: {"total limit exceeded", []}] + end + + test "when scheduled_at is earlier than 5 minute from now" do + user = insert(:user) + + scheduled_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(4), :millisecond) + |> NaiveDateTime.to_iso8601() + + attrs = %{params: %{}, scheduled_at: scheduled_at} + {:error, changeset} = ScheduledActivity.create(user, attrs) + assert changeset.errors == [scheduled_at: {"must be at least 5 minutes from now", []}] + end + end +end diff --git a/test/scheduled_activity_worker_test.exs b/test/scheduled_activity_worker_test.exs new file mode 100644 index 000000000..b9c91dda6 --- /dev/null +++ b/test/scheduled_activity_worker_test.exs @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2018 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ScheduledActivityWorkerTest do + use Pleroma.DataCase + alias Pleroma.ScheduledActivity + import Pleroma.Factory + + test "creates a status from the scheduled activity" do + user = insert(:user) + scheduled_activity = insert(:scheduled_activity, user: user, params: %{status: "hi"}) + Pleroma.ScheduledActivityWorker.perform(:execute, scheduled_activity.id) + + refute Repo.get(ScheduledActivity, scheduled_activity.id) + activity = Repo.all(Pleroma.Activity) |> Enum.find(&(&1.actor == user.ap_id)) + assert activity.data["object"]["content"] == "hi" + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index e1a08315a..ea59912cf 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -240,6 +240,16 @@ def oauth_token_factory do } end + def oauth_authorization_factory do + %Pleroma.Web.OAuth.Authorization{ + token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false), + scopes: ["read", "write", "follow", "push"], + valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10), + user: build(:user), + app: build(:oauth_app) + } + end + def push_subscription_factory do %Pleroma.Web.Push.Subscription{ user: build(:user), @@ -257,4 +267,28 @@ def notification_factory do user: build(:user) } end + + def scheduled_activity_factory do + %Pleroma.ScheduledActivity{ + user: build(:user), + scheduled_at: NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(60), :millisecond), + params: build(:note) |> Map.from_struct() |> Map.get(:data) + } + end + + def registration_factory do + user = insert(:user) + + %Pleroma.Registration{ + user: user, + provider: "twitter", + uid: "171799000", + info: %{ + "name" => "John Doe", + "email" => "john@doe.com", + "nickname" => "johndoe", + "description" => "My bio" + } + } + end end diff --git a/test/user_test.exs b/test/user_test.exs index 2368f95f5..d2167a970 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1150,4 +1150,21 @@ test "bookmarks" do assert {:ok, user_state3} = User.bookmark(user, id2) assert user_state3.bookmarks == [id2] end + + test "follower count is updated when a follower is blocked" do + user = insert(:user) + follower = insert(:user) + follower2 = insert(:user) + follower3 = insert(:user) + + {:ok, follower} = Pleroma.User.follow(follower, user) + {:ok, _follower2} = Pleroma.User.follow(follower2, user) + {:ok, _follower3} = Pleroma.User.follow(follower3, user) + + {:ok, _} = Pleroma.User.block(user, follower) + + user_show = Pleroma.Web.TwitterAPI.UserView.render("show.json", %{user: user}) + + assert Map.get(user_show, "followers_count") == 2 + end end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 62b973c4f..47cffe257 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1028,9 +1028,6 @@ test "it upgrades a user to activitypub" do assert user.info.note_count == 1 assert user.follower_address == "https://niu.moe/users/rye/followers" - # Wait for the background task - :timer.sleep(1000) - user = User.get_by_id(user.id) assert user.info.note_count == 1 diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index 6b9961d82..758214e68 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -193,4 +193,16 @@ test "returns the what was collected if there are less pages than specified" do assert Utils.fetch_ordered_collection("http://example.com/outbox", 5) == [0, 1] end end + + test "make_json_ld_header/0" do + assert Utils.make_json_ld_header() == %{ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "http://localhost:4001/schemas/litepub-0.1.jsonld", + %{ + "@language" => "und" + } + ] + } + end end diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index acae64361..ca6bd0e97 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -74,6 +74,50 @@ test "when the user doesn't exist", %{conn: conn} do end end + describe "/api/pleroma/admin/user/follow" do + test "allows to force-follow another user" do + admin = insert(:user, info: %{is_admin: true}) + user = insert(:user) + follower = insert(:user) + + build_conn() + |> assign(:user, admin) + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/user/follow", %{ + "follower" => follower.nickname, + "followed" => user.nickname + }) + + user = User.get_by_id(user.id) + follower = User.get_by_id(follower.id) + + assert User.following?(follower, user) + end + end + + describe "/api/pleroma/admin/user/unfollow" do + test "allows to force-unfollow another user" do + admin = insert(:user, info: %{is_admin: true}) + user = insert(:user) + follower = insert(:user) + + User.follow(follower, user) + + build_conn() + |> assign(:user, admin) + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/user/unfollow", %{ + "follower" => follower.nickname, + "followed" => user.nickname + }) + + user = User.get_by_id(user.id) + follower = User.get_by_id(follower.id) + + refute User.following?(follower, user) + end + end + describe "PUT /api/pleroma/admin/users/tag" do setup do admin = insert(:user, info: %{is_admin: true}) diff --git a/test/web/mastodon_api/account_view_test.exs b/test/web/mastodon_api/account_view_test.exs index 48580ff1e..d7487bed9 100644 --- a/test/web/mastodon_api/account_view_test.exs +++ b/test/web/mastodon_api/account_view_test.exs @@ -71,6 +71,20 @@ test "Represent a user account" do assert expected == AccountView.render("account.json", %{user: user}) end + test "Represent the user account for the account owner" do + user = insert(:user) + + notification_settings = %{ + "remote" => true, + "local" => true, + "followers" => true, + "follows" => true + } + + assert %{pleroma: %{notification_settings: ^notification_settings}} = + AccountView.render("account.json", %{user: user, for: user}) + end + test "Represent a Service(bot) account" do user = insert(:user, %{ diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 811a2dd7b..3ac5c37a6 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.ScheduledActivity alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI @@ -2359,4 +2360,297 @@ test "accounts fetches correct account for nicknames beginning with numbers", %{ refute acc_one == acc_two assert acc_two == acc_three end + + describe "custom emoji" do + test "with tags", %{conn: conn} do + [emoji | _body] = + conn + |> get("/api/v1/custom_emojis") + |> json_response(200) + + assert Map.has_key?(emoji, "shortcode") + assert Map.has_key?(emoji, "static_url") + assert Map.has_key?(emoji, "tags") + assert is_list(emoji["tags"]) + assert Map.has_key?(emoji, "url") + assert Map.has_key?(emoji, "visible_in_picker") + end + end + + describe "index/2 redirections" do + setup %{conn: conn} do + session_opts = [ + store: :cookie, + key: "_test", + signing_salt: "cooldude" + ] + + conn = + conn + |> Plug.Session.call(Plug.Session.init(session_opts)) + |> fetch_session() + + test_path = "/web/statuses/test" + %{conn: conn, path: test_path} + end + + test "redirects not logged-in users to the login page", %{conn: conn, path: path} do + conn = get(conn, path) + + assert conn.status == 302 + assert redirected_to(conn) == "/web/login" + end + + test "does not redirect logged in users to the login page", %{conn: conn, path: path} do + token = insert(:oauth_token) + + conn = + conn + |> assign(:user, token.user) + |> put_session(:oauth_token, token.token) + |> get(path) + + assert conn.status == 200 + end + + test "saves referer path to session", %{conn: conn, path: path} do + conn = get(conn, path) + return_to = Plug.Conn.get_session(conn, :return_to) + + assert return_to == path + end + + test "redirects to the saved path after log in", %{conn: conn, path: path} do + app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".") + auth = insert(:oauth_authorization, app: app) + + conn = + conn + |> put_session(:return_to, path) + |> get("/web/login", %{code: auth.token}) + + assert conn.status == 302 + assert redirected_to(conn) == path + end + + test "redirects to the getting-started page when referer is not present", %{conn: conn} do + app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".") + auth = insert(:oauth_authorization, app: app) + + conn = get(conn, "/web/login", %{code: auth.token}) + + assert conn.status == 302 + assert redirected_to(conn) == "/web/getting-started" + end + end + + describe "scheduled activities" do + test "creates a scheduled activity", %{conn: conn} do + user = insert(:user) + scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "scheduled", + "scheduled_at" => scheduled_at + }) + + assert %{"scheduled_at" => expected_scheduled_at} = json_response(conn, 200) + assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(scheduled_at) + assert [] == Repo.all(Activity) + end + + test "creates a scheduled activity with a media attachment", %{conn: conn} do + user = insert(:user) + scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "media_ids" => [to_string(upload.id)], + "status" => "scheduled", + "scheduled_at" => scheduled_at + }) + + assert %{"media_attachments" => [media_attachment]} = json_response(conn, 200) + assert %{"type" => "image"} = media_attachment + end + + test "skips the scheduling and creates the activity if scheduled_at is earlier than 5 minutes from now", + %{conn: conn} do + user = insert(:user) + + scheduled_at = + NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(5) - 1, :millisecond) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "not scheduled", + "scheduled_at" => scheduled_at + }) + + assert %{"content" => "not scheduled"} = json_response(conn, 200) + assert [] == Repo.all(ScheduledActivity) + end + + test "returns error when daily user limit is exceeded", %{conn: conn} do + user = insert(:user) + + today = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(6), :millisecond) + |> NaiveDateTime.to_iso8601() + + attrs = %{params: %{}, scheduled_at: today} + {:ok, _} = ScheduledActivity.create(user, attrs) + {:ok, _} = ScheduledActivity.create(user, attrs) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => today}) + + assert %{"error" => "daily limit exceeded"} == json_response(conn, 422) + end + + test "returns error when total user limit is exceeded", %{conn: conn} do + user = insert(:user) + + today = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(6), :millisecond) + |> NaiveDateTime.to_iso8601() + + tomorrow = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.hours(36), :millisecond) + |> NaiveDateTime.to_iso8601() + + attrs = %{params: %{}, scheduled_at: today} + {:ok, _} = ScheduledActivity.create(user, attrs) + {:ok, _} = ScheduledActivity.create(user, attrs) + {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow}) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => tomorrow}) + + assert %{"error" => "total limit exceeded"} == json_response(conn, 422) + end + + test "shows scheduled activities", %{conn: conn} do + user = insert(:user) + scheduled_activity_id1 = insert(:scheduled_activity, user: user).id |> to_string() + scheduled_activity_id2 = insert(:scheduled_activity, user: user).id |> to_string() + scheduled_activity_id3 = insert(:scheduled_activity, user: user).id |> to_string() + scheduled_activity_id4 = insert(:scheduled_activity, user: user).id |> to_string() + + conn = + conn + |> assign(:user, user) + + # min_id + conn_res = + conn + |> get("/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result + + # since_id + conn_res = + conn + |> get("/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^scheduled_activity_id4}, %{"id" => ^scheduled_activity_id3}] = result + + # max_id + conn_res = + conn + |> get("/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result + end + + test "shows a scheduled activity", %{conn: conn} do + user = insert(:user) + scheduled_activity = insert(:scheduled_activity, user: user) + + res_conn = + conn + |> assign(:user, user) + |> get("/api/v1/scheduled_statuses/#{scheduled_activity.id}") + + assert %{"id" => scheduled_activity_id} = json_response(res_conn, 200) + assert scheduled_activity_id == scheduled_activity.id |> to_string() + + res_conn = + conn + |> assign(:user, user) + |> get("/api/v1/scheduled_statuses/404") + + assert %{"error" => "Record not found"} = json_response(res_conn, 404) + end + + test "updates a scheduled activity", %{conn: conn} do + user = insert(:user) + scheduled_activity = insert(:scheduled_activity, user: user) + + new_scheduled_at = + NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + + res_conn = + conn + |> assign(:user, user) + |> put("/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{ + scheduled_at: new_scheduled_at + }) + + assert %{"scheduled_at" => expected_scheduled_at} = json_response(res_conn, 200) + assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(new_scheduled_at) + + res_conn = + conn + |> assign(:user, user) + |> put("/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at}) + + assert %{"error" => "Record not found"} = json_response(res_conn, 404) + end + + test "deletes a scheduled activity", %{conn: conn} do + user = insert(:user) + scheduled_activity = insert(:scheduled_activity, user: user) + + res_conn = + conn + |> assign(:user, user) + |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}") + + assert %{} = json_response(res_conn, 200) + assert nil == Repo.get(ScheduledActivity, scheduled_activity.id) + + res_conn = + conn + |> assign(:user, user) + |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}") + + assert %{"error" => "Record not found"} = json_response(res_conn, 404) + end + end end diff --git a/test/web/mastodon_api/scheduled_activity_view_test.exs b/test/web/mastodon_api/scheduled_activity_view_test.exs new file mode 100644 index 000000000..ecbb855d4 --- /dev/null +++ b/test/web/mastodon_api/scheduled_activity_view_test.exs @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2018 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ScheduledActivityViewTest do + use Pleroma.DataCase + alias Pleroma.ScheduledActivity + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.ScheduledActivityView + alias Pleroma.Web.MastodonAPI.StatusView + import Pleroma.Factory + + test "A scheduled activity with a media attachment" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "hi"}) + + scheduled_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(10), :millisecond) + |> NaiveDateTime.to_iso8601() + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + + attrs = %{ + params: %{ + "media_ids" => [upload.id], + "status" => "hi", + "sensitive" => true, + "spoiler_text" => "spoiler", + "visibility" => "unlisted", + "in_reply_to_id" => to_string(activity.id) + }, + scheduled_at: scheduled_at + } + + {:ok, scheduled_activity} = ScheduledActivity.create(user, attrs) + result = ScheduledActivityView.render("show.json", %{scheduled_activity: scheduled_activity}) + + expected = %{ + id: to_string(scheduled_activity.id), + media_attachments: + %{"media_ids" => [upload.id]} + |> Utils.attachments_from_ids() + |> Enum.map(&StatusView.render("attachment.json", %{attachment: &1})), + params: %{ + in_reply_to_id: to_string(activity.id), + media_ids: [upload.id], + poll: nil, + scheduled_at: nil, + sensitive: true, + spoiler_text: "spoiler", + text: "hi", + visibility: "unlisted" + }, + scheduled_at: Utils.to_masto_date(scheduled_activity.scheduled_at) + } + + assert expected == result + end +end diff --git a/test/web/mastodon_api/status_view_test.exs b/test/web/mastodon_api/status_view_test.exs index 8db92ac16..db2fdc2f6 100644 --- a/test/web/mastodon_api/status_view_test.exs +++ b/test/web/mastodon_api/status_view_test.exs @@ -101,7 +101,7 @@ test "a note activity" do muted: false, pinned: false, sensitive: false, - spoiler_text: note.data["object"]["summary"], + spoiler_text: HtmlSanitizeEx.basic_html(note.data["object"]["summary"]), visibility: "public", media_attachments: [], mentions: [], @@ -126,7 +126,9 @@ test "a note activity" do ], pleroma: %{ local: true, - conversation_id: convo_id + conversation_id: convo_id, + content: %{"text/plain" => HtmlSanitizeEx.strip_tags(note.data["object"]["content"])}, + spoiler_text: %{"text/plain" => HtmlSanitizeEx.strip_tags(note.data["object"]["summary"])} } } diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index a9a0b9ed4..ac7843f9b 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -5,24 +5,330 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do use Pleroma.Web.ConnCase import Pleroma.Factory + import Mock + alias Pleroma.Registration alias Pleroma.Repo alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Token + @session_opts [ + store: :cookie, + key: "_test", + signing_salt: "cooldude" + ] + + describe "in OAuth consumer mode, " do + setup do + oauth_consumer_strategies_path = [:auth, :oauth_consumer_strategies] + oauth_consumer_strategies = Pleroma.Config.get(oauth_consumer_strategies_path) + Pleroma.Config.put(oauth_consumer_strategies_path, ~w(twitter facebook)) + + on_exit(fn -> + Pleroma.Config.put(oauth_consumer_strategies_path, oauth_consumer_strategies) + end) + + [ + app: insert(:oauth_app), + conn: + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + ] + end + + test "GET /oauth/authorize renders auth forms, including OAuth consumer form", %{ + app: app, + conn: conn + } do + conn = + get( + conn, + "/oauth/authorize", + %{ + "response_type" => "code", + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "scope" => "read" + } + ) + + assert response = html_response(conn, 200) + assert response =~ "Sign in with Twitter" + assert response =~ o_auth_path(conn, :prepare_request) + end + + test "GET /oauth/prepare_request encodes parameters as `state` and redirects", %{ + app: app, + conn: conn + } do + conn = + get( + conn, + "/oauth/prepare_request", + %{ + "provider" => "twitter", + "scope" => "read follow", + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "a_state" + } + ) + + assert response = html_response(conn, 302) + + redirect_query = URI.parse(redirected_to(conn)).query + assert %{"state" => state_param} = URI.decode_query(redirect_query) + assert {:ok, state_components} = Poison.decode(state_param) + + expected_client_id = app.client_id + expected_redirect_uri = app.redirect_uris + + assert %{ + "scope" => "read follow", + "client_id" => ^expected_client_id, + "redirect_uri" => ^expected_redirect_uri, + "state" => "a_state" + } = state_components + end + + test "with user-bound registration, GET /oauth//callback redirects to `redirect_uri` with `code`", + %{app: app, conn: conn} do + registration = insert(:registration) + + state_params = %{ + "scope" => Enum.join(app.scopes, " "), + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "" + } + + with_mock Pleroma.Web.Auth.Authenticator, + get_registration: fn _, _ -> {:ok, registration} end do + conn = + get( + conn, + "/oauth/twitter/callback", + %{ + "oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM", + "oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs", + "provider" => "twitter", + "state" => Poison.encode!(state_params) + } + ) + + assert response = html_response(conn, 302) + assert redirected_to(conn) =~ ~r/#{app.redirect_uris}\?code=.+/ + end + end + + test "with user-unbound registration, GET /oauth//callback renders registration_details page", + %{app: app, conn: conn} do + registration = insert(:registration, user: nil) + + state_params = %{ + "scope" => "read write", + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "a_state" + } + + with_mock Pleroma.Web.Auth.Authenticator, + get_registration: fn _, _ -> {:ok, registration} end do + conn = + get( + conn, + "/oauth/twitter/callback", + %{ + "oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM", + "oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs", + "provider" => "twitter", + "state" => Poison.encode!(state_params) + } + ) + + assert response = html_response(conn, 200) + assert response =~ ~r/name="op" type="submit" value="register"/ + assert response =~ ~r/name="op" type="submit" value="connect"/ + assert response =~ Registration.email(registration) + assert response =~ Registration.nickname(registration) + end + end + + test "on authentication error, GET /oauth//callback redirects to `redirect_uri`", %{ + app: app, + conn: conn + } do + state_params = %{ + "scope" => Enum.join(app.scopes, " "), + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "" + } + + conn = + conn + |> assign(:ueberauth_failure, %{errors: [%{message: "(error description)"}]}) + |> get( + "/oauth/twitter/callback", + %{ + "oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM", + "oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs", + "provider" => "twitter", + "state" => Poison.encode!(state_params) + } + ) + + assert response = html_response(conn, 302) + assert redirected_to(conn) == app.redirect_uris + assert get_flash(conn, :error) == "Failed to authenticate: (error description)." + end + + test "GET /oauth/registration_details renders registration details form", %{ + app: app, + conn: conn + } do + conn = + get( + conn, + "/oauth/registration_details", + %{ + "scopes" => app.scopes, + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "a_state", + "nickname" => nil, + "email" => "john@doe.com" + } + ) + + assert response = html_response(conn, 200) + assert response =~ ~r/name="op" type="submit" value="register"/ + assert response =~ ~r/name="op" type="submit" value="connect"/ + end + + test "with valid params, POST /oauth/register?op=register redirects to `redirect_uri` with `code`", + %{ + app: app, + conn: conn + } do + registration = insert(:registration, user: nil, info: %{"nickname" => nil, "email" => nil}) + + conn = + conn + |> put_session(:registration_id, registration.id) + |> post( + "/oauth/register", + %{ + "op" => "register", + "scopes" => app.scopes, + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "a_state", + "nickname" => "availablenick", + "email" => "available@email.com" + } + ) + + assert response = html_response(conn, 302) + assert redirected_to(conn) =~ ~r/#{app.redirect_uris}\?code=.+/ + end + + test "with invalid params, POST /oauth/register?op=register renders registration_details page", + %{ + app: app, + conn: conn + } do + another_user = insert(:user) + registration = insert(:registration, user: nil, info: %{"nickname" => nil, "email" => nil}) + + params = %{ + "op" => "register", + "scopes" => app.scopes, + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "a_state", + "nickname" => "availablenickname", + "email" => "available@email.com" + } + + for {bad_param, bad_param_value} <- + [{"nickname", another_user.nickname}, {"email", another_user.email}] do + bad_params = Map.put(params, bad_param, bad_param_value) + + conn = + conn + |> put_session(:registration_id, registration.id) + |> post("/oauth/register", bad_params) + + assert html_response(conn, 403) =~ ~r/name="op" type="submit" value="register"/ + assert get_flash(conn, :error) == "Error: #{bad_param} has already been taken." + end + end + + test "with valid params, POST /oauth/register?op=connect redirects to `redirect_uri` with `code`", + %{ + app: app, + conn: conn + } do + user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("testpassword")) + registration = insert(:registration, user: nil) + + conn = + conn + |> put_session(:registration_id, registration.id) + |> post( + "/oauth/register", + %{ + "op" => "connect", + "scopes" => app.scopes, + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "a_state", + "auth_name" => user.nickname, + "password" => "testpassword" + } + ) + + assert response = html_response(conn, 302) + assert redirected_to(conn) =~ ~r/#{app.redirect_uris}\?code=.+/ + end + + test "with invalid params, POST /oauth/register?op=connect renders registration_details page", + %{ + app: app, + conn: conn + } do + user = insert(:user) + registration = insert(:registration, user: nil) + + params = %{ + "op" => "connect", + "scopes" => app.scopes, + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "a_state", + "auth_name" => user.nickname, + "password" => "wrong password" + } + + conn = + conn + |> put_session(:registration_id, registration.id) + |> post("/oauth/register", params) + + assert html_response(conn, 401) =~ ~r/name="op" type="submit" value="connect"/ + assert get_flash(conn, :error) == "Invalid Username/Password" + end + end + describe "GET /oauth/authorize" do setup do - session_opts = [ - store: :cookie, - key: "_test", - signing_salt: "cooldude" - ] - [ app: insert(:oauth_app, redirect_uris: "https://redirect.url"), conn: build_conn() - |> Plug.Session.call(Plug.Session.init(session_opts)) + |> Plug.Session.call(Plug.Session.init(@session_opts)) |> fetch_session() ] end @@ -327,6 +633,32 @@ test "rejects token exchange for valid credentials belonging to unconfirmed user refute Map.has_key?(resp, "access_token") end + test "rejects token exchange for valid credentials belonging to deactivated user" do + password = "testpassword" + + user = + insert(:user, + password_hash: Comeonin.Pbkdf2.hashpwsalt(password), + info: %{deactivated: true} + ) + + app = insert(:oauth_app) + + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "password", + "username" => user.nickname, + "password" => password, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + assert resp = json_response(conn, 403) + assert %{"error" => _} = resp + refute Map.has_key?(resp, "access_token") + end + test "rejects an invalid authorization code" do app = insert(:oauth_app) diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 3f9f3d809..6bac2c9f6 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -64,17 +64,19 @@ test "performs sending notifications" do } ) - assert Impl.perform_send(notif) == [:ok, :ok] + assert Impl.perform(notif) == [:ok, :ok] end + @tag capture_log: true test "returns error if notif does not match " do - assert Impl.perform_send(%{}) == :error + assert Impl.perform(%{}) == :error end test "successful message sending" do assert Impl.push_message(@message, @sub, @api_key, %Subscription{}) == :ok end + @tag capture_log: true test "fail message sending" do assert Impl.push_message( @message, diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index e4dd97d46..a4b3d651a 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -3,6 +3,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do alias Pleroma.Notification alias Pleroma.Repo + alias Pleroma.User alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -79,6 +80,26 @@ test "it marks a single notification as read", %{conn: conn} do end end + describe "PUT /api/pleroma/notification_settings" do + test "it updates notification settings", %{conn: conn} do + user = insert(:user) + + conn + |> assign(:user, user) + |> put("/api/pleroma/notification_settings", %{ + "remote" => false, + "followers" => false, + "bar" => 1 + }) + |> json_response(:ok) + + user = Repo.get(User, user.id) + + assert %{"remote" => false, "local" => true, "followers" => false, "follows" => true} == + user.info.notification_settings + end + end + describe "GET /api/statusnet/config.json" do test "returns the state of safe_dm_mentions flag", %{conn: conn} do option = Pleroma.Config.get([:instance, :safe_dm_mentions]) @@ -170,6 +191,24 @@ test "returns everything in :pleroma, :frontend_configurations", %{conn: conn} d end end + describe "/api/pleroma/emoji" do + test "returns json with custom emoji with tags", %{conn: conn} do + emoji = + conn + |> get("/api/pleroma/emoji") + |> json_response(200) + + assert Enum.all?(emoji, fn + {_key, + %{ + "image_url" => url, + "tags" => tags + }} -> + is_binary(url) and is_list(tags) + end) + end + end + describe "GET /ostatus_subscribe?acct=...." do test "adds status to pleroma instance if the `acct` is a status", %{conn: conn} do conn =