Skip to content

wintermeyer/vutuv

Repository files navigation

vutuv

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.

Development Setup

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 image package, which needs libvips. Install with brew install vips (macOS) or apt-get install libvips-dev (Debian/Ubuntu).
  • Chromium (optional) — only needed for URL screenshots and moderation evidence screenshots; set CHROMIUM_PATH if 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.

Secret config

Create config/dev.secret.exs:

import Config

config :vutuv, VutuvWeb.Endpoint,
  secret_key_base: "generate-with-mix-phx-gen-secret"

Start the application

mix setup           # deps.get + ecto.create/migrate/seeds + esbuild/tailwind install
mix phx.server

Visit http://localhost:4000. (mix setup does everything except the config/dev.secret.exs step above.)

Email in development

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.

AI tooling in development

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.

Admin access

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).

Architecture

  • Views: mostly Phoenix 1.8 HTML modules with embed_templates (no phoenix_view dependency); 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 shared app layout via live_render, so the chrome and badges are live on every page. The Messages (/messages), Notifications (/notifications) and Search (/search) pages are LiveViews under a live_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 by Vutuv.Search.parse/2: vorname:/nachname: (aka first:/last:), @handle, double quotes for exact, plus the combinable people filters tag:/skill: (has the tag) and ort:/stadt:/city: (address in that city) - e.g. müller tag:php or müller ort:koblenz. The profile (/:slug, VutuvWeb.UserProfileLive) is a LiveView too — embedded by its controller via live_render (so the .md/.txt/.json/.xml/.vcf agent siblings keep flowing through the controller). The feed (/feed, VutuvWeb.PostLive.Feed) is fronted the same way by VutuvWeb.NewsfeedController so its own agent siblings can be negotiated (see Agent formats below), so it is the one LiveView no longer in the live_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 over Vutuv.Activity (Phoenix.PubSub on "user:<id>"); online status and typing use VutuvWeb.Presence. A site-wide online dot (green badge on a member's avatar everywhere — lists, profiles, post authors, the top bar) rides the same VutuvWeb.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 — and Vutuv.DayClock broadcasts 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 into root.html.heex (document shell) and app.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 the users.notifications_read_at read 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.MemberCounter keeps the total in a lock-free :atomics cell (ref in :persistent_term), so the per-render read (count/0) and the per-signup bump (increment/0, called from Accounts.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 embedded VutuvWeb.MemberCountLive (rendered via live_render, like the shell).
  • Follow + connect (the social graph): one action, two readings (Vutuv.Social). A follow (Vutuv.Social.Follow, table follows) 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/connections lists 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 legacy connections request/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 /blocks itself (so the "block my ex" case needs no detour through their profile). All three run the same Social.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 /blocks also 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/2 with 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|public and 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 @ Organization line 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 nullable users.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 single current_job chokepoint, 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.csv is 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 @handle of an existing member is auto-linked to their profile with the member's name as a hover tooltip, and a #hashtag is auto-linked to that tag's /tags/:slug page 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 /feed is 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 generated search_tsv column, 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 via live_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 the post_likes / post_bookmarks / post_reposts rows 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, tables user_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 /likes and /bookmarks lists 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/2 enforces 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_replies row 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/1 shows 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 section Vutuv.Posts.collapse_threads/1 folds 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 flat divide-y rows). The notification page reuses the compact post_preview/1 for 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, plus heic when the libvips build can decode it — capability-detected via priv/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 private originals/ tree and is never served. Every image byte goes through the authorizing proxy GET /post_images/:token/:version (VutuvWeb.PostImageController), so a post's audience guards its images too — served with send_file everywhere (the X-Accel-Redirect handoff was disabled after it failed in production; see Deployment). Legacy …/feed.webp URLs 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 a Vutuv.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/reporters shows 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 in moderation_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/:token page), stored under the private moderation_evidence/ tree and shown to admins via the authorizing /admin/moderation/:id/evidence route. 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 via Accept: text/markdown / text/plain / application/json / application/xml content 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.txt documents 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=de opts 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 by VutuvWeb.NewsfeedController — the controller in front of the /feed LiveView) render the signed-in viewer's own timeline, so they are login-only and sent private, no-store + noindex/noai (an agent-format request without a session 404s, and a feed has no .vcf). Documents carry schema_version + generated_at; responses carry Content-Signal, Vary: Accept and x-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=, robots noindex) and noai? (AI agents/LLMs → ai-train=/ai-input=, robots noai, noimageai) — any combination is valid; pages that are noindexed page-level (profile sections, people lists, restricted posts) send every signal as no. Existing members were migrated as AI-opted-out (they were never asked) and can opt in on the edit form. The extension parsing lives in VutuvWeb.Plug.AgentFormat (endpoint; only the five known extensions are stripped, so dotted slugs keep working, and a .md URL 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.xml per member, /posts/feed.xml site-wide, VutuvWeb.Feeds), robots.txt names the AI crawlers and declares draft Content-Signal directives from the one policy source (VutuvWeb.ContentPolicy, config :ai_crawler_policy — flips robots.txt and the response headers together), Link headers 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-verified SKILL.md) plus security.txt
  • Link previews (Open Graph): every HTML page carries og:* + twitter:card tags 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 through VutuvWeb.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. /ads is a public page with agent-format siblings (VutuvWeb.AgentDocs.AdsDoc). The whole system sits behind a global switch (config :vutuv, :ads_enabled, read via Vutuv.Ads.enabled?/0), off by default: with it off no banner serves and the /ads flow plus the /admin/ads review 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 via Vutuv.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, from Vutuv.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 the Emailer chokepoint (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. Behind config :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 /admin panel, 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, @handle or 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 legacy POST /admin/users) — so the old identity-verification queue is now just the ?flag=unverified view of this page (the /admin panel 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, so User.changeset/2 clears identity_verified? whenever one changes (the security chokepoint, so it bites on the edit form and the /api/2.0 PATCH /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 (the PageScroll JS hook returns you to the top of the list on each page change) and clear-filters round it out. Query, filter and sort live in Vutuv.Accounts.admin_user_filters/1 + list_admin_users/3 (offset-paginated via Vutuv.Pages, 50/page). Admins-only via the :admin live_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, @handle or 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 one Vutuv.Accounts.admin_delete_user/1 chokepoint, 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) via delete_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 :admin live_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 one Emailer.newsletter_email/1 chokepoint (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 /admin pipeline. Every vutuv.de link in the HTML body is click-tracked: its href (not the visible URL) carries a signed per-recipient ?nlt= token (VutuvWeb.NewsletterToken), so when a recipient follows it the :browser-pipeline plug VutuvWeb.Plug.NewsletterClick records 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 is VutuvWeb.Admin.NewsletterGroupLive at /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 :admin live_session, guarded by the :require_admin on_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 @handle and 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-text country on profile addresses, chosen from existing values), age (min/max from birthdate, translated to birthdate bounds), 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 as included_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 Ecto dynamic so 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 targets group ∩ 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: /:slug is 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/new and /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.ReservedSlugs keeps users from registering a slug that equals a route prefix. The old read-only /api/1.0 JSON 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 like profile:read/posts:write, mandatory 30/90/365-day expiry, shown exactly once, SHA-256-hashed at rest, prefix vutuv_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 a viewer); 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 /feed with 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_apps is the suspend kill switch that fails every app token on its next request), members approve scopes on the /oauth/authorize consent screen and manage/withdraw access at /connected_apps. Webhooks (Vutuv.Webhooks): per-app subscriptions deliver signed thin event envelopes (HMAC-SHA256 in X-Vutuv-Signature, ids/usernames only, never content) for members who granted the matching scope; DB-backed queue with exponential backoff drained by Vutuv.Webhooks.Deliverer, auto-disable after sustained failure, test ping from the app page. Developer docs in English with curl examples at /developers (Markdown files in priv/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's noindex?/noai? consent flags in-band (the public .json/.md siblings signal the same via Content-Signal/X-Robots-Tag headers)
  • 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.Export builds the document; a new per-user subsystem must add its section there (just like Accounts.delete_user/1 must 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, default Other; 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-doc schema_version. On the profile contact card, German numbers are shown to German viewers in national format (+49 261 98868030261 9886803) while every tel: link keeps the canonical E.164 form, via Vutuv.Phone (the ex_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 default DE region, 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 position column per table, backfilled in creation order; the shared Vutuv.Ordering context owns the bookkeeping). Each management page (/:slug/links, /phone_numbers, /addresses, /social_media_accounts, /emails) carries an owner-only ordering tool, the embedded VutuvWeb.SectionReorderLive (rendered with live_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 (the Reorder JS hook does the drag; the arrows are phx-click), and the arrow reorders glide into place with a small FLIP animation (~180ms, disabled under prefers-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.username carries 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 the username_changes ledger) 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 (/login mails 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, the wax_ library, table user_credentials; the browser ceremony is assets/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 same Accounts.login/2 exit 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, table user_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/3 on the query plus the <.pager> component for the numbered links; feed LiveViews (notifications) use cursor pagination instead — Vutuv.Activity.notifications_page/2 behind a numbered "Load 50 of 80 more" button that appends to the stream. Displayed counts (badges, follower numbers) are compacted site-wide via VutuvWeb.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 via delimited_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 from assets/css/components.css
  • HTTP server: Bandit
  • Email: Swoosh, sent multipart (compile-time EEx text bodies + a shared HTML framework, VutuvWeb.EmailComponents); all mail built from Emailer.base_email/0 and sent through one Emailer.deliver/1 chokepoint 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 respects users.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's mail.log (the /webhooks/bounces DSN endpoint feeds the same path) and marks a hard-bounced address undeliverable, deliver/1 then 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 in docs/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); see Vutuv.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 one mix vutuv.images.regenerate run. 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 (no Plug.Static mount, 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 a location /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 is sha256(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 nginx alias serves it directly (no rewrite). The fingerprint is stored in users.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 legacy avatar_<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); see Vutuv.PageScreenshot. Needs a chromium/chrome binary on the host (set CHROMIUM_PATH if it is not on $PATH)

Context modules

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

API

The third-party REST/JSON API lives at /api/2.0 (Bearer tokens, JSON in/out). Get started in two steps:

  1. Create a personal access token at /access_tokens (the form is pre-filled; the default profile:read scope is enough for reading).
  2. 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/.

Running tests

mix test

Deployment

v6 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_map map 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 .webp image 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) runs mix precommit (compile with --warnings-as-errors, unused-deps, format, credo --strict, tests) on every pull request and on pushes to main.
  • Deploy (.github/workflows/deploy.yml) runs on every push to main. So merging or pushing anything to main ships 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.

nginx for avatars, covers and screenshots (one-time setup)

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 image serving (no nginx setup needed)

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-Redirect from an internal location (:accel_redirect). It was tried in production on 2026-06-29 and failed: even with the internal_post_images location 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 bare internal 404 instead of streaming the file — so every post image came back broken while the app itself was behaving correctly. send_file is 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_redirect mode still exists in the controller for if the nginx handoff is ever root-caused and re-enabled; if so, add this internal location to every app-serving vhost (vutuv.de and new.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).

Email bounce handling

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).

Maintenance / ops tasks

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 current Vutuv.Uploads.Spec, relocating legacy public originals into the private originals/ tree first. Idempotent; rows whose original is missing are skipped with a warning and left untouched. On the production release (no Mix) run bin/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 (set CHROMIUM_PATH if 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.

About

vutuv is a social network. Think of it as a fast, secure and less annoying open-source alternative for LinkedIn, Facebook and X.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Contributors