Birth dates, birthday reminders API, allow instance admins to require minimum age

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-01-18 14:57:48 +01:00
parent 84dcb55b0f
commit b108b05650
16 changed files with 168 additions and 14 deletions

View file

@ -259,7 +259,9 @@
password_reset_token_validity: 60 * 60 * 24, password_reset_token_validity: 60 * 60 * 24,
profile_directory: true, profile_directory: true,
privileged_staff: false, privileged_staff: false,
max_endorsed_users: 20 max_endorsed_users: 20,
birth_date_required: false,
birth_date_min_age: 0
config :pleroma, :welcome, config :pleroma, :welcome,
direct_message: [ direct_message: [

View file

@ -957,6 +957,16 @@
type: :boolean, type: :boolean,
description: description:
"Let moderators access sensitive data (e.g. updating user credentials, get password reset token, delete users, index and read private statuses and chats)" "Let moderators access sensitive data (e.g. updating user credentials, get password reset token, delete users, index and read private statuses and chats)"
},
%{
key: :birth_date_required,
type: :boolean,
description: "Require users to provide birth day."
},
%{
key: :birth_date_min_age,
type: :integer,
description: "Min age for users to create account. Only makes sense if birth date is required."
} }
] ]
}, },

View file

@ -154,6 +154,9 @@ defmodule Pleroma.User do
field(:pinned_objects, :map, default: %{}) field(:pinned_objects, :map, default: %{})
field(:is_suggested, :boolean, default: false) field(:is_suggested, :boolean, default: false)
field(:last_status_at, :naive_datetime) field(:last_status_at, :naive_datetime)
field(:birth_date, :date)
field(:hide_birth_date, :boolean, default: false)
embeds_one( embeds_one(
:notification_settings, :notification_settings,
@ -470,7 +473,8 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do
:actor_type, :actor_type,
:also_known_as, :also_known_as,
:accepts_chat_messages, :accepts_chat_messages,
:pinned_objects :pinned_objects,
:birth_date
] ]
) )
|> cast(params, [:name], empty_values: []) |> cast(params, [:name], empty_values: [])
@ -531,9 +535,11 @@ def update_changeset(struct, params \\ %{}) do
:is_discoverable, :is_discoverable,
:actor_type, :actor_type,
:accepts_chat_messages, :accepts_chat_messages,
:disclose_client :disclose_client,
:birth_date
] ]
) )
|> validate_min_age()
|> unique_constraint(:nickname) |> unique_constraint(:nickname)
|> validate_format(:nickname, local_nickname_regex()) |> validate_format(:nickname, local_nickname_regex())
|> validate_length(:bio, max: bio_limit) |> validate_length(:bio, max: bio_limit)
@ -738,7 +744,8 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do
:password_confirmation, :password_confirmation,
:emoji, :emoji,
:accepts_chat_messages, :accepts_chat_messages,
:registration_reason :registration_reason,
:birth_date
]) ])
|> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_required([:name, :nickname, :password, :password_confirmation])
|> validate_confirmation(:password) |> validate_confirmation(:password)
@ -760,6 +767,8 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do
|> validate_length(:name, min: 1, max: name_limit) |> validate_length(:name, min: 1, max: name_limit)
|> validate_length(:registration_reason, max: reason_limit) |> validate_length(:registration_reason, max: reason_limit)
|> maybe_validate_required_email(opts[:external]) |> maybe_validate_required_email(opts[:external])
|> maybe_validate_required_birth_date
|> validate_min_age()
|> put_password_hash |> put_password_hash
|> put_ap_id() |> put_ap_id()
|> unique_constraint(:ap_id) |> unique_constraint(:ap_id)
@ -776,6 +785,26 @@ def maybe_validate_required_email(changeset, _) do
end end
end end
defp maybe_validate_required_birth_date(changeset) do
if Config.get([:instance, :birth_date_required]) do
validate_required(changeset, [:birth_date])
else
changeset
end
end
defp validate_min_age(changeset) do
changeset
|> validate_change(:birth_date, fn :birth_date, birth_date ->
valid? =
Date.utc_today()
|> Date.diff(birth_date) >=
Config.get([:instance, :birth_date_min_age])
if valid?, do: [], else: [birth_date: "Invalid birth date"]
end)
end
defp put_ap_id(changeset) do defp put_ap_id(changeset) do
ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)}) ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)})
put_change(changeset, :ap_id, ap_id) put_change(changeset, :ap_id, ap_id)

