diff options
35 files changed, 733 insertions, 38 deletions
| diff --git a/config/config.exs b/config/config.exs index 28867ed4c..6839b489b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -34,7 +34,7 @@ config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.  # Upload configuration  config :pleroma, Pleroma.Upload,    uploader: Pleroma.Uploaders.Local, -  filters: [], +  filters: [Pleroma.Upload.Filter.Dedupe],    link_name: true,    proxy_remote: false,    proxy_opts: [ @@ -370,6 +370,17 @@ config :auto_linker,      rel: false    ] +config :pleroma, :ldap, +  enabled: System.get_env("LDAP_ENABLED") == "true", +  host: System.get_env("LDAP_HOST") || "localhost", +  port: String.to_integer(System.get_env("LDAP_PORT") || "389"), +  ssl: System.get_env("LDAP_SSL") == "true", +  sslopts: [], +  tls: System.get_env("LDAP_TLS") == "true", +  tlsopts: [], +  base: System.get_env("LDAP_BASE") || "dc=example,dc=com", +  uid: System.get_env("LDAP_UID") || "cn" +  config :pleroma, :auth, oauth_consumer_enabled: false  config :ueberauth, diff --git a/config/test.exs b/config/test.exs index a3f36c9e1..3691e5bd1 100644 --- a/config/test.exs +++ b/config/test.exs @@ -17,7 +17,7 @@ config :pleroma, Pleroma.Captcha,  # Print only warnings and errors during test  config :logger, level: :warn -config :pleroma, Pleroma.Upload, link_name: false +config :pleroma, Pleroma.Upload, filters: [], link_name: false  config :pleroma, Pleroma.Uploaders.Local, uploads: "test/uploads" diff --git a/docs/Custom-Emoji.md b/docs/Custom-Emoji.md index d4af5c97c..9d90e5822 100644 --- a/docs/Custom-Emoji.md +++ b/docs/Custom-Emoji.md @@ -1,3 +1,5 @@ +# Custom emoji +  To add custom emoji:  * Add the image file(s) to `priv/static/emoji/custom`  * In case of conflicts: add the desired shortcode with the path to `config/custom_emoji.txt`, comma-separated and one per line diff --git a/docs/Message-Rewrite-Facility-configuration.md b/docs/Message-Rewrite-Facility-configuration.md index 708098b41..35ce52ea9 100644 --- a/docs/Message-Rewrite-Facility-configuration.md +++ b/docs/Message-Rewrite-Facility-configuration.md @@ -1,3 +1,4 @@ +# Message Rewrite Facility configuration  The Message Rewrite Facility (MRF) is a subsystem that is implemented as a series of hooks that allows the administrator to rewrite or discard messages.  Possible uses include: diff --git a/docs/Pleroma-API.md b/docs/Pleroma-API.md index 379d3dbed..478c9d874 100644 --- a/docs/Pleroma-API.md +++ b/docs/Pleroma-API.md @@ -108,3 +108,11 @@ See [Admin-API](Admin-API.md)  * Response: JSON string. Returns the user flavour or the default one.  * Example response: "glitch"  * Note: This is intended to be used only by mastofe + +## `/api/pleroma/notifications/read` +### Mark a single notification as read +* Method `POST` +* Authentication: required +* Params: +    * `id`: notifications's id +* Response: JSON. Returns `{"status": "success"}` if the reading was successful, otherwise returns `{"error": "error_msg"}` diff --git a/docs/config.md b/docs/config.md index e34ffe980..201180373 100644 --- a/docs/config.md +++ b/docs/config.md @@ -331,3 +331,26 @@ config :auto_linker,      rel: false    ]  ``` + +## :ldap + +Use LDAP for user authentication.  When a user logs in to the Pleroma +instance, the name and password will be verified by trying to authenticate +(bind) to an LDAP server.  If a user exists in the LDAP directory but there +is no account with the same name yet on the Pleroma instance then a new +Pleroma account will be created with the same name as the LDAP user name. + +* `enabled`: enables LDAP authentication +* `host`: LDAP server hostname +* `port`: LDAP port, e.g. 389 or 636 +* `ssl`: true to use SSL, usually implies the port 636 +* `sslopts`: additional SSL options +* `tls`: true to start TLS, usually implies the port 389 +* `tlsopts`: additional TLS options +* `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 + +* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator +* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 05253157e..5b152d926 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -95,6 +95,13 @@ defmodule Pleroma.HTML.Scrubber.TwitterText do    Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], @valid_schemes)    Meta.allow_tag_with_these_attributes("a", ["name", "title", "class"]) +  Meta.allow_tag_with_this_attribute_values("a", "rel", [ +    "tag", +    "nofollow", +    "noopener", +    "noreferrer" +  ]) +    # paragraphs and linebreaks    Meta.allow_tag_with_these_attributes("br", [])    Meta.allow_tag_with_these_attributes("p", []) @@ -137,6 +144,13 @@ defmodule Pleroma.HTML.Scrubber.Default do    Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], @valid_schemes)    Meta.allow_tag_with_these_attributes("a", ["name", "title", "class"]) +  Meta.allow_tag_with_this_attribute_values("a", "rel", [ +    "tag", +    "nofollow", +    "noopener", +    "noreferrer" +  ]) +    Meta.allow_tag_with_these_attributes("abbr", ["title"])    Meta.allow_tag_with_these_attributes("b", []) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index fe8181d8b..765191275 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Notification do    alias Pleroma.Web.CommonAPI.Utils    import Ecto.Query +  import Ecto.Changeset    schema "notifications" do      field(:seen, :boolean, default: false) @@ -22,6 +23,11 @@ defmodule Pleroma.Notification do      timestamps()    end +  def changeset(%Notification{} = notification, attrs) do +    notification +    |> cast(attrs, [:seen]) +  end +    # TODO: Make generic and unify (see activity_pub.ex)    defp restrict_max(query, %{"max_id" => max_id}) do      from(activity in query, where: activity.id < ^max_id) @@ -68,6 +74,14 @@ defmodule Pleroma.Notification do      Repo.update_all(query, [])    end +  def read_one(%User{} = user, notification_id) do +    with {:ok, %Notification{} = notification} <- get(user, notification_id) do +      notification +      |> changeset(%{seen: true}) +      |> Repo.update() +    end +  end +    def get(%{id: user_id} = _user, id) do      query =        from( diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 6298b92f4..a3f177fec 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -311,7 +311,25 @@ defmodule Pleroma.ReverseProxy do        end      if attachment? do -      disposition = "attachment; filename=" <> Keyword.get(opts, :attachment_name, "attachment") +      name = +        try do +          {{"content-disposition", content_disposition_string}, _} = +            List.keytake(headers, "content-disposition", 0) + +          [name | _] = +            Regex.run( +              ~r/filename="((?:[^"\\]|\\.)*)"/u, +              content_disposition_string || "", +              capture: :all_but_first +            ) + +          name +        rescue +          MatchError -> Keyword.get(opts, :attachment_name, "attachment") +        end + +      disposition = "attachment; filename=\"#{name}\"" +        List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})      else        headers diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 381642281..7f8b282e0 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1423,4 +1423,8 @@ defmodule Pleroma.User do        offset: ^((page - 1) * page_size)      )    end + +  def showing_reblogs?(%User{} = user, %User{} = target) do +    target.ap_id not in user.info.muted_reblogs +  end  end diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index e3fd65a6e..740a46727 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -21,6 +21,7 @@ defmodule Pleroma.User.Info do      field(:blocks, {:array, :string}, default: [])      field(:domain_blocks, {:array, :string}, default: [])      field(:mutes, {:array, :string}, default: []) +    field(:muted_reblogs, {:array, :string}, default: [])      field(:deactivated, :boolean, default: false)      field(:no_rich_text, :boolean, default: false)      field(:ap_enabled, :boolean, default: false) @@ -259,4 +260,16 @@ defmodule Pleroma.User.Info do        moderator: is_moderator      }    end + +  def add_reblog_mute(info, ap_id) do +    params = %{muted_reblogs: info.muted_reblogs ++ [ap_id]} + +    cast(info, params, [:muted_reblogs]) +  end + +  def remove_reblog_mute(info, ap_id) do +    params = %{muted_reblogs: List.delete(info.muted_reblogs, ap_id)} + +    cast(info, params, [:muted_reblogs]) +  end  end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 70db419ca..2470b4a71 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -370,20 +370,38 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do            content: content          } = params        ) do -    additional = params[:additional] || %{} -      # only accept false as false value      local = !(params[:local] == false) +    forward = !(params[:forward] == false) -    %{ +    additional = params[:additional] || %{} + +    params = %{        actor: actor,        context: context,        account: account,        statuses: statuses,        content: content      } -    |> make_flag_data(additional) -    |> insert(local) + +    additional = +      if forward do +        Map.merge(additional, %{"to" => [], "cc" => [account.ap_id]}) +      else +        Map.merge(additional, %{"to" => [], "cc" => []}) +      end + +    with flag_data <- make_flag_data(params, additional), +         {:ok, activity} <- insert(flag_data, local), +         :ok <- maybe_federate(activity) do +      Enum.each(User.all_superusers(), fn superuser -> +        superuser +        |> Pleroma.AdminEmail.report(actor, account, statuses, content) +        |> Pleroma.Mailer.deliver_async() +      end) + +      {:ok, activity} +    end    end    def fetch_activities_for_context(context, opts \\ %{}) do @@ -679,6 +697,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do    defp restrict_pinned(query, _), do: query +  defp restrict_muted_reblogs(query, %{"muting_user" => %User{info: info}}) do +    muted_reblogs = info.muted_reblogs || [] + +    from( +      activity in query, +      where: fragment("not ?->>'type' = 'Announce'", activity.data), +      where: fragment("not ? = ANY(?)", activity.actor, ^muted_reblogs) +    ) +  end + +  defp restrict_muted_reblogs(query, _), do: query +    def fetch_activities_query(recipients, opts \\ %{}) do      base_query =        from( @@ -706,6 +736,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      |> restrict_replies(opts)      |> restrict_reblogs(opts)      |> restrict_pinned(opts) +    |> restrict_muted_reblogs(opts)    end    def fetch_activities(recipients, opts \\ %{}) do diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 1247e4b61..8e4bf7b47 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -355,6 +355,40 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do      end    end +  # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them +  # with nil ID. +  def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do +    with context <- data["context"] || Utils.generate_context_id(), +         content <- data["content"] || "", +         %User{} = actor <- User.get_cached_by_ap_id(actor), + +         # Reduce the object list to find the reported user. +         %User{} = account <- +           Enum.reduce_while(objects, nil, fn ap_id, _ -> +             with %User{} = user <- User.get_cached_by_ap_id(ap_id) do +               {:halt, user} +             else +               _ -> {:cont, nil} +             end +           end), + +         # Remove the reported user from the object list. +         statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do +      params = %{ +        actor: actor, +        context: context, +        account: account, +        statuses: statuses, +        content: content, +        additional: %{ +          "cc" => [account.ap_id] +        } +      } + +      ActivityPub.flag(params) +    end +  end +    # disallow objects with bogus IDs    def handle_incoming(%{"id" => nil}), do: :error    def handle_incoming(%{"id" => ""}), do: :error diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 182f9cacb..af317245f 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -621,7 +621,13 @@ defmodule Pleroma.Web.ActivityPub.Utils do    #### Flag-related helpers    def make_flag_data(params, additional) do -    status_ap_ids = Enum.map(params.statuses || [], & &1.data["id"]) +    status_ap_ids = +      Enum.map(params.statuses || [], fn +        %Activity{} = act -> act.data["id"] +        act when is_map(act) -> act["id"] +        act when is_binary(act) -> act +      end) +      object = [params.account.ap_id] ++ status_ap_ids      %{ diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex new file mode 100644 index 000000000..6c65cff27 --- /dev/null +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -0,0 +1,145 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.LDAPAuthenticator do +  alias Pleroma.User + +  require Logger + +  @behaviour Pleroma.Web.Auth.Authenticator + +  @connection_timeout 10_000 +  @search_timeout 10_000 + +  def get_user(%Plug.Conn{} = conn, params) do +    if Pleroma.Config.get([:ldap, :enabled]) do +      {name, password} = +        case params do +          %{"authorization" => %{"name" => name, "password" => password}} -> +            {name, password} + +          %{"grant_type" => "password", "username" => name, "password" => password} -> +            {name, password} +        end + +      case ldap_user(name, password) do +        %User{} = user -> +          {:ok, user} + +        {:error, {:ldap_connection_error, _}} -> +          # When LDAP is unavailable, try default authenticator +          Pleroma.Web.Auth.PleromaAuthenticator.get_user(conn, params) + +        error -> +          error +      end +    else +      # Fall back to default authenticator +      Pleroma.Web.Auth.PleromaAuthenticator.get_user(conn, params) +    end +  end + +  def get_or_create_user_by_oauth(conn, params), do: get_user(conn, params) + +  def handle_error(%Plug.Conn{} = _conn, error) do +    error +  end + +  def auth_template, do: nil + +  defp ldap_user(name, password) do +    ldap = Pleroma.Config.get(:ldap, []) +    host = Keyword.get(ldap, :host, "localhost") +    port = Keyword.get(ldap, :port, 389) +    ssl = Keyword.get(ldap, :ssl, false) +    sslopts = Keyword.get(ldap, :sslopts, []) + +    options = +      [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] ++ +        if sslopts != [], do: [{:sslopts, sslopts}], else: [] + +    case :eldap.open([to_charlist(host)], options) do +      {:ok, connection} -> +        try do +          if Keyword.get(ldap, :tls, false) do +            :application.ensure_all_started(:ssl) + +            case :eldap.start_tls( +                   connection, +                   Keyword.get(ldap, :tlsopts, []), +                   @connection_timeout +                 ) do +              :ok -> +                :ok + +              error -> +                Logger.error("Could not start TLS: #{inspect(error)}") +            end +          end + +          bind_user(connection, ldap, name, password) +        after +          :eldap.close(connection) +        end + +      {:error, error} -> +        Logger.error("Could not open LDAP connection: #{inspect(error)}") +        {:error, {:ldap_connection_error, error}} +    end +  end + +  defp bind_user(connection, ldap, name, password) do +    uid = Keyword.get(ldap, :uid, "cn") +    base = Keyword.get(ldap, :base) + +    case :eldap.simple_bind(connection, "#{uid}=#{name},#{base}", password) do +      :ok -> +        case User.get_by_nickname_or_email(name) do +          %User{} = user -> +            user + +          _ -> +            register_user(connection, base, uid, name, password) +        end + +      error -> +        error +    end +  end + +  defp register_user(connection, base, uid, name, password) do +    case :eldap.search(connection, [ +           {:base, to_charlist(base)}, +           {:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))}, +           {:scope, :eldap.wholeSubtree()}, +           {:attributes, ['mail', 'email']}, +           {:timeout, @search_timeout} +         ]) do +      {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _}} -> +        with {_, [mail]} <- List.keyfind(attributes, 'mail', 0) do +          params = %{ +            email: :erlang.list_to_binary(mail), +            name: name, +            nickname: name, +            password: password, +            password_confirmation: password +          } + +          changeset = User.register_changeset(%User{}, params) + +          case User.register(changeset) do +            {:ok, user} -> user +            error -> error +          end +        else +          _ -> +            Logger.error("Could not find LDAP attribute mail: #{inspect(attributes)}") +            {:error, :ldap_registration_missing_attributes} +        end + +      error -> +        error +    end +  end +end diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index 5583f41a9..2e2bcfb70 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -8,9 +8,16 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do    @behaviour Pleroma.Web.Auth.Authenticator -  def get_user(%Plug.Conn{} = _conn, %{ -        "authorization" => %{"name" => name, "password" => password} -      }) do +  def get_user(%Plug.Conn{} = _conn, params) do +    {name, password} = +      case params do +        %{"authorization" => %{"name" => name, "password" => password}} -> +          {name, password} + +        %{"grant_type" => "password", "username" => name, "password" => password} -> +          {name, password} +      end +      with {_, %User{} = user} <- {:user, User.get_by_nickname_or_email(name)},           {_, true} <- {:checkpw, Pbkdf2.checkpw(password, user.password_hash)} do        {:ok, user} @@ -20,8 +27,6 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do      end    end -  def get_user(%Plug.Conn{} = _conn, _params), do: {:error, :missing_credentials} -    def get_or_create_user_by_oauth(          %Plug.Conn{assigns: %{ueberauth_auth: %{provider: provider, uid: uid} = auth}},          _params diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index de0759fb0..b5f79c3bf 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -284,14 +284,9 @@ defmodule Pleroma.Web.CommonAPI do               actor: user,               account: account,               statuses: statuses, -             content: content_html +             content: content_html, +             forward: data["forward"] || false             }) do -      Enum.each(User.all_superusers(), fn superuser -> -        superuser -        |> Pleroma.AdminEmail.report(user, account, statuses, content_html) -        |> Pleroma.Mailer.deliver_async() -      end) -        {:ok, activity}      else        {:error, err} -> {:error, err} @@ -299,4 +294,24 @@ defmodule Pleroma.Web.CommonAPI do        {:account, nil} -> {:error, "Account not found"}      end    end + +  def hide_reblogs(user, muted) do +    ap_id = muted.ap_id + +    if ap_id not in user.info.muted_reblogs do +      info_changeset = User.Info.add_reblog_mute(user.info, ap_id) +      changeset = Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset) +      User.update_and_set_cache(changeset) +    end +  end + +  def show_reblogs(user, muted) do +    ap_id = muted.ap_id + +    if ap_id in user.info.muted_reblogs do +      info_changeset = User.Info.remove_reblog_mute(user.info, ap_id) +      changeset = Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset) +      User.update_and_set_cache(changeset) +    end +  end  end diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index 265bf837e..952aa2453 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -723,11 +723,25 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do    def follow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do      with %User{} = followed <- Repo.get(User, id), +         false <- User.following?(follower, followed),           {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do        conn        |> put_view(AccountView)        |> render("relationship.json", %{user: follower, target: followed})      else +      true -> +        followed = User.get_cached_by_id(id) + +        {:ok, follower} = +          case conn.params["reblogs"] do +            true -> CommonAPI.show_reblogs(follower, followed) +            false -> CommonAPI.hide_reblogs(follower, followed) +          end + +        conn +        |> put_view(AccountView) +        |> render("relationship.json", %{user: follower, target: followed}) +        {:error, message} ->          conn          |> put_resp_content_type("application/json") diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index c32f27be2..b5f3bbb9d 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -55,7 +55,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do        muting_notifications: false,        requested: requested,        domain_blocking: false, -      showing_reblogs: false, +      showing_reblogs: User.showing_reblogs?(user, target),        endorsed: false      }    end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index d39c4a713..588933d31 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -5,7 +5,6 @@  defmodule Pleroma.Web.OAuth.OAuthController do    use Pleroma.Web, :controller -  alias Comeonin.Pbkdf2    alias Pleroma.Repo    alias Pleroma.User    alias Pleroma.Web.Auth.Authenticator @@ -154,6 +153,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do           fixed_token = fix_padding(params["code"]),           %Authorization{} = auth <-             Repo.get_by(Authorization, token: fixed_token, app_id: app.id), +         %User{} = user <- Repo.get(User, auth.user_id),           {:ok, token} <- Token.exchange_token(app, auth),           {:ok, inserted_at} <- DateTime.from_naive(token.inserted_at, "Etc/UTC") do        response = %{ @@ -162,7 +162,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do          refresh_token: token.refresh_token,          created_at: DateTime.to_unix(inserted_at),          expires_in: 60 * 10, -        scope: Enum.join(token.scopes, " ") +        scope: Enum.join(token.scopes, " "), +        me: user.ap_id        }        json(conn, response) @@ -175,11 +176,10 @@ defmodule Pleroma.Web.OAuth.OAuthController do    def token_exchange(          conn, -        %{"grant_type" => "password", "username" => name, "password" => password} = params +        %{"grant_type" => "password"} = params        ) do -    with %App{} = app <- get_app_from_request(conn, params), -         %User{} = user <- User.get_by_nickname_or_email(name), -         true <- Pbkdf2.checkpw(password, user.password_hash), +    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)},           scopes <- oauth_scopes(params, app.scopes),           [] <- scopes -- app.scopes, @@ -191,7 +191,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do          access_token: token.token,          refresh_token: token.refresh_token,          expires_in: 60 * 10, -        scope: Enum.join(token.scopes, " ") +        scope: Enum.join(token.scopes, " "), +        me: user.ap_id        }        json(conn, response) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d490b9b58..9b6784120 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -195,6 +195,12 @@ defmodule Pleroma.Web.Router do        post("/blocks_import", UtilController, :blocks_import)        post("/follow_import", UtilController, :follow_import)      end + +    scope [] do +      pipe_through(:oauth_read) + +      post("/notifications/read", UtilController, :notifications_read) +    end    end    scope "/oauth", Pleroma.Web.OAuth do diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index 5850a9579..7425bfb54 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.Streamer do    alias Pleroma.Object    alias Pleroma.Repo    alias Pleroma.User +  alias Pleroma.Web.ActivityPub.ActivityPub    alias Pleroma.Web.ActivityPub.Visibility    alias Pleroma.Web.MastodonAPI.NotificationView @@ -199,10 +200,12 @@ defmodule Pleroma.Web.Streamer do          user = User.get_cached_by_ap_id(socket.assigns[:user].ap_id)          blocks = user.info.blocks || []          mutes = user.info.mutes || [] +        reblog_mutes = user.info.muted_reblogs || []          parent = Object.normalize(item.data["object"])          unless is_nil(parent) or item.actor in blocks or item.actor in mutes or +                 item.actor in reblog_mutes or not ActivityPub.contain_activity(item, user) or                   parent.data["actor"] in blocks or parent.data["actor"] in mutes do            send(socket.transport_pid, {:text, represent_update(item, user)})          end @@ -233,7 +236,8 @@ defmodule Pleroma.Web.Streamer do          blocks = user.info.blocks || []          mutes = user.info.mutes || [] -        unless item.actor in blocks or item.actor in mutes do +        unless item.actor in blocks or item.actor in mutes or +                 not ActivityPub.contain_activity(item, user) do            send(socket.transport_pid, {:text, represent_update(item, user)})          end        else diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 8ed02a93f..320ec778c 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do    alias Comeonin.Pbkdf2    alias Pleroma.Emoji +  alias Pleroma.Notification    alias Pleroma.PasswordResetToken    alias Pleroma.Repo    alias Pleroma.User @@ -142,6 +143,17 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do      end    end +  def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do +    with {:ok, _} <- Notification.read_one(user, notification_id) do +      json(conn, %{status: "success"}) +    else +      {:error, message} -> +        conn +        |> put_resp_content_type("application/json") +        |> send_resp(403, Jason.encode!(%{"error" => message})) +    end +  end +    def config(conn, _params) do      instance = Pleroma.Config.get(:instance)      instance_fe = Pleroma.Config.get(:fe) @@ -23,11 +23,14 @@ defmodule Pleroma.Mixfile do          logo: "priv/static/static/logo.png",          extras: [            "README.md", -          "docs/config.md", -          "docs/Pleroma-API.md",            "docs/Admin-API.md",            "docs/Clients.md", -          "docs/Differences-in-MastodonAPI-Responses.md" +          "docs/config.md", +          "docs/Custom-Emoji.md", +          "docs/Differences-in-MastodonAPI-Responses.md", +          "docs/Message-Rewrite-Facility-configuration.md", +          "docs/Pleroma-API.md", +          "docs/static_dir.md"          ],          main: "readme",          output: "priv/static/doc" diff --git a/test/html_test.exs b/test/html_test.exs index 29cab17f3..0b5d3d892 100644 --- a/test/html_test.exs +++ b/test/html_test.exs @@ -10,6 +10,8 @@ defmodule Pleroma.HTMLTest do      <b>this is in bold</b>      <p>this is a paragraph</p>      this is a linebreak<br /> +    this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a> +    this is a link with not allowed "rel" attribute: <a href="http://example.com/" rel="tag noallowed">example.com</a>      this is an image: <img src="http://example.com/image.jpg"><br />      <script>alert('hacked')</script>    """ @@ -24,6 +26,8 @@ defmodule Pleroma.HTMLTest do        this is in bold          this is a paragraph          this is a linebreak +        this is a link with allowed "rel" attribute: example.com +        this is a link with not allowed "rel" attribute: example.com          this is an image:           alert('hacked')        """ @@ -44,6 +48,8 @@ defmodule Pleroma.HTMLTest do        this is in bold          <p>this is a paragraph</p>          this is a linebreak<br /> +        this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a> +        this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>          this is an image: <img src="http://example.com/image.jpg" /><br />          alert('hacked')        """ @@ -66,6 +72,8 @@ defmodule Pleroma.HTMLTest do        <b>this is in bold</b>          <p>this is a paragraph</p>          this is a linebreak<br /> +        this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a> +        this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>          this is an image: <img src="http://example.com/image.jpg" /><br />          alert('hacked')        """ diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 2b83bfb1d..035778218 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -424,6 +424,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do        assert length(activities) == 20        assert last == last_expected      end + +    test "doesn't return reblogs for users for whom reblogs have been muted" do +      activity = insert(:note_activity) +      user = insert(:user) +      booster = insert(:user) +      {:ok, user} = CommonAPI.hide_reblogs(user, booster) + +      {:ok, activity, _} = CommonAPI.repeat(activity.id, booster) + +      activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) + +      refute Enum.member?(activities, activity) +    end    end    describe "like an object" do diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 8184dbbae..afb931934 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -5,6 +5,7 @@  defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do    use Pleroma.DataCase    alias Pleroma.Activity +  alias Pleroma.Object    alias Pleroma.Repo    alias Pleroma.User    alias Pleroma.Web.ActivityPub.ActivityPub @@ -764,6 +765,30 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do        assert object.data["attachment"] == [attachment]      end + +    test "it accepts Flag activities" do +      user = insert(:user) +      other_user = insert(:user) + +      {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) +      object = Object.normalize(activity.data["object"]) + +      message = %{ +        "@context" => "https://www.w3.org/ns/activitystreams", +        "cc" => [user.ap_id], +        "object" => [user.ap_id, object.data["id"]], +        "type" => "Flag", +        "content" => "blocked AND reported!!!", +        "actor" => other_user.ap_id +      } + +      assert {:ok, activity} = Transmogrifier.handle_incoming(message) + +      assert activity.data["object"] == [user.ap_id, object.data["id"]] +      assert activity.data["content"] == "blocked AND reported!!!" +      assert activity.data["actor"] == other_user.ap_id +      assert activity.data["cc"] == [user.ap_id] +    end    end    describe "prepare outgoing" do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 181813c76..f83f80b40 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -221,4 +221,27 @@ defmodule Pleroma.Web.CommonAPITest do               } = flag_activity      end    end + +  describe "reblog muting" do +    setup do +      muter = insert(:user) + +      muted = insert(:user) + +      [muter: muter, muted: muted] +    end + +    test "add a reblog mute", %{muter: muter, muted: muted} do +      {:ok, muter} = CommonAPI.hide_reblogs(muter, muted) + +      assert Pleroma.User.showing_reblogs?(muter, muted) == false +    end + +    test "remove a reblog mute", %{muter: muter, muted: muted} do +      {:ok, muter} = CommonAPI.hide_reblogs(muter, muted) +      {:ok, muter} = CommonAPI.show_reblogs(muter, muted) + +      assert Pleroma.User.showing_reblogs?(muter, muted) == true +    end +  end  end diff --git a/test/web/mastodon_api/account_view_test.exs b/test/web/mastodon_api/account_view_test.exs index c2ffc21da..6dc60afe9 100644 --- a/test/web/mastodon_api/account_view_test.exs +++ b/test/web/mastodon_api/account_view_test.exs @@ -144,7 +144,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do        muting_notifications: false,        requested: false,        domain_blocking: false, -      showing_reblogs: false, +      showing_reblogs: true,        endorsed: false      } @@ -202,7 +202,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do            muting_notifications: false,            requested: false,            domain_blocking: false, -          showing_reblogs: false, +          showing_reblogs: true,            endorsed: false          }        } diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 059d5237d..74bf05708 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -1632,7 +1632,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do        assert user = json_response(conn, 200)        assert user["note"] == -               ~s(I drink <a class="hashtag" data-tag="cofe" href="http://localhost:4001/tag/cofe">#cofe</a> with <span class="h-card"><a data-user=") <> +               ~s(I drink <a class="hashtag" data-tag="cofe" href="http://localhost:4001/tag/cofe" rel="tag">#cofe</a> with <span class="h-card"><a data-user=") <>                   user2.id <>                   ~s(" class="u-url mention" href=") <>                   user2.ap_id <> ~s(">@<span>) <> user2.nickname <> ~s(</span></a></span>) diff --git a/test/web/oauth/ldap_authorization_test.exs b/test/web/oauth/ldap_authorization_test.exs new file mode 100644 index 000000000..5bf7eb93c --- /dev/null +++ b/test/web/oauth/ldap_authorization_test.exs @@ -0,0 +1,189 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do +  use Pleroma.Web.ConnCase +  alias Pleroma.Repo +  alias Pleroma.Web.OAuth.Token +  import Pleroma.Factory +  import ExUnit.CaptureLog +  import Mock + +  setup_all do +    ldap_authenticator = +      Pleroma.Config.get(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.PleromaAuthenticator) + +    ldap_enabled = Pleroma.Config.get([:ldap, :enabled]) + +    on_exit(fn -> +      Pleroma.Config.put(Pleroma.Web.Auth.Authenticator, ldap_authenticator) +      Pleroma.Config.put([:ldap, :enabled], ldap_enabled) +    end) + +    Pleroma.Config.put(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.LDAPAuthenticator) +    Pleroma.Config.put([:ldap, :enabled], true) + +    :ok +  end + +  test "authorizes the existing user using LDAP credentials" do +    password = "testpassword" +    user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) +    app = insert(:oauth_app, scopes: ["read", "write"]) + +    host = Pleroma.Config.get([:ldap, :host]) |> to_charlist +    port = Pleroma.Config.get([:ldap, :port]) + +    with_mocks [ +      {:eldap, [], +       [ +         open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, +         simple_bind: fn _connection, _dn, ^password -> :ok end, +         close: fn _connection -> +           send(self(), :close_connection) +           :ok +         end +       ]} +    ] do +      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 %{"access_token" => token} = json_response(conn, 200) + +      token = Repo.get_by(Token, token: token) + +      assert token.user_id == user.id +      assert_received :close_connection +    end +  end + +  test "creates a new user after successful LDAP authorization" do +    password = "testpassword" +    user = build(:user) +    app = insert(:oauth_app, scopes: ["read", "write"]) + +    host = Pleroma.Config.get([:ldap, :host]) |> to_charlist +    port = Pleroma.Config.get([:ldap, :port]) + +    with_mocks [ +      {:eldap, [], +       [ +         open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, +         simple_bind: fn _connection, _dn, ^password -> :ok end, +         equalityMatch: fn _type, _value -> :ok end, +         wholeSubtree: fn -> :ok end, +         search: fn _connection, _options -> +           {:ok, +            {:eldap_search_result, [{:eldap_entry, '', [{'mail', [to_charlist(user.email)]}]}], +             []}} +         end, +         close: fn _connection -> +           send(self(), :close_connection) +           :ok +         end +       ]} +    ] do +      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 %{"access_token" => token} = json_response(conn, 200) + +      token = Repo.get_by(Token, token: token) |> Repo.preload(:user) + +      assert token.user.nickname == user.nickname +      assert_received :close_connection +    end +  end + +  test "falls back to the default authorization when LDAP is unavailable" do +    password = "testpassword" +    user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) +    app = insert(:oauth_app, scopes: ["read", "write"]) + +    host = Pleroma.Config.get([:ldap, :host]) |> to_charlist +    port = Pleroma.Config.get([:ldap, :port]) + +    with_mocks [ +      {:eldap, [], +       [ +         open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:error, 'connect failed'} end, +         simple_bind: fn _connection, _dn, ^password -> :ok end, +         close: fn _connection -> +           send(self(), :close_connection) +           :ok +         end +       ]} +    ] do +      log = +        capture_log(fn -> +          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 %{"access_token" => token} = json_response(conn, 200) + +          token = Repo.get_by(Token, token: token) + +          assert token.user_id == user.id +        end) + +      assert log =~ "Could not open LDAP connection: 'connect failed'" +      refute_received :close_connection +    end +  end + +  test "disallow authorization for wrong LDAP credentials" do +    password = "testpassword" +    user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) +    app = insert(:oauth_app, scopes: ["read", "write"]) + +    host = Pleroma.Config.get([:ldap, :host]) |> to_charlist +    port = Pleroma.Config.get([:ldap, :port]) + +    with_mocks [ +      {:eldap, [], +       [ +         open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, +         simple_bind: fn _connection, _dn, ^password -> {:error, :invalidCredentials} end, +         close: fn _connection -> +           send(self(), :close_connection) +           :ok +         end +       ]} +    ] do +      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 %{"error" => "Invalid credentials"} = json_response(conn, 400) +      assert_received :close_connection +    end +  end +end diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index ed94416ff..ff1e56fe9 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -132,11 +132,12 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do          "client_secret" => app.client_secret        }) -    assert %{"access_token" => token} = json_response(conn, 200) +    assert %{"access_token" => token, "me" => ap_id} = json_response(conn, 200)      token = Repo.get_by(Token, token: token)      assert token      assert token.scopes == auth.scopes +    assert user.ap_id == ap_id    end    test "issues a token for `password` grant_type with valid credentials, with full permissions by default" do diff --git a/test/web/streamer_test.exs b/test/web/streamer_test.exs index 0a2e91298..bfe18cb7f 100644 --- a/test/web/streamer_test.exs +++ b/test/web/streamer_test.exs @@ -202,4 +202,34 @@ defmodule Pleroma.Web.StreamerTest do      Task.await(task)    end + +  test "it doesn't send muted reblogs" do +    user1 = insert(:user) +    user2 = insert(:user) +    user3 = insert(:user) +    CommonAPI.hide_reblogs(user1, user2) + +    task = +      Task.async(fn -> +        refute_receive {:text, _}, 1_000 +      end) + +    fake_socket = %{ +      transport_pid: task.pid, +      assigns: %{ +        user: user1 +      } +    } + +    {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"}) +    {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2) + +    topics = %{ +      "public" => [fake_socket] +    } + +    Streamer.push_to_socket(topics, "public", announce_activity) + +    Task.await(task) +  end  end diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index fc762ab18..6e8a25056 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -1,6 +1,9 @@  defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do    use Pleroma.Web.ConnCase +  alias Pleroma.Notification +  alias Pleroma.Repo +  alias Pleroma.Web.CommonAPI    import Pleroma.Factory    describe "POST /api/pleroma/follow_import" do @@ -52,6 +55,25 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do      end    end +  describe "POST /api/pleroma/notifications/read" do +    test "it marks a single notification as read", %{conn: conn} do +      user1 = insert(:user) +      user2 = insert(:user) +      {:ok, activity1} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"}) +      {:ok, activity2} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"}) +      {:ok, [notification1]} = Notification.create_notifications(activity1) +      {:ok, [notification2]} = Notification.create_notifications(activity2) + +      conn +      |> assign(:user, user1) +      |> post("/api/pleroma/notifications/read", %{"id" => "#{notification1.id}"}) +      |> json_response(:ok) + +      assert Repo.get(Notification, notification1.id).seen +      refute Repo.get(Notification, notification2.id).seen +    end +  end +    describe "GET /api/statusnet/config.json" do      test "it returns the managed config", %{conn: conn} do        Pleroma.Config.put([:instance, :managed_config], false) diff --git a/test/web/twitter_api/views/activity_view_test.exs b/test/web/twitter_api/views/activity_view_test.exs index 6f0786b1c..d9df01c6e 100644 --- a/test/web/twitter_api/views/activity_view_test.exs +++ b/test/web/twitter_api/views/activity_view_test.exs @@ -82,7 +82,7 @@ defmodule Pleroma.Web.TwitterAPI.ActivityViewTest do      result = ActivityView.render("activity.json", activity: activity)      assert result["statusnet_html"] == -             "<a class=\"hashtag\" data-tag=\"bike\" href=\"http://localhost:4001/tag/bike\">#Bike</a> log - Commute Tuesday<br /><a href=\"https://pla.bike/posts/20181211/\">https://pla.bike/posts/20181211/</a><br /><a class=\"hashtag\" data-tag=\"cycling\" href=\"http://localhost:4001/tag/cycling\">#cycling</a> <a class=\"hashtag\" data-tag=\"chscycling\" href=\"http://localhost:4001/tag/chscycling\">#CHScycling</a> <a class=\"hashtag\" data-tag=\"commute\" href=\"http://localhost:4001/tag/commute\">#commute</a><br />MVIMG_20181211_054020.jpg" +             "<a class=\"hashtag\" data-tag=\"bike\" href=\"http://localhost:4001/tag/bike\" rel=\"tag\">#Bike</a> log - Commute Tuesday<br /><a href=\"https://pla.bike/posts/20181211/\">https://pla.bike/posts/20181211/</a><br /><a class=\"hashtag\" data-tag=\"cycling\" href=\"http://localhost:4001/tag/cycling\" rel=\"tag\">#cycling</a> <a class=\"hashtag\" data-tag=\"chscycling\" href=\"http://localhost:4001/tag/chscycling\" rel=\"tag\">#CHScycling</a> <a class=\"hashtag\" data-tag=\"commute\" href=\"http://localhost:4001/tag/commute\" rel=\"tag\">#commute</a><br />MVIMG_20181211_054020.jpg"      assert result["text"] ==               "#Bike log - Commute Tuesday\nhttps://pla.bike/posts/20181211/\n#cycling #CHScycling #commute\nMVIMG_20181211_054020.jpg" | 
