logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma git clone https://hacktivis.me/git/pleroma.git
commit: 01a5f839c58d89be802e162280bd02c577bdec89
parent 918c406a914d49b15beb3611e1c780d0e0d253cd
Author: marcin mikołajczak <git@mkljczk.pl>
Date:   Sat,  6 Apr 2024 10:42:23 +0200

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

Diffstat:

M.gitlab-ci.yml4++--
Achangelog.d/bookmark-folders.add2++
Achangelog.d/bookmark-folders.skip0
Achangelog.d/fix-bookmark-folder-tests.skip0
Achangelog.d/postgres-jit.change1+
Achangelog.d/test-improvements.skip0
Achangelog.d/transient-validators-defaults.change1+
Mconfig/config.exs2+-
Mconfig/test.exs2+-
Mdocs/development/API/differences_in_mastoapi_responses.md7+++++++
Mdocs/development/API/pleroma_api.md46++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/installation/debian_based_jp.md4++--
Mdocs/installation/generic_dependencies.include2+-
Mlib/pleroma/application.ex23+----------------------
Mlib/pleroma/bookmark.ex31++++++++++++++++++++++++++-----
Alib/pleroma/bookmark_folder.ex115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/pleroma/search/database_search.ex9+--------
Mlib/pleroma/web/activity_pub/object_validators/attachment_validator.ex4++--
Mlib/pleroma/web/activity_pub/object_validators/question_options_validator.ex4++--
Mlib/pleroma/web/api_spec.ex3++-
Alib/pleroma/web/api_spec/operations/pleroma_bookmark_folder_operation.ex125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/pleroma/web/api_spec/operations/status_operation.ex22+++++++++++++++++++++-
Alib/pleroma/web/api_spec/schemas/bookmark_folder.ex26++++++++++++++++++++++++++
Mlib/pleroma/web/mastodon_api/controllers/status_controller.ex17++++++++++++++---
Mlib/pleroma/web/mastodon_api/views/instance_view.ex3++-
Mlib/pleroma/web/mastodon_api/views/status_view.ex28++++++++++++++++++++++------
Alib/pleroma/web/pleroma_api/controllers/bookmark_folder_controller.ex68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex42++++++++++++++++++++++++++++++++++++++++++
Mlib/pleroma/web/router.ex5+++++
Apriv/repo/migrations/20240223165000_create_bookmark_folders.exs27+++++++++++++++++++++++++++
Atest/pleroma/bookmark_folder_test.exs60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/pleroma/bookmark_test.exs17++++++++++++++++-
Mtest/pleroma/search/database_search_test.exs15---------------
Mtest/pleroma/web/mastodon_api/controllers/search_controller_test.exs30++++++++++++------------------
Mtest/pleroma/web/mastodon_api/controllers/status_controller_test.exs54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/pleroma/web/mastodon_api/views/status_view_test.exs3++-
Atest/pleroma/web/pleroma_api/controllers/bookmark_folder_controller_test.exs161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/test_helper.exs2++
Duploads/.gitignore3---
39 files changed, 872 insertions(+), 96 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml @@ -26,10 +26,10 @@ cache: &global_cache_policy - _build stages: - - check-changelog - build - lint - test + - check-changelog - benchmark - deploy - release @@ -113,7 +113,7 @@ benchmark: variables: MIX_ENV: benchmark services: - - name: postgres:9.6-alpine + - name: postgres:11.22-alpine alias: postgres command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] script: diff --git a/changelog.d/bookmark-folders.add b/changelog.d/bookmark-folders.add @@ -0,0 +1 @@ +Allow to group bookmarks in folders +\ No newline at end of file diff --git a/changelog.d/bookmark-folders.skip b/changelog.d/bookmark-folders.skip diff --git a/changelog.d/fix-bookmark-folder-tests.skip b/changelog.d/fix-bookmark-folder-tests.skip diff --git a/changelog.d/postgres-jit.change b/changelog.d/postgres-jit.change @@ -0,0 +1 @@ +Disable jit by default for PostgreSQL diff --git a/changelog.d/test-improvements.skip b/changelog.d/test-improvements.skip diff --git a/changelog.d/transient-validators-defaults.change b/changelog.d/transient-validators-defaults.change @@ -0,0 +1 @@ +Set default values on validators for transient objects (attachment, poll options) diff --git a/config/config.exs b/config/config.exs @@ -799,7 +799,7 @@ config :pleroma, :modules, runtime_dir: "instance/modules" config :pleroma, configurable_from_database: false config :pleroma, Pleroma.Repo, - parameters: [gin_fuzzy_search_limit: "500"], + parameters: [gin_fuzzy_search_limit: "500", jit: "off"], prepare: :unnamed config :pleroma, :connections_pool, diff --git a/config/test.exs b/config/test.exs @@ -49,7 +49,7 @@ config :pleroma, Pleroma.Repo, hostname: System.get_env("DB_HOST") || "localhost", port: System.get_env("DB_PORT") || "5432", pool: Ecto.Adapters.SQL.Sandbox, - pool_size: 50 + pool_size: System.schedulers_online() * 2 config :pleroma, :dangerzone, override_repo_pool_size: true diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md @@ -41,6 +41,7 @@ Has these additional fields under the `pleroma` object: - `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise. - `quotes_count`: the count of status quotes. - `non_anonymous`: true if the source post specifies the poll results are not anonymous. Currently only implemented by Smithereen. +- `bookmark_folder`: the ID of the folder bookmark is stored within (if any). The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes: @@ -66,6 +67,12 @@ Some apps operate under the assumption that no more than 4 attachments can be re Pleroma does not process remote images and therefore cannot include fields such as `meta` and `blurhash`. It does not support focal points or aspect ratios. The frontend is expected to handle it. +## Bookmarks + +The `GET /api/v1/bookmarks` endpoint accepts optional parameter `folder_id` for bookmark folder ID. + +The `POST /api/v1/statuses/:id/bookmark` endpoint accepts optional parameter `folder_id` for bookmark folder ID. + ## Accounts The `id` parameter can also be the `nickname` of the user. This only works in these endpoints, not the deeper nested ones for following etc. diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md @@ -283,6 +283,52 @@ See [Admin-API](admin_api.md) * `id`: the id of the status * Response: JSON, returns a list of Mastodon Status entities +## `GET /api/v1/pleroma/bookmark_folders` +### Gets user bookmark folders +* Authentication: required + +* Response: JSON. Returns a list of bookmark folders. +* Example response: +```json +[ + { + "id": "9umDrYheeY451cQnEe", + "name": "Read later", + "emoji": "🕓", + "source": { + "emoji": "🕓" + } + } +] +``` + +## `POST /api/v1/pleroma/bookmark_folders` +### Creates a bookmark folder +* Authentication: required + +* Params: + * `name`: folder name + * `emoji`: folder emoji (optional) +* Response: JSON. Returns a single bookmark folder. + +## `PATCH /api/v1/pleroma/bookmark_folders/:id` +### Updates a bookmark folder +* Authentication: required + +* Params: + * `id`: folder id + * `name`: folder name (optional) + * `emoji`: folder emoji (optional) +* Response: JSON. Returns a single bookmark folder. + +## `DELETE /api/v1/pleroma/bookmark_folders/:id` +### Deletes a bookmark folder +* Authentication: required + +* Params: + * `id`: folder id +* Response: JSON. Returns a single bookmark folder. + ## `/api/v1/pleroma/mascot` ### Gets user mascot image * Method `GET` diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md @@ -12,8 +12,8 @@ Note: This article is potentially outdated because at this time we may not have ### 必要なソフトウェア -- PostgreSQL 9.6以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください) -- `postgresql-contrib` 9.6以上 (同上) +- PostgreSQL 11.0以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください) +- `postgresql-contrib` 11.0以上 (同上) - Elixir 1.8 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like)。または [asdf](https://github.com/asdf-vm/asdf) をpleromaユーザーでインストールしてください) - `erlang-dev` - `erlang-nox` diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include @@ -1,6 +1,6 @@ ## Required dependencies -* PostgreSQL >=9.6 +* PostgreSQL >=11.0 * Elixir >=1.11.0 <1.15 * Erlang OTP >=22.2.0 (supported: <27) * git diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex @@ -119,28 +119,7 @@ defmodule Pleroma.Application do max_restarts = Application.get_env(:pleroma, __MODULE__)[:max_restarts] opts = [strategy: :one_for_one, name: Pleroma.Supervisor, max_restarts: max_restarts] - result = Supervisor.start_link(children, opts) - - set_postgres_server_version() - - result - end - - defp set_postgres_server_version do - version = - with %{rows: [[version]]} <- Ecto.Adapters.SQL.query!(Pleroma.Repo, "show server_version"), - {num, _} <- Float.parse(version) do - num - else - e -> - Logger.warning( - "Could not get the postgres version: #{inspect(e)}.\nSetting the default value of 9.6" - ) - - 9.6 - end - - :persistent_term.put({Pleroma.Repo, :postgres_version}, version) + Supervisor.start_link(children, opts) end def load_custom_modules do diff --git a/lib/pleroma/bookmark.ex b/lib/pleroma/bookmark.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Bookmark do alias Pleroma.Activity alias Pleroma.Bookmark + alias Pleroma.BookmarkFolder alias Pleroma.Repo alias Pleroma.User @@ -18,33 +19,46 @@ defmodule Pleroma.Bookmark do schema "bookmarks" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) + belongs_to(:folder, BookmarkFolder, type: FlakeId.Ecto.CompatType) timestamps() end @spec create(Ecto.UUID.t(), Ecto.UUID.t()) :: {:ok, Bookmark.t()} | {:error, Ecto.Changeset.t()} - def create(user_id, activity_id) do + def create(user_id, activity_id, folder_id \\ nil) do attrs = %{ user_id: user_id, - activity_id: activity_id + activity_id: activity_id, + folder_id: folder_id } %Bookmark{} - |> cast(attrs, [:user_id, :activity_id]) + |> cast(attrs, [:user_id, :activity_id, :folder_id]) |> validate_required([:user_id, :activity_id]) |> unique_constraint(:activity_id, name: :bookmarks_user_id_activity_id_index) - |> Repo.insert() + |> Repo.insert( + on_conflict: [set: [folder_id: folder_id]], + conflict_target: [:user_id, :activity_id] + ) end @spec for_user_query(Ecto.UUID.t()) :: Ecto.Query.t() - def for_user_query(user_id) do + def for_user_query(user_id, folder_id \\ nil) do Bookmark |> where(user_id: ^user_id) + |> maybe_filter_by_folder(folder_id) |> join(:inner, [b], activity in assoc(b, :activity)) |> preload([b, a], activity: a) end + defp maybe_filter_by_folder(query, nil), do: query + + defp maybe_filter_by_folder(query, folder_id) do + query + |> where(folder_id: ^folder_id) + end + def get(user_id, activity_id) do Bookmark |> where(user_id: ^user_id) @@ -62,4 +76,11 @@ defmodule Pleroma.Bookmark do |> Repo.one() |> Repo.delete() end + + def set_folder(bookmark, folder_id) do + bookmark + |> cast(%{folder_id: folder_id}, [:folder_id]) + |> validate_required([:folder_id]) + |> Repo.update() + end end diff --git a/lib/pleroma/bookmark_folder.ex b/lib/pleroma/bookmark_folder.ex @@ -0,0 +1,115 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.BookmarkFolder do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.BookmarkFolder + alias Pleroma.Emoji + alias Pleroma.Repo + alias Pleroma.User + + @type t :: %__MODULE__{} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + schema "bookmark_folders" do + field(:name, :string) + field(:emoji, :string) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + + timestamps() + end + + def get_by_id(id), do: Repo.get_by(BookmarkFolder, id: id) + + def create(user_id, name, emoji \\ nil) do + %BookmarkFolder{} + |> cast( + %{ + user_id: user_id, + name: name, + emoji: emoji + }, + [:user_id, :name, :emoji] + ) + |> validate_required([:user_id, :name]) + |> fix_emoji() + |> validate_emoji() + |> unique_constraint([:user_id, :name]) + |> Repo.insert() + end + + def update(folder_id, name, emoji \\ nil) do + get_by_id(folder_id) + |> cast( + %{ + name: name, + emoji: emoji + }, + [:name, :emoji] + ) + |> fix_emoji() + |> validate_emoji() + |> unique_constraint([:user_id, :name]) + |> Repo.update() + end + + defp fix_emoji(changeset) do + with {:emoji_field, emoji} when is_binary(emoji) <- + {:emoji_field, get_field(changeset, :emoji)}, + {:fixed_emoji, emoji} <- + {:fixed_emoji, + emoji + |> Pleroma.Emoji.fully_qualify_emoji() + |> Pleroma.Emoji.maybe_quote()} do + put_change(changeset, :emoji, emoji) + else + {:emoji_field, _} -> changeset + end + end + + defp validate_emoji(changeset) do + validate_change(changeset, :emoji, fn + :emoji, nil -> + [] + + :emoji, emoji -> + if Emoji.unicode?(emoji) or valid_local_custom_emoji?(emoji) do + [] + else + [emoji: "Invalid emoji"] + end + end) + end + + defp valid_local_custom_emoji?(emoji) do + with %{file: _path} <- Emoji.get(emoji) do + true + else + _ -> false + end + end + + def delete(folder_id) do + BookmarkFolder + |> Repo.get_by(id: folder_id) + |> Repo.delete() + end + + def for_user(user_id) do + BookmarkFolder + |> where(user_id: ^user_id) + |> Repo.all() + end + + def belongs_to_user?(folder_id, user_id) do + BookmarkFolder + |> where(id: ^folder_id, user_id: ^user_id) + |> Repo.exists?() + end +end diff --git a/lib/pleroma/search/database_search.ex b/lib/pleroma/search/database_search.ex @@ -23,19 +23,12 @@ defmodule Pleroma.Search.DatabaseSearch do offset = Keyword.get(options, :offset, 0) author = Keyword.get(options, :author) - search_function = - if :persistent_term.get({Pleroma.Repo, :postgres_version}) >= 11 do - :websearch - else - :plain - end - try do Activity |> Activity.with_preloaded_object() |> Activity.restrict_deactivated_users() |> restrict_public(user) - |> query_with(index_type, search_query, search_function) + |> query_with(index_type, search_query, :websearch) |> maybe_restrict_local(user) |> maybe_restrict_author(author) |> maybe_restrict_blocked(user) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -12,13 +12,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do @primary_key false embedded_schema do field(:id, :string) - field(:type, :string) + field(:type, :string, default: "Link") field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream") field(:name, :string) field(:blurhash, :string) embeds_many :url, UrlObjectValidator, primary_key: false do - field(:type, :string) + field(:type, :string, default: "Link") field(:href, ObjectValidators.Uri) field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream") field(:width, :integer) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex @@ -14,10 +14,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator do embeds_one :replies, Replies, primary_key: false do field(:totalItems, :integer) - field(:type, :string) + field(:type, :string, default: "Collection") end - field(:type, :string) + field(:type, :string, default: "Note") end def changeset(struct, data) do diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex @@ -138,7 +138,8 @@ defmodule Pleroma.Web.ApiSpec do "Scheduled statuses", "Search", "Status actions", - "Media attachments" + "Media attachments", + "Bookmark folders" ] }, %{ diff --git a/lib/pleroma/web/api_spec/operations/pleroma_bookmark_folder_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_bookmark_folder_operation.ex @@ -0,0 +1,125 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaBookmarkFolderOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BookmarkFolder + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + import Pleroma.Web.ApiSpec.Helpers + + @spec open_api_operation(any()) :: any() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Bookmark folders"], + summary: "All bookmark folders", + security: [%{"oAuth" => ["read:bookmarks"]}], + operationId: "PleromaAPI.BookmarkFolderController.index", + responses: %{ + 200 => + Operation.response("Array of Bookmark Folders", "application/json", %Schema{ + type: :array, + items: BookmarkFolder + }) + } + } + end + + def create_operation do + %Operation{ + tags: ["Bookmark folders"], + summary: "Create a bookmark folder", + security: [%{"oAuth" => ["write:bookmarks"]}], + operationId: "PleromaAPI.BookmarkFolderController.create", + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Bookmark Folder", "application/json", BookmarkFolder), + 422 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Bookmark folders"], + summary: "Update a bookmark folder", + security: [%{"oAuth" => ["write:bookmarks"]}], + operationId: "PleromaAPI.BookmarkFolderController.update", + parameters: [id_param()], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Bookmark Folder", "application/json", BookmarkFolder), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError), + 422 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Bookmark folders"], + summary: "Delete a bookmark folder", + security: [%{"oAuth" => ["write:bookmarks"]}], + operationId: "PleromaAPI.BookmarkFolderController.delete", + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Bookmark Folder", "application/json", BookmarkFolder), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "BookmarkFolderCreateRequest", + type: :object, + properties: %{ + name: %Schema{ + type: :string, + description: "Folder name" + }, + emoji: %Schema{ + type: :string, + nullable: true, + description: "Folder emoji" + } + } + } + end + + defp update_request do + %Schema{ + title: "BookmarkFolderUpdateRequest", + type: :object, + properties: %{ + name: %Schema{ + type: :string, + nullable: true, + description: "Folder name" + }, + emoji: %Schema{ + type: :string, + nullable: true, + description: "Folder emoji" + } + } + } + end + + def id_param do + Operation.parameter(:id, :path, FlakeID.schema(), "Bookmark Folder ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -256,6 +256,18 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do description: "Privately bookmark a status", operationId: "StatusController.bookmark", parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + title: "StatusUpdateRequest", + type: :object, + properties: %{ + folder_id: %Schema{ + nullable: true, + allOf: [FlakeID], + description: "ID of bookmarks folder, if any" + } + } + }), responses: %{ 200 => status_response() } @@ -430,7 +442,15 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do summary: "Bookmarked statuses", description: "Statuses the user has bookmarked", operationId: "StatusController.bookmarks", - parameters: pagination_params(), + parameters: [ + Operation.parameter( + :folder_id, + :query, + FlakeID.schema(), + "If provided, only display bookmarks from given folder" + ) + | pagination_params() + ], security: [%{"oAuth" => ["read:bookmarks"]}], responses: %{ 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) diff --git a/lib/pleroma/web/api_spec/schemas/bookmark_folder.ex b/lib/pleroma/web/api_spec/schemas/bookmark_folder.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.BookmarkFolder do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "BookmarkFolder", + description: "Response schema for a bookmark folder", + type: :object, + properties: %{ + id: FlakeID, + name: %Schema{type: :string, description: "Folder name"}, + emoji: %Schema{type: :string, description: "Folder emoji", nullable: true} + }, + example: %{ + "id" => "9toJCu5YZW7O7gfvH6", + "name" => "Read later", + "emoji" => nil + } + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -12,6 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do alias Pleroma.Activity alias Pleroma.Bookmark + alias Pleroma.BookmarkFolder alias Pleroma.Object alias Pleroma.Repo alias Pleroma.ScheduledActivity @@ -411,13 +412,22 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do @doc "POST /api/v1/statuses/:id/bookmark" def bookmark( - %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{body_params: body_params, params: %{id: id}}} + } = conn, _ ) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), %User{} = user <- User.get_cached_by_nickname(user.nickname), true <- Visibility.visible_for_user?(activity, user), - {:ok, _bookmark} <- Bookmark.create(user.id, activity.id) do + folder_id <- Map.get(body_params, :folder_id, nil), + folder_id <- + if(folder_id && BookmarkFolder.belongs_to_user?(folder_id, user.id), + do: folder_id, + else: nil + ), + {:ok, _bookmark} <- Bookmark.create(user.id, activity.id, folder_id) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) end end @@ -573,10 +583,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do @doc "GET /api/v1/bookmarks" def bookmarks(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do user = User.get_cached_by_id(user.id) + folder_id = Map.get(params, :folder_id) bookmarks = user.id - |> Bookmark.for_user_query() + |> Bookmark.for_user_query(folder_id) |> Pleroma.Pagination.fetch_paginated(params) activities = diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -143,7 +143,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do "profile_directory" end, "pleroma:get:main/ostatus", - "pleroma:group_actors" + "pleroma:group_actors", + "pleroma:bookmark_folders" ] |> Enum.filter(& &1) end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -184,7 +184,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) - bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil + bookmark = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) + + bookmark_folder = + if bookmark != nil do + bookmark.folder_id + else + nil + end mentions = activity.recipients @@ -213,7 +220,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do favourites_count: 0, reblogged: reblogged?(reblogged_parent_activity, opts[:for]), favourited: present?(favorited), - bookmarked: present?(bookmarked), + bookmarked: present?(bookmark), muted: false, pinned: pinned?, sensitive: false, @@ -227,7 +234,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do emojis: [], pleroma: %{ local: activity.local, - pinned_at: pinned_at + pinned_at: pinned_at, + bookmark_folder: bookmark_folder } } end @@ -264,7 +272,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) - bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil + bookmark = Activity.get_bookmark(activity, opts[:for]) + + bookmark_folder = + if bookmark != nil do + bookmark.folder_id + else + nil + end client_posted_this_activity = opts[:for] && user.id == opts[:for].id @@ -418,7 +433,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do favourites_count: like_count, reblogged: reblogged?(activity, opts[:for]), favourited: present?(favorited), - bookmarked: present?(bookmarked), + bookmarked: present?(bookmark), muted: muted, pinned: pinned?, sensitive: sensitive, @@ -448,7 +463,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do emoji_reactions: emoji_reactions, parent_visible: visible_for_user?(reply_to, opts[:for]), pinned_at: pinned_at, - quotes_count: object.data["quotesCount"] || 0 + quotes_count: object.data["quotesCount"] || 0, + bookmark_folder: bookmark_folder } } end diff --git a/lib/pleroma/web/pleroma_api/controllers/bookmark_folder_controller.ex b/lib/pleroma/web/pleroma_api/controllers/bookmark_folder_controller.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.BookmarkFolderController do + use Pleroma.Web, :controller + + alias Pleroma.BookmarkFolder + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + # Note: scope not present in Mastodon: read:bookmarks + plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :index) + + # Note: scope not present in Mastodon: write:bookmarks + plug( + OAuthScopesPlug, + %{scopes: ["write:bookmarks"]} when action in [:create, :update, :delete] + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaBookmarkFolderOperation + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + def index(%{assigns: %{user: user}} = conn, _params) do + with folders <- BookmarkFolder.for_user(user.id) do + conn + |> render("index.json", %{folders: folders, as: :folder}) + end + end + + def create( + %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: params}}} = conn, + _ + ) do + with {:ok, folder} <- BookmarkFolder.create(user.id, params[:name], params[:emoji]) do + render(conn, "show.json", folder: folder) + end + end + + def update( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{body_params: params, params: %{id: id}}} + } = conn, + _ + ) do + with true <- BookmarkFolder.belongs_to_user?(id, user.id), + {:ok, folder} <- BookmarkFolder.update(id, params[:name], params[:emoji]) do + render(conn, "show.json", folder: folder) + else + false -> {:error, :forbidden} + end + end + + def delete( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do + with true <- BookmarkFolder.belongs_to_user?(id, user.id), + {:ok, folder} <- BookmarkFolder.delete(id) do + render(conn, "show.json", folder: folder) + else + false -> {:error, :forbidden} + end + end +end diff --git a/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex b/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.BookmarkFolderView do + use Pleroma.Web, :view + + alias Pleroma.BookmarkFolder + alias Pleroma.Emoji + alias Pleroma.Web.Endpoint + + def render("show.json", %{folder: %BookmarkFolder{} = folder}) do + %{ + id: folder.id |> to_string(), + name: folder.name, + emoji: folder.emoji, + emoji_url: get_emoji_url(folder.emoji) + } + end + + def render("index.json", %{folders: folders} = opts) do + render_many(folders, __MODULE__, "show.json", Map.delete(opts, :folders)) + end + + defp get_emoji_url(nil) do + nil + end + + defp get_emoji_url(emoji) do + if Emoji.unicode?(emoji) do + nil + else + emoji = Emoji.get(emoji) + + if emoji != nil do + Endpoint.url() |> URI.merge(emoji.file) |> to_string() + else + nil + end + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex @@ -585,6 +585,11 @@ defmodule Pleroma.Web.Router do get("/backups", BackupController, :index) post("/backups", BackupController, :create) + + get("/bookmark_folders", BookmarkFolderController, :index) + post("/bookmark_folders", BookmarkFolderController, :create) + patch("/bookmark_folders/:id", BookmarkFolderController, :update) + delete("/bookmark_folders/:id", BookmarkFolderController, :delete) end scope [] do diff --git a/priv/repo/migrations/20240223165000_create_bookmark_folders.exs b/priv/repo/migrations/20240223165000_create_bookmark_folders.exs @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.CreateBookmarkFolders do + use Ecto.Migration + + def change do + create_if_not_exists table(:bookmark_folders, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:name, :string, null: false) + add(:emoji, :string) + add(:user_id, references(:users, type: :uuid, on_delete: :delete_all)) + + timestamps() + end + + alter table(:bookmarks) do + add_if_not_exists( + :folder_id, + references(:bookmark_folders, type: :uuid, on_delete: :nilify_all) + ) + end + + create_if_not_exists(unique_index(:bookmark_folders, [:user_id, :name])) + end +end diff --git a/test/pleroma/bookmark_folder_test.exs b/test/pleroma/bookmark_folder_test.exs @@ -0,0 +1,60 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.BookmarkFolderTest do + use Pleroma.DataCase, async: true + import Pleroma.Factory + alias Pleroma.BookmarkFolder + + describe "create/3" do + test "with valid params" do + user = insert(:user) + {:ok, folder} = BookmarkFolder.create(user.id, "Read later", "🕓") + assert folder.user_id == user.id + assert folder.name == "Read later" + assert folder.emoji == "🕓" + end + + test "with invalid params" do + {:error, changeset} = BookmarkFolder.create(nil, "", "not an emoji") + refute changeset.valid? + + assert changeset.errors == [ + emoji: {"Invalid emoji", []}, + user_id: {"can't be blank", [validation: :required]}, + name: {"can't be blank", [validation: :required]} + ] + end + end + + test "update/3" do + user = insert(:user) + {:ok, folder} = BookmarkFolder.create(user.id, "Read ltaer") + {:ok, folder} = BookmarkFolder.update(folder.id, "Read later") + assert folder.name == "Read later" + end + + test "for_user/1" do + user = insert(:user) + other_user = insert(:user) + + {:ok, _} = BookmarkFolder.create(user.id, "Folder 1") + {:ok, _} = BookmarkFolder.create(user.id, "Folder 2") + {:ok, _} = BookmarkFolder.create(other_user.id, "Folder 3") + + folders = BookmarkFolder.for_user(user.id) + + assert length(folders) == 2 + end + + test "belongs_to_user?/2" do + user = insert(:user) + other_user = insert(:user) + + {:ok, folder} = BookmarkFolder.create(user.id, "Folder") + + assert true == BookmarkFolder.belongs_to_user?(folder.id, user.id) + assert false == BookmarkFolder.belongs_to_user?(folder.id, other_user.id) + end +end diff --git a/test/pleroma/bookmark_test.exs b/test/pleroma/bookmark_test.exs @@ -6,15 +6,17 @@ defmodule Pleroma.BookmarkTest do use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Bookmark + alias Pleroma.BookmarkFolder alias Pleroma.Web.CommonAPI - describe "create/2" do + describe "create/3" do test "with valid params" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"}) {:ok, bookmark} = Bookmark.create(user.id, activity.id) assert bookmark.user_id == user.id assert bookmark.activity_id == activity.id + assert bookmark.folder_id == nil end test "with invalid params" do @@ -26,6 +28,19 @@ defmodule Pleroma.BookmarkTest do activity_id: {"can't be blank", [validation: :required]} ] end + + test "update existing bookmark folder" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"}) + + {:ok, bookmark} = Bookmark.create(user.id, activity.id) + assert bookmark.folder_id == nil + + {:ok, bookmark_folder} = BookmarkFolder.create(user.id, "Read later") + + {:ok, bookmark} = Bookmark.create(user.id, activity.id, bookmark_folder.id) + assert bookmark.folder_id == bookmark_folder.id + end end describe "destroy/2" do diff --git a/test/pleroma/search/database_search_test.exs b/test/pleroma/search/database_search_test.exs @@ -35,21 +35,6 @@ defmodule Pleroma.Search.DatabaseSearchTest do assert [] = Search.search(nil, "wednesday") end - test "using plainto_tsquery on postgres < 11" do - old_version = :persistent_term.get({Pleroma.Repo, :postgres_version}) - :persistent_term.put({Pleroma.Repo, :postgres_version}, 10.0) - on_exit(fn -> :persistent_term.put({Pleroma.Repo, :postgres_version}, old_version) end) - - user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"}) - {:ok, _post2} = CommonAPI.post(user, %{status: "it's wednesday my bros"}) - - # plainto doesn't understand complex queries - assert [result] = Search.search(nil, "wednesday -dudes") - - assert result.id == post.id - end - test "using websearch_to_tsquery" do user = insert(:user) {:ok, _post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"}) diff --git a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs @@ -322,26 +322,20 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do end test "search fetches remote statuses and prefers them over other results", %{conn: conn} do - old_version = :persistent_term.get({Pleroma.Repo, :postgres_version}) - :persistent_term.put({Pleroma.Repo, :postgres_version}, 10.0) - on_exit(fn -> :persistent_term.put({Pleroma.Repo, :postgres_version}, old_version) end) - - capture_log(fn -> - {:ok, %{id: activity_id}} = - CommonAPI.post(insert(:user), %{ - status: "check out http://mastodon.example.org/@admin/99541947525187367" - }) + {:ok, %{id: activity_id}} = + CommonAPI.post(insert(:user), %{ + status: "check out http://mastodon.example.org/@admin/99541947525187367" + }) - results = - conn - |> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367") - |> json_response_and_validate_schema(200) + %{"url" => result_url, "id" => result_id} = + conn + |> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367") + |> json_response_and_validate_schema(200) + |> Map.get("statuses") + |> List.first() - assert [ - %{"url" => "http://mastodon.example.org/@admin/99541947525187367"}, - %{"id" => ^activity_id} - ] = results["statuses"] - end) + refute match?(^result_id, activity_id) + assert match?(^result_url, "http://mastodon.example.org/@admin/99541947525187367") end test "search doesn't show statuses that it shouldn't", %{conn: conn} do diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1828,6 +1828,60 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do json_response_and_validate_schema(bookmarks, 200) end + test "bookmark folders" do + %{conn: conn, user: user} = oauth_access(["write:bookmarks", "read:bookmarks"]) + + {:ok, folder} = Pleroma.BookmarkFolder.create(user.id, "folder") + author = insert(:user) + + folder_bookmarks_uri = "/api/v1/bookmarks?folder_id=#{folder.id}" + + {:ok, activity1} = CommonAPI.post(author, %{status: "heweoo?"}) + {:ok, activity2} = CommonAPI.post(author, %{status: "heweoo!"}) + + # Add bookmark with a folder + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity1.id}/bookmark", %{folder_id: folder.id}) + + assert json_response_and_validate_schema(response, 200)["bookmarked"] == true + + assert json_response_and_validate_schema(response, 200)["pleroma"]["bookmark_folder"] == + folder.id + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity2.id}/bookmark") + + assert json_response_and_validate_schema(response, 200)["bookmarked"] == true + assert json_response_and_validate_schema(response, 200)["pleroma"]["bookmark_folder"] == nil + + bookmarks = + get(conn, folder_bookmarks_uri) + |> json_response_and_validate_schema(200) + + assert length(bookmarks) == 1 + + # Update folder for existing bookmark + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity2.id}/bookmark", %{folder_id: folder.id}) + + assert json_response_and_validate_schema(response, 200)["bookmarked"] == true + + assert json_response_and_validate_schema(response, 200)["pleroma"]["bookmark_folder"] == + folder.id + + bookmarks = + get(conn, folder_bookmarks_uri) + |> json_response_and_validate_schema(200) + + assert length(bookmarks) == 2 + end + describe "conversation muting" do setup do: oauth_access(["write:mutes"]) diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -341,7 +341,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do emoji_reactions: [], parent_visible: false, pinned_at: nil, - quotes_count: 0 + quotes_count: 0, + bookmark_folder: nil } } diff --git a/test/pleroma/web/pleroma_api/controllers/bookmark_folder_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/bookmark_folder_controller_test.exs @@ -0,0 +1,161 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.PleromaAPI.BookmarkFolderControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.BookmarkFolder + # alias Pleroma.Object + # alias Pleroma.Tests.Helpers + # alias Pleroma.UnstubbedConfigMock, as: ConfigMock + # alias Pleroma.User + # alias Pleroma.Web.ActivityPub.ActivityPub + # alias Pleroma.Web.CommonAPI + + # import Mox + import Pleroma.Factory + + describe "GET /api/v1/pleroma/bookmark_folders" do + setup do: oauth_access(["read:bookmarks"]) + + test "it lists bookmark folders", %{conn: conn, user: user} do + {:ok, folder} = BookmarkFolder.create(user.id, "Bookmark folder") + + folder_id = folder.id + + result = + conn + |> get("/api/v1/pleroma/bookmark_folders") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "id" => ^folder_id, + "name" => "Bookmark folder", + "emoji" => nil, + "emoji_url" => nil + } + ] = result + end + end + + describe "POST /api/v1/pleroma/bookmark_folders" do + setup do: oauth_access(["write:bookmarks"]) + + test "it creates a bookmark folder", %{conn: conn} do + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/bookmark_folders", %{ + name: "Bookmark folder", + emoji: "📁" + }) + |> json_response_and_validate_schema(200) + + assert %{ + "name" => "Bookmark folder", + "emoji" => "📁", + "emoji_url" => nil + } = result + end + + test "it creates a bookmark folder with custom emoji", %{conn: conn} do + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/bookmark_folders", %{ + name: "Bookmark folder", + emoji: ":firefox:" + }) + |> json_response_and_validate_schema(200) + + assert %{ + "name" => "Bookmark folder", + "emoji" => ":firefox:", + "emoji_url" => "http://localhost:4001/emoji/Firefox.gif" + } = result + end + + test "it returns error for invalid emoji", %{conn: conn} do + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/bookmark_folders", %{ + name: "Bookmark folder", + emoji: "not an emoji" + }) + |> json_response_and_validate_schema(422) + + assert %{"error" => "Invalid emoji"} = result + end + end + + describe "PATCH /api/v1/pleroma/bookmark_folders/:id" do + setup do: oauth_access(["write:bookmarks"]) + + test "it updates a bookmark folder", %{conn: conn, user: user} do + {:ok, folder} = BookmarkFolder.create(user.id, "Bookmark folder") + + result = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/pleroma/bookmark_folders/#{folder.id}", %{ + name: "bookmark folder" + }) + |> json_response_and_validate_schema(200) + + assert %{ + "name" => "bookmark folder" + } = result + end + + test "it returns error when updating others' folders", %{conn: conn} do + other_user = insert(:user) + + {:ok, folder} = BookmarkFolder.create(other_user.id, "Bookmark folder") + + result = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/pleroma/bookmark_folders/#{folder.id}", %{ + name: "bookmark folder" + }) + |> json_response_and_validate_schema(403) + + assert %{ + "error" => "Access denied" + } = result + end + end + + describe "DELETE /api/v1/pleroma/bookmark_folders/:id" do + setup do: oauth_access(["write:bookmarks"]) + + test "it deleting a bookmark folder", %{conn: conn, user: user} do + {:ok, folder} = BookmarkFolder.create(user.id, "Bookmark folder") + + assert conn + |> delete("/api/v1/pleroma/bookmark_folders/#{folder.id}") + |> json_response_and_validate_schema(200) + + folders = BookmarkFolder.for_user(user.id) + + assert length(folders) == 0 + end + + test "it returns error when deleting others' folders", %{conn: conn} do + other_user = insert(:user) + + {:ok, folder} = BookmarkFolder.create(other_user.id, "Bookmark folder") + + result = + conn + |> patch("/api/v1/pleroma/bookmark_folders/#{folder.id}") + |> json_response_and_validate_schema(403) + + assert %{ + "error" => "Access denied" + } = result + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs @@ -4,6 +4,8 @@ Code.put_compiler_option(:warnings_as_errors, true) +ExUnit.configure(max_cases: System.schedulers_online()) + ExUnit.start(exclude: [:federated, :erratic]) if match?({:unix, :darwin}, :os.type()) do diff --git a/uploads/.gitignore b/uploads/.gitignore @@ -1,3 +0,0 @@ -# Git will ignore everything in this directory except this file. -* -!.gitignore