View file

@ -59,7 +59,9 @@ defmodule Pleroma.User.Query do
order_by: term(), order_by: term(),
select: term(), select: term(),
limit: pos_integer(), limit: pos_integer(),
actor_types: [String.t()] actor_types: [String.t()],
birth_day: pos_integer(),
birth_month: pos_integer()
} }
| map() | map()
@ -230,6 +232,18 @@ defp compose_query({:internal, false}, query) do
|> where([u], not like(u.nickname, "internal.%")) |> where([u], not like(u.nickname, "internal.%"))
end end
defp compose_query({:birth_day, day}, query) do
query
|> where([u], not is_nil(u.birth_date))
|> where([u], fragment("date_part('day', ?)", u.birth_date) == ^day)
end
defp compose_query({:birth_month, month}, query) do
query
|> where([u], not is_nil(u.birth_date))
|> where([u], fragment("date_part('month', ?)", u.birth_date) == ^month)
end
defp compose_query(_unsupported_param, query), do: query defp compose_query(_unsupported_param, query), do: query
defp location_query(query, local) do defp location_query(query, local) do

View file

@ -1523,7 +1523,8 @@ defp object_to_user_data(data) do
inbox: data["inbox"], inbox: data["inbox"],
shared_inbox: shared_inbox, shared_inbox: shared_inbox,
accepts_chat_messages: accepts_chat_messages, accepts_chat_messages: accepts_chat_messages,
pinned_objects: pinned_objects pinned_objects: pinned_objects,
birth_date: data["vcard:bday"]
} }
# nickname can be nil because of virtual actors # nickname can be nil because of virtual actors

View file

@ -92,6 +92,11 @@ def render("user.json", %{user: user}) do
%{} %{}
end end
birth_date =
if !user.hide_birth_date,
do: user.birth_date,
else: nil
%{ %{
"id" => user.ap_id, "id" => user.ap_id,
"type" => user.actor_type, "type" => user.actor_type,
@ -116,7 +121,8 @@ def render("user.json", %{user: user}) do
# Note: key name is indeed "discoverable" (not an error) # Note: key name is indeed "discoverable" (not an error)
"discoverable" => user.is_discoverable, "discoverable" => user.is_discoverable,
"capabilities" => capabilities, "capabilities" => capabilities,
"alsoKnownAs" => user.also_known_as "alsoKnownAs" => user.also_known_as,
"vcard:bday" => birth_date
} }
|> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user))
|> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user))

View file

@ -543,7 +543,13 @@ defp create_request do
type: :string, type: :string,
nullable: true, nullable: true,
description: "Invite token required when the registrations aren't public" description: "Invite token required when the registrations aren't public"
} },
birth_date: %Schema{
type: :string,
nullable: true,
description: "User's birth date",
format: :date
},
}, },
example: %{ example: %{
"username" => "cofe", "username" => "cofe",
@ -720,7 +726,18 @@ defp update_credentials_request do
description: description:
"Discovery (listing, indexing) of this account by external services (search bots etc.) is allowed." "Discovery (listing, indexing) of this account by external services (search bots etc.) is allowed."
}, },
actor_type: ActorType actor_type: ActorType,
birth_date: %Schema{
type: :string,
nullable: true,
description: "User's birth date",
format: :date
},
hide_birth_date: %Schema{
allOf: [BooleanLike],
nullable: true,
description: "User's birth date will be hidden"
}
}, },
example: %{ example: %{
bot: false, bot: false,
@ -740,7 +757,9 @@ defp update_credentials_request do
allow_following_move: false, allow_following_move: false,
also_known_as: ["https://foo.bar/users/foo"], also_known_as: ["https://foo.bar/users/foo"],
discoverable: false, discoverable: false,
actor_type: "Person" actor_type: "Person",
hide_birth_date: true,
birth_date: "2001-02-12"
} }
} }
end end

