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

This commit is contained in:
marcin mikołajczak 2024-04-06 10:42:23 +02:00
commit 01a5f839c5
39 changed files with 871 additions and 96 deletions

View File

@ -26,10 +26,10 @@ cache: &global_cache_policy
- _build - _build
stages: stages:
- check-changelog
- build - build
- lint - lint
- test - test
- check-changelog
- benchmark - benchmark
- deploy - deploy
- release - release
@ -113,7 +113,7 @@ benchmark:
variables: variables:
MIX_ENV: benchmark MIX_ENV: benchmark
services: services:
- name: postgres:9.6-alpine - name: postgres:11.22-alpine
alias: postgres alias: postgres
command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
script: script:

View File

@ -0,0 +1 @@
Allow to group bookmarks in folders

View File

View File

@ -0,0 +1 @@
Disable jit by default for PostgreSQL

View File

View File

@ -0,0 +1 @@
Set default values on validators for transient objects (attachment, poll options)

View File

@ -799,7 +799,7 @@
config :pleroma, configurable_from_database: false config :pleroma, configurable_from_database: false
config :pleroma, Pleroma.Repo, config :pleroma, Pleroma.Repo,
parameters: [gin_fuzzy_search_limit: "500"], parameters: [gin_fuzzy_search_limit: "500", jit: "off"],
prepare: :unnamed prepare: :unnamed
config :pleroma, :connections_pool, config :pleroma, :connections_pool,

View File

@ -49,7 +49,7 @@
hostname: System.get_env("DB_HOST") || "localhost", hostname: System.get_env("DB_HOST") || "localhost",
port: System.get_env("DB_PORT") || "5432", port: System.get_env("DB_PORT") || "5432",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 50 pool_size: System.schedulers_online() * 2
config :pleroma, :dangerzone, override_repo_pool_size: true config :pleroma, :dangerzone, override_repo_pool_size: true

View File

@ -41,6 +41,7 @@ Has these additional fields under the `pleroma` object:
- `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise. - `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise.
- `quotes_count`: the count of status quotes. - `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. - `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: 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. 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 ## 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. 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.

View File

