Skip to content
Merged
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
9 changes: 7 additions & 2 deletions assets/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -2976,8 +2976,13 @@ html.lightbox-open { overflow: hidden; }
font-size: 0.8125rem;
color: #94a3b8;
}
.lightbox__license { color: #94a3b8; text-decoration: underline; }
.lightbox__license:hover { color: #e2e8f0; }
/* The licence, and beside it the quiet way out for a rights holder who disputes
it (issue #2089): one weight for both, and never the download's — one act is
what the page is for and the other is a complaint about it. */
.lightbox__license,
.lightbox__report { color: #94a3b8; text-decoration: underline; }
.lightbox__license:hover,
.lightbox__report:hover { color: #e2e8f0; }
.lightbox__license.is-plain { text-decoration: none; pointer-events: none; }
.lightbox__download {
font-weight: 600;
Expand Down
10 changes: 10 additions & 0 deletions assets/js/lightbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function applyLabels(gallery) {
prev: gallery.dataset.labelPrev || "",
next: gallery.dataset.labelNext || "",
download: gallery.dataset.labelDownload || "",
report: gallery.dataset.labelReport || "",
}

q("[data-lb-close]").setAttribute("aria-label", labels.close)
Expand Down Expand Up @@ -65,6 +66,7 @@ function build() {
<span class="lightbox__position" data-lb-position></span>
<a class="lightbox__license" data-lb-license target="_blank" rel="license noopener"></a>
<a class="lightbox__download" data-lb-download download></a>
<a class="lightbox__report" data-lb-report></a>
</p>
</figcaption>
</figure>
Expand Down Expand Up @@ -160,6 +162,14 @@ function show(index) {
text(download, href ? labels.download : "")
if (href) download.href = href

// Reporting the picture itself (issue #2089), on the press surfaces only: a
// post photo is reported through its post, so its tiles carry no such
// address and the line stays hidden the way every empty one here does.
const report = q("[data-lb-report]")
const reportHref = photo.dataset.photoReport
text(report, reportHref ? labels.report : "")
if (reportHref) report.href = reportHref

// Only ever navigate within one gallery.
const many = photos.length > 1
q("[data-lb-prev]").hidden = !many
Expand Down
112 changes: 77 additions & 35 deletions lib/vutuv/images.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ defmodule Vutuv.Images do

alias Vutuv.Accounts.User
alias Vutuv.Images.Image
alias Vutuv.PressKit
alias Vutuv.PressKitStore
alias Vutuv.Repo
alias Vutuv.Uploads
Expand Down Expand Up @@ -638,41 +639,6 @@ defmodule Vutuv.Images do
end
end

@doc """
Where this picture's bytes are **right now**, wherever that is: the takedown
hold while a copyright case holds it (`Vutuv.Uploads.hold_dir/1`), otherwise
the tree its member row points at. `nil` when neither has them.
The one answer the two case pages need — a frozen picture is out of every
tree nginx serves, so an admin ruling on a copyright claim can see it only
through this.
"""
def bytes_path(%Image{} = image, version \\ nil) do
config = @profile_columns[image.kind]
version = version || config.preview

case Uploads.held_version_path(image.id, version, image.fingerprint) do
path when is_binary(path) ->
path

nil ->
with %User{} = owner <- owner(image),
do: stored_path(owner, image.kind, version)
end
end

@doc """
What a reader is shown for this picture — the same URL the profile renders,
so a picture already held by another case (or still in the AI gate) shows the
silhouette here too rather than a URL nothing answers. For the report form.
"""
def preview_url(%Image{} = image) do
config = @profile_columns[image.kind]

with %User{} = owner <- owner(image),
do: config.module.display_url(owner, config.preview)
end

@doc """
Records the picture a member just uploaded, replacing whatever row that
member had for this kind.
Expand Down Expand Up @@ -1031,6 +997,82 @@ defmodule Vutuv.Images do
"""
def takedown_ready?(%Image{kind: kind}), do: is_map_key(@takedown, kind)

@doc """
Where this picture's bytes are **right now**, wherever that is: the takedown
hold while a copyright case holds it (`Vutuv.Uploads.hold_dir/1`), otherwise
the tree its member row points at. `nil` when neither has them.
The one answer the two case pages need — a frozen picture is out of every
tree nginx serves, so an admin ruling on a copyright claim can see it only
through this.
**Per takedown strategy, not per column map.** Both this and `preview_url/1`
used to index `@profile_columns` by kind and dereference what came back, which
is a `BadMapError` the moment a kind outside that map is reportable — and
#2084 made `press_kit` exactly that, so the report form and the admin case
page's picture endpoint (the step an uphold needs) both 500ed on one. A kind
with no takedown at all answers `nil`: nothing can report it, so nobody can
ask, and a raise there would be a crash rather than an empty preview.
"""
# The version a human is shown a press picture at, on the report form and on
# the two case pages. Both shelves derive a `large`, so one name answers for a
# photo and for a logo variant.
@press_kit_preview "large"

def bytes_path(image, version \\ nil)

def bytes_path(%Image{kind: kind} = image, version) when is_map_key(@takedown, kind),
do: bytes_path_by(@takedown[kind], image, version)

def bytes_path(%Image{}, _version), do: nil

defp bytes_path_by(:profile, %Image{} = image, version) do
version = version || @profile_columns[image.kind].preview

case Uploads.held_version_path(image.id, version, image.fingerprint) do
path when is_binary(path) ->
path

nil ->
with %User{} = owner <- owner(image),
do: stored_path(owner, image.kind, version)
end
end

# A press picture's files are named by version alone inside a directory of the
# row's own token (`press_kit/<token>/large.avif`), so neither of the two
# naming schemes `Uploads.held_version_path/3` globs for is on disk — the
# store owns that name and answers for both trees.
defp bytes_path_by(:press_kit, %Image{} = image, version) do
version = version || @press_kit_preview

PressKitStore.held_version_path(image, version) ||
PressKitStore.version_path(image.token, version)
end

@doc """
What a reader is shown for this picture — the same URL the profile renders,
so a picture already held by another case (or still in the AI gate) shows the
silhouette here too rather than a URL nothing answers. For the report form.
"""
def preview_url(%Image{kind: kind} = image) when is_map_key(@takedown, kind),
do: preview_url_by(@takedown[kind], image)

def preview_url(%Image{}), do: nil

defp preview_url_by(:profile, %Image{} = image) do
config = @profile_columns[image.kind]

with %User{} = owner <- owner(image),
do: config.module.display_url(owner, config.preview)
end

# The context that owns the kind owns which version a human is shown and when
# there is one at all — `Vutuv.PressKit.preview_url/1` says why. Every other
# per-kind hook here points down at the module that owns the files in exactly
# this way.
defp preview_url_by(:press_kit, %Image{} = image), do: PressKit.preview_url(image)

# A kind whose row exists but whose takedown does not. Loud rather than
# half-done: the alternative is a stamped `frozen_at` no reader consults and
# files nothing moved, which reads from the case page exactly like a
Expand Down
75 changes: 66 additions & 9 deletions lib/vutuv/moderation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ defmodule Vutuv.Moderation do
Strike
}

alias Vutuv.Organizations
alias Vutuv.Organizations.Organization
alias Vutuv.Pages
alias Vutuv.Posts
alias Vutuv.Posts.Post
alias Vutuv.PressKit
alias Vutuv.Repo
alias Vutuv.SearchText
alias Vutuv.Token
Expand Down Expand Up @@ -162,6 +164,35 @@ defmodule Vutuv.Moderation do
def can_report?(%User{} = reporter, content),
do: can_report?(reporter, content, open_case_for(content))

@doc """
Whether there is anybody to hold accountable for `content` — the half of
`can_report?/2` that depends on the content alone, for a surface deciding
whether to **draw** a Report control at all (issue #2089).
A page's content answers to the member who claimed the page, and that column
is `nilify_all`: once that account is gone there is no strike ladder and
`report_content/3` refuses, so a link rendered anyway would send the reporter
to a 404. Cheap for a member's row (a column) and one indexed read for a
page's, which is why a caller drawing a whole shelf asks it **once** — every
picture on one has the same owner.
"""
def reportable?(content), do: owner_id(content) != nil

@doc """
Which report categories `content` offers — the one place the two forms and the
changeset read it from, so a form cannot show a choice the changeset then
refuses.
It takes the **row** rather than the wire string because one type answers two
ways: a profile picture leaves `spam` out on purpose (an advert as an avatar
is a complaint about the account), while a press picture is published for
redistribution and can perfectly well *be* the advert (issue #2089).
"""
# The whole list, because a press picture is published for redistribution and
# a section full of them is exactly where a picture is itself the advert.
def report_categories(%Image{kind: "press_kit"}), do: Report.categories()
def report_categories(content), do: Report.categories_for(content_type(content))

# The arity-3 twin exists so `report_content/3`, which has already loaded the
# open case to decide what to do with it, does not read the same row twice.
defp can_report?(%User{} = reporter, content, open) do
Expand All @@ -187,7 +218,7 @@ defmodule Vutuv.Moderation do

defp open_new_case(reporter, content, attrs) do
report_changeset =
Report.changeset(%Report{reporter_id: reporter.id}, attrs, content_type(content))
Report.changeset(%Report{reporter_id: reporter.id}, attrs, report_categories(content))

# Read off the changeset rather than out of `attrs`, so the answer does not
# depend on whether the caller passed string or atom keys. A crafted
Expand Down Expand Up @@ -274,7 +305,7 @@ defmodule Vutuv.Moderation do
Report.changeset(
%Report{reporter_id: reporter.id, case_id: open.id},
attrs,
content_type(content)
report_categories(content)
)

case Repo.insert(report_changeset) do
Expand Down Expand Up @@ -327,7 +358,7 @@ defmodule Vutuv.Moderation do
# request locale is per-process web state.
reporter_locale: locale
}
|> Report.outside_changeset(attrs, content_type(content))
|> Report.outside_changeset(attrs, report_categories(content))

case open_case_for(content) do
nil -> open_notice_case(content, changeset, token)
Expand Down Expand Up @@ -2271,9 +2302,8 @@ defmodule Vutuv.Moderation do
# since lost the role would otherwise still carry strikes for it. When that
# member is gone (`nilify_all`) there is nobody to strike and the report is
# refused, exactly as it already is for the organization page itself.
defp owner_id(%Post{user_id: nil, organization_id: id}) when is_binary(id) do
Repo.one(from(o in Organization, where: o.id == ^id, select: o.created_by_user_id))
end
defp owner_id(%Post{user_id: nil, organization_id: id}) when is_binary(id),
do: Organizations.accountable_user_id(id)

defp owner_id(%Post{user_id: user_id}), do: user_id
defp owner_id(%Message{sender_id: sender_id}), do: sender_id
Expand All @@ -2282,11 +2312,28 @@ defmodule Vutuv.Moderation do
# creator has since deleted their account (nilify_all) has no owner to strike,
# so report_content/3 refuses it (owner_id == nil), leaving the report path
# only for admin freeze.
defp owner_id(%Organization{created_by_user_id: user_id}), do: user_id
defp owner_id(%Organization{} = organization),
do: Organizations.accountable_user_id(organization)

defp owner_id(%JobPosting{user_id: user_id}), do: user_id
# A press picture published in a **page's** name (issue #2089). Its
# `images.user_id` is NULL — the pair `images_press_kit_has_one_owner` holds —
# so reading that column alone left the kind takedown-ready and
# un-reportable: `can_report?/2` refuses a nil owner, and #2084 measured
# exactly that. The answer is the organization-post clause above, the same
# member for the same reason: the page's accountability must not move from
# person to person with each upload, and `uploader_user_id` cannot be it —
# it is nulled when that account goes.
#
# Matched on the **column**, never on a preloaded `:organization`: half the
# callers hand a bare row over straight from a query.
defp owner_id(%Image{user_id: nil, organization_id: id}) when is_binary(id),
do: Organizations.accountable_user_id(id)

# A picture with no member owner is one of the kinds #2015 brings into the
# table (a post photo, an organization logo). There is nobody to strike and
# no member row to clear, so `can_report?/2` refuses it rather than guessing.
# table (a post photo, an organization logo), or a review's cover. There is
# nobody to strike and no member row to clear, so `can_report?/2` refuses it
# rather than guessing.
defp owner_id(%Image{user_id: user_id}), do: user_id

defp snapshot(%Post{body: body}), do: body
Expand Down Expand Up @@ -2337,6 +2384,16 @@ defmodule Vutuv.Moderation do
# case opened on such a row would raise the moment an admin upheld it. Those
# keep the affordance they had before the row existed: reporting the post,
# the posting or the page the picture sits on.
# A press picture (issue #2089) adds the visibility half back, which the two
# profile kinds never needed: an avatar is as public as the profile it sits
# on, while a press picture can be waiting for the AI gate, held by an earlier
# case, or on a page that is not on the public site — and none of those is
# something a reporter has seen. Reporting one is then either pointless (it is
# already off the site) or an oracle for a row id, so it answers 404 exactly
# as the public notice form does for the same picture.
defp reportable_by?(reporter, %Image{kind: "press_kit"} = image),
do: Images.takedown_ready?(image) and PressKit.visible_to?(image, reporter)

defp reportable_by?(_reporter, %Image{} = image), do: Images.takedown_ready?(image)

defp reportable_by?(reporter, %JobPosting{} = posting),
Expand Down
32 changes: 31 additions & 1 deletion lib/vutuv/moderation/content_url.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ defmodule Vutuv.Moderation.ContentUrl do
alias Vutuv.Accounts.User
alias Vutuv.Fediverse
alias Vutuv.Images
alias Vutuv.Images.Image
alias Vutuv.Jobs
alias Vutuv.Moderation
alias Vutuv.Organizations
alias Vutuv.Posts
alias Vutuv.PressKit
alias Vutuv.Repo
alias Vutuv.UUIDv7
alias Vutuv.Videos
Expand Down Expand Up @@ -125,6 +127,15 @@ defmodule Vutuv.Moderation.ContentUrl do
defp from_segments(["post_videos", token, _file]),
do: post_of(Videos.get_video_by_token(token))

# A press photo or a logo variant (issue #2089), by any of the four addresses
# it has: the served versions, the stand-in, the download and a logo's PNG all
# hang off one path shape, and what a journalist copies is whichever of them
# their browser gave them. Unlike a post's photo the **picture** is the
# reportable thing here — it is published on its own, for redistribution, and
# the freeze takes exactly it offline.
defp from_segments(["system", "press_kit", token | _rest]),
do: visible_press_picture(token)

# A profile picture or cover, by two of the three addresses one has. The
# served files sit in an id-scoped public tree (`/avatars/<user id>/…`),
# which is what a "copy image address" hands over; the third spelling,
Expand All @@ -133,7 +144,14 @@ defmodule Vutuv.Moderation.ContentUrl do
defp from_segments(["avatars", user_id | _rest]), do: visible_image(user_id, "avatar")
defp from_segments(["covers", user_id | _rest]), do: visible_image(user_id, "cover")

defp from_segments(["organizations", slug]),
# A page, by its own address or by any deeper path under it — its press
# section, its jobs, its followers all still name the page, which is the
# reportable thing there. The twin of the `[handle | _rest]` clause below, and
# what makes a pasted `/organizations/acme/press` a notice about the page
# rather than the "correct address, nobody's content" a site page gets
# (issue #2089). The post permalink above is the exception, and it is above
# for that reason.
defp from_segments(["organizations", slug | _rest]),
do: ok_or_nil(Organizations.fetch_visible_organization(slug, nil))

defp from_segments(["jobs", slug]), do: ok_or_nil(Jobs.fetch_visible_job_posting(slug, nil))
Expand Down Expand Up @@ -209,6 +227,18 @@ defmodule Vutuv.Moderation.ContentUrl do
# reachable through the address the old file had. A picture another case
# already holds is not offered either — it is off the site, and reporting it
# again would say the notice did something it did not.
# Only a picture an anonymous visitor can already fetch, which is the whole
# module's rule and here also the freeze's: a picture a case already took down
# answers 404 at every one of its addresses, so resolving it would tell a
# stranger it exists. `visible_to?/2` with no viewer is that question,
# released picture and visible owner included.
defp visible_press_picture(token) do
case PressKit.get_by_token(token) do
%Image{} = image -> if PressKit.visible_to?(image, nil), do: image
nil -> nil
end
end

defp visible_image(nil, _kind), do: nil

defp visible_image(%User{} = owner, kind) do
Expand Down
Loading