Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions lib/philomena/attribution/actor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,8 @@ defmodule Philomena.Attribution.Actor do
@enforce_keys [:ip]
defstruct user: nil, ip: nil, fingerprint: nil

# `%User{}` is used rather than `User.t()` to match the existing
# `t:Philomena.Users.principal/0` type: the `User` schema does not define a
# `t/0` type, so referencing it here would fail `--warnings-as-errors`.
@type t :: %__MODULE__{
user: %User{} | nil,
user: User.t() | nil,
ip: EctoNetwork.INET.t(),
fingerprint: String.t() | nil
}
Expand Down
12 changes: 11 additions & 1 deletion lib/philomena/authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ defmodule Philomena.Authorization do
special-casing is required here.
"""

alias Philomena.Attribution.Actor

@doc """
Authorizes `actor` to perform `action` on `subject`.

Returns `:ok` when Canada permits the action, otherwise
`{:error, :unauthorized}`.

`actor` may be `nil` for an anonymous visitor.
`actor` may be `nil` for an anonymous visitor. It may also be a
`Philomena.Attribution.Actor`: permissions are decided by its `user` alone -
the IP and fingerprint attribute the action but grant nothing - so contexts
that take an attribution can pass it here unchanged.
Comment on lines +27 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`actor` may be `nil` for an anonymous visitor. It may also be a
`Philomena.Attribution.Actor`: permissions are decided by its `user` alone -
the IP and fingerprint attribute the action but grant nothing - so contexts
that take an attribution can pass it here unchanged.
`actor` may be `nil` for an anonymous visitor. It may also be a
`Philomena.Attribution.Actor`, where permissions are decided by its `user` alone.


## Examples

Expand All @@ -32,9 +37,14 @@ defmodule Philomena.Authorization do
iex> authorize(nil, :hide, image)
{:error, :unauthorized}

iex> authorize(%Actor{user: moderator, ip: ip}, :revert, TagChange)
:ok

"""
@spec authorize(actor :: any(), action :: atom(), subject :: any()) ::
:ok | {:error, :unauthorized}
def authorize(%Actor{user: user}, action, subject), do: authorize(user, action, subject)

def authorize(actor, action, subject) do
if Canada.Can.can?(actor, action, subject), do: :ok, else: {:error, :unauthorized}
end
Expand Down
48 changes: 48 additions & 0 deletions lib/philomena/integer_id.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
defmodule Philomena.IntegerId do
@moduledoc """
Parsing of integer ids taken straight from request paths and query strings.

Interpolating an unparsed path segment into `where(id: ^id)` raises rather
than returning no rows: `Ecto.Query.CastError` for a non-integer, and
`DBConnection.EncodeError` for a value too large for the `integer` column.
Callers use `parse/1` to turn both into an ordinary "no such row".
"""

# Bounds of the Postgres `integer` (int4) columns these ids are stored in.
@int_min -2_147_483_648
@int_max 2_147_483_647

@doc """
Parses an id that an `integer` column could hold.

Accepts an integer, or a string that is entirely an integer literal. Returns
`:error` for anything else, including values outside the column's range.

## Examples

iex> Philomena.IntegerId.parse("42")
{:ok, 42}

iex> Philomena.IntegerId.parse("not-a-number")
:error

iex> Philomena.IntegerId.parse("99999999999999999999")
:error

"""
@spec parse(any()) :: {:ok, integer()} | :error
def parse(id) when is_integer(id) do
if in_range?(id), do: {:ok, id}, else: :error
end

def parse(id) when is_binary(id) do
case Integer.parse(id) do
{int, ""} -> parse(int)
_ -> :error
end
end

def parse(_id), do: :error

defp in_range?(id), do: id >= @int_min and id <= @int_max
end
167 changes: 155 additions & 12 deletions lib/philomena/tag_changes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ defmodule Philomena.TagChanges do
"""

import Ecto.Query, warn: false
import Philomena.Authorization, only: [authorize: 3]