@ -283,6 +283,52 @@ See [Admin-API](admin_api.md)
* `id`: the id of the status * `id`: the id of the status
* Response: JSON, returns a list of Mastodon Status entities * 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` ## `/api/v1/pleroma/mascot`
### Gets user mascot image ### Gets user mascot image
* Method `GET` * Method `GET`

View File

@ -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 11.0以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください)
- `postgresql-contrib` 9.6以上 (同上) - `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ユーザーでインストールしてください) - 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-dev`
- `erlang-nox` - `erlang-nox`

View File

@ -1,6 +1,6 @@
## Required dependencies ## Required dependencies
* PostgreSQL >=9.6 * PostgreSQL >=11.0
* Elixir >=1.11.0 <1.15 * Elixir >=1.11.0 <1.15
* Erlang OTP >=22.2.0 (supported: <27) * Erlang OTP >=22.2.0 (supported: <27)
* git * git

View File

@ -119,28 +119,7 @@ def start(_type, _args) do
max_restarts = Application.get_env(:pleroma, __MODULE__)[:max_restarts] max_restarts = Application.get_env(:pleroma, __MODULE__)[:max_restarts]
opts = [strategy: :one_for_one, name: Pleroma.Supervisor, max_restarts: max_restarts] opts = [strategy: :one_for_one, name: Pleroma.Supervisor, max_restarts: max_restarts]
result = Supervisor.start_link(children, opts) 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)
end end
def load_custom_modules do def load_custom_modules do

View File

@ -10,6 +10,7 @@ defmodule Pleroma.Bookmark do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Bookmark alias Pleroma.Bookmark
alias Pleroma.BookmarkFolder
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
@ -18,33 +19,46 @@ defmodule Pleroma.Bookmark do
schema "bookmarks" do schema "bookmarks" do
belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
belongs_to(:folder, BookmarkFolder, type: FlakeId.Ecto.CompatType)
timestamps() timestamps()
end end
@spec create(Ecto.UUID.t(), Ecto.UUID.t()) :: @spec create(Ecto.UUID.t(), Ecto.UUID.t()) ::
{:ok, Bookmark.t()} | {:error, Ecto.Changeset.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 = %{ attrs = %{
user_id: user_id, user_id: user_id,
activity_id: activity_id activity_id: activity_id,
folder_id: folder_id
} }
%Bookmark{} %Bookmark{}
|> cast(attrs, [:user_id, :activity_id]) |> cast(attrs, [:user_id, :activity_id, :folder_id])
|> validate_required([:user_id, :activity_id]) |> validate_required([:user_id, :activity_id])
|> unique_constraint(:activity_id, name: :bookmarks_user_id_activity_id_index) |> 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 end
@spec for_user_query(Ecto.UUID.t()) :: Ecto.Query.t() @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 Bookmark
|> where(user_id: ^user_id) |> where(user_id: ^user_id)
|> maybe_filter_by_folder(folder_id)
|> join(:inner, [b], activity in assoc(b, :activity)) |> join(:inner, [b], activity in assoc(b, :activity))
|> preload([b, a], activity: a) |> preload([b, a], activity: a)
end 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 def get(user_id, activity_id) do
Bookmark Bookmark
|> where(user_id: ^user_id) |> where(user_id: ^user_id)
@ -62,4 +76,11 @@ def destroy(user_id, activity_id) do
|> Repo.one() |> Repo.one()
|> Repo.delete() |> Repo.delete()
end end
def set_folder(bookmark, folder_id) do
bookmark
|> cast(%{folder_id: folder_id}, [:folder_id])
|> validate_required([:folder_id])
|> Repo.update()
end
end end

View File

@ -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

View File

@ -23,19 +23,12 @@ def search(user, search_query, options \\ []) do
offset = Keyword.get(options, :offset, 0) offset = Keyword.get(options, :offset, 0)
author = Keyword.get(options, :author) author = Keyword.get(options, :author)
search_function =
if :persistent_term.get({Pleroma.Repo, :postgres_version}) >= 11 do
:websearch
else
:plain
end
try do try do
Activity Activity
|> Activity.with_preloaded_object() |> Activity.with_preloaded_object()
|> Activity.restrict_deactivated_users() |> Activity.restrict_deactivated_users()
|> restrict_public(user) |> restrict_public(user)
|> query_with(index_type, search_query, search_function) |> query_with(index_type, search_query, :websearch)
|> maybe_restrict_local(user) |> maybe_restrict_local(user)
|> maybe_restrict_author(author) |> maybe_restrict_author(author)
|> maybe_restrict_blocked(user) |> maybe_restrict_blocked(user)

View File

@ -12,13 +12,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
@primary_key false @primary_key false
embedded_schema do embedded_schema do
field(:id, :string) field(:id, :string)
field(:type, :string) field(:type, :string, default: "Link")
field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream") field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream")
field(:name, :string) field(:name, :string)
field(:blurhash, :string) field(:blurhash, :string)
embeds_many :url, UrlObjectValidator, primary_key: false do embeds_many :url, UrlObjectValidator, primary_key: false do
field(:type, :string) field(:type, :string, default: "Link")
field(:href, ObjectValidators.Uri) field(:href, ObjectValidators.Uri)
field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream") field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream")
field(:width, :integer) field(:width, :integer)

View File

@ -14,10 +14,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator do
embeds_one :replies, Replies, primary_key: false do embeds_one :replies, Replies, primary_key: false do
field(:totalItems, :integer) field(:totalItems, :integer)
field(:type, :string) field(:type, :string, default: "Collection")
end end
field(:type, :string) field(:type, :string, default: "Note")
end end
def changeset(struct, data) do def changeset(struct, data) do

View File

@ -138,7 +138,8 @@ def spec(opts \\ []) do
"Scheduled statuses", "Scheduled statuses",
"Search", "Search",
"Status actions", "Status actions",
"Media attachments" "Media attachments",
"Bookmark folders"
] ]
}, },
%{ %{

View File

@ -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

View File

@ -256,6 +256,18 @@ def bookmark_operation do
description: "Privately bookmark a status", description: "Privately bookmark a status",
operationId: "StatusController.bookmark", operationId: "StatusController.bookmark",
parameters: [id_param()], 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: %{ responses: %{
200 => status_response() 200 => status_response()
} }
@ -430,7 +442,15 @@ def bookmarks_operation do
summary: "Bookmarked statuses", summary: "Bookmarked statuses",
description: "Statuses the user has bookmarked", description: "Statuses the user has bookmarked",
operationId: "StatusController.bookmarks", 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"]}], security: [%{"oAuth" => ["read:bookmarks"]}],
responses: %{ responses: %{
200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses())

View File

@ -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

View File

@ -12,6 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Bookmark alias Pleroma.Bookmark
alias Pleroma.BookmarkFolder
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.ScheduledActivity alias Pleroma.ScheduledActivity
@ -411,13 +412,22 @@ def unpin(
@doc "POST /api/v1/statuses/:id/bookmark" @doc "POST /api/v1/statuses/:id/bookmark"
def 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 ) do
with %Activity{} = activity <- Activity.get_by_id_with_object(id), with %Activity{} = activity <- Activity.get_by_id_with_object(id),
%User{} = user <- User.get_cached_by_nickname(user.nickname), %User{} = user <- User.get_cached_by_nickname(user.nickname),
true <- Visibility.visible_for_user?(activity, user), 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) try_render(conn, "show.json", activity: activity, for: user, as: :activity)
end end
end end
@ -573,10 +583,11 @@ def favourites(
@doc "GET /api/v1/bookmarks" @doc "GET /api/v1/bookmarks"
def bookmarks(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do def bookmarks(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do
user = User.get_cached_by_id(user.id) user = User.get_cached_by_id(user.id)
folder_id = Map.get(params, :folder_id)
bookmarks = bookmarks =
user.id user.id
|> Bookmark.for_user_query() |> Bookmark.for_user_query(folder_id)
|> Pleroma.Pagination.fetch_paginated(params) |> Pleroma.Pagination.fetch_paginated(params)
activities = activities =

View File

@ -143,7 +143,8 @@ def features do
"profile_directory" "profile_directory"
end, end,
"pleroma:get:main/ostatus", "pleroma:get:main/ostatus",
"pleroma:group_actors" "pleroma:group_actors",
"pleroma:bookmark_folders"
] ]
|> Enum.filter(& &1) |> Enum.filter(& &1)
end end

View File

@ -184,7 +184,14 @@ def render(
favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) 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 = mentions =
activity.recipients activity.recipients
@ -213,7 +220,7 @@ def render(
favourites_count: 0, favourites_count: 0,
reblogged: reblogged?(reblogged_parent_activity, opts[:for]), reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
favourited: present?(favorited), favourited: present?(favorited),
bookmarked: present?(bookmarked), bookmarked: present?(bookmark),
muted: false, muted: false,
pinned: pinned?, pinned: pinned?,
sensitive: false, sensitive: false,
@ -227,7 +234,8 @@ def render(
emojis: [], emojis: [],
pleroma: %{ pleroma: %{
local: activity.local, local: activity.local,
pinned_at: pinned_at pinned_at: pinned_at,
bookmark_folder: bookmark_folder
} }
} }
end end
@ -264,7 +272,14 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity}
favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) 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 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
@ -418,7 +433,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity}
favourites_count: like_count, favourites_count: like_count,
reblogged: reblogged?(activity, opts[:for]), reblogged: reblogged?(activity, opts[:for]),
favourited: present?(favorited), favourited: present?(favorited),
bookmarked: present?(bookmarked), bookmarked: present?(bookmark),
muted: muted, muted: muted,
pinned: pinned?, pinned: pinned?,
sensitive: sensitive, sensitive: sensitive,
@ -448,7 +463,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity}
emoji_reactions: emoji_reactions, emoji_reactions: emoji_reactions,
parent_visible: visible_for_user?(reply_to, opts[:for]), parent_visible: visible_for_user?(reply_to, opts[:for]),
pinned_at: pinned_at, pinned_at: pinned_at,
quotes_count: object.data["quotesCount"] || 0 quotes_count: object.data["quotesCount"] || 0,
bookmark_folder: bookmark_folder
} }
} }
end end

View File

@ -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

View File

@ -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

View File

@ -585,6 +585,11 @@ defmodule Pleroma.Web.Router do
get("/backups", BackupController, :index) get("/backups", BackupController, :index)
post("/backups", BackupController, :create) 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 end
scope [] do scope [] do

View File

@ -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

View File

@ -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

View File

@ -6,15 +6,17 @@ defmodule Pleroma.BookmarkTest do
use Pleroma.DataCase, async: true use Pleroma.DataCase, async: true
import Pleroma.Factory import Pleroma.Factory
alias Pleroma.Bookmark alias Pleroma.Bookmark
alias Pleroma.BookmarkFolder
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
describe "create/2" do describe "create/3" do
test "with valid params" do test "with valid params" do
user = insert(:user) user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"}) {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"})
{:ok, bookmark} = Bookmark.create(user.id, activity.id) {:ok, bookmark} = Bookmark.create(user.id, activity.id)
assert bookmark.user_id == user.id assert bookmark.user_id == user.id
assert bookmark.activity_id == activity.id assert bookmark.activity_id == activity.id
assert bookmark.folder_id == nil
end end
test "with invalid params" do test "with invalid params" do
@ -26,6 +28,19 @@ test "with invalid params" do
activity_id: {"can't be blank", [validation: :required]} activity_id: {"can't be blank", [validation: :required]}
] ]
end 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 end
describe "destroy/2" do describe "destroy/2" do

View File

@ -35,21 +35,6 @@ test "it does not find local-only posts for anonymous users" do
assert [] = Search.search(nil, "wednesday") assert [] = Search.search(nil, "wednesday")
end 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 test "using websearch_to_tsquery" do
user = insert(:user) user = insert(:user)
{:ok, _post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"}) {:ok, _post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"})

View File

@ -322,26 +322,20 @@ test "search", %{conn: conn} do
end end
test "search fetches remote statuses and prefers them over other results", %{conn: conn} do 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}} = {:ok, %{id: activity_id}} =
CommonAPI.post(insert(:user), %{ CommonAPI.post(insert(:user), %{
status: "check out http://mastodon.example.org/@admin/99541947525187367" status: "check out http://mastodon.example.org/@admin/99541947525187367"
}) })
results = %{"url" => result_url, "id" => result_id} =
conn conn
|> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367") |> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367")
|> json_response_and_validate_schema(200) |> json_response_and_validate_schema(200)
|> Map.get("statuses")
|> List.first()
assert [ refute match?(^result_id, activity_id)
%{"url" => "http://mastodon.example.org/@admin/99541947525187367"}, assert match?(^result_url, "http://mastodon.example.org/@admin/99541947525187367")
%{"id" => ^activity_id}
] = results["statuses"]
end)
end end
test "search doesn't show statuses that it shouldn't", %{conn: conn} do test "search doesn't show statuses that it shouldn't", %{conn: conn} do

View File

@ -1828,6 +1828,60 @@ test "bookmarks" do
json_response_and_validate_schema(bookmarks, 200) json_response_and_validate_schema(bookmarks, 200)
end 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 describe "conversation muting" do
setup do: oauth_access(["write:mutes"]) setup do: oauth_access(["write:mutes"])

View File

@ -341,7 +341,8 @@ test "a note activity" do
emoji_reactions: [], emoji_reactions: [],
parent_visible: false, parent_visible: false,
pinned_at: nil, pinned_at: nil,
quotes_count: 0 quotes_count: 0,
bookmark_folder: nil
} }
} }

View File

@ -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

View File

@ -4,6 +4,8 @@
Code.put_compiler_option(:warnings_as_errors, true) Code.put_compiler_option(:warnings_as_errors, true)
ExUnit.configure(max_cases: System.schedulers_online())
ExUnit.start(exclude: [:federated, :erratic]) ExUnit.start(exclude: [:federated, :erratic])
if match?({:unix, :darwin}, :os.type()) do if match?({:unix, :darwin}, :os.type()) do

3
uploads/.gitignore vendored
View File

@ -1,3 +0,0 @@
# Git will ignore everything in this directory except this file.
*
!.gitignore