Skip to content

Commit ff7bc28

Browse files
authored
Eine Bildmeldung fragt, ob das Herunternehmen die Bildart kennt (#2058)
Das Tor vor der Meldung leitete sich aus `@mirrored` ab, also aus der Liste, die eine Contract-Freigabe löscht. Sobald eine Bildart ihren Umzug in die gemeinsame Tabelle beendet, hätte es „ja" gesagt, während nichts diese Art herunternehmen kann: die Meldung wird angenommen, und die Bestätigung wirft der Administratorin eine Fehlerseite hin. Tor, Einfrieren, Auftauen und Löschen lesen jetzt dieselbe Karte `@takedown`, und ein Test friert jede Art wirklich ein, die das Tor zulässt. Die drei Freigaben, die der Umzug einer Art braucht, stehen jetzt einmal und eindeutig in `docs/architecture/images.md`. Diesen Text hat ein KI-Agent in meinem Namen geschrieben. Ich weiß, dass das problematisch ist. Claude-Session: https://claude.ai/code/session_01UGPe7bHVS11M1W7fNehSku
1 parent 920b312 commit ff7bc28

4 files changed

Lines changed: 164 additions & 45 deletions

File tree

docs/architecture/images.md

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,9 @@ that already knows.
345345
`bin/vutuv eval "Vutuv.Release.check_image_rows()"`) is the gate before the
346346
cut: it counts every picture against its row *and* against its file on
347347
disk — the quarantine tree while the picture is `"pending"`, the served tree
348-
otherwise — prints one line per kind plus the members behind each class of
349-
mismatch, and **fails the command** when anything is outstanding. Both of
348+
otherwise — prints one line per kind plus a bounded sample of the ids behind
349+
each class of mismatch (`Backfill.check/1`'s own doc names what each class is
350+
sampled by), and **fails the command** when anything is outstanding. Both of
350351
those come from `check/1` itself rather than from either entry point, because
351352
the first version put the printing in the mix task alone: it fell out of step
352353
with the shape `check/1` returns and crashed on every invocation, while the
@@ -421,9 +422,9 @@ default is one nobody knows how to take offline.
421422