alias Philomena.Repo
alias PhilomenaQuery.Parse.IpParser
alias PhilomenaQuery.Search
alias Philomena.Attribution.Actor
alias Philomena.IntegerId
alias Philomena.ModerationLogs
alias Philomena.ModerationLogs.Paths
alias Philomena.TagChangeRevertWorker
alias Philomena.TagChanges
alias Philomena.TagChanges.TagChange
Expand All @@ -18,7 +24,44 @@ defmodule Philomena.TagChanges do
alias Philomena.Tags.Tag
alias Philomena.Users.User

# Accepts a list of TagChanges.TagChange IDs.
@doc """
Reverts the tag changes named by `ids` on behalf of `actor`.

Changes on images hidden from users are silently skipped, and an empty or
fully-skipped list is a successful reversion of zero changes.

Returns `{:ok, reverted_tag_changes}`, `{:error, :unauthorized}`, or
`{:error, :invalid_ids}` when `ids` is not a list. Failures inside the
batch update surface as their own `{:error, _}` shapes.

@liamwhite liamwhite Jul 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"as their own {:error, _} shapes" - unhelpful. Are the error shapes known?

"""
@spec revert_tag_changes(Actor.t(), any()) ::
{:ok, [TagChange.t()]} | {:error, any()}
def revert_tag_changes(%Actor{} = actor, ids) do
with :ok <- authorize(actor, :revert, TagChange),
{:ok, tag_changes} <- mass_revert_for(actor, ids) do
ModerationLogs.create_moderation_log(
actor.user,
"TagChange.Revert:create",
Paths.profile_path(actor.user),
"Reverted #{length(tag_changes)} tag changes"
)

{:ok, tag_changes}
end
end

defp mass_revert_for(actor, ids) when is_list(ids) do
mass_revert(ids, %{
ip: actor.ip,
fingerprint: actor.fingerprint,
user_id: actor.user.id
})
end

defp mass_revert_for(_actor, _ids), do: {:error, :invalid_ids}

