Skip to content

Commit f92a0b5

Browse files
committed
Add verified personal-webpage links (rel=me + domain proof, v7.91.0) [cold-deploy]
Members can now prove a profile Link is really their own webpage and earn a small verified ✓ next to it, the people-side twin of verified company pages. Two proof methods, the member's choice on /settings/links/:id/verify: - Back-link (rel=me), the default: add a rel="me" link back to your profile on your page, and vutuv fetches it and confirms. Works on any page you can edit, including shared hosting (a blog, a github.io page, a portfolio) — the IndieWeb / Mastodon standard, completing the loop vutuv already half-emits (rel="me" in the profile head and on social chips). - Domain control: a DNS TXT record or a .well-known file, the same mechanic companies use, for a member who controls the whole host. The domain-proof mechanics were extracted from Vutuv.Companies.Verification into a shared, config-agnostic Vutuv.WebVerification (also gaining the new rel=me fetch + parse), so both contexts build on a neutral util instead of coupling to each other; the company API, its :verify_company_domains gate and its test seams are unchanged. rel=me is parsed with a targeted regex to avoid a new HTML-parser dependency. Verification is per-link and independent, with no uniqueness constraint (two members may each prove the same shared host by their own proof, and rel=me is member-specific). State lives on the urls row (five nullable columns, an N-1 safe single-deploy addition); editing a link to a different URL clears its mark. Verified links are re-checked hourly (Vutuv.Profiles.LinkRecheckSweeper) with a 7-day grace window before the mark drops, mirroring the company domain re-check. Gated by :verify_user_links (env VERIFY_USER_LINKS, default on; off = no outbound calls, intranet-safe). The agent-format siblings carry the verified fact, and docs (ADMINS, architecture, design) are updated. Cold deploy: new migration, a new supervisor child in application.ex, and config changes. Claude-Session: https://claude.ai/code/session_01Py5uBbwCN7gsQzPzwnf9j9
1 parent 6d38e1c commit f92a0b5

33 files changed

Lines changed: 2112 additions & 618 deletions