422423
A post photo, an organization image, a job-posting picture and a review cover
423424
each kept a table and an uploader of their own, so the `image` report type and
424-
the freeze knew one kind only. They move in one release per kind, smallest
425-
first — a **job-posting picture** went first (#2054) precisely to settle the
426-
shape.
425+
the freeze knew one kind only. They move one kind at a time and three releases
426+
per kind (the sequence is spelled out below), smallest first — a **job-posting
427+
picture** went first (#2054) precisely to settle the shape.
427428

428429
**Three of the four are the same shape; the review cover is not.** A post
429430
photo, an organization image and a job-posting picture each have a row of their
@@ -432,7 +433,9 @@ is `cover` / `cover_status` / `cover_moderation` **columns on the review row**
432433
with no token and no table of its own (`Vutuv.Posts.PostReview`), which is the
433434
profile picture's shape, not this one — #2055 lands on `member_columns/0`'s side
434435
of the fence, and `Vutuv.Images.Backfill`'s `%{cols: …}` source is what it
435-
extends. What #2052 and #2053 copy from here is everything below.
436+
extends. One thing it does share with the three: a report cannot name one of
437+
its pictures until the kind has a strategy in `Vutuv.Images`'s `@takedown`.
438+
What #2052 and #2053 copy from here is everything below.
436439

437440
**The token is the join key, not a pointer.** #2013 added
438441
`users.avatar_image_id` because a member row had no stable handle of its own;
@@ -500,17 +503,36 @@ it, and `Vutuv.Moderation` refuses a report that names one
500503
(`Vutuv.Images.takedown_ready?/1`), because a case opened on a row nothing
501504
consults would go through an uphold that takes nothing offline. A member
502505
reports the posting instead, which is all there was before the row existed.
503-
504-
**Retiring `job_posting_images` is not this release.** It is the deploy after,
505-
and it has to remove the double write in the same step — the mirror, the
506-
`forget/2` calls, the `@gallery_sources` entry in the backfill and
507-
`Vutuv.Images.mirrored?/1`'s answer — because a migration may drop only what
508-
the currently deployed release has stopped using. Before it: run
509-
`mix vutuv.images.backfill --only job_posting_image` and read the check. The
510-
release in between is the one that moves the proxy, the form and the freeze
511-
onto the row, which is what makes the old table unread in the first place; and
512-
that release, not this one, is where the kind decides whether it wants a
513-
**bridge** the way `member_image/2` has one.
506+
That gate reads `@takedown`, the map naming which kinds have a takedown at all,
507+
so it turns yes in step 2 below and not a moment earlier (issue #2057).
508+
509+
**A gallery kind moves in three releases, and #2054 was the first.** Each is
510+
N-1 safe on its own and no two can be merged; this milestone has already paid
511+
for an off-by-one in that count once, in #2027.
512+
513+
1. **Expand**: what #2054 shipped for `job_posting_image`. Every path that
514+
touches the picture writes and drops the `images` row beside the old one,
515+
while every reader, and the truth, stay in `job_posting_images`. Nothing
516+
here can take such a picture offline yet: `freeze/1` raises for the row and
517+
a report cannot name it. Between this release and the next, an operator runs
518+
`mix vutuv.images.backfill --only <kind>` and reads its check; after step 2
519+
the two copies can no longer be compared, so this is the last chance.
520+
2. **Move the readers, the writes and the takedown onto the row.** The proxy,
521+
the edit form and the AI gate read the `images` row, and the context writes
522+
that row directly, so the mirror goes out in the same change: the
523+
`write_mirrored/2`, `mirror/2` and `forget/2` calls and the kind's entry in
524+
`@mirrored` (`Vutuv.Images.mirror_source/1`, `mirrored?/1`). *The backfill
525+
needs nothing removed*, at this step or any other: `Backfill.kinds/0` is
526+
`Vutuv.Images.mirrored_kinds/0` plus the profile kinds, and every gallery
527+
source builds itself from `mirror_source/1`, so it holds no per-kind list of
528+
its own. The kind also gets its takedown here: a strategy in `@takedown` and
529+
the `freeze`/`unfreeze`/`purge` clauses that go with it, which is what opens
530+
the report form on these pictures. No migration in this release: the old
531+
table is still standing and the release one step back is still reading and
532+
writing it.
533+
3. **Contract**: the migration that drops `job_posting_images`, and nothing
534+
else. Step 2 is what stopped using the table, and step 2 is what serves
535+
while this migration runs.
514536

515537
**This kind needs no bridge.** #2027 needed one because it moved every reader
516538
onto the row in the same deploy as the row's first appearance, so a picture the

docs/architecture/moderation.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,16 @@ report type for the picture itself: `fetch_content/2` resolves it through
143143
cover, and the ⋯ menu on a profile offers "Report the profile picture" beside
144144
the profile's own Report whenever that picture has a row.
145145

146+
**Only a picture the freeze can act on may be reported** (issue #2057). A row
147+
in `images` is no longer proof of that: #2015 is moving four more picture kinds
148+
into the table one at a time, and each arrives with a row a release before
149+
anything can take it offline. So `reportable_by?/2` asks
150+
`Vutuv.Images.takedown_ready?/1`, which reads the same `@takedown` map the
151+
freeze itself dispatches on; the three releases of a kind's move are in
152+
[images.md](images.md). A kind it refuses keeps the affordance it had before
153+
its row existed: the member reports the post, the posting or the page the
154+
picture sits on.
155+
146156
**Its freeze is a file move, not a column write.** `Vutuv.Images.freeze/1`
147157
stamps `frozen_at`, clears the member row's four columns for that kind, and
148158
moves every derived version, the private original and anything still in AI

lib/vutuv/images.ex

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ defmodule Vutuv.Images do
2525
a mirror, written by `mirror/2` and dropped by `forget/2`, joined on the
2626
`token` both carry rather than by a pointer. Nothing here reads it yet, which
2727
is why `freeze/1` raises for one and `takedown_ready?/1` answers false: a case
28-
opened on a row nobody consults would take nothing offline. Moving the readers
29-
across, and only then retiring the old table, is the deploy after.
28+
opened on a row nobody consults would take nothing offline. **Such a kind
29+
moves in three releases**: the mirror, then the one that moves the readers and
30+
wires the takedown, then the migration that retires the old table.
31+
`docs/architecture/images.md` spells the three out.
3032
3133
`serving/1` is the second thing here that is not a column: how a kind reaches
3234
a reader decides what its off switch is, and a kind nobody has declared
@@ -66,7 +68,9 @@ defmodule Vutuv.Images do
6668
# compares the two and fails the build on it.
6769
#
6870
# An entry goes when that kind's contract release retires its old table,
69-
# together with the double write; `takedown_ready?/1` below flips with it.
71+
# together with the double write. It does **not** take the report gate with
72+
# it: that reads `@takedown` below, which the release before that one, the one
73+
# that moves the readers and wires the takedown, is what extends.
7074
# #2052 (post photos) and #2053 (organization images) add one entry each;
7175
# #2055 (review covers) does **not** — a review's cover is columns on the
7276
# review row with no token and no table, which is the `@profile_columns`
@@ -649,33 +653,44 @@ defmodule Vutuv.Images do
649653
:ok
650654
end
651655

656+
# Which takedown a kind gets, and (by the presence of a key) whether it has
657+
# one at all. `takedown_ready?/1` and the three functions below all guard on
658+
# `is_map_key(@takedown, kind)`, so the gate that lets a report name a picture
659+
# and the code that takes it offline read one map and cannot answer
660+
# differently. A kind arrives here in the same change as its strategy's
661+
# clauses (issue #2057).
662+
#
663+
# The gate used to be derived from `@mirrored` instead, and the two agreed by
664+
# arithmetic rather than by meaning: that entry is what a **contract** release
665+
# deletes, so the gate would have opened on the deploy that retires a kind's
666+
# old table whether or not anything had wired that kind's freeze, and the
667+
# first report accepted on it would have raised in front of the admin who
668+
# upheld it.
669+
@takedown Map.new(@profile_kinds, &{&1, :profile})
670+
652671
@doc """
653672
Whether a copyright case can act on this picture at all — what
654673
`Vutuv.Moderation` asks before letting a report name it.
655674
656-
A gallery kind that has only just arrived here (#2054 and its siblings ship
657-
the **expand** half: the row exists, every URL and every gate still reads the
658-
old table) has no takedown path yet, and `freeze/1` below has no clause for
659-
it. Answering "yes" would open a case whose uphold raises, so a report is
660-
refused until the kind's own contract release wires the freeze — which is all
661-
there was before the row existed.
662-
663-
Derived from `mirrored?/1` rather than listed a second time, so the two
664-
cannot drift: "this picture's truth is still elsewhere" and "nothing here can
665-
take it offline" are one fact, and the contract release that deletes the
666-
mirror entry flips both at once.
675+
True for exactly the kinds `freeze/1` below can act on (`@takedown`), because
676+
that is the fact the gate needs: a report accepted on a kind nothing can take
677+
offline opens a case whose uphold raises. A gallery kind that has only just
678+
arrived here (#2054 and its siblings ship the **expand** half: the row exists,
679+
every URL and every gate still reads the old table) is therefore refused until
680+
the release that wires its takedown, which leaves it the affordance it had
681+
before the row existed: reporting the posting or the page the picture sits on.
667682
"""
668-
def takedown_ready?(%Image{kind: kind}), do: kind in @kinds and not mirrored?(kind)
683+
def takedown_ready?(%Image{kind: kind}), do: is_map_key(@takedown, kind)
669684

670-
# A kind whose row exists but whose freeze does not. Loud rather than
685+
# A kind whose row exists but whose takedown does not. Loud rather than
671686
# half-done: the alternative is a stamped `frozen_at` no reader consults and
672687
# files nothing moved, which reads from the case page exactly like a
673688
# completed takedown.
674689
defp no_takedown_path!(%Image{kind: kind}, action) do
675690
raise ArgumentError, """
676-
cannot #{action} an image of kind #{inspect(kind)}: this release mirrors it \
677-
into the images table but no reader consults the row yet, so nothing would \
678-
go offline. Wire the kind's freeze before letting a case reach it.\
691+
cannot #{action} an image of kind #{inspect(kind)}: nothing here can take a \
692+
picture of that kind offline, so nothing would go offline. Give the kind a \
693+
strategy in Vutuv.Images' @takedown before letting a case reach it.\
679694
"""
680695
end
681696

@@ -703,7 +718,14 @@ defmodule Vutuv.Images do
703718
already invisible and a job `reconcile_holds/0` finishes. The other order
704719
would leave files in a hold that nothing knows to bring back.
705720
"""
706-
def freeze(%Image{kind: kind} = image) when kind in @profile_kinds do
721+
def freeze(%Image{kind: kind} = image) when is_map_key(@takedown, kind),
722+
do: freeze_by(@takedown[kind], image)
723+
724+
def freeze(%Image{} = image), do: no_takedown_path!(image, "freeze")
725+
726+
# The profile strategy: the files are served straight off disk (`serving/1`
727+
# answers `:static`), so taking the picture offline means moving them.
728+
defp freeze_by(:profile, %Image{} = image) do
707729
# `is_nil(frozen_at)` so a second pass — `reconcile_holds/0` finishing an
708730
# interrupted move — re-asserts the freeze without moving the moment it
709731
# happened, which is what the case and the statement of reasons quote.
@@ -720,8 +742,6 @@ defmodule Vutuv.Images do
720742
:ok
721743
end
722744

723-
def freeze(%Image{} = image), do: no_takedown_path!(image, "freeze")
724-
725745
@doc """
726746
Puts a frozen picture back exactly where it was: every file returns to the
727747
tree it came from under the name it had, the member row gets its four columns
@@ -738,7 +758,12 @@ defmodule Vutuv.Images do
738758
only once the member row names the files again, so a half-finished restore is
739759
still a hold for `reconcile_holds/0` to find.
740760
"""
741-
def unfreeze(%Image{kind: kind} = image) when kind in @profile_kinds do
761+
def unfreeze(%Image{kind: kind} = image) when is_map_key(@takedown, kind),
762+
do: unfreeze_by(@takedown[kind], image)
763+
764+
def unfreeze(%Image{} = image), do: no_takedown_path!(image, "unfreeze")
765+
766+
defp unfreeze_by(:profile, %Image{} = image) do
742767
{_count, _} =
743768
Repo.update_all(from(i in Image, where: i.id == ^image.id),
744769
set: [frozen_at: nil, updated_at: now()]
@@ -756,8 +781,6 @@ defmodule Vutuv.Images do
756781
:ok
757782
end
758783

759-
def unfreeze(%Image{} = image), do: no_takedown_path!(image, "unfreeze")
760-
761784
@doc """
762785
Deletes this picture for good — every derived version, the private original
763786
and the held copies — and forgets the row. What an upheld copyright case does,
@@ -767,7 +790,12 @@ defmodule Vutuv.Images do
767790
nothing points at (which `reconcile_holds/0` collects), never a member row
768791
naming files that are gone.
769792
"""
770-
def purge(%Image{kind: kind} = image) when kind in @profile_kinds do
793+
def purge(%Image{kind: kind} = image) when is_map_key(@takedown, kind),
794+
do: purge_by(@takedown[kind], image)
795+
796+
def purge(%Image{} = image), do: no_takedown_path!(image, "purge")
797+
798+
defp purge_by(:profile, %Image{} = image) do
771799
case owner(image) do
772800
%User{} = user ->
773801
config = @profile_columns[image.kind]
@@ -785,8 +813,6 @@ defmodule Vutuv.Images do
785813
:ok
786814
end
787815

788-
def purge(%Image{} = image), do: no_takedown_path!(image, "purge")
789-
790816
@doc """
791817
Finishes every move a dying slot left half-done, in both directions — the
792818
standing job behind `freeze/1` and `unfreeze/1`, run by

test/vutuv/moderation_image_takedown_test.exs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ defmodule Vutuv.ModerationImageTakedownTest do
5656

5757
defp reload(user), do: Repo.get!(User, user.id)
5858

59+
defp reload_row(%ImageRow{id: id}), do: Repo.get(ImageRow, id)
60+
5961
defp notice(attrs \\ %{}) do
6062
Map.merge(
6163
%{
@@ -351,6 +353,65 @@ defmodule Vutuv.ModerationImageTakedownTest do
351353
end
352354
end
353355

356+
# `Images.takedown_ready?/1` decides whether a report may name a picture, and
357+
# the freeze is what accepting one eventually runs — so a kind the gate admits
358+
# and the freeze cannot act on is an error page in front of the admin who
359+
# upholds the case. Both tests split `Images.kinds()` by the gate rather than
360+
# naming today's three, so a kind that joins the table has to answer for
361+
# itself (issue #2057).
362+
describe "the gate and the takedown answer one question (issue #2057)" do
363+
setup %{owner: owner} do
364+
{:ok, owner} = Accounts.update_user(owner, %{cover_photo: jpeg_upload("wide.jpg")})
365+
366+
{ready, refused} =
367+
Enum.split_with(Images.kinds(), &Images.takedown_ready?(%ImageRow{kind: &1}))
368+
369+
{:ok, owner: owner, ready: ready, refused: refused}
370+
end
371+
372+
test "every kind it admits really freezes, unfreezes and purges",
373+
%{owner: owner, ready: ready} do
374+
# Real pictures, because "the takedown can act on it" is a claim about
375+
# files moving, not about a clause existing.
376+
real = Map.new(~w(avatar cover), &{&1, Images.profile_image(owner.id, &1)})
377+
378+
for kind <- ready do
379+
row = Map.get(real, kind)
380+
381+
assert row,
382+
"#{kind} is takedown-ready but nothing here takes one offline. Either " <>
383+
"it has no strategy in Vutuv.Images' @takedown, and then the gate " <>
384+
"must not admit it, or it has one and this test needs a real " <>
385+
"picture of that kind."
386+
387+
assert :ok = Images.freeze(row)
388+
assert reload_row(row).frozen_at
389+
390+
assert :ok = Images.unfreeze(reload_row(row))
391+
refute reload_row(row).frozen_at
392+
393+
assert :ok = Images.purge(reload_row(row))
394+
refute reload_row(row)
395+
end
396+
end
397+
398+
test "every kind it refuses is refused by all three, loudly",
399+
%{owner: owner, refused: refused} do
400+
for kind <- refused do
401+
# No row is needed: the guard refuses before anything is read.
402+
row = %ImageRow{id: Vutuv.UUIDv7.generate(), kind: kind, user_id: owner.id}
403+
404+
for {action, fun} <- [
405+
{"freeze", &Images.freeze/1},
406+
{"unfreeze", &Images.unfreeze/1},
407+
{"purge", &Images.purge/1}
408+
] do
409+
assert_raise ArgumentError, ~r/cannot #{action} an image of kind/, fn -> fun.(row) end
410+
end
411+
end
412+
end
413+
end
414+
354415
describe "the picture-row bookkeeping" do
355416
# A frozen picture's member columns are empty on purpose, which is exactly
356417
# what the backfill calls an orphan row — and the row is the only record of

0 commit comments

Comments
 (0)