# Accepts a list of TagChanges.TagChange IDs. This performs the actual reversion,
# and performs no authorization or logging.
def mass_revert(ids, attributes) do
tag_changes =
Repo.all(
Expand Down Expand Up @@ -65,14 +108,69 @@ defmodule Philomena.TagChanges do
Images.batch_update(changes_per_image, attributes)
end

def full_revert(%{user_id: _user_id, attributes: _attributes} = params),
do: Exq.enqueue(Exq, "indexing", TagChangeRevertWorker, [params])
@doc """
Enqueues a background reversion of every tag change made by one identity,
on behalf of `actor`.

Returns `{:ok, target}`, `{:error, :unauthorized}`, or
`{:error, :invalid_target}` when `params` names no target.
"""
@spec full_revert(Actor.t(), map()) ::
{:ok, map()} | {:error, :unauthorized | :invalid_target}
def full_revert(%Actor{} = actor, params) do
with :ok <- authorize(actor, :revert, TagChange),
{:ok, target} <- full_revert_target(params) do
attributes = %{
ip: to_string(actor.ip),
fingerprint: actor.fingerprint,
user_id: actor.user.id,
batch_size: 100
}

Exq.enqueue(Exq, "indexing", TagChangeRevertWorker, [
Map.put(target, :attributes, attributes)
])

def full_revert(%{ip: _ip, attributes: _attributes} = params),
do: Exq.enqueue(Exq, "indexing", TagChangeRevertWorker, [params])
log_full_revert(actor.user, target)

def full_revert(%{fingerprint: _fingerprint, attributes: _attributes} = params),
do: Exq.enqueue(Exq, "indexing", TagChangeRevertWorker, [params])
{:ok, target}
end
end

defp full_revert_target(%{"user_id" => user_id}), do: {:ok, %{user_id: user_id}}
defp full_revert_target(%{"ip" => ip}), do: {:ok, %{ip: ip}}
defp full_revert_target(%{"fingerprint" => fingerprint}), do: {:ok, %{fingerprint: fingerprint}}
defp full_revert_target(_params), do: {:error, :invalid_target}

defp log_full_revert(user, target) do
{subject, subject_path} =
case target do
%{user_id: user_id} ->
full_revert_log_user(user_id)

%{ip: ip} ->
{"ip #{ip}", Paths.ip_profile_path(ip)}

%{fingerprint: fingerprint} ->
{"fingerprint #{fingerprint}", Paths.fingerprint_profile_path(fingerprint)}
end

ModerationLogs.create_moderation_log(
user,
"TagChange.FullRevert:create",
subject_path,
"Reverted all tag changes for #{subject}"
)
end

defp full_revert_log_user(user_id) do
with {:ok, id} <- IntegerId.parse(user_id),
%User{} = user <- Repo.get(User, id) do
{"user #{user.name}", Paths.profile_path(user)}
else
_ -> {"user #{user_id}", "/tag_changes"}
end
end

@doc """
Updates tag change search indices when a user's name changes.
Expand Down Expand Up @@ -228,28 +326,73 @@ defmodule Philomena.TagChanges do
end

@doc """
Deletes a TagChange.
Deletes the tag change named by the raw request `id` from the history, on
behalf of `actor` (a user, or `nil` for an anonymous visitor).

An id that cannot name a row is `{:error, :not_found}`, while a well-formed id
that names no row authorizes `nil` - which no rule permits - and is
therefore `{:error, :unauthorized}`.
Comment on lines +332 to +334

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
An id that cannot name a row is `{:error, :not_found}`, while a well-formed id
that names no row authorizes `nil` - which no rule permits - and is
therefore `{:error, :unauthorized}`.
An id that cannot name a row is `{:error, :not_found}`.
A well-formed id that names no row is `{:error, :unauthorized}`.


## Examples

iex> delete_tag_change(tag_change)
iex> delete_tag_change(moderator, "1")
{:ok, %TagChange{}}

iex> delete_tag_change(tag_change)
{:error, %Ecto.Changeset{}}
iex> delete_tag_change(user, "1")
{:error, :unauthorized}

iex> delete_tag_change(moderator, "not-an-integer")
{:error, :not_found}

"""
def delete_tag_change(%TagChange{} = tag_change) do
@spec delete_tag_change(User.t() | nil, any()) ::
{:ok, TagChange.t()}
| {:error, :unauthorized | :not_found}
| {:error, Ecto.Changeset.t()}
def delete_tag_change(actor, id) do
case IntegerId.parse(id) do
{:ok, id} ->
tag_change =
TagChange
|> preload([:user, :image, tags: [:tag]])
|> Repo.get(id)

with :ok <- authorize(actor, :delete, tag_change) do
delete_loaded_tag_change(actor, tag_change)
end

:error ->
{:error, :not_found}
end
end

defp delete_loaded_tag_change(actor, %TagChange{} = tag_change) do
case Repo.delete(tag_change) do
{:ok, %TagChange{} = tc} = result ->
Search.delete_document(tc.id, TagChange)
log_tag_change_deletion(actor, tc)
result

result ->
result
end
end

defp log_tag_change_deletion(actor, %TagChange{user: user, image: image, tags: tags, ip: ip}) do
name =
case user do
%{name: name} -> name
_ -> to_string(ip)
end

ModerationLogs.create_moderation_log(
actor,
"TagChange:delete",
Paths.image_path(image),
"Deleted tag change by #{name} containing #{length(tags)} tags on image #{image.id} from history"
)
end

@doc """
Deletes tag changes that have no associated tags.
## Examples
Expand Down
2 changes: 2 additions & 0 deletions lib/philomena/tag_changes/tag_change.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule Philomena.TagChanges.TagChange do
use Ecto.Schema

@type t :: %__MODULE__{}

schema "tag_changes" do
belongs_to :user, Philomena.Users.User
belongs_to :image, Philomena.Images.Image
Expand Down
2 changes: 2 additions & 0 deletions lib/philomena/users/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ defmodule Philomena.Users.User do
alias Philomena.Donations.Donation
alias Philomena.UserNameChanges.UserNameChange

@type t :: %__MODULE__{}

@derive {Phoenix.Param, key: :slug}
@derive {Inspect, except: [:password]}
schema "users" do
Expand Down
Loading
Loading