View file

@ -4,6 +4,7 @@
defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do
alias OpenApiSpex.Operation alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.AccountOperation alias Pleroma.Web.ApiSpec.AccountOperation
alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship
alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.ApiError
@ -112,6 +113,34 @@ def unsubscribe_operation do
} }
end end
def birthdays_operation do
%Operation{
tags: ["Retrieve account information"],
summary: "Birthday reminders",
description: "Birthday reminders about users you follow.",
operationId: "PleromaAPI.AccountController.birthdays",
parameters: [
Operation.parameter(
:day,
:query,
%Schema{type: :integer},
"Day of users' birthdays"
),
Operation.parameter(
:month,
:query,
%Schema{type: :integer},
"Month of users' birthdays"
)
],
security: [%{"oAuth" => ["read:accounts"]}],
responses: %{
200 =>
Operation.response("Accounts", "application/json", AccountOperation.array_of_accounts())
}
}
end
defp id_param do defp id_param do
Operation.parameter(:id, :path, FlakeID, "Account ID", Operation.parameter(:id, :path, FlakeID, "Account ID",
example: "9umDrYheeY451cQnEe", example: "9umDrYheeY451cQnEe",

View file

@ -47,12 +47,14 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do
description: "whether the user allows automatically follow moved following accounts" description: "whether the user allows automatically follow moved following accounts"
}, },
background_image: %Schema{type: :string, nullable: true, format: :uri}, background_image: %Schema{type: :string, nullable: true, format: :uri},
birth_date: %Schema{type: :string, format: :date},
chat_token: %Schema{type: :string}, chat_token: %Schema{type: :string},
is_confirmed: %Schema{ is_confirmed: %Schema{
type: :boolean, type: :boolean,
description: description:
"whether the user account is waiting on email confirmation to be activated" "whether the user account is waiting on email confirmation to be activated"
}, },
hide_birth_date: %Schema{type: :boolean},
hide_favorites: %Schema{type: :boolean}, hide_favorites: %Schema{type: :boolean},
hide_followers_count: %Schema{ hide_followers_count: %Schema{
type: :boolean, type: :boolean,
@ -202,7 +204,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do
}, },
"settings_store" => %{ "settings_store" => %{
"pleroma-fe" => %{} "pleroma-fe" => %{}
} },
"birth_date" => "2001-02-12"
}, },
"source" => %{ "source" => %{
"fields" => [], "fields" => [],

View file

@ -191,7 +191,8 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p
:skip_thread_containment, :skip_thread_containment,
:allow_following_move, :allow_following_move,
:also_known_as, :also_known_as,
:accepts_chat_messages :accepts_chat_messages,
:hide_birth_date
] ]
|> Enum.reduce(%{}, fn key, acc -> |> Enum.reduce(%{}, fn key, acc ->
Maps.put_if_present(acc, key, params[key], &{:ok, Params.truthy_param?(&1)}) Maps.put_if_present(acc, key, params[key], &{:ok, Params.truthy_param?(&1)})
@ -219,6 +220,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p
|> Maps.put_if_present(:is_locked, params[:locked]) |> Maps.put_if_present(:is_locked, params[:locked])
# Note: param name is indeed :discoverable (not an error) # Note: param name is indeed :discoverable (not an error)
|> Maps.put_if_present(:is_discoverable, params[:discoverable]) |> Maps.put_if_present(:is_discoverable, params[:discoverable])
|> Maps.put_if_present(:birth_date, params[:birth_date])
# What happens here: # What happens here:
# #

View file

@ -249,6 +249,11 @@ defp do_render("show.json", %{user: user} = opts) do
nil nil
end end
birth_date =
if !user.hide_birth_date or opts[:for] == user,
do: user.birth_date,
else: nil
%{ %{
id: to_string(user.id), id: to_string(user.id),
username: username_from_nickname(user.nickname), username: username_from_nickname(user.nickname),
@ -297,7 +302,8 @@ defp do_render("show.json", %{user: user} = opts) do
skip_thread_containment: user.skip_thread_containment, skip_thread_containment: user.skip_thread_containment,
background_image: image_url(user.background) |> MediaProxy.url(), background_image: image_url(user.background) |> MediaProxy.url(),
accepts_chat_messages: user.accepts_chat_messages, accepts_chat_messages: user.accepts_chat_messages,
favicon: favicon favicon: favicon,
birth_date: birth_date
} }
} }
|> maybe_put_role(user, opts[:for]) |> maybe_put_role(user, opts[:for])