‎.claude/rules/design.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ LiveView can sit on either style.
120120
- `<.presence_dot size="xs|sm|md|lg" hook online={…} />` — the green dot itself, the **one** definition of its colour/ring/size/position. Each mode owns its visibility so it can never render an always-on dot: `presence_wrap` renders it with `hook` (adds `.presence-dot`, hidden until the JS hook's generated stylesheet reveals it by `data-presence-user-id`); the two **server-driven** dots (the shell's own top-bar avatar, the messages sidebar) pass `online={…}` from their own state and the dot renders only when true. With neither it renders nothing. Don't hand-write the emerald dot span; render this.
121121
- `<.chip>` — tag chip; pass `navigate`/`href` to render it as a link.
122122
- `<.employment_status_badge status={…} />` — the member's **job-availability pill** (issue #870) shown on its own line in the profile header, right below the member's name (above the tagline): a small brand-tint rounded-full pill (`bg-brand-50`/`text-brand-700 ring-1 ring-brand-100`, dark variants) reading "Open to offers" (`"open"`) or "Looking for a job" (`"looking"`). Renders **nothing** for the unset default (`nil`) or any unknown value, so it drops in unconditionally. The wording is the schema's single source (`Vutuv.Accounts.User.employment_status_label/1`), shared by the badge, the Basics-form select and the agent docs (a `- Employment status: …` fact line in md/txt, the raw value in JSON/XML) so they can never disagree. Carries `data-employment-status` for tests.
123+
- `<.verified_mark title={…} class={…} />` — the small emerald ✓ shown next to a member's **verified webpage** link (`Vutuv.Profiles.LinkVerification`: a member proved a profile Link is really their page via a rel=me back-link or the company-style DNS / well-known domain proof). The people-side twin of the company `<.verified_badge>`; icon-only, so it carries a `title` / `aria-label` (default gettext("Verified webpage")). Emerald reuses the "verified"/"active" status colour (the company badge, the online dot). Rendered next to the link on the profile Links card, the `/:slug/links` index + show pages (for every viewer when `url.verified_at`), and the owner-only `/settings/links/:id/verify` page; the agent-doc siblings carry the same fact (`SectionDocs.link_entry/1` adds `verified`, md/text show "(verified webpage)").
123124
- `<.tag_vote user={…} user_tag={…} viewer={…} />` — the profile **Tags** chip: a brand-tint tag chip whose name links to the tag page, with the visible-endorsement count as a **calm brand-tint pill** right after the name (`bg-brand-100`/`text-brand-700`, dark `bg-brand-800`/`text-brand-100`; recoloured off the coral accent on purpose, a vouch count is social proof, not an alert). For the **read-only** viewer (owner / logged-out) the pill is a plain `<span>`, hidden at 0. For the **actionable** viewer (logged-in non-owner) the **same pill is the endorse toggle**: it is a `<button>` (carrying `data-tag-vote-count`) inside the CSRF `<.vote_form>`, looking identical to the read-only pill until you endorse, then **filling in** (`data-[endorsed=true]:bg-brand-600`/`text-white`); a **zero-count tag shows a `+`** so there is something to click. No more caret + floating corner badge (that split is gone, along with `<.tag_count_badge>` and `caret_up`). Hovering or focusing the chip raises a small **voter roster popover** (`group-hover`/`group-focus-within`, pure CSS, no JS, no library): each endorser is a **link to their profile** (the card takes pointer events and an invisible `after:` strip bridges the `mb-2` gap so the cursor can reach it), names **truncate**, and an "and N more" link to this member's per-tag endorser list (`/:slug/tags/:tag/endorsers`, a public paginated people list, a `UserTagController` action served in HTML and the agent formats, listing everyone who endorses this member for the tag) closes out the capped list (~6). The actionable viewer's **own row is pre-rendered** in the roster (hidden, marked `data-self-endorser`, and excluded from the server list so it never doubles); the `TagVote` JS reveals it the moment they endorse, so they appear in the roster **without a reload**. For them the popover is rendered even on a zero-roster tag, but hover is disabled (`active?` toggles the `group-hover`/`group-focus-within` classes, which the JS turns on once there is a visible row) so an empty card never pops. The toggle is a CSRF `<.form>` (the no-JS fallback) that the `TagVote` enhancement in `app.js` drives over fetch (POST endorse / DELETE undo), flipping `data-endorsed`, swapping the `+`↔count text and revealing/hiding the self row. The count is the **visible** endorsement tally (`compact_count`, the issue #783 gate); the roster comes from the profile's `UserTagEndorsement.visible_with_endorser/0` preload (endorsers loaded with the endorsement, sorted newest-first by their UUID v7 id, so it is in-memory, no per-tag query). The controller (`UserTagEndorsementController`) answers the fetch request with `{count, endorsed}` JSON and a plain submit with the classic flash + redirect. **`live?`** (default false): on the profile (a LiveView, `VutuvWeb.UserProfileLive`) the chip is passed `live?`, so the pill is a `phx-click="endorse"`/`"unendorse"` button (`phx-value-id` the user_tag id) and the LiveView re-renders the pill + roster server-side on every toggle — no fetch, no `TagVote` JS, no reload (the count, the `+`↔count text and the self-row in the roster all come from the re-render). The CSRF `<.vote_form>` + `TagVote` enhancement stays the path for any non-live caller. **Honor tags** (`user_tag.tag.honor?` — the reserved, admin-granted badge like `vutuv_developer`, assigned only from `/admin/tags/:slug`): the chip renders the name plus a small brand-tint **`honor_tag_badge`** (icon-only heroicons check-badge, `title`/`aria-label` gettext("Honor tag") — no flow text, so `tag_wording_test` stays about tags) and **drops the whole endorsement apparatus** (no toggle button, no count pill, no voter popover — they are authoritative, not peer-vouched, so non-endorsable end to end). The agent-doc siblings mark them too (`SectionDocs.tag_entry/1` carries `honor`, md/text show "(honor tag)").
124125
- `<.secret_once flash={@flash} key={:new_token} label={…} class="mt-4" />` — the **show-once credential reveal** (access tokens, OAuth client secrets, webhook signing secrets): a brand-tint box with a `select-all` `<code>` line, rendered only while the one-shot flash under `key` holds the freshly minted secret (`data-secret-once` hook). Every "copy it now, it will not be shown again" box goes through this; don't hand-roll a fourth.
125126
- `<.count_badge count={n} class="…" />` — coral unread badge; renders nothing when 0; `class` positions it. Add `ring-2 ring-white dark:ring-slate-900` via `class` when it overlaps an icon/avatar (the shell does this).

‎config/config.exs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,22 @@ config :vutuv, :post_images, max_filesize: 6_000_000, max_per_post: 10
135135
# via :companies_dns_resolver / :companies_req_options.
136136
config :vutuv, :verify_company_domains, true
137137

138+
# Verified personal-webpage links: whether a member may prove a profile link is
139+
# their own webpage (a rel=me back-link, or the same DNS / well-known domain
140+
# proof companies use) and earn a small verified mark. On = the /settings/links
141+
# verify page offers the methods and re-checks them periodically; off = link
142+
# verification is disabled on this installation (no outbound calls), so no new
143+
# link can be verified (existing marks keep working). Runtime override:
144+
# VERIFY_USER_LINKS=false. Tests turn it off and stub DNS / HTTP per test via
145+
# :user_links_dns_resolver / :user_links_req_options.
146+
config :vutuv, :verify_user_links, true
147+
148+
# Whether the hourly GenServer that re-checks verified links runs (off in tests,
149+
# where it would touch the SQL sandbox from outside; tests call
150+
# Vutuv.Profiles.LinkVerification.recheck/1 directly). The re-check itself is
151+
# also a no-op when :verify_user_links is off.
152+
config :vutuv, :recheck_user_links, true
153+
138154
# The most invitations a single member may send in one Berlin calendar day
139155
# (see Vutuv.Invitations) — the abuse guard on outbound invite email, which
140156
# protects the installation's sender reputation. Tune it per installation

‎config/runtime.exs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ if config_env() == :prod do
136136
config :vutuv, :verify_company_domains, false
137137
end
138138

139+
# Verified personal-webpage links. VERIFY_USER_LINKS=false disables the rel=me,
140+
# DNS and well-known link proofs (and their periodic re-check), so no new link
141+
# can be verified — for installations that must not call out (intranets).
142+
# Existing verified marks keep working.
143+
if System.get_env("VERIFY_USER_LINKS") == "false" do
144+
config :vutuv, :verify_user_links, false
145+
end
146+
139147
# The per-member daily cap on outbound invitations (see Vutuv.Invitations).
140148
if invitation_daily_cap = System.get_env("INVITATION_DAILY_CAP") do
141149
config :vutuv, :invitation_daily_cap, String.to_integer(invitation_daily_cap)

‎config/test.exs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ config :vutuv, :fetch_code_stats, false
6767
config :vutuv, :recheck_company_domains, false
6868
config :vutuv, :verify_company_domains, false
6969

70+
# Personal-webpage link verification: same story as the company flags above. The
71+
# hourly re-check GenServer stays off (tests call
72+
# Vutuv.Profiles.LinkVerification.recheck/1 directly); verification is off so the
73+
# suite never hits real DNS / HTTP, and the link tests flip :verify_user_links on
74+
# per-test and stub via :user_links_dns_resolver / :user_links_req_options.
75+
config :vutuv, :recheck_user_links, false
76+
config :vutuv, :verify_user_links, false
77+
7078
# Keep the Regenerator's stdout progress lines out of the test output.
7179
config :vutuv, :regenerator_quiet, true
7280

‎docs/ADMINS.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Everything else has a default (the vutuv.de production value):
109109
| `MAIL_LOG_PATH` | `/var/log/mail.log` | Postfix log the bounce watcher tails; `""` = watcher off |
110110
| `FEDIVERSE_ENABLED` | `true` | `false` turns follow-only ActivityPub federation off entirely (endpoints 404, nothing is delivered) — set it on intranet installations |
111111
| `VERIFY_COMPANY_DOMAINS` | `true` | `false` disables the verified-company-page domain proof (the DNS TXT and well-known-file checks and their periodic re-check) — no new company page can be verified, existing ones keep working. Set it on installations that must not make outbound DNS/HTTP calls. A newly verified company sends an operator notice to `OPERATOR_EMAIL` |
112+
| `VERIFY_USER_LINKS` | `true` | `false` disables verified personal-webpage links (a member proving a profile link is their own page via a rel=me back-link, or the same DNS TXT / well-known-file domain proof, plus their periodic re-check) — no new link can be verified, existing marks keep working. Set it on installations that must not make outbound DNS/HTTP calls |
112113
| `GITHUB_API_TOKEN` | – | Optional token for the profile code-stats fetches (GitHub allows 60 unauthenticated requests/hour per IP; a token raises that to 5,000). A [fine-grained PAT](https://github.com/settings/personal-access-tokens) with **no** scopes/permissions is enough — the fetches read public data only. Can be added (or rotated) at any time; without it everything still works, the 7-day snapshot cache is sized for the unauthenticated limit |
113114
| `MAIL_LOG_POLL_MS` | `5000` | Bounce watcher poll interval |
114115
| `INVITATION_DAILY_CAP` | `50` | Most invitations a single member may send per day (abuse guard on outbound invite email) |
@@ -247,6 +248,10 @@ vutuv runs fine without internet access:
247248
but the well-known fetch is blocked by the SSRF guard for internal hosts; set
248249
the flag to `false` to hide the feature entirely if company pages are not
249250
wanted.
251+
- Consider `VERIFY_USER_LINKS=false`: verified personal-webpage links (rel=me
252+
back-link, DNS TXT or well-known file) reach out to the member's page the same
253+
way, with the same SSRF guard against internal hosts. Set the flag to `false`
254+
to hide the feature if link verification is not wanted on the installation.
250255
- Turn off the features that call out to the internet (compile-time flags in
251256
`config/config.exs`): `:fetch_gravatar` (avatar lookup at registration),
252257
`:fetch_mastodon_posts` / `:fetch_bluesky_posts` (the social-feed card on

‎docs/architecture/companies.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ member's handle + nullable `companies.username`).
6161

6262
Edit rights over a page come **only** from proving control of the domain, never
6363
from a self-asserted employment claim. Two proof methods, both proving control
64-
of the domain itself (`Vutuv.Companies.Verification`):
64+
of the domain itself. The proof mechanics live in the shared
65+
`Vutuv.WebVerification` (also used by verified personal-webpage links — see
66+
`profiles.md`); `Vutuv.Companies.Verification` is the company-flavoured wrapper
67+
that owns the company gate and test seams:
6568

6669
- **DNS** — a `vutuv-verify=<token>` TXT record on the domain.
6770
- **Website file** — the token served at

‎docs/architecture/profiles.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,44 @@ Every change renumbers positions 1..n server-side, scoped to the owner.
340340
The chosen order drives the profile preview, the section page and every
341341
agent-format sibling; new entries append to the end.
342342

343+
## Verified webpage links (`Vutuv.Profiles.LinkVerification`)
344+
345+
A member can prove a profile **Link** is really their own webpage; it then earns
346+
a small emerald ✓ (`<.verified_mark>`) next to it on the profile Links card, the
347+
`/:slug/links` pages and the agent-format siblings (`SectionDocs.link_entry/1`
348+
carries `verified`; md/text render "(verified webpage)"). This is the people-side
349+
twin of verified company pages, and shares the same proof mechanics
350+
(`Vutuv.WebVerification`; see `companies.md`).
351+
352+
Three methods, the member's choice on the owner-only page at
353+
`/settings/links/:id/verify` (each a small `<.verify_form>` posting its method):
354+
355+
- **Back-link (rel=me)** — the default. The member adds a link back to their
356+
profile marked `rel="me"` on the page; the verifier fetches the page (SSRF-
357+
guarded, no redirects, size-capped) and scans for an `<a>`/`<link>` whose `rel`
358+
contains `me` and whose `href` resolves to the member's canonical profile URL
359+
(derived from `Endpoint.url()`, installability-safe). No token — the back-link
360+
target is the proof. Works on any page the member can edit, including shared
361+
hosting (a blog, a `github.io` page, a hosted portfolio); it is the IndieWeb /
362+
Mastodon standard, completing the loop vutuv already half-emits (`rel="me"` in
363+
the profile head and on social chips).
364+
- **DNS / well-known** — the same domain proofs companies use, for a member who
365+
controls the whole host, using a per-link `verification_token`.
366+
367+
State lives on the `urls` row (`verification_method`, `verification_token`,
368+
`verified_at`, `last_checked_at`, `grace_deadline_at`) — per-link and independent,
369+
with **no** uniqueness constraint (unlike company domains: two members may each
370+
prove the same shared host by their own proof, and rel=me is member-specific).
371+
Editing a link to a different URL clears its mark (`Url.changeset/2`). Verified
372+
links are re-checked hourly (`Vutuv.Profiles.LinkRecheckSweeper` →
373+
`recheck_due_links/0`) with a 7-day grace window before the mark drops, mirroring
374+
the company domain re-check.
375+
376+
Gated by `config :vutuv, :verify_user_links` (env `VERIFY_USER_LINKS`, default
377+
on). Off = disabled on the installation (no outbound calls); existing marks keep
378+
working, no new link can be verified. Tests stub DNS / HTTP via
379+
`:user_links_dns_resolver` / `:user_links_req_options`.
380+
343381
## Email & phone number types, addresses and maps
344382

345383
Every email address and phone number carries an owner-editable type label.

‎lib/vutuv/application.ex‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ defmodule Vutuv.Application do
5555
optional_child(:bounce_watcher, Vutuv.Deliverability.Watcher) ++
5656
optional_child(:sweep_unreachable_accounts, Vutuv.Deliverability.Sweeper) ++
5757
optional_child(:resume_stuck_broadcasts, Vutuv.Newsletters.BroadcastResumer) ++
58-
optional_child(:recheck_company_domains, Vutuv.Companies.DomainRecheckSweeper)
58+
optional_child(:recheck_company_domains, Vutuv.Companies.DomainRecheckSweeper) ++
59+
optional_child(:recheck_user_links, Vutuv.Profiles.LinkRecheckSweeper)
5960

6061
opts = [strategy: :one_for_one, name: Vutuv.Supervisor]
6162
Supervisor.start_link(children, opts)

0 commit comments

Comments
 (0)