From 531e2a69d4186cb2382734d176587eff4a0e1eff Mon Sep 17 00:00:00 2001 From: Stefan Wintermeyer Date: Sat, 19 Sep 2026 10:42:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Anzeigen=20schaltet=20jede=20Installation?= =?UTF-8?q?=20selbst=20an,=20=C3=BCber=20ADS=5FENABLED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Schalter lag nur in config.exs, also hätte "an für vutuv.de" bedeutet: an für jeden, der vutuv installiert. Das wäre keine Kleinigkeit, denn die Preise stehen fest im Code und die Betreiber-Mails sind fest deutsch — eine fremde Installation bekäme einen Laden, der in Euro zu unseren Sätzen abrechnet. Also liest runtime.exs jetzt ADS_ENABLED, der ausgelieferte Default bleibt aus. Nur das exakte Wort "true" schaltet an. Ein Schalter, ab dem Mitgliedern Geld in Rechnung gestellt wird, darf einen Tippfehler, ein "1" aus der Shell oder ein gebrülltes YES nicht als Zustimmung lesen. Der Test liest die Bedingung aus runtime.exs statt sie abzuschreiben, weil eine Kopie weiterläuft, wenn das Original abgedriftet ist; runtime.exs wird im Test-Env sonst nie ausgeführt. Diesen Text hat ein KI-Agent in meinem Namen geschrieben. Ich weiß, dass das problematisch ist. --- config/config.exs | 8 +++-- config/runtime.exs | 10 ++++++ docs/ADMINS.md | 1 + test/vutuv/ads/enabled_env_test.exs | 54 +++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 test/vutuv/ads/enabled_env_test.exs diff --git a/config/config.exs b/config/config.exs index f9821e272..f534b9db7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -364,9 +364,11 @@ config :vutuv, :welcome_suggestions, %{ } # The global on/off switch for the daily text-ad system (see Vutuv.Ads). -# Off for now: no ad serves, the public /system/ads flow and the admin review -# dashboard 404. "ads" stays a reserved username slug either way, so the -# handle is kept free for when the system is switched back on. +# Off unless an installation asks for it with ADS_ENABLED=true (runtime.exs): +# no ad serves, the public /system/ads flow and the admin review dashboard 404. +# "ads" stays a reserved username slug either way, so the handle is kept free. +# The shipped default stays off because the prices and the German operator +# notices are ours, not every installation's. config :vutuv, :ads_enabled, false # The VAT rate the operator adds on top of every quoted ad price, in percent diff --git a/config/runtime.exs b/config/runtime.exs index 9414d96ac..4a5cf085b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -115,6 +115,16 @@ if config_env() == :prod do config :vutuv, :post_edit_window_minutes, String.to_integer(minutes) end + # The daily text-ad system, off unless an installation asks for it + # (ADS_ENABLED=true). An env var rather than the shipped default, because the + # prices are ours (`Vutuv.Ads.tiers/0`) and the operator notices are fixed + # German: turning it on for everybody would hand each installation a shop + # billing in euros at vutuv.de's rates. Only "true" counts - a typo leaves it + # off, which is the safe way round for a switch that starts charging people. + if System.get_env("ADS_ENABLED") == "true" do + config :vutuv, :ads_enabled, true + end + # The VAT rate on ad prices, in percent (default 19, the German rate; see # config/config.exs). Every quoted price is net, so an installation invoicing # elsewhere sets its own rate, and 0 drops the VAT line entirely. diff --git a/docs/ADMINS.md b/docs/ADMINS.md index ed0325f76..284b75bef 100644 --- a/docs/ADMINS.md +++ b/docs/ADMINS.md @@ -165,6 +165,7 @@ Everything else has a default (the vutuv.de production value): | `MAIL_LOG_PATH` | `/var/log/mail.log` | Postfix log the bounce watcher tails; `""` = watcher off | | `DEPLOY_MINUTES` | `10` | How long installing a new version takes on your setup. The 500 page tells the visitor this number, because a deploy in flight is the commonest reason they are looking at it, and "come back later" is only useful with a figure on it. Ours is a blue/green deploy that builds, migrates and health-gates before the switch; set yours if your pipeline is slower | | `POST_EDIT_WINDOW_MINUTES` | `30` | How long a post stays editable after publishing. Editing also closes with the first like, repost or reply, whatever this value says (an edit would silently rewrite what somebody else endorsed); deleting is never blocked. Raise it for a closed community where posts get little immediate engagement | +| `ADS_ENABLED` | `false` | Whether this installation sells the daily text ad — one ad per calendar day, booked by a member at `/system/ads`, released by an admin at `/admin/ads`, invoiced by you afterwards. Off by default on purpose: the prices are vutuv.de's and are not configurable yet (350 € net a day, 2.000 € a week, 7.500 € a month), and the mails that reach you about a booking are written in German, so switching it on means agreeing to both. Off, no ad serves, the public flow and the review dashboard answer 404, and nothing can be booked; `ads` stays a reserved username either way. Only the exact value `true` turns it on | | `ADS_VAT_PERCENT` | `19` | The VAT you add on top of every ad price, in percent. Every price the ad system quotes — the offer page, the booking form, the preview, "My bookings" and both mails — is **net**, and this is the rate that turns it into the figure your invoice asks for (19 % is the German rate vutuv.de invoices at). Set your own country's rate, or `0` if you invoice without VAT, which drops the VAT line from every one of those surfaces | | `POST_DRAFT_RETENTION_DAYS` | `30` | How long the composer keeps a post somebody started and never sent, counted from the last change. Drafts are stored so a page reload cannot eat them, which means unpublished text of your members sits in your database — this is the retention promise your privacy page should quote. A draft is dropped the moment its post is sent, and any photo attached to it goes with it | | `DATA_LOCATION` | `Deutschland` | Where your installation's data physically lives, named on the start page's "Your data stays here" card ("on our own servers in X, in no foreign cloud"). **Set it to your own country or data centre, or to an empty value to drop that claim entirely** — which is what you must do if you run vutuv on rented cloud infrastructure, since the sentence says *our own servers*. The cookie sentence beside it is a property of the software and stays whatever you set here: vutuv sets one first-party cookie and loads nothing from another host | diff --git a/test/vutuv/ads/enabled_env_test.exs b/test/vutuv/ads/enabled_env_test.exs new file mode 100644 index 000000000..f431a18ab --- /dev/null +++ b/test/vutuv/ads/enabled_env_test.exs @@ -0,0 +1,54 @@ +defmodule Vutuv.Ads.EnabledEnvTest do + @moduledoc """ + `ADS_ENABLED`, the switch that decides whether an installation sells ad days + at all. + + It reads the condition out of `config/runtime.exs` rather than restating it + here: a copy goes on passing once the real one has drifted, and what is being + pinned is that only a deliberate `true` starts charging anybody. `runtime.exs` + is not loaded in the test env, so this is the only place that branch is ever + exercised before production. + + `async: false` because it writes the real `ADS_ENABLED` environment variable, + which the SQL sandbox does not roll back; nothing else in the suite reads it + (the app asks `Application.get_env(:vutuv, :ads_enabled)`, which `config/test.exs` + sets), so the blast radius is this file. + """ + use ExUnit.Case, async: false + + setup_all do + source = File.read!(Path.join([__DIR__, "..", "..", "..", "config", "runtime.exs"])) + + [_, condition] = + Regex.run( + ~r/if (System\.get_env\("ADS_ENABLED"\)[^\n]*?) do\n\s*config :vutuv, :ads_enabled, true/, + source + ) + + on_exit(fn -> System.delete_env("ADS_ENABLED") end) + %{condition: condition} + end + + defp switched_on?(condition, value) do + if value, do: System.put_env("ADS_ENABLED", value), else: System.delete_env("ADS_ENABLED") + {result, _binding} = Code.eval_string(condition) + result + end + + test "the exact word true switches it on", %{condition: condition} do + assert switched_on?(condition, "true") + end + + test "anything else leaves it off, which is the safe way round", %{condition: condition} do + # A switch that starts invoicing members must not read a typo, a shell's + # `1`, or a shouted YES as consent. + for value <- [nil, "", "1", "yes", "TRUE", "True", " true", "false"] do + refute switched_on?(condition, value), "#{inspect(value)} should not enable ads" + end + end + + test "the knob is documented, or an operator cannot find it" do + admins = File.read!(Path.join([__DIR__, "..", "..", "..", "docs", "ADMINS.md"])) + assert admins =~ "`ADS_ENABLED`" + end +end From c234c9dd4d62b281dd5702d5c849f552d13729ba Mon Sep 17 00:00:00 2001 From: Stefan Wintermeyer Date: Sat, 19 Sep 2026 11:11:49 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Die=20Woche=20zur=C3=BCckdatieren,=20ohne?= =?UTF-8?q?=20dass=20sie=20sich=20selbst=20im=20Weg=20steht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Test-Helfer verschob die Zeilen eines Blocks einzeln und las sie ohne order_by, also in der Reihenfolge, die Postgres gerade einfiel. Wird eine Woche um weniger als sieben Tage zurückdatiert, landet jede Zeile auf einem Tag, den der Block selbst noch hält — kommt eine spätere zuerst dran, schlägt ads_day_index mit 23505 zu. Hier war es wochenlang die Einfügereihenfolge und auf CI dann etwas anderes, also älteste Zeile zuerst. Diesen Text hat ein KI-Agent in meinem Namen geschrieben. Ich weiß, dass das problematisch ist. --- test/vutuv/ads_test.exs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/vutuv/ads_test.exs b/test/vutuv/ads_test.exs index d17b8091c..a756113a8 100644 --- a/test/vutuv/ads_test.exs +++ b/test/vutuv/ads_test.exs @@ -57,7 +57,12 @@ defmodule Vutuv.AdsTest do do: from(a in Ad, where: a.group_id == ^ad.group_id), else: from(a in Ad, where: a.id == ^ad.id) - for row <- Repo.all(scope) do + # Earliest day first, and the order is load-bearing: a block shifted back by + # less than its own length moves each row onto a day the block still holds, + # so moving a later row first hits `ads_day_index` (23505). Without the + # `order_by` Postgres answers in whatever order it likes - which was + # insertion order here for weeks and something else on CI. + for row <- Repo.all(from(a in scope, order_by: [asc: a.day])) do Repo.update_all(from(a in Ad, where: a.id == ^row.id), set: [day: Date.add(row.day, -days)] )