View file

@ -51,6 +51,11 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do
when action == :endorsements when action == :endorsements
) )
plug(
OAuthScopesPlug,
%{scopes: ["read:accounts"]} when action == :birthdays
)
plug(RateLimiter, [name: :account_confirmation_resend] when action == :confirmation_resend) plug(RateLimiter, [name: :account_confirmation_resend] when action == :confirmation_resend)
plug( plug(
@ -137,4 +142,18 @@ def unsubscribe(%{assigns: %{user: user, account: subscription_target}} = conn,
{:error, message} -> json_response(conn, :forbidden, %{error: message}) {:error, message} -> json_response(conn, :forbidden, %{error: message})
end end
end end
@doc "GET /api/v1/pleroma/birthday_reminders"
def birthdays(%{assigns: %{user: %User{} = user}} = conn, %{day: day, month: month} = _params) do
birthdays =
User.Query.build(%{friends: user, deactivated: false, birth_day: day, birth_month: month})
|> Pleroma.Repo.all()
conn
|> render("index.json",
for: user,
users: birthdays,
as: :user
)
end
end end

View file

@ -448,6 +448,8 @@ defmodule Pleroma.Web.Router do
post("/accounts/:id/subscribe", AccountController, :subscribe) post("/accounts/:id/subscribe", AccountController, :subscribe)
post("/accounts/:id/unsubscribe", AccountController, :unsubscribe) post("/accounts/:id/unsubscribe", AccountController, :unsubscribe)
get("/birthday_reminders", AccountController, :birthdays)
end end
post("/accounts/confirmation_resend", AccountController, :confirmation_resend) post("/accounts/confirmation_resend", AccountController, :confirmation_resend)

View file

@ -20,6 +20,7 @@ def register_user(params, opts \\ []) do
|> Map.put(:name, Map.get(params, :fullname, params[:username])) |> Map.put(:name, Map.get(params, :fullname, params[:username]))
|> Map.put(:password_confirmation, params[:password]) |> Map.put(:password_confirmation, params[:password])
|> Map.put(:registration_reason, params[:reason]) |> Map.put(:registration_reason, params[:reason])
|> Map.put(:birth_date, params[:birth_date])
if Pleroma.Config.get([:instance, :registrations_open]) do if Pleroma.Config.get([:instance, :registrations_open]) do
create_user(params, opts) create_user(params, opts)

View file

@ -0,0 +1,10 @@
defmodule Pleroma.Repo.Migrations.AddBirthDateToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add_if_not_exists(:birth_date, :date)
add_if_not_exists(:hide_birth_date, :boolean, default: false, null: false)
end
end
end

View file

@ -35,7 +35,8 @@
"alsoKnownAs": { "alsoKnownAs": {
"@id": "as:alsoKnownAs", "@id": "as:alsoKnownAs",
"@type": "@id" "@type": "@id"
} },
"vcard": "http://www.w3.org/2006/vcard/ns#"
} }
] ]
} }