vutuv is a free, fast and open source social network service to host and share information about humans and organizations. It's hosted at https://vutuv.de.
We use MIT License.
vutuv is a Phoenix Framework 1.8 application. Prerequisites:
- Erlang 28.5.0.1 and Elixir 1.20.0-otp-28 — install via mise (pinned in
.tool-versions) - PostgreSQL 17 — installed separately (not covered by
.tool-versions)
Two system libraries are also required (not managed by mise):
- libvips — all image processing (avatars, cover photos, post images, URL screenshots) goes through the
imagepackage, which needs libvips. Install withbrew install vips(macOS) orapt-get install libvips-dev(Debian/Ubuntu). - Chromium (optional) — only needed for URL screenshots and moderation evidence screenshots; set
CHROMIUM_PATHif the binary is not on$PATH.
No Node.js is required: esbuild and Tailwind are installed as Elixir deps via mix assets.setup.
All database ids are UUID v7 (Vutuv.UUIDv7): time-ordered, minted in the app, never integers or UUID v4.
Create config/dev.secret.exs:
import Config
config :vutuv, VutuvWeb.Endpoint,
secret_key_base: "generate-with-mix-phx-gen-secret"mix setup # deps.get + ecto.create/migrate/seeds + esbuild/tailwind install
mix phx.serverVisit http://localhost:4000. (mix setup does everything except the
config/dev.secret.exs step above.)
Emails are displayed in the browser via Swoosh's mailbox preview at http://localhost:4000/sent_emails.
Every vutuv email is machine-generated, so all of it carries the Auto-Submitted: auto-generated (RFC 3834) and X-Auto-Response-Suppress: All headers to keep out-of-office and other auto-responders silent. Mail is built from Vutuv.Notifications.Emailer.base_email/0 and sent through the single Emailer.deliver/1 chokepoint, the only place allowed to call Vutuv.Mailer.deliver/1.
Every email goes out as multipart (text/plain + text/html). The text body lives in the per-locale *.text.eex templates (lib/vutuv_web/templates/email/); the HTML alternative lives in the matching *.html.heex bodies (lib/vutuv_web/templates/email_body/), composed from one shared, inline-styled framework (VutuvWeb.EmailComponents: a brand-wordmark layout, dark mode, and blocks like the PIN box, CTA button and key/value panel). The two formats are paired by a drift test, so an email added with only one fails the build.
Tidewave runs in the dev server (dev-only dependency): AI coding agents can connect to the MCP endpoint at http://localhost:4000/tidewave/mcp to eval code in the running app, query Ecto and read logs.
Flag your account as admin (the column is admin?, so it must be quoted; match
on your handle since ids are UUIDs):
UPDATE users SET "admin?" = true WHERE username = 'your-handle';Admin panel: http://localhost:4000/admin — reached from the account menu (an "Admin" entry that only admins see; there is no other link to it). At the very top sits a live activity dashboard (VutuvWeb.Admin.DashboardLive, embedded like the shell): an at-a-glance pulse of the system — how many members are online right now, plus today-vs-yesterday post, direct-message and confirmed-sign-up counts and the time of the last post and message. The Currently online and New members cards also list the newest ten members behind each figure (avatar + name, linking straight to the profile), so an admin can look at who is online or who just joined without searching. The "online now" figure and its list update the instant a member connects or disconnects (via VutuvWeb.Presence); the rest refreshes on a gentle timer, so it stays current without a reload. Below it the panel groups the sections by what you come to do — Moderation & queues (moderation, member browser, deliverability), Communication (newsletters, audiences), Content & taxonomy (tags, usernames) and System & insights (API apps, daily report, ads) — each tile with a one-line description and, where it matters, a live count badge. Every actionable admin page is a LiveView whose actions apply with no reload: the member browser (/admin/users), the moderation queue + case page (/admin/moderation, reload-free uphold/reject), the deliverability dashboard (/admin/deliverability, thaw/clear) and the OAuth-app list (/admin/api_apps, suspend/reactivate); the classic CSRF POST routes stay as the no-JS / scriptable fallback.
A logged-in member without admin rights who opens /admin gets a 403 page that explains exactly this: admin rights are granted by the instance operator directly in the database (contact via Impressum).
- Views: mostly Phoenix 1.8 HTML modules with
embed_templates(nophoenix_viewdependency); LiveView is being adopted incrementally for the real-time parts (see below) - Real-time shell (LiveView): the app shell
VutuvWeb.ShellLive(sticky top bar + mobile bottom tab bar, with live unread badges) is embedded in the sharedapplayout vialive_render, so the chrome and badges are live on every page. The Messages (/messages), Notifications (/notifications) and Search (/search) pages are LiveViews under alive_session; search is search-as-you-type (results from three letters on, exact and similar-sounding name matches clearly separated,?q=plus the filters keeps the URL shareable and a settled query is recorded once) with scope chips (all/people/tags/posts), an exact-only toggle and query operators parsed byVutuv.Search.parse/2:vorname:/nachname:(akafirst:/last:),@handle, double quotes for exact, plus the combinable people filterstag:/skill:(has the tag) andort:/stadt:/city:(address in that city) - e.g.müller tag:phpormüller ort:koblenz. The profile (/:slug,VutuvWeb.UserProfileLive) is a LiveView too — embedded by its controller vialive_render(so the.md/.txt/.json/.xml/.vcfagent siblings keep flowing through the controller). The feed (/feed,VutuvWeb.PostLive.Feed) is fronted the same way byVutuvWeb.NewsfeedControllerso its own agent siblings can be negotiated (see Agent formats below), so it is the one LiveView no longer in thelive_session. Every state-changing control fires a LiveView event, so the page never reloads: the follow pill, the ⋯-menu mute/bookmark/like/block (and unblock), the follower/following/who-to-follow follow buttons, the tag-endorsement pills, and the owner "View as" switcher. The follower/following/connection counts and the tag-endorsement counts also update live over PubSub even when the change is made on another page or by another member (e.g. someone follows you from their feed); plain links (Message, Report, vCard, the agent-format links) stay navigation, and the post action bars are their own embedded live views. In-app updates flow overVutuv.Activity(Phoenix.PubSubon"user:<id>"); online status and typing useVutuvWeb.Presence. A site-wide online dot (green badge on a member's avatar everywhere — lists, profiles, post authors, the top bar) rides the sameVutuvWeb.Presence: the always-present shell tracks the current member online on one global topic and pushes each viewer their own online-id set to a tiny JS hook that toggles the dot on every<.avatar presence>in the page (classic controller pages included). It is public except across a block (the shell filters each viewer's set both ways) and each member can switch it off on the Privacy settings page (show_online_status?), after which they are never tracked or shown as online. Post timestamps render server-side in Berlin time (VutuvWeb.UI.post_time/1): a post from today shows just the time ("09:50 Uhr"), yesterday's the word plus the time ("Gestern, 09:50 Uhr"), older posts the full date — andVutuv.DayClockbroadcasts at Berlin midnight so every open feed / profile / notifications / likes page rolls its stamps over to the new day with no reload. The layout is split intoroot.html.heex(document shell) andapp.html.heex(chrome), shared by classic controller pages and LiveViews. Notifications are real data derived at read time from the existing event tables (followers, endorsements, connections — mutual follows —, replies, likes; retroactively, no notifications table); each entry links to what it reports (the post, the actor's profile), and a reply or like entry quotes the post it is about so the feed is scannable at a glance: a like quotes the liked post, a reply quotes both the member's own post and the reply itself (each truncated to its first lines and linked to its own permalink, the reply respecting post visibility so a restricted one never leaks). The only stored state is theusers.notifications_read_atread marker behind the unread badge. - Live member counter: the logged-out landing page shows the exact number of members and ticks it up in real time as people register.
Vutuv.Accounts.MemberCounterkeeps the total in a lock-free:atomicscell (ref in:persistent_term), so the per-render read (count/0) and the per-signup bump (increment/0, called fromAccounts.register_user/2) are O(1) and never hit the database — a signup spike just races on one atomic add. A single owner GenServer seeds the cell from the DB at boot, re-reads the authoritative count on a slow timer (self-healing against deletions), and broadcasts the value only when it changed, so a burst of signups coalesces into at most one PubSub message per tick instead of a fan-out storm. The pill is the embeddedVutuvWeb.MemberCountLive(rendered vialive_render, like the shell). - Follow + connect (the social graph): one action, two readings (
Vutuv.Social). A follow (Vutuv.Social.Follow, tablefollows) is the only relationship action: a one-directional subscription — follow anyone, no approval — that decides whose posts reach your/feed. Two people who follow each other are vernetzt (connected), derived from the two follow edges (Vutuv.Social.connected?/2), not a separate record — so there is no request / accept / decline / cooldown. A follow-back pushes a live "you are now connected" notification; the profile header carries the<.follow_button>, an inert "✓ Vernetzt" status when the follow is mutual, and a<.mute_button>once you follow the member./:slug/connectionslists a member's vernetzt people (the owner ends a connection by unfollowing). Mute is a per-follow flag (follows.muted,<.mute_button>→ PUT/follows/:id/mute): a muted follow keeps the relationship and any vernetzt status but drops the followee's posts out of your feed — silent and one-directional, unlike a block. Posts keep a connections-only audience that now resolves to "mutual followers" (see below). (The legacyconnectionsrequest/accept table was retired; outstanding pending requests were converted to follows, and the table is dropped in a follow-up expand/contract deploy.) - Blocking (
Vutuv.Social.Block): reachable wherever you decide to block someone — a quiet "Block" next to the profile footer's Report, a calm overflow menu in the direct-message thread (the moment unwanted contact usually arrives), and a "Block someone by @handle" form on/blocksitself (so the "block my ex" case needs no detour through their profile). All three run the sameSocial.block_user/2. Blocking severs both follow edges (Social.sever_between/2, which also ends any vernetzt status), freezes the 1:1 conversation, and refuses every new interaction in both directions (follow, message, reply, like, repost); third-party reposts of a blocked author also stay out of the blocker's feed. Reading is untouched (public stays public). The blocked party only ever sees the same generic refusals a decline/freeze produces. The private list at/blocksalso unblocks; unblocking restores nothing (deliberately unlike a rejected moderation report) but thaws the conversation its own block froze, unless a reverse block or an active report severance still stands - New-member onboarding: sign-up requires at least three distinct tags (tags are how members are found; validated in
User.registration_changeset/2with the same comma/space parsing and case-insensitive de-duplication the tag creation uses). After the confirmation PIN a fresh member lands on their own profile, where the "Complete your profile" checklist (owner-only, first 24h or 24h after a dormant return) opens with the tag step already checked — 1/4 done — and leads through photo → tagline (Kurzbeschreibung) → first post, the last step suggesting a topic from the member's own tags ("Zum Beispiel ein Gedanke zu #elixir"). Work experience is deliberately not on the checklist; its section card keeps its own add tile. - Profile "view as" preview (owner-only): on your own profile a slim "Ansehen als" switcher re-renders the page by the relationship tiers the app already names —
Sie(your full view) /Follower(someone who follows you) /Vernetzt(a mutual connection) /Öffentlich(logged-out visitors and search engines). Each tier shows exactly what that relationship reveals: a Follower sees your followers-only posts; a Vernetzt connection also sees connections-only posts and your private emails (a connection is a mutual follow, and vutuv shows private emails to people you follow); Öffentlich sees public posts and the public email set only, with restricted posts gone. Owner chrome (Edit, the completion checklist, dashed add tiles, manage footers, the post author menus) disappears, and the rail follow controls render from the logged-out viewpoint so no control fires as you. Post visibility per tier is enforced server-side via?view_as=follower|connection|publicand a simulated-relationship scope (Vutuv.Posts.scope_visible_preview/2), so a connections-only post can never leak into a Follower or public preview — private data never reaches a preview's HTML to be hidden client-side. Honored only for the owner (a stranger's?view_as=is ignored); the header's relationship controls show in their correct state (Folge ich, the "✓ Vernetzt" status, Mute) but are inert (pointer-events-none). - Profile job title chooser (issue #833): the
Title @ Organizationline under a member's name is auto-picked from their work experiences (VutuvWeb.UserHelpers.current_job/1: the first open-ended dated role, else the most recent), but a member can pin which work experience supplies it — an owner-only star toggle on the work-experiences management page (/:slug/work_experiences→ PUT/DELETE…/:id/pin, stored as the nullableusers.profile_work_experience_id), cleared back to automatic anytime and nulled by the DB when the pinned role is deleted (ON DELETE SET NULL). The choice runs through the singlecurrent_jobchokepoint, so it shows on the profile header, every listing row, the meta description, JSON-LD and all agent formats; with nothing pinned, behaviour is exactly as before. - Education profile section: a member's schools sit in their own profile section (
Vutuv.Profiles.Education,/:slug/educations) alongside work experience — school, degree, field of study, description and a start/end period, newest first. It mirrors the work-experience section end to end: an Education card on the profile, owner CRUD on the management page, and Markdown / plain text / JSON / XML siblings under the same URL plus an extension (kept in sync by the agent-docs drift test), plus a line in the GDPR data export. - Import from LinkedIn: on the account hub (
/:slug/settings/import/linkedin, owner-only) a member uploads their LinkedIn data-export ZIP ("Get a copy of your data") and vutuv pre-fills their profile from it (Vutuv.Imports.LinkedIn). The parser is filename-independent (it classifies each CSV by its header signature, since LinkedIn localizes the names), tolerates a UTF-8 BOM and CRLF, and maps Positions → work experiences, Education → the new education section, Skills → tags, and the profile's Websites / Twitter handles → links / social accounts;Connections.csvis skipped and email addresses are shown read-only (never auto-created, since each is PIN-verified). It is preview-and-confirm: the member sees everything found, entries already on their profile are pre-unchecked, and nothing is written until they confirm. The apply step runs in one transaction, skips duplicates (so a re-import never doubles a row) and fills only blank name/headline fields (an import never overwrites existing content). Safeguards: the upload is capped (20 MB), the archive is inspected via its central directory before any decompression and rejected on a zip-bomb signature (per-entry / total-uncompressed / entry-count caps, and unrecognized/huge members are never inflated), imports are rate-limited per member, the CSVs are only ever decompressed into memory (never written to disk), and the uploaded temp file is deleted as soon as it is read. - Direct messages: persisted 1:1 conversations (
Vutuv.Chat) at/messages, with live delivery, typing indicators and online dots. Anyone validated can write to anyone, but the conversation lands directly only when the recipient already follows the sender — otherwise it is a message request the recipient accepts (explicitly or by replying) or declines; declining is silent (the sender cannot tell it from being ignored) and opening new requests is rate-limited. The shell badge counts conversations with unread messages, and a debounced email quotes the message and points the recipient back at the thread. Each member controls this on the notifications settings page: whether they are emailed about every unread message or only the first of a burst (the default), and how long a message may sit unread before the email goes out (0 to 120 minutes, default 15); every such email says which mode is active and deep-links to those settings. - Posts + newsfeed: Markdown posts (up to 20k chars) with images and tags. An
@handleof an existing member is auto-linked to their profile with the member's name as a hover tooltip, and a#hashtagis auto-linked to that tag's/tags/:slugpage only when the tag exists and has at least one visible member (so a link never lands on an empty tag page) — everywhere the Markdown renderer runs (VutuvWeb.Markdown: posts, chat messages, ads, the RSS/JSON renderings), skipping entities typed inside code spans/blocks or existing links and resolving all of a body's mentions and hashtags in one batched query each. Everything post-related lives under/:slug/posts: the author archive (/:slug/posts, scopable to a year/month/day —/:slug/posts/2026/06), and permalinks keyed by the post's UUID v7:/:slug/posts/:id(non-canonical casing redirects to the lowercase URL). The feed at/feedis a member's home once they follow at least one account (Vutuv.Social.follows_anyone?/1): logging in then lands here, and the vutuv logo / visiting/redirect here (VutuvWeb.Home). A member who follows nobody yet (most visibly a brand-new sign-up, whose feed would be empty) lands on their own profile instead, where they can fill it in and find people to follow. It is a LiveView: a collapsed compose tile (the same dashed<.empty_add>tile as the profile's Beiträge section) expands the inline composer, a pull-model timeline (own + followed authors' posts and reposts, with a "Reposted by X" line) with cursor "Load more", a "Show N new posts" pill fed by{:new_post, …}/{:new_repost, …}broadcasts, and a desktop-only "Who to follow" rail (most-followed members you do not yet follow,Vutuv.Social.most_followed_users/1, live follow). The profile page and the archive show the author's timeline (posts + reposts). Audiences are deny-based (Vutuv.Posts): a post with no denials is public; denials exclude groups of the author's followees, single users, or wildcards (non_connections,non_followers,non_followees,logged_out,everyone) — the composer offers presets (public / followers / connections / only me) plus a custom "Hide from…" sheet with a person typeahead. The search page (/search) also finds words in fully public posts (Postgres FTS over a generatedsearch_tsvcolumn,websearch_to_tsquery, 'simple' config); any denial keeps a post out of search. Any denial also hides the post from logged-out visitors and noindexes it; a followers-only post shows a follow teaser and a connections-only (mutual-follow) post a follow-each-other teaser to denied readers, every other denial 404s. Deleting a group that posts deny is refused (it would silently widen audiences). - Likes, bookmarks, reposts: every post card carries a live action bar (
VutuvWeb.PostLive.Actions, one embedded LiveView per card vialive_render), so the like/repost/bookmark counters tick in real time on the feed and on classic pages (permalink, profile, archive). Counters are counted live from thepost_likes/post_bookmarks/post_repostsrows and broadcast as absolute values on the post topic ("post:<id>"). Likes and bookmarks work on any visible post and on any member — from a profile a logged-in visitor can like / bookmark another member (Vutuv.Social, tablesuser_likes/user_bookmarks), a private, silent save that needs no follow or connection and is refused only across a block. The private saved-items hub at/likesand/bookmarkslists both saved posts and saved people, each under a Posts / People sub-tab, with a search box (post body + author name; person name,@handle, headline) and a sort control (newest / oldest / name), offset "Load more", and entries that appear and disappear live across sessions. Reposts work on public posts only and distribute the post into the reposter's followers' feeds; while reposts exist the author cannot restrict the post's audience (the composer pins it to Public,Vutuv.Posts.update_post/2enforces it) but can always delete the post. - Replies (threads): a reply is a normal post (own permalink, audience, images, tags, likes/reposts/bookmarks, shows up in the replier's feed and profile) plus a
post_repliesrow naming the parent (Vutuv.Posts.create_reply/3). Replying works on public parents only (the reply button on restricted posts is disabled, like repost) and pins the parent's audience open like reposts do. Replies to replies are allowed. A post is rendered by one shared component everywhere (VutuvWeb.PostComponents):post_thread_entry/1shows a reply as a nested conversation — the posts it answers are stacked above it as full cards (each keeping its own like/repost/bookmark bar), oldest-first, with a connector line that runs from each avatar into the reply's avatar (a vertical drop down the card, then an elbow curving into the next avatar) and every reply indented one step further right under the post it answers, so the thread of a whole multi-post, multi-author conversation reads at a glance, instead of the feed's old flat "Replying to @handle" text banner. Indentation is capped at 2 levels (@thread_indent_cap) so a deep thread can't scroll a phone sideways; past the cap replies stay in the same column and the connector is a straight vertical drop. On the feed and the profile Posts sectionVutuv.Posts.collapse_threads/1folds each visible chain: it drops the ancestors' own standalone rows (so a middle post is no longer shown twice) and hands each surviving leaf its ordered:ancestors, so however many posts or authors a thread spans it renders once; the archive, saved lists and permalink fall back to nesting the single direct parent. All read the same (each a single card of flatdivide-yrows). The notification page reuses the compactpost_preview/1for the post a like/reply quotes. The permalink page lists the visible replies oldest-first (nesting off there — the parent is the page itself), the action bar carries a live reply counter, and the parent's author gets a derived "replied to your post" notification (self-replies excluded). A reply outlives its parent: where the parent is gone the card falls back to a banner (which names the account as@handle, never the clear name) that degrades from "Reply to a now-deleted post by @handle" (profile link) to a nameless "Reply to a deleted post" once the account is gone too — no name is retained past account deletion. - Post images: uploaded eagerly in the composer (so inline
references work before submitting; abandoned uploads are swept after a day), up to 10 per post, 6 MB each (jpg/png/webp, plusheicwhen the libvips build can decode it — capability-detected viapriv/heic_probe.heic). All served versions are AVIF (see Images below), EXIF-autorotated and metadata-stripped (no GPS leaks); the original keeps its metadata in the privateoriginals/tree and is never served. Every image byte goes through the authorizing proxyGET /post_images/:token/:version(VutuvWeb.PostImageController), so a post's audience guards its images too — served withsend_fileeverywhere (the X-Accel-Redirect handoff was disabled after it failed in production; see Deployment). Legacy…/feed.webpURLs in old post bodies keep resolving. - Moderation (family-friendly by design): any member can report a post, a private message or a whole profile (quiet "Report" affordances on every post card, message bubble and profile footer; categories: not family-friendly, bullying/harassment, spam, other). A report from a reporter in good standing freezes the content instantly (
frozen_at) — it vanishes for everyone but the owner and admins, with no public tombstone — and opens aVutuv.Moderation.Case. The owner is notified (in-app + email) and can settle it without an admin at/moderation/cases/:id: delete it, edit it (auto-unfreezes; a re-report then skips self-service) or dispute it ("my content is fine" — stays frozen, escalates). Silence for 72h escalates too (Vutuv.Moderation.Sweeper), so the admin queue at/admin/moderation(a LiveView; the case page rules reload-free and drops back to the queue) only carries disputes, ignored cases, re-reports and profile cases. Admin rulings are one click: uphold (owner gets a strike: warning → one-week suspension → permanent deactivation; strikes expire after 12 months) or reject (unfreezes; rejections lower the reporter's trust, and reports marked abusive strike the reporter on the same ladder — reporting-as-a-weapon is treated as bullying). Reporters with a bad track record lose the instant freeze (their reports only flag for review), whole profiles freeze only on a second independent trusted report, and/admin/moderation/reportersshows every reporter's track record. Suspended/deactivated accounts cannot log in and disappear from feeds, profiles and search. House rules live at/community. Reporting someone also separates the two accounts on the spot (before any second report or admin ruling): connection and follows are removed and the 1:1 conversation is frozen for both sides; the report form warns a tied reporter up front (including that the separation de-facto reveals who reported), and after sending the reporter is told again (flash + a notification) that the pause works in both directions and is undone if admins find the report unfounded - a rejected case restores exactly what was cut (recorded inmoderation_severances), an upheld one leaves the separation in place. Profile and message reports also capture a full-page evidence screenshot at report time (Vutuv.Moderation.EvidenceScreenshot: headless Chromium with a very tall window, trimmed by libvips; message threads render through the token-guarded/moderation/evidence/:tokenpage), stored under the privatemoderation_evidence/tree and shown to admins via the authorizing/admin/moderation/:id/evidenceroute. Every case carries an audit log (moderation_events: reports, freezes, severances, owner self-service, escalations, rulings, strikes) rendered as the History timeline on the admin case page, and the urgent admin email names the profile, category and reporter's note instead of just a link. - Agent formats (markdown for agents): every public page is also served as Markdown, plain text (80 columns), JSON and XML under the same URL plus an extension —
/stefan.wintermeyer.md/.txt/.json/.xml, the profile additionally as.vcf(vCard 3.0) — or viaAccept: text/markdown/text/plain/application/json/application/xmlcontent negotiation (the Cloudflare "markdown for agents" convention). Covered pages: profile, post permalinks, the post archive, follower/following lists, tag pages and the most-followed listing;/llms.txtdocuments the scheme. An "Other formats" card surfaces these links on the profile aside, the post permalink and the feed rail. Labels default to English (the canonical, cache-safe rendering — the session locale is deliberately ignored);?lang=deopts into a translated rendering, and the card links it for visitors browsing in German. These variants render the anonymous public view from one doc map per page (VutuvWeb.AgentDocs.*Doc— the single source of truth; a drift test fails when a page's HTML and its docs diverge). The newsfeed is the one exception:/feed.md/.txt/.json/.xml(VutuvWeb.AgentDocs.FeedDoc, negotiated byVutuvWeb.NewsfeedController— the controller in front of the/feedLiveView) render the signed-in viewer's own timeline, so they are login-only and sentprivate, no-store+noindex/noai(an agent-format request without a session 404s, and a feed has no.vcf). Documents carryschema_version+generated_at; responses carryContent-Signal,Vary: Acceptandx-markdown-tokens. The signals render two independent member choices (VutuvWeb.ContentPolicy), both asked at sign-up and editable on the profile form:noindex?(search engines →search=, robotsnoindex) andnoai?(AI agents/LLMs →ai-train=/ai-input=, robotsnoai, noimageai) — any combination is valid; pages that are noindexed page-level (profile sections, people lists, restricted posts) send every signal asno. Existing members were migrated as AI-opted-out (they were never asked) and can opt in on the edit form. The extension parsing lives inVutuvWeb.Plug.AgentFormat(endpoint; only the five known extensions are stripped, so dotted slugs keep working, and a.mdURL that no controller answers 404s instead of serving HTML). Agent readiness (per specification.website):/sitemap.xml(chunked index over members/posts/tags,Vutuv.Sitemap), RSS 2.0 feeds with full post content (/:slug/posts/feed.xmlper member,/posts/feed.xmlsite-wide,VutuvWeb.Feeds), robots.txt names the AI crawlers and declares draftContent-Signaldirectives from the one policy source (VutuvWeb.ContentPolicy, config:ai_crawler_policy— flips robots.txt and the response headers together),Linkheaders advertise llms.txt/sitemap/per-page alternates (VutuvWeb.Plug.AgentLinks), schema.org JSON-LD (Person on profiles, BlogPosting on permalinks, WebSite+SearchAction on the homepage —VutuvWeb.JsonLd, drift-tested against the doc builders), and/.well-known/serves agent-skills discovery (Cloudflare draft, digest-verifiedSKILL.md) plussecurity.txt - Link previews (Open Graph): every HTML page carries
og:*+twitter:cardtags derived in one chokepoint (VutuvWeb.OpenGraph, rendered by the root layout; the plain description meta shares the same derivation). Pages about a member preview their name, work info and avatar — served as a scraper-friendly square JPEG at/:slug/avatar.jpg(VutuvWeb.AvatarController; preview scrapers don't decode the site's AVIF), derived on the fly from the kept original, metadata-stripped. Public posts preview as articles with their first line, date and first image (/post_images/<token>/og.jpg, derived on the fly by the authorizing proxy, so audience changes keep guarding it); restricted posts and teasers never leak the body or an image. Everything else falls back to/og-card.png(VutuvWeb.OgCard): the white wordmark (shipped pre-rasterized as a PNG) composed onto the brand gradient, generated once per node (no font or SVG-loader dependency, so it renders identically in dev, test, CI and production). - Daily text ad (
Vutuv.Ads): one discreet, text-only ad per calendar day (Europe/Berlin via the fixed EU DST rule, no tz dependency), rendered between the top navigation and the content in the style of classic text ads, always labeled "Ad"/"Werbung". A visitor sees it at most once per hour (session-tracked, and only counted when the banner actually rendered), it hides itself after two minutes (app.js), and its ✕ dismisses ads for the rest of the day (a day-stamped client cookie the plug honors). On unbooked days a short house ad sells the slot. Booking is online at/ads→/ads/new(logged-in only): pick a free day (one ad/day, unique index), enter the invoice address, ad text as Markdown (max 2048 chars, must be family-friendly, rendered throughVutuvWeb.Markdown). 1.250 € net per day, payment by invoice: the booking mail (billing data + ad text) goes to the operator, who invoices manually; serving on the booked day is automatic. Every ad is admin-approved before it runs (approved_at; an unapproved ad never serves, the house ad fills its day): the review dashboard lives at/admin/ads(with a pending badge on the admin panel), the member sees the approval state of their bookings at/ads/bookings, and the earliest bookable day is three days out to leave room for the review. Bookings are accepted only inside the booking window (through the end of next month); the booking form shows it as month-grid calendars with free days as radio buttons and booked days struck through, and submits to a preview step that renders the ad through the real banner component (without its auto-hide/seen-marker hooks) before the binding confirm POST books it./adsis a public page with agent-format siblings (VutuvWeb.AgentDocs.AdsDoc). The whole system sits behind a global switch (config :vutuv, :ads_enabled, read viaVutuv.Ads.enabled?/0), off by default: with it off no banner serves and the/adsflow plus the/admin/adsreview dashboard 404, while"ads"stays a reserved username slug so the handle is kept free - Daily activity report (
Vutuv.Reports): a basic operator metric for one German calendar day (Europe/Berlin viaVutuv.BerlinTime.day_bounds_utc/1) — confirmed-by-PIN new registrations (email_confirmed?accounts created that day) plus the day's posts, reposts, likes and bookmarks, and the day's email-deliverability events (hard bounces, address deactivations, account freezes and thaws, fromVutuv.Deliverability). Admins read it at/admin/reports, a time machine:?date=YYYY-MM-DD(defaulting to yesterday) with prev/next links and a date picker.Vutuv.Reports.DailyReporter, a supervised cron-style GenServer, schedules itself for the next 00:05 Berlin and mails the previous day's report to the operator through theEmailerchokepoint (daily_report_email/1), skipping any day whose every metric is zero. The email subject lists only the non-zero numbers (e.g. "vutuv Tagesbericht 20.06.2026: 5 Registrierungen, 12 Beiträge, 1 eingefrorenes Konto"), so the day's signal is readable at a glance. Behindconfig :vutuv, :daily_report_email(off in tests, on by default). - Admin member browser (
/admin/users,VutuvWeb.Admin.UserLive): a LiveView list of every account, linked from the/adminpanel, that feels like a native app — every change updates the page with no reload. The default view answers "who just joined?": PIN-registered members (email_confirmed?), newest first. Search-as-you-type by name,@handleor email address (debounced; the email is matched server-side to find the account for support/moderation but is never shown in the listing), filter by registration (PIN-confirmed / not confirmed / all) and account flag (admins / identity-verified / awaiting verification / frozen / suspended / deactivated / unreachable), and click a column header (Member, Username, Joined) to sort either way. The whole filter/sort state lives in the URL (push_patch), so a view is shareable and the back button restores it. Each row shows the registration plus moderation/deliverability state as badges, and an inline Verify button that flips the row to "Verified" in place and emails the member (Vutuv.Accounts.verify_identity/1, shared with the legacyPOST /admin/users) — so the old identity-verification queue is now just the?flag=unverifiedview of this page (the/adminpanel links straight to it and badges its size). Verification is auto-revoked if the member later edits any identity detail the badge vouches for (any name part — first/middle/last name, nickname or honorific title — plus gender or birthday): the admin's ID check was made against exactly those details, soUser.changeset/2clearsidentity_verified?whenever one changes (the security chokepoint, so it bites on the edit form and the/api/2.0PATCH /me), and the profile editor shows a friendly toast explaining the badge was removed and why (to keep verified profiles trustworthy and stop fake verified accounts). Re-verification is a fresh admin action. Live result count, paging (thePageScrollJS hook returns you to the top of the list on each page change) and clear-filters round it out. Query, filter and sort live inVutuv.Accounts.admin_user_filters/1+list_admin_users/3(offset-paginated viaVutuv.Pages, 50/page). Admins-only via the:adminlive_session (on_mount :require_admin). - Admin delete account (
/admin/users/delete,VutuvWeb.Admin.UserDeleteLive): a focused, admins-only LiveView for permanently removing an account. Search-as-you-type by name,@handleor email address (the same matcher as the member browser), then hit Delete on a row to open an "Are you sure?" confirmation modal naming the account. Confirming runs the oneVutuv.Accounts.admin_delete_user/1chokepoint, which snapshots the account's details (name,@handle, id, every email address and phone number, post count, join date) before the cascade, then deletes the account and everything it owns (posts, phone numbers, email addresses, tags, endorsements, images, follows) viadelete_user/1. The deleted member is never emailed; instead the operator (sw@wintermeyer-consulting.de) gets a record email (Emailer.account_deleted_notice/1) listing what was removed and the exact deletion timestamp (UTC + Europe/Berlin). Admins-only via the:adminlive_session (on_mount :require_admin). - Email newsletter / "Rundbrief" (
Vutuv.Newsletters): admins compose a broadcast email at/admin/newsletters, save it as a draft, send a test to any single address, then broadcast it to every eligible member — and read the per-recipient delivery log (the protocol: when which email went out, with status), which is searchable (recipient email/@handle), filterable (kind: test/broadcast, status: sent/suppressed/error), sortable by any column and paginated (Vutuv.Pages+<.pager>, 50/page). In the dev environment the newsletter pages link to the Swoosh mailbox at/sent_emails(dev_mailbox?/0). The body is trusted Markdown rendered to inline-styled HTML for clients (Vutuv.Newsletters.Markdown, Earmark direct, no sanitizer so the styles survive), with merge variables ({{greeting}}for a localized personal salutation, plus{{first_name}}/{{last_name}}/{{name}}/{{username}}/{{email}}) substituted per recipient — HTML-escaped in the HTML body, raw in the text body and subject. Every message goes through the oneEmailer.newsletter_email/1chokepoint (bulk headers + RFC 8058 one-click unsubscribe). It is opt-out (users.newsletter_emails?, default true, toggle on the notification settings page or the unsubscribe link); the broadcast skips unconfirmed, unreachable, suspended, deactivated and unsubscribed members, picks each member's lowest-position deliverable address, and is single-send (draft → sending → sent, an atomic lock stops a double click). Sends run in the background (Vutuv.TaskSupervisor; inline in tests). Admins-only via the/adminpipeline. Every vutuv.de link in the HTML body is click-tracked: itshref(not the visible URL) carries a signed per-recipient?nlt=token (VutuvWeb.NewsletterToken), so when a recipient follows it the:browser-pipeline plugVutuvWeb.Plug.NewsletterClickrecords who clicked which link when (newsletter_clicks) and redirects to the clean URL; the plain-text version keeps the bare link, and external links are left alone. The newsletter show page then carries a "Resonance" success overview (recipients, members who clicked + rate, total clicks, and a clicks-per-link table, all over the broadcast audience) plus a paginated click log at/admin/newsletters/:id/clicks(who clicked which link, when). The Datenschutzerklärung describes the tracking (legitimate interest, opt-out via the newsletter unsubscribe). - Newsletter audiences / groups (
Vutuv.Newsletters.NewsletterGroup, the builder isVutuvWeb.Admin.NewsletterGroupLiveat/admin/newsletter_groups): instead of always sending to everyone, an admin builds a fixed audience and watches the matching-member count update live (a LiveView in its own:adminlive_session, guarded by the:require_adminon_mount). The builder has two modes, chosen by a toggle at the top: From filters (the default, below) and Specific accounts — a hand-picked allowlist (e.g. a small group of testers) where the filters are hidden and the admin searches members by@handleand adds them one by one; the audience is then exactly those accounts. (This relies on the audience query treating an empty filter as matching nobody once any positive selector — a picked account or an added group — is present, so a hand-picked list resolves to just those members rather than everyone-plus-them; with no selector at all it still means everyone, which is what the "all members" and "send to the rest" groups need.) Filters: language (locale), country (the free-textcountryon profile addresses, chosen from existing values), age (min/max frombirthdate, translated tobirthdatebounds), tag (members holding a tag, looked up by name) and username (an ILIKE handle pattern where*/?are wildcards and a plain term is a contains-match). An optional cap (max_size) takes N members for a test run — either the oldest first (by join date) or a random sample of the pool (random_sample) — and a group can add (included_group_ids, a union, bypassing the filters) and/or subtract other groups (excluded_group_ids) so "test run of 100, then the rest" partitions cleanly. On top of the filters, individual accounts can be hand-picked: the paginated preview/search list has a checkbox per member (tick to include, untick to exclude), stored asincluded_user_ids/excluded_user_ids(exclusion wins); a search-by-handle box finds any eligible member to add, Select all / Unselect all apply to the whole current view (all matches, not just the page), and excluded members show as undo-able "Removed" chips (capped, with a "+N more" overflow so a bulk unselect can't render thousands). The selection survives filter changes and paging. The filter clauses combine as one Ectodynamicso they can be OR-ed with the added groups/accounts. The name field comes pre-filled with a timestamped default (e.g. "Audience 2026-06-23 05:54", Berlin time), like the access-token form. The builder shows a live, paginated preview of matching members (with links to their profiles) so the admin can eyeball the filter, and each saved audience has a show page (:show) listing its frozen members paginated, again as profile links. On save the matching members are frozen into a snapshot (newsletter_group_members), so the subtraction is stable; the broadcast then targetsgroup ∩ still-eligible(a member who unsubscribed or bounced after the snapshot is skipped). The newsletter's broadcast card has an audience picker (all eligible members, or a group) and records which group was used (newsletters.group_id). - Routes: Verified routes (
~p"..."sigils). Profiles live at the URL root, GitHub-style:/:slugis the profile and all per-user sub-pages hang off it (/:slug/links,/:slug/followers,/:slug/following,/:slug/connections, ...). The legacy/users/:slug/...URLs,/sessions/newand/search_queries/...301 to their new homes (/login,/logout,/search). The user scope is the last in the router, so static routes always win;Vutuv.Accounts.ReservedSlugskeeps users from registering a slug that equals a route prefix. The old read-only/api/1.0JSON API was removed in favor of/api/2.0; only the session-aware vCard survived, at/:slug/vcard - Third-party API (
/api/2.0,Vutuv.ApiAuth): an authenticated REST/JSON API for scripts and third-party apps. Bearer tokens only (no session/CSRF; CORS wide open since no cookie authenticates): members mint personal access tokens at/access_tokens(scoped permissions likeprofile:read/posts:write, mandatory 30/90/365-day expiry, shown exactly once, SHA-256-hashed at rest, prefixvutuv_pat_for secret scanners; the new-token form is pre-filled — dated name,profile:read, 90 days — so the quickstart is one click) and revoke per token or all at once — every request verifies against the DB, so revocation, account moderation and app suspension bite on the very next request. Reads go through the authorizing member's eyes (same visibility rules as the website, via the AgentDocs doc builders with aviewer); writes go through the same context functions as the UI, so blocking, moderation, audience locks, cooldowns and live broadcasts behave identically. Covered: profile (PATCH /me) + section CRUD (emails read-only — PIN-verified identities), follow/unfollow (a mutual follow makes the pair vernetzt — no separate connection lifecycle) +GET …/relationship, posts (compose with deny-based audiences, replies, like/bookmark/repost switches, the cursor-paginated/feedwith signed opaque cursors), direct messages (request model included; a declined request stays indistinguishable from silence) and the notification feed. Per-token rate limit (5,000/h,X-RateLimit-*headers), RFC 9457 problem+json errors (422 with per-field messages), additive-only within/api/2.0(breaking changes mean a new version prefix). OAuth 2 for real third-party apps (authorization code + mandatory PKCE S256, confidential clients, rotating refresh tokens with reuse-revocation, RFC 7009 revocation): developers register apps at/developers/apps(self-service, always owned by a vutuv account;/admin/api_appsis the suspend kill switch that fails every app token on its next request), members approve scopes on the/oauth/authorizeconsent screen and manage/withdraw access at/connected_apps. Webhooks (Vutuv.Webhooks): per-app subscriptions deliver signed thin event envelopes (HMAC-SHA256 inX-Vutuv-Signature, ids/usernames only, never content) for members who granted the matching scope; DB-backed queue with exponential backoff drained byVutuv.Webhooks.Deliverer, auto-disable after sustained failure, test ping from the app page. Developer docs in English with curl examples at/developers(Markdown files inpriv/dev_docs/, also served raw under.md): overview with a development/bug-reporting section, authentication, a task-recipe cookbook ("how do I post / send a DM?"), the data model (entities + visibility rules), the endpoint reference and webhooks — linked from the footer of every page. API profile responses carry the member'snoindex?/noai?consent flags in-band (the public.json/.mdsiblings signal the same viaContent-Signal/X-Robots-Tagheaders) - Data export (GDPR): every member can download everything vutuv stores about them as one JSON file at
/:slug/export(linked from the edit-profile sidebar). Strictly owner-only — it includes private data (all email addresses, direct messages, ad bookings).Vutuv.Exportbuilds the document; a new per-user subsystem must add its section there (just likeAccounts.delete_user/1must learn to delete it) - Email & phone number types: every email address and phone number carries an owner-editable type label. Emails are Work / Personal / Other (
Vutuv.Accounts.Email.email_type, defaultOther; offered on the registration form and the add-email form, the add-email choice carried through the two-step PIN confirmation via the session, editable later on the email edit form), phone numbers Work / Cell / Home / Fax. The label shows on the profile contact card and the management/show pages, is exported in the vCard (EMAIL;TYPE=/TEL;TYPE=) and the GDPR JSON, and rides in the agent-format docs — giving an email a type changed an email entry from a bare address string to a{id, type, value}map (matching phone numbers), a breaking change that bumped the agent-docschema_version. On the profile contact card, German numbers are shown to German viewers in national format (+49 261 9886803→0261 9886803) while everytel:link keeps the canonical E.164 form, viaVutuv.Phone(theex_phone_number/libphonenumber port); foreign numbers and non-German viewers see the stored value unchanged. On the way in, the changeset (Vutuv.Phone.normalize/1) parses a typed number against the defaultDEregion, rejects anything libphonenumber does not recognise as a valid number (so only real numbers are saved), and stores the rest in canonical international form (0261-123456→+49 261 123456); a foreign number keeps its own country code. Addresses follow the same German-viewer rule (Vutuv.Address): a German viewer (locale == "de") looking at a German address sees no redundant "Deutschland" line, while foreign addresses and non-German viewers keep the country; every address on the profile card also links out to the major map services — Google Maps, OpenStreetMap and Apple Maps — and each logged-in member chooses on the account settings hub which of those to show and which is the default (rendered as the primary "Open in …" button, the rest a quiet "Also on" line); opening a non-default service promotes it to the new default live across the page and persists it (Vutuv.Maps; with JS off they stay plain links that still open). A logged-out visitor sees the default set (Google primary). The geocoding query keeps the country even when it is hidden on screen - Ordered profile sections: members arrange their links, phone numbers, addresses, social media accounts and email addresses in the order they want instead of by creation date (a nullable
positioncolumn per table, backfilled in creation order; the sharedVutuv.Orderingcontext owns the bookkeeping). Each management page (/:slug/links,/phone_numbers,/addresses,/social_media_accounts,/emails) carries an owner-only ordering tool, the embeddedVutuvWeb.SectionReorderLive(rendered withlive_render, like the app shell): drag an entry by its handle, or use the per-row up/down arrows. Both persist over the LiveView socket with no page reload (theReorderJS hook does the drag; the arrows arephx-click), and the arrow reorders glide into place with a small FLIP animation (~180ms, disabled underprefers-reduced-motion); the tool is mobile-first, so on touch — where native drag is unavailable — the comfortably-sized arrows are the reorder path. Every change renumbers positions 1..n server-side, scoped to the owner. The chosen order drives the profile preview, the section page and every agent-format sibling; new entries append to the end. - Username (@handle) changes: members change their username at
/:slug/usernames/new, linked from the edit-profile sidebar. Handles follow the Twitter username mechanism: letters, digits and underscores, 3 to 15 characters, stored lowercase, unique (users.usernamecarries the unique index; there is no slugs table), never a reserved route word; the form checks availability live while typing (GET /:slug/usernames/availability). Renaming frees the old handle immediately: no redirect, no reservation, anyone can claim it. Changes are limited to 4 per rolling 90 days (counted via theusername_changesledger) and the form spells the quota out, including the next possible date once it is used up. - Authentication & sessions: vutuv is passwordless. The baseline login is a two-step email-PIN flow (
/loginmails a 6-digit PIN, the second step verifies it; no password is ever stored). Returning members can also enrol one or more passkeys (WebAuthn / FIDO2 — Touch ID, Windows Hello, a security key) from the Account hub and sign in with one as an alternative first factor, skipping the email round-trip entirely (Vutuv.Credentials, thewax_library, tableuser_credentials; the browser ceremony isassets/js/webauthn.js, revealed only on supporting browsers). A passkey is enrolled only while logged in, so the email PIN stays the always-available fallback and the only way to bootstrap an account — a passkey is a faster return login, never the root of trust. If a member types their address and clicks "Sign in with a passkey" but that account has no passkey, the challenge endpoint quietly falls back to the email-PIN flow — it mails a PIN and drops them on the PIN screen with a friendly note — instead of stranding them at an empty native prompt (issue #834). Passkey verification funnels into the sameAccounts.login/2exit as the PIN, so it gets the identical server-side per-device session row, new-device security email and live-socket wiring. Each login is a tracked server-side session (Vutuv.Sessions, tableuser_sessions, SHA-256-hashed token): members see where they are signed in, revoke a single device or all others, and add / remove passkeys at/:slug/settings; a noteworthy login (new device, suspicious location) mails a security alert - Pagination: browse pages (followers, tags, the admin member browser) use offset pagination —
Vutuv.Pages.paginate/3on the query plus the<.pager>component for the numbered links; feed LiveViews (notifications) use cursor pagination instead —Vutuv.Activity.notifications_page/2behind a numbered "Load 50 of 80 more" button that appends to the stream. Displayed counts (badges, follower numbers) are compacted site-wide viaVutuvWeb.UI.compact_count/1: exact up to 999, then 1K/80K/5M. The one exception is the landing-page member counter, which shows the exact total viadelimited_count/1(see Live member counter below) - Forms:
<.form>component with<.inputs_for>for nested forms - Assets: esbuild + Tailwind CSS v4; dark mode follows the system (
prefers-color-scheme, no toggle) — legacy pages get their dark styles centrally fromassets/css/components.css - HTTP server: Bandit
- Email: Swoosh, sent multipart (compile-time EEx text bodies + a shared HTML framework,
VutuvWeb.EmailComponents); all mail built fromEmailer.base_email/0and sent through oneEmailer.deliver/1chokepoint that stamps the auto-generated robot headers and the bounce envelope sender (Sender: bounces@vutuv.de→ SMTP MAIL FROM). Notification mail is opt-out: the unread-message nudge respectsusers.notification_emails?, carries RFC 8058 one-click unsubscribe headers and a tokenized footer link (/unsubscribe/:token, no login needed); transactional mail (PINs, moderation) cannot be opted out of. Bounces feed back (Vutuv.Deliverability): the production log watcher tails Postfix'smail.log(the/webhooks/bouncesDSN endpoint feeds the same path) and marks a hard-bounced address undeliverable,deliver/1then drops automatic mail to it; PIN mail still sends, and a successful login PIN through the address clears the mark. A confirmed account whose every address is dead is frozen as unreachable (hidden from others, owner and admins still see it); admins track and undo all of it at/admin/deliverability. Full design indocs/production-email-and-bounces.md - Images: avatars, profile cover photos, URL screenshots and post images are stored on local disk and processed with
image(libvips); seeVutuv.Avatar/Vutuv.Cover/Vutuv.Screenshot/Vutuv.PostImageStore. Every served version is AVIF; the resolution, crop and quality of every version live in one module,Vutuv.Uploads.Spec, so a future format/compression change is a Spec edit plus onemix vutuv.images.regeneraterun. Every uploaded original is kept verbatim (format + metadata) under the private<UPLOADS_DIR_PREFIX>/originals/tree (Vutuv.Uploads.Originals) as the source for re-deriving — it must never be served (noPlug.Staticmount, no nginx alias; a regression test enforces this). Cover photos are uploaded via the Edit profile form and served from<UPLOADS_DIR_PREFIX>/covers/(nginx needs alocation /covers/alias in production, mirroring/avatars/) - Fingerprinted avatar/cover filenames: avatar and cover files are named
<handle>-<version>-<fingerprint>.avif(e.g.swintermeyer-medium-1a2b3c4d.avif), where the fingerprint issha256(original)[0..11]. The handle makes a downloaded file carry the username; the fingerprint makes the URL immutable, so it needs no?v=cache-buster and the existing nginxaliasserves it directly (no rewrite). The fingerprint is stored inusers.avatar_fingerprint/cover_fingerprint; a username change re-derives the files under the new handle. A row with no fingerprint has not been migrated yet and serves the legacyavatar_<version>.avif?v=...URL unchanged. The migration is expand/contract: the regenerator writes the new files and keeps the legacy ones (so the previous release and a rollback keep serving them); once the scheme is confirmed healthy in production,mix vutuv.images.sweep_legacy(Vutuv.Release.sweep_legacy_images()) deletes the legacy files — a deliberate, manual step, never part of the deploy - URL screenshots: rendered by local headless Chromium, wrapped in a browser window frame (
Vutuv.BrowserFrame); seeVutuv.PageScreenshot. Needs achromium/chromebinary on the host (setCHROMIUM_PATHif it is not on$PATH)
Business logic is organized into Phoenix context modules under lib/vutuv/:
| Context | Schemas | Purpose |
|---|---|---|
Vutuv.Accounts |
User, Email, UsernameChange, SearchTerm, LoginPin, Locale, Exonym | Registration, PIN-based authentication, user management |
Vutuv.Sessions |
UserSession | Server-side per-device sessions: signed-in-devices list, remote logout, new-device security email |
Vutuv.Credentials |
UserCredential | Passkeys (WebAuthn/FIDO2): enrolment + assertion verification for passkey login |
Vutuv.ApiAuth |
Token, App, Grant, AuthCode | API credentials: personal access tokens, OAuth 2 apps/grants/codes, scopes |
Vutuv.Webhooks |
Subscription, Delivery | Signed webhook deliveries to registered apps (queue, backoff, kill switch) |
Vutuv.Profiles |
Address, PhoneNumber, SocialMediaAccount, Url, WorkExperience | User profile data |
Vutuv.Social |
Follow, Block, Group, Membership, UserLike, UserBookmark | Follows (a mutual follow = vernetzt), per-follow mute, blocking, groups, liking/bookmarking people |
Vutuv.Posts |
Post, PostDenial, PostImage, PostTag, PostLike, PostBookmark, PostRepost, PostReply | Posts, deny-model audiences, the feed, likes/bookmarks/reposts, replies/threads |
Vutuv.Tags |
Tag, UserTag, UserTagEndorsement | Tagging and endorsements |
Vutuv.Search |
SearchQuery, SearchQueryRequester, SearchQueryResult | Search functionality (people/tags; post full-text via Vutuv.Posts.search_public/2) |
Vutuv.Chat |
Conversation, Participant, Message | 1:1 direct messages, message requests, unread email notifier |
Vutuv.Moderation |
Case, Report, Strike | Reports, the content freezer, the strike ladder, reporter trust |
Vutuv.Notifications |
Emailer | Email notifications |
Vutuv.Newsletters |
Newsletter, NewsletterDelivery, NewsletterClick, NewsletterGroup, NewsletterGroupMember, Markdown | Admin email newsletter ("Rundbrief"): draft, test send, broadcast, delivery log, link click tracking + success overview, and filter-built audience groups |
Vutuv.Deliverability |
Event, MailLog, Watcher, Sweeper | Bounce detection: deactivate dead addresses, freeze unreachable accounts, admin dashboard |
Vutuv.Ads |
Ad | The daily text ad: booking, billing record, serving |
The third-party REST/JSON API lives at /api/2.0 (Bearer tokens, JSON in/out).
Get started in two steps:
- Create a personal access token at
/access_tokens(the form is pre-filled; the defaultprofile:readscope is enough for reading). -
curl -H "Authorization: Bearer vutuv_pat_YOUR_TOKEN" https://vutuv.de/api/2.0/me
The full developer documentation — quickstart, authentication & scopes, a
task-recipe cookbook, the data model, the endpoint reference and webhooks —
is served at /developers and lives in this
repo as Markdown under priv/dev_docs/.
mix testv6 cutover (history): the two non-routine one-time migrations, the UUID v7 re-key (every integer id became a UUID v7, image directories relabelled) and the AVIF image pipeline, shipped to production on 2026-06-18. The rollback soak was ended early the same day after verification: the
legacy_id_mapmap table was dropped and a fresh v6 backup was taken (the pre-v6 backup is kept as a cold archive). One transitional bit is deliberately left in place: the.webpimage fallback, still needed by a handful of old screenshots whose originals could not be re-encoded.
Deployment is automatic. Two GitHub Actions workflows drive it:
- CI (
.github/workflows/ci.yml) runsmix precommit(compile with--warnings-as-errors, unused-deps, format,credo --strict, tests) on every pull request and on pushes tomain. - Deploy (
.github/workflows/deploy.yml) runs on every push tomain. So merging or pushing anything tomainships it to production; there is no separate deploy command.
The Deploy job runs on the self-hosted vutuv3 runner (on bremen2) and executes scripts/deploy.sh, a blue/green zero-downtime deploy: it builds a prod release, runs migrations against vutuv3_prod, starts the release on the idle slot (vutuv3@blue on port 4003 / vutuv3@green on port 4005), waits until GET /health answers 200 with a live database connection, switches the nginx upstream (/etc/nginx/snippets/vutuv3-upstream.conf) with a graceful reload, drains for 30 s and stops the old slot. A failed build or boot leaves the old slot serving, untouched. A deploy-production concurrency group ensures two production deploys never overlap.
Because the old code briefly serves against the already-migrated database, migrations must be backward-compatible; a deploy that cannot be (such as the one-time UUID v7 re-key, which shipped on 2026-06-18 as a planned-downtime deploy) must be run deliberately, not pushed casually to main. The systemd slot template lives in scripts/systemd/vutuv3@.service.
These are public images served straight off disk. The vhost needs one
prefix-location alias per directory, all three pointing into
<UPLOADS_DIR_PREFIX> (/srv/vutuv3 in prod). All three are required — a
missing block (it has happened with /covers/) means those images 404 even
though the file exists on disk and the app emits the right URL, because the
request falls through to the app, which does not serve them. Add to the vhost:
location /avatars/ {
alias /srv/vutuv3/avatars/;
expires 30d;
add_header Cache-Control "public";
}
location /covers/ {
alias /srv/vutuv3/covers/;
expires 30d;
add_header Cache-Control "public";
}
location /screenshots/ {
alias /srv/vutuv3/screenshots/;
expires 1y;
add_header Cache-Control "public, immutable";
}The private originals tree (/srv/vutuv3/originals/) must not get any
location/alias: uploaded originals are never served.
Post images are auth-proxied: the app checks the post's audience (deny-model, Vutuv.Posts.visible_to?/2) and then serves the bytes itself with send_file (the sendfile syscall, no in-memory buffering); nginx proxies the response like any other app response. No nginx location is required — that is the point of :post_image_serving being :send_file (config/runtime.exs), and it serves both vutuv.de and the staging new.vutuv.de vhost with zero per-vhost config.
Why not X-Accel-Redirect? The original design handed the file off to nginx via
X-Accel-Redirectfrom aninternallocation (:accel_redirect). It was tried in production on 2026-06-29 and failed: even with theinternal_post_imageslocation present and correct, the files readable by the nginx user, and the URL matching the regex, nginx rejected every X-Accel internal redirect with its bareinternal404 instead of streaming the file — so every post image came back broken while the app itself was behaving correctly.send_fileis the path dev/test already use, so it is well tested and audience-guarded identically, at the cost of the app (not nginx) pushing the bytes — negligible at this scale. The:accel_redirectmode still exists in the controller for if the nginx handoff is ever root-caused and re-enabled; if so, add thisinternallocation to every app-serving vhost (vutuv.deandnew.vutuv.de):location ~ ^/internal_post_images/(?<token>[A-Za-z0-9_-]+)/(?<version>thumb|feed|large)\.avif$ { internal; alias /srv/vutuv3/post_images/$token/$version.avif; }
Uploads run over the LiveView websocket (no client_max_body_size change needed for the 6 MB images unless the websocket location caps buffers unusually small).
All outbound mail uses bounces@vutuv.de as its SMTP envelope sender. When a
recipient address dies, vutuv stops mailing it (and, eventually, freezes
accounts that have become permanently unreachable). The signal comes from
Postfix's own delivery log (/var/log/mail.log) — the production host
(bremen2) is a multi-tenant relay and vutuv.de's MX is on Google, so a DSN
to bounces@vutuv.de would not reach a local pipe anyway. A confirmed hard
bounce feeds the existing POST /webhooks/bounces endpoint, which marks the
address undeliverable (emails.undeliverable_at, shown to the owner on their
emails page): automatic mail to it is dropped, PIN mail still sends, and a
successful login PIN through the address clears the mark. Without
BOUNCE_WEBHOOK_TOKEN the endpoint 404s and bounce handling is simply off.
The full topology, DSN-code taxonomy, the decision not to change the MX, and a
new-server runbook live in
docs/production-email-and-bounces.md.
Bounce handling is not yet switched on in production (no token, no
detector).
These tasks operate on the on-disk uploads under <UPLOADS_DIR_PREFIX>/... (see config/runtime.exs). They are meant to be run manually on the server.
mix vutuv.images.regenerate [--only avatars|covers|screenshots|post_images] [--dry-run]re-derives every served image version (AVIF) from the kept originals per the currentVutuv.Uploads.Spec, relocating legacy public originals into the privateoriginals/tree first. Idempotent; rows whose original is missing are skipped with a warning and left untouched. On the production release (no Mix) runbin/vutuv eval "Vutuv.Release.regenerate_images()"instead — safe while the app is serving traffic.mix urls.create_screenshots(re)renders URL screenshots. Needs the headless Chromium binary already described above (setCHROMIUM_PATHif it is not on$PATH).
The release console commands (bin/vutuv eval, bin/vutuv rpc, bin/vutuv remote) cd into the release directory before booting the VM (see rel/env.sh.eex), so they run correctly from any working directory — including a sudo -u vutuv3 shell that inherited root's unreadable /root as its cwd. Without that hook the boot dies with a masked persistent_term:get(code_server) badarg (the BEAM cannot read the cwd it puts on the code path). rpc/remote additionally need distribution enabled (RELEASE_DISTRIBUTION is none on the blue/green slots), so eval is the supported way to run the tasks above on production.