Stream nid values are decimal identifiers stored in the database's BIGINT columns. They are generated from the published timestamp followed by a fixed-width random suffix, so database ordering remains chronological. A nid can exceed PHP_INT_MAX on a 32-bit PHP build even though its timestamp component is representable, and even a 64-bit PHP integer exceeds JavaScript's exact-integer range. The application therefore keeps stream identifiers as normalized decimal strings from HTTP route and cursor input through models, pagination, database parameters, and JSON output. In particular, local status id and nid fields must be strings so browser clients can round-trip the exact identifier; Tools\Nid handles comparisons without converting the full value to a PHP integer. Do not cast a stream nid, or bind it as PARAM_INT; bind it as a decimal string. Ordinary counters, timestamps, media sizes, and auto-increment row IDs remain integers.
Regression coverage includes identifiers above the 32-bit signed integer range and verifies normalization, ordering, and generator shape. Database integration runs against the supported database engines on the normal 64-bit CI runtime; Nextcloud documents 32-bit PHP as supported but recommends 64-bit PHP.
- Introduction
- Directory Structure
- HTTP routing
- Database Schema
- Key Services
- ActivityPub Federation
- Frontend Architecture
- Integration Points
- Account export and import
- External users
- Security
- Keeping this document in sync
Nextcloud Social is a federated social networking app built on the W3C ActivityPub standard. It integrates into Nextcloud as an app, providing each user with an ActivityPub identity (Person actor) that can interact with Mastodon, Friendica, and other Fediverse platforms.
App ID: social
Namespace: OCA\Social
License: AGPL-3.0-or-later
App version: 0.26.96
Supported Nextcloud versions: 34 – 36
Supported PHP versions: 8.3 – 8.5
All of the above come from appinfo/info.xml.
social/
├── appinfo/
│ ├── info.xml # App metadata, dependencies, cron jobs, occ commands
│ └── routes.php # One route; the other 258 are attributes on the controller methods
├── lib/
│ ├── AP.php # ActivityPub type registry (factory + interface lookup)
│ ├── AppInfo/
│ │ └── Application.php # Bootstrap, integration registration
│ ├── Command/ # occ CLI commands (+ ExtendedBase, a shared base that registers no command of its own)
│ ├── Controller/ # HTTP entry points (ActivityPub, Mastodon-ish API, local API, OAuth, OStatus, navigation, queue, config, moderation, public pages)
│ ├── Cron/ # Background jobs (Cache, Queue, ScheduledPosts, ExpiredStories, InterestHides; DomainPurge and ActorCleanup are queued with an argument)
│ ├── Dashboard/ # Nextcloud Dashboard widgets
│ ├── Db/ # Query-builder based repositories (`*Request` + `*RequestBuilder` pairs), on the public `IQueryBuilder`
│ ├── Exceptions/ # Custom exceptions
│ ├── Interfaces/ # Per-ActivityPub-type handlers (Activity/, Actor/, Object/, Internal/)
│ ├── Listeners/ # Event listeners (ProfileSectionListener, UserAccountListener, UserDeletedListener)
│ ├── Migration/ # Database schema migrations + repair steps
│ ├── Model/ # ActivityPub model objects + support models
│ ├── Notification/ # Nextcloud notification integration (Notifier)
│ ├── Providers/ # Contacts menu integration
│ ├── Search/ # Unified search integration
│ ├── Service/ # Business logic services
│ ├── Security/ # Key cipher, secret hasher, HTML sanitizer, outbound-address guard
│ ├── Settings/ # Admin settings (moderation panel: reports, Fediverse access list, Server card)
│ ├── SetupChecks/ # The four ISetupCheck classes shown in Administration → Overview
│ ├── Tools/ # Vendored helper layer (query builder helpers, traits, exceptions) — see docs/Technical-Debt.md
│ ├── UserMigration/ # Account export/import (`SocialMigrator`, the Nextcloud user-migration framework)
│ ├── Traits/ # TDetails
│ └── WellKnown/ # WebFinger / NodeInfo / host-meta handler and responses
├── src/ # Vue 3 frontend
│ ├── main.js # Main SPA entry
│ ├── dashboard.js # Dashboard widget entry
│ ├── oauth.js # OAuth authorization page entry
│ ├── ostatus.js # OStatus entry (built, but no server route loads it — see below)
│ ├── profile.js # Profile-page custom element entry
│ ├── App.vue # Root component of the main SPA
│ ├── router.js # Vue Router configuration
│ ├── store/ # Pinia stores (index.js + timeline, account, settings, errors, notifications)
│ ├── views/ # Route- and entry-level components
│ ├── components/ # UI components (`.vue`, plus MessageContent.js)
│ ├── services/ # eventBus, logger, notifications, clock, draft, shortcuts
│ ├── composables/ # useAccount, useCurrentUser, useServerData
│ ├── directives/ # focusOnCreate
│ ├── utils/ # sanitizeHtml (+ its unit test), dominantColour, emojiCodePoint, instanceIdentity, relativeTime, viewTransition
│ └── types/ # JSDoc type definitions (ActivityPub, Mastodon)
├── templates/ # PHP templates: main.php (SPA), oauth2.php
├── l10n/ # Translations
└── docs/ # Documentation
There is no lib/bootstrap.php; Composer's autoloader is pulled in by lib/AppInfo/Application.php.
The app's routes, apart from the few in appinfo/routes.php, are
#[FrontpageRoute] attributes on the controller method that answers the request, next to the #[PublicPage],
#[NoCSRFRequired] and rate-limit attributes that decide who may call it — url
and policy in one place. None are #[ApiRoute]: that is the OCS type, and the
server serves OCS routes under /ocsapp, which is not where these paths are
published.
Two things about the order the server reads them in, because two routes of this app can match the same url:
OC\Route\Router::getAttributeRoutes()walkslib/Controllerwith aDirectoryIteratorand reflects over each*Controller.php, so attributes are read in method-declaration order within a class, and in whatever order the filesystem lists the files between classes. Where a url is ambiguous, only the within-a-class order can be relied on:ActivityPubController::displayPost()(/@{username}/{token}) is declared aftergetInbox(),outbox(),followers()andfollowing()for that reason, and has to stay there.appinfo/routes.phpis loaded after every attribute route of the app. That is whyAccountApiController::accountGet()is still declared there: its/api/v1/accounts/{id}accepts slashes in{id}, so it also matches/api/v1/accounts/{account}/listsand/api/v1/accounts/{account}/featured_tags, which live inListControllerandDiscoveryController— no arrangement of attributes can put it after routes of another class.- A trait's methods come after the class's own.
statusWatched()once sat in a trait, sostatusAction()'s/api/v1/statuses/{nid}/{act}was offered first andPOST …/watchednever reached it;StatusApiControllerdeclares it, and every other route that path shape also matches, ahead ofstatusAction().NotificationController::policy()is declared ahead ofgroup()for the same reason:/api/v2/notifications/{group_key}matches/api/v2/notifications/policy.
tests/AppInfo/RouteOrderTest.php holds all three rules: it reads the routes in
the server's order and fails on any route a path of which is caught by one read
before it — or by one in another controller, whose order is not defined.
A route's name is derived, not written: the controller's short name without the
Controller suffix, then #, then the method. Two routes on one method
therefore share a name unless one carries a postfix, and the later one wins —
which is what NavigationController::navigate() uses postfix for.
Every query in the app is built by one class chain. ExtendedQueryBuilder
(lib/Tools/Db/) holds a query builder the server handed it through
IDBConnection::getQueryBuilder() and delegates the whole IQueryBuilder
interface to it, adding the limitTo* / searchIn* helpers the lib/Db/
repositories are written against. SocialCoreQueryBuilder adds the viewer,
and SocialCross, SocialLimits, SocialFilters and SocialQueryBuilder
add the joins, visibility filters and pagination on top of it.
It is composition rather than inheritance on purpose: the chain used to extend
OC\DB\QueryBuilder\QueryBuilder from the server's lib/private/, which put
all 68 repository classes on a constructor signature that carries no stability
promise and broke outright when Nextcloud 35 added a method to the public
interface. Nothing in lib/ now names a class outside OCP\.
The tables are created by lib/Migration/Version1000Date20221118000002.php — the whole schema in one step — all prefixed with social_:
| Table | Purpose |
|---|---|
social_action |
Like/Announce actions as ActivityPub objects (actor → object, with type) |
social_actor |
Local user actors (tied to NC accounts, holds the RSA key pair) |
social_cache_actor |
Cached remote federated actors (inbox/outbox URLs, public keys, counts), the refresh bookkeeping sync_attempt/sync_failures, and account_lower, the handle lowercased and indexed (social_ca_al) for the account search and the lookup by handle |
social_cache_doc |
Cached remote and local media attachments |
social_client |
OAuth 2.0 client registrations |
social_follow |
Follow relationships (actor → object, with accepted flag) |
social_hashtag |
Hashtag trend data: a JSON trend blob per hashtag, plus one sortable integer column per window (trend_1h … trend_10d) |
social_instance |
Known federated instances (version, metadata) |
social_req_queue |
Outbound ActivityPub delivery queue, indexed for the drain's own sort on (status, priority, tries, last) |
social_host_breaker |
The delivery circuit breaker: one row per peer that failed within the last hour, with its consecutive failures and the moment it is worth asking again, in unix seconds. Read once per drain by ActivityService, so a row addressed to a dead peer is held back without spending a timeout; cleared when the peer answers, forgotten an hour after its last failure (Cron\Queue) |
social_stream |
Core content table: posts, notes, activities. Nine JSON-in-TEXT columns (to_array, cc, bcc, hashtags, tags, details, instances, attachments, cache) beside the scalar ones; source holds the ActivityPub wire object verbatim, and archived — a post its author has put away |
social_discover_cat |
The subjects an instance says Explore is about: a name and the hashtags it means, in the order an administrator put them in |
social_trend_review |
What a moderator has decided about something that is trending: one row per rejected (or approved) tag, link or status |
social_relay |
The relays this instance subscribes to: one row per subscription, with the Follow it sent, the inbox to deliver to and whether the relay answered |
social_channel |
The Group actors an account publishes videos under: one row per channel, binding it to the account that owns it |
social_watch |
Where a reader stopped watching a video: one row per (post, viewer), never federated |
social_video_rendition |
The rungs of a local video's ladder: one row per (video, height), each naming one fragmented MP4 and the playlist that addresses it |
| social_quote_grant | The permissions this instance has given out to quote its posts: one row per (quoted post, quoting post), with the QuoteRequest it answered so the grant can be taken back |
| social_team | The accounts a Nextcloud group posts from: one row per team account, bound to the group whose members may speak as it |
| social_team_post | Who actually wrote each post from a team account: one row per post, shown to the team and to moderators |
| social_portfolio | One page of work per account: whether it is published, what it says, how it is laid out and which pictures it shows |
| social_media_tag | Who is named in a post's pictures: one row per (post, account), deleted with the post |
| social_story_react | What was said back to a story: one row per reaction or reply, deleted with the story it answers |
| social_stream_view | Who has opened a post's own page: one row per (post, viewer), unique on the pair, so the number the author sees is people rather than visits |
| social_stream_act | Per-viewer stream flags (liked, boosted, replied, bookmarked, values) |
| social_stream_dest | Stream visibility targets (who sees what) |
| social_stream_queue | Inbound stream processing queue |
| social_stream_tag | Stream-to-hashtag mapping: one row per (post, tag), the tag in the form FollowedTagsRequest::normalise() gives it, so every reader compares it as it stands |
| social_actor_relation | Blocks and mutes: one row per (local actor, target actor, block/mute/blocked_by) |
| social_report | Moderation reports, local and federated Flag activities, with a resolved flag |
| social_media_block | Pictures this instance refuses by sha256 of the file, with the reason, who decided it, and how many times it has since been turned away |
| social_moderation | The decision taken about an account: one row per silenced or suspended actor (level), and force_sensitive — every post by this account marked sensitive |
| social_strike | Every decision ever taken about an account, including the warnings that took none: action, text, who took it and which report it came from |
| social_client_auth | One authorization per (app, account): the code, the token, the scopes granted and the account they were granted to. Unique on (client, account) |
| social_access_block | The blocks that are about an address rather than an account: an IP range this instance answers nothing from, and an email domain it gives no fediverse account to |
| social_announce_react | The emoji accounts have put on an announcement, unique on (announcement, account, emoji) |
| social_emoji | The custom emoji this instance publishes: shortcode (unique), the appdata file behind it, its media type, its picker category and whether a picker offers it |
| social_stream_card | The link-preview card of a status (url, title, description, image, provider), one row per stream |
| social_reaction | Who reacted to which post with which emoji; unique on (actor, post, emoji), so a redelivered EmojiReact is refused rather than counted twice |
| social_gif | The instance's shared picture library the composer offers, unique on the slug; the bytes live in appdata beside the custom emoji |
| social_followed_tag | The hashtags an account follows: one row per (actor, lowercased tag), unique on the pair |
| social_interest | My interests: one row per (reader, lowercased tag), unique on the pair — the score reading earned it, when (scored_at, unix time, so decay is worked out on read), whether the reader added it (manual), the rank they pinned it to (position, null to float) and the score as the current week began (score_week, for the trend arrow) |
| social_interest_hide | The posts a reader said "less like this" about, by stream_nid, unique on (reader, post); forgotten once older than the feed looks back |
| social_collection | Collections: an album an account curates out of its own posts, with its title, description and visibility |
| social_collection_item | What is in a collection: one row per (collection, post), unique on the pair, ordered by position |
| social_place | Places: one row per distinct place this instance has seen, deduplicated on (name, country). No geocoder — see the migration |
| social_import_post | What an account has brought over from another server: one row per (account, original id), unique on the pair, naming the local post it became |
| social_post_hold | The posts waiting for a moderator: the client's request, the rule that held it, and the digest the queue is unique on |
| social_story | Stories: one picture that expires after a day, with its caption, hold time, expires_at, the ActivityPub id it travels under (source_id/source_id_prim) and whether this instance wrote it (local) |
| social_story_view | Who has seen a story: one row per (story, viewer), unique on the pair |
| social_list | Mastodon lists: one row per (owner, list), with its title, replies_policy and exclusive flag — and group_id, the Nextcloud group a list follows, '' for one made by hand |
| social_list_member | Who is in a list: one row per (list, account), unique on the pair |
| social_filter | Keyword filters: one row per (account, filter) with its contexts, action and expiry |
| social_filter_kw | The keywords of a filter: one row per keyword, with its whole_word flag |
| social_filter_st | The posts a filter covers by name: one row per (filter, post) |
| social_convo_state | What one account has done with one conversation: how far it has read and dismissed the thread, both as a message nid, and whether it has muted it, one row per (account, thread root) |
| social_domain_block | Instances one account has blocked for itself: one row per (account, domain), unique on the pair |
| social_account_note | The private note one account keeps about another: one row per pair, never federated |
| social_mute_expiry | When a mute runs out: one row per (muter, muted), and only for a mute that was given a duration |
| social_stream_rev | The versions a status has been through: one row per version including the original, oldest first |
| social_featured_tag | The hashtags an account pins to its profile: one row per (actor, lowercased tag), unique on the pair |
| social_announcement | The instance's announcements: one row per notice, with the text as typed and the window it is served in (both bounds nullable) |
| social_announce_read | Who has dismissed which announcement: one row per (account, announcement), unique on the pair |
| social_scheduled | Posts asked to be published later: one row per waiting post, with the client's request as JSON in params and the resolved visibility inside it |
| social_durable_cache | DurableCache's entries on an instance with no memcache: a hashed key, a JSON value and the unix time it expires at, indexed on that for the purge in Cron\Cache. Empty wherever a memcache is configured |
| social_ext_user | The logins of self-registered external users: the Nextcloud user id, which is also their handle, its lowercase form (unique), the password hash, the display name and how the account came to be (origin). Everything else about the person is where Nextcloud keeps it for any user |
| social_ext_signup | Registrations of external users that are not accounts yet: the handle and email they reserve, the password hash, the SHA-256 of the confirmation token, whether the email is confirmed (verified) and whether an administrator has to decide (approval), the invitation used and a salted hash of the address it came from |
| social_ext_invite | Invitation links for external users: the token as it is sent, who made it, how many registrations it admits (max_uses, 0 for any number), how many it has (uses) and when it expires (expires, unix seconds, 0 for never) |
Version1000Date20260927000010 adds the three tables of self-registered external users, social_ext_user, social_ext_signup and social_ext_invite — see External users.
Version1000Date20260611000001 only drops the abandoned social_3_* tables from an earlier prototype. Version1000Date20260907000001 adds the timeline indexes and the missing primary keys, Version1000Date20260907000002 adds social_actor_relation, Version1000Date20260907000003 adds the bookmarked flag to social_stream_act, Version1000Date20260908000001 widens social_client.app_client_secret for its hashed value, Version1000Date20260908000002 adds the locked flag to social_actor, Version1000Date20260908000003 adds social_report (moderation reports), Version1000Date20260908000004 adds the fields column to social_actor (the profile metadata fields), Version1000Date20260908000005 adds social_stream_card (link previews), Version1000Date20260909000001 adds social_moderation (the silence/suspend decisions, indexed on level), Version1000Date20260910000001 adds the indexes the hot paths were querying as if they existed (social_cache_doc.id_prim and parent_id_prim, social_stream_act by (actor, flag), social_stream_tag by tag, social_action by (object, type), both queues by status/id, social_client.token, social_stream.creation, social_cache_actor by (local, details_update) and social_follow by (object, actor)) and drops the redundant five-column ipoha unique index on social_stream, Version1000Date20260910000002 adds the sortable trend_* counter columns to social_hashtag zeroed (the JSON trend column stays and remains what the API hands back), Version1000Date20260910000003 fills those columns in from the JSON, Version1000Date20260911000001 adds the sensitive flag to social_stream, and Version1000Date20260911000004 adds social_followed_tag (the hashtags an account follows, unique on (actor, tag) — which is also the index the home timeline reads). Version1000Date20260911000005 adds social_list and social_list_member — Mastodon's lists and their membership, the membership table unique on (list, account), which is both what makes adding an account twice a no-op and the index the list timeline joins social_stream.attributed_to_prim on. Version1000Date20260911000006 adds social_filter and social_filter_kw — the keyword filters an account mutes posts with, indexed by owner and by filter, which are the two reads there are. Version1000Date20260911000007 adds social_convo_state, unique on (account, thread root) — the read and dismissed markers behind /api/v1/conversations. The conversations themselves get no table: a conversation is a thread of social_stream rows derived from in_reply_to at read time, and its id is the nid of the thread root. Version1000Date20260911000008 adds social_domain_block, social_account_note and social_mute_expiry — the per-account instance blocks, the private notes and the expiry of a timed mute, each unique on the pair it is keyed by, which is both what makes writing one twice a no-op and the index its read path probes. An endorsement is not among them: it is a row in social_actor_relation with type endorse, which is what that table already holds. Version1000Date20260911000009 adds social_stream_rev (the revisions of an edited status, indexed on (status, id), which is the only read there is) and Version1000Date20260911000010 adds social_featured_tag (the hashtags an account pins to its profile, unique on (actor, tag)). Version1000Date20260911000011 adds social_announcement and social_announce_read — the announcements and their dismissals, the dismissal table unique on (account, announcement), which is both what makes dismissing twice a no-op and the index the client read probes. The announcements table gets no index beyond its key: every read of it is its whole active set, and it holds a handful of rows. Version1000Date20260911000014 adds social_scheduled — the posts a client asked to have published later — with two indexes, one per read there is: (actor_id_prim, scheduled_at) for one account's list and the daily cap, and (scheduled_at) for the cron's "what is due across every account", which the first index cannot answer because its leading column is the account. Version1000Date20260911000020 adds forwarded to social_report: whether a report was passed on to the instance that hosts the reported account, which the admin API used to answer as a hardcoded false. Version1000Date20260912000001 adds the two indexes Version1000Date20260910000001 left out: social_actor.user_id, which resolves the logged-in user's actor on every authenticated request and had no index at all, and the four trend windows of social_hashtag other than trend_1d (trend_1h, trend_12h, trend_3d, trend_10d), each of which getTrending() filters and orders on. Version1000Date20260912000002 adds social_actor.bot — whether a local account is automated, which is what Mastodon's bot reports and what decides whether the actor document says Service or Person; it was accepted from clients and dropped. Version1000Date20260912000003 adds social_strike — the history of moderation decisions, indexed on the account, which is the only read there is. Version1000Date20260912000011 adds social_convo_state.muted — the thread an account has stopped hearing from, a column rather than a table because that row already records what one account has done with one thread. Version1000Date20260912000010 adds social_client_auth, unique on (client, account) with an index on each of the two secrets it is looked up by — and carries the authorization already on each client row across, so a token in use today goes on working. Version1000Date20260912000006 adds social_access_block, unique on (type, value) — one table for two lists, because what differs between Mastodon's two is a severity column and a count, and neither is worth a second table on an instance that holds tens of these rows. Version1000Date20260912000005 adds social_announce_react, unique on (announcement, account, emoji) — both what makes reacting twice with the same emoji a no-op and the index its two reads use. Version1000Date20260912000004 adds social_emoji, unique on the shortcode — which is both what makes re-adding one a replacement rather than a second row nothing can tell from the first, and the index every read of it uses. Version1000Date20260912000008 adds social_collection and social_collection_item — the albums an account curates out of its own posts, the item table unique on (collection, post) so adding one twice is a no-op, with a second index on the post because deleting one has to find every collection holding it. Version1000Date20260912000009 adds social_story and social_story_view — a picture that expires after a day and who has seen it, indexed on (account, expiry) for the reads and on expiry alone for the cron that sweeps them, the view table unique on (story, viewer). Version1000Date20260912000012 adds social_place and social_stream.place_id — where a post was taken, deduplicated on (name, country) with the name hashed because a unique index on a TEXT column is not portable; no geocoder is involved anywhere, see the migration. Version1000Date20260914000002 adds social_list.group_id, indexed — the Nextcloud group a list follows, which is what GroupListService reads when a group changes: every list bound to it, whoever owns it. Version1000Date20260914000001 adds social_req_queue.object_id_prim, indexed — the md5 of the id of the object a queued delivery is about, which is what lets a post ask the queue where it got to; empty on rows queued before the column existed, which are at most a few days of retries. Version1000Date20260914000005 adds social_gif — the shared pictures the composer offers, unique on the slug, with the bytes in appdata beside the custom emoji: it is the same shape of thing, a small curated set served to everybody that must not break because a file moved in somebody's Files. Version1000Date20260914000004 adds social_reaction — who reacted to which post with which emoji — unique on (actor, post, emoji) and indexed on the post, which is the read it exists for. A table of its own rather than another type in social_action: a like and a boost are a fact about a pair, which is what that table's key says, while a reaction carries a third thing and one account may react to one post several times over. Version1000Date20260915000001 adds social_filter_st — the individual posts a filter covers, beside the keywords it matches, which is the other half of Mastodon's v2 filters and the only part of that API this app did not serve. The post is named by its nid, the id a client sends, with an index on the filter (how a filter's entries are read and deleted) and one on the status (not unique: two filters of one account, and two accounts, may each cover the same post). Version1000Date20260915000002 adds social_import_post — what an account has brought over from an export, unique on (account, original id). A post written by the importer is a new local post: its original id belongs to the server it was written on and cannot be kept, so without this row nothing would remember where it came from, a second run of the same archive would write every post again, and a reply — which an archive names by its parent's original id — would have nothing to hang off. Nothing cascades: a post the account later deletes leaves its row, because the row says "this was imported" and importing it again because it was deleted here would undo a decision the account made. Version1000Date20260915000003 adds source_id, source_id_prim and local to social_story, unique on the hashed id. A story used to be local by definition — no ActivityPub identity, no recipients — so the row had no way of saying whose network it belonged to; it is published to followers as an Add now and arrives from peers the same way. The unique index is what makes a story delivered twice one row, which matters more here than elsewhere because a fan-out reaches an instance once per follower on it, and local is what decides which stories are published outward and which may be deleted through the API. Existing rows are local and have their id minted on first read rather than in a postSchemaChange: a story lives a day, so within a day of the migration the question has answered itself. Version1000Date20260915000004 adds social_post_hold — the review queue. What it stores is the request, the same shape social_scheduled holds, and not a post: a held post that existed as a row in social_stream with a flag on it would be one forgotten predicate away from a timeline, a hashtag page, a profile or an outbox, and this app has shipped exactly that leak before. A post that is not in the table cannot be read out of it by code nobody has written yet. Unique on digest, the md5 of the account and the text: a client told its post was held will be pressed again by its user and the Pixelfed app retries a 422 by itself, so without it one post held once would be twenty identical rows for a moderator to work through. Indexed on (actor_id_prim, id) for the author's own list and the per-account cap; the queue itself is read in id order off the primary key, because it is drained by people. Version1000Date20260915000005 adds social_stream.archived — a post its author has put away. Deleting was the only thing this app offered somebody who no longer wanted a post on their profile, which is a bad answer to a common question: a photograph from four years ago is not something to destroy because it has stopped belonging at the top of a profile. A column rather than a table because it is one fact about one post and every read that must not show one is a read of social_stream; no index, because almost every row is false and always will be, and the queries that filter it are already selected by their own timeline's index. The filter is fail-closed: StreamRequestBuilder::hideArchived() is applied by the two base selects every stream read is built from, and the two reads that should see an archived post — the author's own list, and a post fetched by its own address — ask for it. A read written later shows none until somebody decides it should, rather than leaking one until somebody notices. Nothing federates: an archived post is still on every server that received it, and taking it back from them is what Delete is for. Version1000Date20260915000006 adds social_moderation.force_sensitive and social_media_block. The first is the step between doing nothing and silencing — an account asked to put a content warning on its pictures without being taken out of the timelines, which is Mastodon's own tier and what Pixelfed calls cw — applied in StreamRequest::save() because a local post, a post that arrived in the inbox and a post the importer restored are the same row and a rule that held for one of them would be a rule nobody could explain. The second is the one thing none of the account-level tools does: stop a file coming back. A hash of the bytes as they arrive, checked in CacheDocumentService::saveFromTempToCache() — the one place an upload and a fetched remote attachment both pass through — with the reason and the moderator beside it, and a count of how many times it has been turned away, because a blocklist with no evidence is one nobody dares remove anything from a year later. Version1000Date20260915000007 adds social_stream_view — who has opened a post. An author could see three likes and had no way to know whether that was three out of five or three out of four hundred; a story has had a view count since it was written and a post had none. What is counted is deliberately narrow: a post's own page, opened by a signed-in account that is not its author. Not an impression in a timeline — a post scrolled past has not been read, counting it would make the number meaningless, and it would write a row for every post on every page of every timeline. Unique on (post, viewer), so the number is people rather than visits. It is never federated and is on the author's copy alone: a count that arrived from another server would be a number about that server's readers added to this one's, meaning neither. Version1000Date20260916000001 adds social_trend_review — what a moderator has decided about something that is trending. Trending is counted and shown with nobody in the loop, so the first ugly hashtag to catch on did so on the Explore page of every account here and the only remedy was to wait for it to fall off; Mastodon has nine admin routes for this and the app had none. Rejected is what is stored, and everything else trends. The other arrangement Mastodon offers — nothing trends until it is approved — would empty the Explore page of every instance on upgrade and leave it empty until somebody found the new panel; approval is still recorded, because a moderator wants to see what they have already looked at, but it grants nothing that was not already so. One table for the three kinds rather than three: what differs is a word (a tag is named by its text, a link by its URL, a status by its id) and splitting them would be three identical schemas, three queries to keep in step and three things each trend read would have to know about. The filter is applied where a trend list is read, never where the counters are written — a rejected tag keeps being counted, so lifting the decision puts it back with the number it would have had, and a decision that could only be undone by waiting for counters to refill is one nobody would risk making. Version1000Date20260916000004 adds social_channel, social_actor.actor_type and social_cache_doc.size — the three things a Video this app publishes needs before a PeerTube will take it. PeerTube has no video without a channel: its builder resolves one by looking for a Group in the video's attributedTo and throws "Cannot find associated video channel" when there is none, then fetches that Group and looks for a Person in its attributedTo. A Social account is a Person and nothing else, so every Video published from here was refused on arrival — silently, and in PeerTube's log rather than in ours. A channel is an actor like any other — key pair, inbox, outbox, followers, followable, moderatable — which is the same design social_team uses and the reason neither needed the actor machinery written twice; what is new is the type it is served as and who owns it. It is stored under a reserved channel/<handle> user id, which no Nextcloud user can have because a user id may not contain a slash. The owner is the Person actor, not the Nextcloud user, so a channel points at the same thing the wire does. One is made for an account the first time it posts a video and used unless the person picks another: nobody should have to learn what a channel is in order to post a video, and an account that never posts one never grows an actor it did not ask for. It is made when the post is written, never when it is serialised — a serialisation happens once per instance a post is delivered to, and making an actor there would be a write on a read path, forty times over. actor_type is how it is served as a Group. A local actor's type was derived from one flag (bot, which moves it between Person and Service) and there was nowhere to say anything else; '' means "decide as before", which is every row written until now. social_cache_doc.size is how many bytes a stored file is. PeerTube's isRemoteVideoUrlValid() wants size as an integer on every video file link and drops a link without one, so a Video from here arrived with nothing to play; it is recorded when the file is stored rather than measured per serialisation, and the per-account video quota asks the same question. A post that cannot make a valid Video stays a Note: no poster (icon is mandatory), no duration, or no channel, and the post is published in the shape Mastodon and Pixelfed both read. Sending a Video that is going to be thrown away is strictly worse than sending the one that works.
Version1000Date20260916000003 adds social_stream.quote_policy and social_quote_grant — who may quote a post, and the permissions that have been given out. This app answers a QuoteRequest already (FEP-044f, the mechanism Mastodon 4.5 quotes run on), but the answer was derived from one thing: whether the post was addressed to the public collection. An author who wanted their public post quoted by their followers and nobody else, or by nobody at all, had no way to say so — and Mastodon's own composer offers exactly that choice on every post. The policy is public, followers or nobody, and empty for every post written before anybody was asked, which keeps meaning what it meant: the visibility rule. It lives on the post rather than on the account because it is a decision about this post; a default for new posts is a client preference and belongs with the composer's other defaults. What it decides is Stream::mayBeQuotedBy(), which is both the rule QuoteRequestInterface applies when a request arrives and the rule quote_approval.current_user reports to a client, so the two cannot drift. It is advertised outward as interactionPolicy.canQuote.automaticApproval — the public collection, the author's followers collection, or the author alone — which is what Mastodon reads before it offers a quote button at all. social_quote_grant is one row per permission given: which post was quoted, which post quotes it, who asked, and the QuoteRequest the grant answers. That last column is the reason the table exists — taking a quote back means sending a Reject naming the request that was accepted, and without a row there is nothing to name. Unique on (quoted, quoting), so a request delivered twice (a peer retries) is one row. Changing the policy is deliberately forward-only: it does not withdraw permissions already given, because a quote that has been published and read is not undone by a switch being flipped. Taking one back is its own route, which says so and tells the other server.
Version1000Date20260916000002 adds social_relay — the relays this instance subscribes to. A small instance sees only what the people on it follow, so its federated timeline is empty on the first day and thin for months: there is nobody here yet to have found anybody out there, and nobody out there has heard of this server. A relay breaks that circle by rebroadcasting the public posts of every instance subscribed to it. The row is the subscription, not the relay: the Follow this instance sent, the inbox to deliver to, and whether the relay answered — so a relay nobody here subscribed to has no row, and an Announce from a server that merely calls itself one is not treated as a relay's. The subscription belongs to the instance, which is why there is no owner column: the Follow is signed by the instance actor, and what comes back is for everybody's federated timeline. That also means it cannot go through social_request_queue, which resolves its signing key from social_actor — HttpSignatureService::signAsInstance() signs it and it goes out inline, the same arrangement a forwarded report has. A relayed Announce is not a boost. Storing it as one would put "relay.example boosted this" in front of every post and hang it off an actor nobody follows; Mastodon treats it as a pointer and so does this — the post is fetched from the server that wrote it through SearchService::resolveStatus(), with the same guard a reader pasting a link gets, that the document has to claim the address it came from. Outbound, a local public activity is delivered to every accepted relay's inbox beside its followers': subscribing and sending nothing is taking without giving, and an instance that only reads is invisible to every other instance on the relay. Not local, because forwarding a third party's activity would put this instance's name on somebody else's post and loop where two instances both relay; not public, because a followers-only post has an audience that was chosen and a relay is the opposite of one. Unique on actor_id_prim, so subscribing twice is the same row.
Version1000Date20260915000013 adds social_team and social_team_post — an account a team posts from. Pixelfed's answer to "several people, one voice" is its Group* family: twenty models, still beta, and a second social graph beside the one it already has. Nextcloud's answer is the one it has had all along, a group of people who already work together, and this is that group given an account — the one thing in this comparison Nextcloud can do and Pixelfed cannot, because Pixelfed has no idea who works with whom. A team account is an actor like any other: a key pair, a followers collection, an inbox and an outbox, followable from Mastodon and Pixelfed, moderatable and suspendable, none of which needed writing again. It is stored under a reserved team/<group> user id, which no Nextcloud user can have because a user id may not contain a slash, so it never resolves as somebody's own account on a path that looks one up by user. No membership is copied: the Nextcloud group is the membership and it is asked live at the moment somebody tries to post, because a copy of a group is a copy that drifts and the drift is somebody who left the organisation still able to speak for it. social_team_post is about accountability rather than display — outside the team the team speaks with one voice, which is the point of having one, and inside it somebody has to be able to find out which of them wrote the thing everybody is now talking about. A moderator sees it too, because a report about a team account is otherwise a report about nobody. Version1000Date20260915000012 adds social_portfolio — a page of somebody's work, to put on a CV. A profile is a feed: everything somebody posted, newest first, with the follow button and the boosts and the replies around it. A portfolio is the opposite, and it is the feature Pixelfed's photographers ask for. One per account, which the unique index on the account says: a person has one portfolio the way they have one profile, and what would be a second is a collection, which this app already has. Off until it is turned on — a row that exists is a draft, active is the moment its owner decides the internet may read it, and a page nobody has activated is a 404 rather than an empty page with their name on it. Public posts only, always: the page is readable signed out, so it is built by asking for the account's posts with no viewer at all, and the account query answers an anonymous reader with public posts and nothing else. That rule lives in the query rather than in a switch, because a switch is a thing somebody can get wrong once and leak a followers-only photograph to a search engine for ever. The display switches are Pixelfed's, minus the two that mean nothing here. Version1000Date20260915000011 adds social_cache_doc.transcoded — whether a stored video has been through the transcoder. This app has never re-encoded anything, deliberately, and the cost of that is not theoretical: Pixelfed's default media_types accepts video/mp4 and nothing else, so every video/quicktime posted from here, which is every video straight off an iPhone, is dropped by its verifyAttachments() without a word to anybody. A column rather than a queue table, because what has to be remembered is one fact about one stored file and the work to do is a query over the documents themselves: a queue table would have to be filled by every path that stores a video and emptied by every path that deletes one, while a column cannot drift from the row it is on. Four states — nobody has looked, converted, not worth converting, tried and failed — and the last two are what stop the same unconvertible file being read on every run for ever. Existing rows are 0, which is correct for both of the things they might be. No index: the job's query is narrowed by media_type LIKE 'video/%' on a table where a video is a small minority, and it runs once a quarter-hour over one page. Version1000Date20260915000010 adds social_media_tag — who is named in a post's pictures. Pixelfed's MediaTag, and a staple of every photo network there has been; somebody arriving from Instagram expects it on the first day and this app had nothing. A tag is a fact about a post rather than about a rectangle on an image: Pixelfed keys its row on a media id and stores a metadata blob that in practice holds only a version number, and no client of its own draws a box — so the row here names the post, which is what both reads want, and the day a client wants a rectangle is the day to add the two columns for it. Unique on (post, account), which is what makes re-sending the same list a no-op; a second index leads with the tagged account, because that is what "photos of you" selects on and without it the page is a table scan on every profile visit. The post is recorded twice — by the id a client addresses it with, which orders "photos of you", and by the hash of its ActivityPub id, which is the column StreamRequest::deleteRelatedTo() cascades on, so a deleted post takes its tags with it. A tag is also written onto the post as a Mention and the post is re-sent as an Update: a tag only this instance knew about would notify local accounts and do nothing at all for everybody else. It does not widen who may see the post. Version1000Date20260915000009 adds social_story_react — the reactions and replies a story has been answered with. A story could be watched and nothing else: Pixelfed has three ways of answering one and a verb for each (View, Story:Reaction, Story:Reply), and this app sent and understood none of them, so a story posted to Pixelfed followers came back silent. One table for reactions and replies because they are the same row with a different word on it — who, about which story, what they said — which is how Pixelfed keeps them too. A reply is deliberately not a post: no social_stream row, so it cannot reach a timeline, a profile or an outbox through a query nobody has written yet, and it is deleted with the story rather than outliving what it was about. Unique on the hash of the activity's own id, which is what makes a retried delivery one row: an inbox is retried, and a reaction counted twice would be two reactions. Indexed on (story, id) for the one read there is and on (story, account) for the per-account cap. Deleting a story now takes its views and its answers with it in one place — StoriesRequest::deleteRelatedTo() — which also closes a gap: a story withdrawn by its author with a Delete used to leave its view rows behind. Version1000Date20260915000008 adds social_discover_cat — the subjects an instance says it is about. Explore is trending, and trending on a small instance is four hashtags and a wedding, which reads as abandoned rather than as somewhere to start. What an instance would like to be known for is a decision its administrators make and not something a counter can arrive at, so this is curated and sits above the counted lists, saying which of the two it is. The hashtags are a JSON array on the row rather than a table of their own: there are a handful of categories on any instance that has them, each naming a handful of tags, nothing joins on them, and the whole set is read at once by the one page that shows it. No index beyond the key, for the same reason. Version1000Date20260914000003 adds the two columns the actor-cache refresh needed and the index the queue drain was missing: social_cache_actor.sync_attempt and sync_failures — when this instance last tried to refresh a cached remote actor (unix time, 0 for never) and how many attempts in a row have failed since one worked, indexed together with local, which is the other predicate of the query that orders on them. Integers rather than a datetime so that "never" is 0 and sorts the same on every database, where a NULL datetime does not. And social_req_queue (status, priority, tries, last): getStandby() selects on status and orders on the other three, 200 rows at a time, and the only index it had was (status, id) — so every drain sorted the whole standby set to pick its window.
Version1000Date20260927000001 drops social_feed and social_feed_item, the tables of the feed subscriptions that shipped in 0.26.60 and were removed in 0.26.95; both steps are guarded, so an instance that never had them is left alone.
Version1000Date20260925000020 adds social_interest and social_interest_hide — My interests. A step of its own rather than a paragraph of the squash, because the squash is recorded as run on every instance that existed when it was made: a table added to it would reach fresh installs and nobody else. SquashedSchemaTest holds only the steps older than the squash to "move data or go into the squash". No index beyond the unique pairs: every read of social_interest is one reader's whole set — a few hundred rows at most, because InterestService forgets the faintest once there are more — and every read of social_interest_hide is one reader's recent hides or the daily purge by creation.
Two of those deserve a warning.
Version1000Date20260907000001 adds an autoincrement BIGINT primary key to social_stream_dest and social_stream_tag, the two highest-cardinality tables. On MySQL/MariaDB that is a full table rebuild, so on a large instance occ upgrade will sit there for a while with the instance in maintenance mode. The change is correct and needed; the cost is not obvious from the migration.
Version1000Date20260910000001 costs what it looks like — fifteen index statements, no table touched — on MySQL/MariaDB and PostgreSQL only. SQLite cannot change an index in place: Doctrine implements every one of them as a full table rebuild, so the same migration copies ten tables (social_stream, social_cache_actor, social_cache_doc, social_follow, social_action, social_client, both queues and both stream side tables) out, drops them, recreates them and copies the rows back. Plan the window for a rewrite of nearly the whole schema and for peak disk of about twice what those tables occupy. Nothing is at risk — SQLite DDL is transactional — but an operator sizing the outage from the MySQL figure will be wrong by an order of magnitude.
Version1000Date20260910000003 exists because the step before it was wrong about the cron. manageHashtags() skips a hashtag whose freshly counted trend equals the JSON already stored, which on an instance that has just upgraded is the normal case — so the zeroed counter columns would never have been written, and getTrending() reads nothing else. The backfill pages through social_hashtag on its primary key, leaves rows already in agreement alone, and is a no-op on a fresh install.
There is no downgrade path, and none is possible: Version1000Date20260611000001 drops tables outright.
Version1000Date20260925000001 brings social_stream_tag.hashtag into that normalised form for the rows written before posts' tags were stored in it: they used to be kept as their author wrote them, compared through LOWER(st.hashtag), and no index answers a comparison over a function of the column. It pages on the primary key, reads only the rows whose tag is not lowercase where the database's LOWER() folds Unicode (every row on SQLite, whose does not), and where two tags of one post become the same one deletes the second rather than letting the unique (stream_id, hashtag) index refuse the rewrite.
Version1000Date20260925000002 adds that account_lower column and fills it in for the rows already cached, paged on the primary key. The account search behind the mention picker, /api/v2/search, /api/v1/accounts/search and the unified search used to match a prefix of account under COLLATE utf8mb4_general_ci on MySQL, and the lookup by handle compared LOWER(account): an unindexed column either way, so every keystroke read every cached actor. They compare the lowercased copy as it stands now (CacheActorsRequest::searchAccounts()), which MySQL and MariaDB answer as an index range; PostgreSQL and SQLite plan a prefix LIKE as a range only under a C collation or case_sensitive_like, so there the search is still a scan, of one short column, while the lookup by handle is an index lookup everywhere.
There is no notifications table: in-app notifications are stored in social_stream as SocialAppNotification items. (A TABLE_NOTIFICATION constant naming a social_notif table that no migration ever created used to be declared here; it has been removed.)
The business logic lives in lib/Service/.
- AccountService — Creates local actors (generating an RSA key pair via
SignatureService) and marks them deleted, refreshes the local actor cache (avatar, display name, follower/following/post counts), performs "blind key rotation", and reaps actors past their deletion retention - ActorService — Saves/updates cached
Personrows and resolves an actor's cached header image - CacheActorService — Central actor cache/resolver. Looks up actors by ActivityPub id or
user@host, fetches unknown remote actors over WebFinger + HTTP on a cache miss, and probes their followers/following/outbox counts. The cron refresh (manageCacheRemoteActors(),SYNC_BATCHof 50 per pass) records every attempt insocial_cache_actor.sync_attempt/sync_failuresand takes the oldest attempt first. It used to select oncreation— which for a remote actor is thepublisheddate its own instance reports and never moves — with no order and no record of having tried, so fifty rows on a dead instance were the fifty the refresh picked every twelve minutes and no live profile was ever refreshed again. A failure now doubles the wait from an hour (CacheActorsRequest::syncWait(), applied both in SQL bylimitToSyncDue()and by the service) and afterSYNC_MAX_FAILURES(10, about six weeks) the refresh stops asking. Nothing is deleted by any of that: the row, the follows pointing at it and the posts it wrote stay, an on-demand fetch still goes to the network, and one that works resets the count - AccountService::assertHandleAvailable() / linkExternalHandle() — The setup screen's two answers.
NavigationController::navigate()no longer creates an actor on the first load: a person without one is handedneedsAccount, asuggestedHandle(generateHandleFromUserId()) and thelinkedHandletheir profile already names, andLocalController::accountCreate()makes the actor when they ask, with the handle they chose — refused when it is another Nextcloud user's id (one person publishing as another) or an actor already holds it.accountLink()writes an account elsewhere into the profile'sfediversefield, which is whereColleagueServicelooks, and creates nothing. Nothing that reads creates an actor either: every controller'sinitViewer(),/api/v1/global/account/infoand/oauth/userinfolook one up without the create flag. They used to set it, so the first request the page made — while the setup screen was still on screen asking — minted the identity, and the answer came back as that handle is taken. The two places that do create are where somebody asks:accountCreate()andocc social:account:create, plus the user-migration import, which is restoring an account that already existed - CacheActorService — Central actor cache/resolver. Looks up actors by ActivityPub id or
user@host, fetches unknown remote actors over WebFinger + HTTP on a cache miss, and probes their followers/following/outbox counts - RelationshipService — blocking and muting: stores the relation, severs follows in both directions on a block, and federates
Block/Undo{Block}unless thefederate_blocksapp setting is0. Mutes are purely local and never federated.
- StreamService — Core stream/timeline engine. Assigns ActivityPub ids, expands recipients (public/unlisted/followers/direct), resolves reply chains, detects stream types, deletes local items, and reads the timelines (home, local, global/federated, tag, account, liked, direct, notifications). Deleting a post takes what belongs to it with it:
StreamRequest::deleteRelatedTo()removes thesocial_stream_dest,social_stream_tag,social_stream_actandsocial_stream_cardrows, thesocial_actionrows pointing at the post, and its cached attachment rows together with the files on disk. The cascade sits inStreamRequest, so every caller gets it rather than retention alone.syncRemoteTimeline()— a profile timeline asking the account's own server for its outbox — walks at mostSYNC_ITEM_LIMIT(20) entries of the page it gets back, and puts every field of each through the same validation helpers the inbox path uses. It used to walk the whole page unbounded and storeurl,content,summaryand the tags unchecked, from a public route, against a host the caller names - PostService — Creates posts (text, attachments, reply-to, mentions, hashtags, the content warning and the
sensitiveflag) and edits existing local posts, delegating federation toActivityService. A visibility a client sent is translated once, inPost::setType(): Mastodon calls a followers-only postprivateand this app calls itfollowers, and a visibility the app does not recognise becomesdirectrather than being addressed toas:Public. The content warning is stored as plain text on both paths (strip_tags()), because it is read as plain text everywhere it is shown — as the object'ssummary, asspoiler_text, and interpolated rather than rendered by this app's own frontend; entity-encoding it federatedBob's finaleand baked the entities into the next edit. The body takes the opposite path: it is rendered byLinkifyServiceafter the recipients and hashtags are resolved, so the links in the published HTML and thetagarray come out of one parse - PostReviewService — First-post review and the spam rules: which posts a person should see before anybody else does, and what happens to one afterwards. Two switches, both on by default (
review_first_post,autospam), and a very short list of rules — the first post of an account that has published nothing here, a wall of links in a short post, and mentions scattered by an account nobody follows and that follows nobody. No wordlist and no score: a queue that says "0.82" tells a moderator nothing they can act on. A direct message is never held, because putting private correspondence in front of a moderator who was not written to, for a machine's reason, is worse than the spam; neither is a post by an account a moderator has already decided about, which is being dealt with by that decision. What is held is the request —StatusAssemblyServiceturns it back into thePostan immediate one would have been, and approving publishes it dated now, since that is when it became a post. Refusing records adelete_statusesstrike, so the author is told and the next moderator can see it happened - StatusAssemblyService — The post a stored request becomes, and the request a client's
Statusbecomes. Shared by the scheduler and the review queue: a note built by one path and federated by another drifts, because the quote approval, the language fallback and the source snapshot all happen insidePostService::createPost() - ScheduledStatusService —
POST /api/v1/statuseswith ascheduled_at, and the publishing of what it stores. Keeps the client's request rather than a rendered post, because publishing has to go throughPostService::createPost()over aPostassembled exactly asStatusApiController::statusNew()assembles one — a note built by one path and federated by another would drift on the quote approval, the language fallback and the source snapshot.MIN_LEAD_TIME(300 s) is Mastodon's five-minute minimum and is also what makes the cron interval workable;MAX_PENDING(300) andMAX_PENDING_PER_DAY(25) are Mastodon's caps, and are needed because a scheduled post is the only thing a client can store unpublished and unbounded where no moderator can find it. The visibility is resolved at scheduling time so a later change ofsource[privacy]cannot move a waiting post's audience, and everythingcreatePost()would refuse — length, an unusable poll — is refused while there is still a client to tell.publishDue()claims a row by deleting it before publishing: two workers that read the same due row both try, the database lets one affect it, and only that one posts - PollService — Federated polls: serves the Mastodon Poll entity of a stored
Questionand votes on remote polls (one vote note per choice to the poll's author; the viewer's choices are remembered in the per-viewer stream action, authoritative counts arrive asUpdate{Question}) - FollowService — Follow/unfollow flows, follower/following collections, and relationship lookups
- LikeService — Creates and undoes Like activities
- StreamPruneService — Retention: deletes remote statuses older than
retention_days(default 0 = disabled) that no local user interacted with, whose author nobody follows, that no local status replies to or boosts, and that are not DMs — together with their dest/action/tag rows and cached attachments. Runs bounded in the Cache cron and unbounded viaocc social:stream:prune - CacheActorSweepService — The other half of retention, for accounts rather than posts: evicts the cached remote actors nothing here refers to any more, with their avatars and headers. A row is written the first time this instance meets an account — a like on a local post, a boost seen in a timeline, a reply in a thread — and until this existed nothing removed one but the account's own instance sending a
Delete(PersonInterface) or a moderator purging its domain (ModerationService), so a year of federating left tens of thousands of rows and a picture each for accounts nobody here has anything to do with. An actor goes when nobody here follows it, it follows nobody here, no post of its is stored, no follow request or block/mute/endorsement names it either way, and it has been neither seen nor tried forcache_actor_days(default 180; 0 disables). Paged likeStreamPruneServiceand bounded per cron pass (Cron\Cache::SWEEP_BATCH, 500). What is swept is fetched again the moment it is needed, so what is lost is a request and never a relationship or a post. A like or a boost of a local post is deliberately not one of the conditions — the same linetootctl accounts prunedraws: thesocial_actionrow stays and names the actor, and the profile is fetched again when the list of who liked the post is opened - BoostService — Creates and undoes Announce (boost/reblog) activities
- ActionService — Dispatcher for the Mastodon-style status actions. favourite/unfavourite create and delete a Like, reblog/unreblog an Announce, and bookmark/unbookmark toggle the viewer's local
bookmarkedflag (never federated, served by/api/v1/bookmarks), and pin/unpin hand off toPinService;mute/unmutemute the conversation.translateis deliberately not one of these: it used to be, returning the status unchanged, which a client cannot tell from a translation — it isStatusApiController::statusTranslate()now, answering a Translation entity from a real provider - TranslationService — One status in the reader's language, through whatever translation provider this Nextcloud has (
OCP\TaskProcessing, the same one Talk, Mail and the assistant use). There is no translation engine in this app and there should not be one. A server with no provider announcesconfiguration.translation.enabled: falseand the route answers 503; it never hands back the original text, which is what the old stub did and what a reader could not tell from a translation. The body and the content warning are always translated, then poll options and alt texts whileMAX_TEXTSlasts — each text is a round-trip — and what is past the budget is left out of the entity rather than returned untranslated - NotificationGroupService — Mastodon 4.3's grouped notifications: favourites and boosts of one post, and follows, become one group with a count and a sample of accounts; mentions never group. The
group_keynames what the group is (favourite-{status id}), never the notifications in it, so it still names the same group after more arrive and a client's dismiss still lands - NotificationPolicyService — The five questions an account may ask about whoever is writing to it (
for_not_following,for_not_followers,for_new_accounts,for_private_mentions,for_limited_accounts), and the requests inbox the held notifications are gathered into, one row per sender. Everything starts ataccept, and an account that has not touched the policy pays no query for it.dropbehaves asfilter: the row is written by the inbox long before anybody reads it, and the policy is applied when the list is read, so a policy loosened next week can still show what it caught this week - HashtagService — Recomputes hashtag trends over 1h/12h/1d/3d/10d windows and searches hashtags
- InterestService / InterestScorer / InterestFeedService — My interests.
InterestScoreris the arithmetic and nothing else — no database, no clock, every number handed to it: how long a post takes to read at an ordinary pace (1.2 s, 35 ms a character, 1.5 s a picture, at most 20 s), a look judged against the reader's own usual ratio of looking to reading time (twice it is +1, at it +0.3, less is nothing, a skip −0.2), a signal shared evenly among a post's hashtags (none past fifteen), a half-life decay worked out on read, and the list: pinned tags at the rank they were pinned to, everything else — manual and followed tags floored at the listing threshold, learned tags above it — filling the free ranks by score up to the cap.InterestServiceis whose interests they are: it reads the posts a signal names as the viewer, through the timelines' visibility filter (StreamRequest::getVisibleByNids()), so a client can neither teach a tag no post carried nor learn the hashtags of a post it may not read; it learns from a favourite, boost and bookmark inActionServiceand a reply inPostService, so a Mastodon app's actions count too; it never throws from those hooks; each (post, kind) counts once a day through a distributed-cache key. No signal is stored — each is folded into its tags' scores as it arrives.InterestFeedServiceranks: candidates are one query (StreamRequest::interestCandidates()) oversocial_stream_tagbounded by the nid of the window's start, the reader's tags, the viewer filter, silences, hides and languages; each is scoredrelevance × 2^(−age/1 day)with relevance the sum of the three best matching rank weights (1/(1+0.15·rank)) plus −0.5 per tag the reader turned away from; then spread — no author twice in any twenty, no hashtag more than eight, the hashtag limit giving way first — with every tenth slot a post carrying a tag that often travels with the reader's own. The ranking is kept an hour in the distributed cache and paged by the last post's nid - StreamActionService — Writes the per-viewer flags in
social_stream_act - PinService — Pinned posts: the own posts an account keeps at the top of its profile. A pin is a row in
social_action(typePin), so there is no schema change, and it is never federated as an activity of its own — remote servers read pins from the actor'sfeaturedcollection. Only a public or unlisted post can be pinned (pin()refuses the rest withInvalidActionException), and pins are read back through the visibility filter like any other status, so a post that stopped being readable stops being served: both readers of this collection — thefeaturedroute and?pinned=trueon the account statuses route — are public pages, and reading these unfiltered served a pinned followers-only post in full to the anonymous internet - LinkPreviewService — Builds the link-preview card of a post from what the linked page says about itself (OpenGraph, falling back to the HTML title/description), fetched once per post from the inbound queue through
CurlService. Cards are never federated, so every instance reads the page itself - MarkerService — How far through a timeline someone has read (Mastodon's markers), stored as a per-user config value rather than a table; the unread badge is the only thing the app itself needs one for
- SearchService — Backing searches for accounts, hashtags, URIs and status content;
searchStreamContent()backs the client API search, the local API and the unified search provider. Each search takes an optional row limit, for a caller that pages; without one it returns whatever the query returns - Every timeline query filters actors the viewer has blocked or muted (and actors who blocked the viewer) through one anti-join,
SocialLimitsQueryBuilder::filterHiddenActors()— blocks apply everywhere, mutes to aggregated timelines and threads but not to a muted account's own profile or a directly opened post, and muted-with-notifications to the notification stream.
- ActivityService — Wraps items in Create/Update/Delete activities, LD-signs them, resolves target inboxes, and drives the delivery queue.
request()sends the single highest-priority entry synchronously and kicks off an async request for the rest - ImportService — Parses incoming ActivityPub JSON into typed model objects (
AP::getItemFromData()) and dispatches to the matching handler inlib/Interfaces/ - SignatureService — RSA-2048 key generation, HTTP Signature verification (checking
datefreshness, then the body againstContent-Lengthif the sender sent one and againstDigest/Content-Digest, before the signature itself) and RsaSignature2017 Linked Data signatures. The signing itself is delegated toHttpSignatureService. Fetching a signing key this instance does not already hold is bounded —UNKNOWN_KEY_CONNECT_TIMEOUT(5 s) for DNS, TCP and TLS withinUNKNOWN_KEY_TIMEOUT(10 s) overall, what Mastodon allows a peer of its own — and a fetch that failed is remembered so a burst naming the same unknown key costs one fetch and not one each: 300 s (KEY_FAILURE_TTL) when the peer answered and the answer was not a usable actor, 30 s (KEY_UNREACHABLE_TTL) when it never answered, because that says the peer was having a bad minute rather than anything about the key - HttpSignatureService — The one place an outbound HTTP signature is produced. Both entry points take the URL the request will actually be sent to and the body it will actually carry, and answer with the headers to send:
(request-target)is the path and query of that very URL andhostits authority, so the signature and the request cannot drift apart. A delivery is signed by its own author over(request-target) content-length date host digest; an ActivityPub GET is signed over(request-target) host datebysignFetch(), as the instance's ownApplicationactor. Never as a person: the owner of a signing key is dereferenced by every peer that checks it, so a borrowed account would appear in every peer's logs as this instance's reader and a block or suspension of it anywhere would stop every signed fetch from here — and an instance whose users have no Social accounts yet would have nobody to borrow. A key that cannot sign raises rather than sending an empty signature, and an instance that cannot produce one at all fetches unsigned, which is what the request was until now - InstanceActorService — The instance's own actor and its key pair, served at
/actor, discoverable asacct:<host>@<host>. The key pair lives in two app config values rather than inoc_social_actor: a row there is a local account — listed by the directory, resolved by webfinger, offered to the client API, counted in the statistics, handed a followers collection and an outbox — and the instance actor is none of those, so every one of those places would have needed a clause excluding it. The private half is sealed with the instance secret the way an actor's is, so a config dump alone is not enough to sign as this server. Generated on first use; when two requests race, both adopt whichever pair was written last, because that is the one the actor document publishes - LinkifyService — The plain text somebody types, turned into the HTML every other implementation publishes:
<p>paragraphs,<br />, and links for URLs, mentions (u-url mention) and hashtags (mention hashtag,rel="tag"). Content used to leave asnl2br(htmlentities(…)), and peers rendercontentwithout looking for anything to linkify, so every link, mention and hashtag written here arrived everywhere as dead text. The text is escaped first and markup is only ever built around the escaped pieces — nothing is un-escaped and no markup is assembled by interpolating input. The entities are found once, and that same list is whatPostServiceaddresses the post from and whatStreamServicebuilds thetagarray out of, so the markup can never link somebody thetagarray does not name — which is the list a receiving instance checks a mention against before it notifies anybody - ForwardService — Inbox forwarding (ActivityPub §7.1.2); see below
- FollowGraphService — "Whom to follow", asked of the fediverse instead of this database.
SuggestionServiceanswers the same question fromsocial_follow, which on a small instance is nearly empty because the only follows it holds are the ones that happened here; this fetches thefollowingcollection of each account the viewer follows (at mostSAMPLE, one page each, inside aBUDGET) and counts the names in them. A suggestion carriesfollowed_byand a few of the handles behind it, because a count somebody can check beats a ranking they cannot. Exclusions areSuggestionService::excludedPrims()rather than a second copy — one list, or a blocked account eventually gets recommended. BelowMINIMUM_FOLLOWSit walks nothing and answersneeds, which is what the page shows a new account instead of an empty list;probe()answers that without touching the network, and is what the page asks on open - ProfileLinkVerifier — The tick next to a profile link: the page a field names is fetched (through the guarded client, http(s) only, no local hosts unless allowed,
MAX_SCANof it read) and has to link back to the actor id or profile URL withrel="me". Verdicts live in the cached actor's details (fields_verifiedkeyed by the field value,fields_checkedfor when), whichPerson::exportAsLocal()reads asverified_at; a verdict standsRECHECK_SECONDS(a day) before the page is asked again. Local accounts are checked byCron\Cache(verifyLocalActors(),LOCAL_BATCHper pass, walking only the accounts whose fields containhttpa page at a time from theprofile_link_cursorthe last pass left; the deleted-account reaper likewise asks forgetDeletedBefore()rows only, never the whole table), remote ones with their details refresh inCacheActorService::manageDetailsRemoteActors() - FeaturedCollection::refresh() — Reads a remote actor's
featuredcollection (the first page of a paged one) with its details refresh and makes the stored pins match: known posts are pinned, an embedded Note by the actor is stored first when this instance does not hold it, pins no longer in the collection come down. Before this anAdd/Removewas applied when it arrived and nothing asked for the pins an account already had - PinService — Pinned posts, and the
Add/Removethat tell the fediverse about one. A pin is not an activity of its own on the wire: what travels names the actor'sfeaturedcollection as itstarget. Without that pair a pin was visible only to a peer that re-read the collection, which nothing prompts it to do — so a pin appeared elsewhere late or never, and an unpin never at all. The row is stored first and a failure to federate is logged rather than raised: the profile here is right either way, and a pin is not worth failing a request over - ReportForwardService — Passes a local report on to the instance that hosts the reported account, as a
Flag. Anonymised: the activity names this instance'sApplicationactor and is signed with its key, so the receiving moderators see the server and not the person who filed it — which is the point, since that person is reporting an account on the instance being told. Delivered inline rather than throughsocial_req_queue, because a queued delivery is signed byHttpSignatureService::signDelivery()fromoc_social_actorby the queue row's author, and the instance actor is deliberately not a row there. One report is one POST with a 3-second timeout; a failure costs the forward and nothing else, andsocial_report.forwardedis set only when the remote inbox accepted it - DomainPurgeService — Removes what a blocked instance already sent: its cached accounts, their posts, the follows in both directions, the notifications they caused and the deliveries still queued towards them. A block on its own only ever stopped the next request. Bounded (50 accounts per step, every underlying delete already batched, no transaction held open), idempotent (each step asks what of the domain is still stored rather than counting off an offset, so an interrupted purge resumes and a repeat is free) and terminating (a step that deletes nothing stops rather than spins). Reuses
ModerationService::purgeActor(), so a domain purge detaches exactly what a suspension detaches. What it deletes is gone — unblocking the domain lets the instance reach us again but restores nothing. Matches the exact host, not subdomains, even thoughisListed()widens a block to cover them: refusing traffic from one instance too many is undone by editing the list, and deleting one is not - InboxLimiter — Per-minute rate limits on the inbox routes, one spent before the signature is checked and one after, and the ceiling on how big a delivery may be (
readBody(), 1 MiB); see Security - RequestQueueService — Manages
social_req_queue: creates entries, hands out the priority entry, and re-offers standby entries once they are due. Thefloor(tries^4 / 3)second backoff and theMAX_TRIES(16) give-up are applied by the query (CoreRequestBuilder::limitToQueueDue()), and exhausted rows are markedSTATUS_ABANDONED(8) before the 200-row window is read — filtered in PHP afterwards, the rows of one dead instance permanently occupied that window and starved every other delivery. A delivered row is kept asSTATUS_SUCCESSand an exhausted one as abandoned forRETENTION_SECONDS(seven days), then purged bypurgeFinished()on every cron pass and byocc social:stream:prune; they used to be deleted the moment they finished, which made the queue a to-do list that could never say where a post had got to. Every row carriesobject_id_prim, the md5 of the id of the object inside the activity, read off the JSON inobjectIdPrimOf()so that forwarded third-party bytes are keyed the same way;DeliveryServicereads the rows of one object back for the author (GET /api/v1/statuses/{nid}/delivery). A row whose delivery fails in a wayActivityServicedoes not handle itself — a corrupt signing key, the database going away — is logged and handed back to standby by the caller (Cron\QueueandQueueController), because it was markedrunningbefore the attempt: left that way it was never retried, never counted againstMAX_TRIES, and took the rest of the 200-row batch with it - StreamQueueService — Manages
social_stream_queue, the inbound side. Two queue types are implemented.Cache: for each Note a received stream references (a reply parent, a boosted post), it fetches that Note, caches its author, stores it, and embeds it in the referencing stream's cache — anything that is not a Note, or whose id does not match the URL it was fetched from, is rejected.LinkPreview: reads the page a post links to, once, and stores the card. Any other type is dropped. This side has the same 200-row batch cap and the same in-query backoff, give-up (MAX_TRIES, 10 here) and delete-on-success as the outbound queue; it used to keep one permanent row per activity ever cached and was never pruned - CurlService — Outbound HTTP for ActivityPub fetches, WebFinger and host-meta lookups, and the async self-call that drains a delivery token. The transport is the server's own client (
OCP\Http\Client\IClientService), and a caller hands it a method, a URL and at most four options —headers,body,timeout,json_headers— which is what the client itself takes; nothing in between describes an HTTP request a second time. So the CA bundle, the proxy configuration and the local-address checks come from the server, and what stays here is federation-specific: the protocol fallback (an instance reachable overhttponly, viadoRequestOverUrls()), the signed fetch and its one unsigned retry, the download size ceiling, and the mapping onto the app's request exceptions. A URL somebody else wrote — an ActivityPub id, a cached-media link, a previewed page — is requested exactly as it is written rather than taken apart and reassembled, which is also what makes the path a signature covers the path the request is sent to - FediverseService — Instance-level access control; its CSV import batches one validated list write, then records an audit event and queues the normal purge per new domain. Import is explicitly operator-triggered and only applies in block-list mode; see Admin.md
- InstanceService — Builds and returns the local instance's NodeInfo-style metadata
- DocumentService — Owns the cached document lifecycle: caching a remote document by id, serving originals and resized copies out of app storage, and caching the local actor's avatar and header. Serving applies a viewer bound: a cached attachment is handed to a logged-in user only if it hangs off a post they may read or is their own upload, and the unauthenticated
/media/{uuid}route only for a row marked public - CacheDocumentService — Writes uploads, remote downloads and temp files into app storage, filters MIME types against an allow-list, and reads content back out. An image and a video take deliberately different paths: an image is read into a string, because the metadata stripping, the HEIC conversion and the resize all work on one, while a video is streamed to storage a chunk at a time and never held whole —
fread()of a two-gigabyte upload is two gigabytes of memory, and PHP's limit was the only thing that ever stopped it. That split is also why there are two size ceilings (max_size,max_video_size) and why the second is applied again against the sniffed type: the request-time check can only go on what the client declared. An upload is created non-public; it takes the visibility of the post it is attached to when that post is created (StatusApiController::scopeMediaToVisibility(), public for public and unlisted, non-public otherwise), because which post an upload belongs to is only known then - VideoThumbnailService — One frame out of a video, so a timeline of them is not a wall of black rectangles each of which has to be downloaded before it shows anything. ffmpeg where the server has it, skipped silently where it does not (an app that refused uploads without ffmpeg would be worse than one that shows no poster), and not transcoding: it decodes one frame and asks ffprobe how long the video runs. The poster becomes the video's
resized_copy— which is what that column means, the small image standing in for the file — and from there the player'sposter, which is what lets a page of videos be scrolled without fetching one. The frame is taken from the bytes as they are written and they are only written once, so a video stored before posters existed (or while the server had no ffmpeg) would never get one:occ social:media:postersis the backfill, and it has to do two things rather than one, because a post keeps its own copy of its attachments. Making the poster changes the document; rewriting the copies the posts carry is what a reader sees - BlurService — Generates a blurhash string from a GD image
- GifService / GifPackService — What the composer's GIF picker offers. Two sources behind one list: the pictures an administrator added here with
occ social:gif add(a row insocial_gif, the bytes in appdata beside the custom emoji), offered first because somebody put them there on purpose, and then the 881 animated emoji every instance has. The second is the reason Giphy and Tenor were refused and the reason the picker is not simply empty: they are Google's Noto Animated Emoji, CC BY 4.0, and only the list ships —data/noto-animated-emoji.json, 70 KiB, against 642 MiB of pictures at the one size Google publishes. The bytes arrive one at a time, the first time anybody on the instance asks for one, through the instance's own/gif/{slug}, and stay in appdata from then on. So no search term leaves the instance, no reader's browser ever talks to a third party, and what an instance ends up storing is what its people actually used.Cron\GifPackwarms the first screenful — the sixty the picker opens on, and no more, because warming all of them would be 440 MiB of appdata on every instance in the world on the chance somebody wants a hedgehog.gif_packturns the whole thing off and leaves an administrator with their own./api/v1/gifsis paged for the same reason the warm-up is bounded: a grid of 881 is 881 fetches because somebody opened a menu - AnnouncementService — The instance-wide notices an admin posts. Two reads that are not the same: a client gets the announcements that apply now, each carrying whether that account has dismissed it, and the administration page gets all of them including one that has not started and one that has run out. The window is a predicate of the query, so an announcement starts and stops being served on time on an instance with no working cron — the same rule a timed mute and an expiring filter follow. Dismissal is per account and never hides the announcement: Mastodon keeps serving it and flips
read. There is no edit route, because changing a notice under the accounts that have already dismissed it is worse than posting a new one - DomainBlockService — Per-account blocks of a whole instance, stored as a domain and applied to the host of an account's actor id. Not
FediverseService, which is the admin's instance-wide access list. Nothing is federated; the timelines enforce it from insidefilterHiddenActors(), so a domain block reaches everything a per-account block reaches - AccountRelationService — The private note, the endorsement and the mute expiry of one account towards another, and
decorate(), which is whereFollowService::generateRelationship()fills indomain_blocking,noteand a mute that has run out. A timed mute stops applying because the read says so, not because anything ran to delete it - StatusRevisionService — The versions a status has been through. The first edit records the version being replaced as well as the new one, so the first entry of a history is always what was posted
- StarterPackService — Named handfuls of accounts worth following, answering the question
SuggestionServicestructurally cannot: suggestions work off the follow graph, and a new account has none, so the fallback is whoever posted recently — a list of strangers sorted by luck. A pack is a list ofuser@hosthandles and nothing else; no table, because the accounts are not this instance's to own and the handles are the only durable reference to them. The index resolves nobody (a handle costs a WebFinger lookup and an actor fetch), so resolution happens only when a pack is opened, and a handle that will not resolve is reported rather than dropped — a pack that quietly shrinks looks like one somebody wrote badly. The shipped packs are the official accounts of the projects this app federates with, which is the one editorial line defensible without becoming a directory nobody agreed to be in; thestarter_packsapp value replaces or extends them, and a configured pack whose slug matches a shipped one replaces it - FediverseDirectoryService — Looking for somebody to follow when you do not know which server they are on. The app could already resolve a handle you had been given and suggest people out of a follow graph a new account is not part of; neither is how anybody finds anyone. This asks several servers' directories at once, over APIs they serve to strangers without a token — nothing here holds credentials for anyone else's instance and nothing here can be made to. It does not aggregate: no list of people is stored, nothing is indexed, an answer is cached for five minutes so typing is not four requests a keystroke, and the people in it are not this instance's to hold. What is stored is about servers — the
fediverse.infolist and each federated peer's NodeInfo software — written byrefresh()fromCron\Cacheinto thedirectory_knownapp value (not a cache: the cron writes it and a web request reads it, and APCu is not shared between the two), sosources()and the Discover page never wait on a remote host. Four kinds —local(this instance, always first, never a network request),mastodon(/api/v1/directoryplus/api/v1/accounts/lookup, which is what Pixelfed and most forks serve too),misskey(users/search) andlemmy(search) — and the kind is configured rather than sniffed, because sniffing is a request before the request for an answer that changes once in a server's lifetime. Mastodon having no public search is a fact about Mastodon, not an omission here: an exact lookup and one page of the directory is the whole of what it will answer, and the query is applied to that page by this app rather than pretending to be a search. Each source's outcome is reported with the results, because "nobody by that name" and "that server did not answer" are different answers. Results areDirectoryAccount, deliberately not MastodonAccount: the id in a remote directory's answer is a row number in somebody else's database, and giving these the same shape would make that mistake invisible — the handle is the durable reference and the only thing a client acts on. Anybody on a domain this instance will not federate with is dropped from what is offered, which guards a different thing from the fetch guard: that one protects the server being asked, this one keeps the instance from recommending exactly what it refuses to deliver to - PeerTrendService — What the rest of the fediverse is talking about.
HashtagServiceranks the tags used on this instance, which on a small server is what the handful of people here posted today and on a new one is nothing; this asks the serversFediverseDirectoryServicealready keeps the same question, since "which servers do we ask about people" has the same answer as "which servers do we ask about tags". Nothing is fetched, ingested or stored — what comes back is a list of strings, so no post is retrieved and no actor is cached, which is why the route needs no viewer where the people search does. Ranked by how many servers named a tag rather than by their counts added together: instances differ in size by four orders of magnitude, so a sum ranks mastodon.social's opinion as everybody's, while a count of servers is the model the follow graph already uses and is the one a row can explain. What each kind of server can answer is a fact about its API —mastodonserves/api/v1/trends/tagspublicly but has no public tag search, so a search there matches the query against that same trending page;misskeyserves bothhashtags/trendandhashtags/search;lemmyand the curated directory have no hashtags at all and are reported asunsupportedrather than asked. A peer's answer is held to what a hashtag is here before it is shown, because it is a string this instance is about to display and offer to follow. This instance's own rejection list is applied to what strangers say: a tag a moderator took off the local trending page has been decided about, and showing it anyway because somebody else's server likes it would overrule that decision - DirectoryService / SuggestionService / TrendService / FeaturedTagService — Discovery. The directory is opt-in through
discoverable, applied as a predicate of the deciding query rather than as a filter over rows already read; suggestions are two counted facts (friends of friends, then locally active accounts) rather than a scoring model; trends count from the rows a like, a boost and a link preview already write, so a trend cannot drift from the counts a status reports - BannerService — The banner across the top of a profile. Three routes set one — a picked file, a URL, and
headeronupdate_credentials— and all three end in the same work: store the bytes, point the cached actor at them, tell the followers. It is one service so those three cannot drift on the parts that matter, which are the banner being public where an attachment is not, and theUpdate{Person}that is the only reason anybody else ever sees it
Serving media. /media/{uuid} answers byte ranges (RangedFileResponse).
FileDisplayResponse, which it used to use, sends the whole file and says
nothing about ranges — fine for a picture and wrong for anything with a timeline
in it: a browser cannot seek a video it can only receive from the beginning, so
the scrub bar does nothing, and asking for the duration alone costs the whole
file. On a page of twenty videos that was twenty full downloads before anybody
had pressed play. The rules are RFC 9110's, and an unparseable range is answered
with the whole file rather than refused, because that is always correct.
Posting a picture that is already in Nextcloud. MediaApiController::mediaFromFile()
(POST /api/v1/media/from-file) attaches a file out of the user's own storage,
so the one thing this app should never ask of the person running it — download
your own photo, then upload it back — is not required. The path is resolved
through IRootFolder::getUserFolder() and checked against that folder again
afterwards: the route names a file and returns its contents, which is the shape
a mistake here would be exploited in, so the boundary is stated twice.
The bytes are copied, not referenced. A post keeps the picture it was
published with, so moving, renaming or deleting the original cannot empty a post
that has already federated, and the attachment can take the post's visibility
the way an upload does. The copy goes through a temp file into
saveFromTempToCache(), which is the same code an upload takes — so the MIME
allow-list, the size ceiling, the resizing and the blurhash cannot drift between
the two ways a picture gets in. MediaApiController::storeAttachment() is the shared
half that guarantees it.
Note what this means for where the file lives afterwards: attachments are held in appdata, not in the user's file tree. A picture posted from Files has a copy in app storage; the original stays where it was, untouched.
- ConfigService — App/user configuration and the derived URLs (cloud URL, social URL, social address, max download size, self-signed toggle), plus ActivityPub id generation. The social URL is derived from the stored cloud URL (
derivedSocialUrl()), never from the request that happens to find it empty. It also owns the two config-derived parts of every outbound request:requestOptions()(the timeout and connect timeout, whether the peer's certificate has to check out, whether local addresses may be reached) andactivityPubHeaders()(theAccepta federation GET carries and theContent-Typea POST does).withRequestTimeout()bounds everything a call makes, overriding what the caller asked for - CheckService — Installation checks (is
/.well-known/webfingerreachable) and repair of invalid follow and note rows - IndexService — Repairs missing
social_stream_destandsocial_stream_tagrows 500 streams perCron\Indexpass. It pages by ascending NID, records the cursor only after both side indexes succeed, and leaves existing rows in place so interrupted work resumes safely. A failing row is logged and holds the cursor before it, so later rows wait until the failure is corrected; the explicitsocial:check:install --indexremains the administrator's full rebuild. - ClientService — OAuth 2.0 client registration, authorization and token issuing
- DetailsService — Computes a
StreamDetailsobject describing which local viewers a stream reaches - MiscService — Logging helper and the running Nextcloud major version
- ReportService — Moderation reports: stores what was filed locally over the client API or arrived from a remote instance as a
Flag, and notifies the instance admins. An incomingFlagkeeps at most 50 of the ids it names, and they are resolved against the actor cache only: nothing it names is fetched - ModerationService — Acts on a report: silence, suspend, or take one post down. the two levels are described under ActivityPub Federation below
- GroupListService — Nextcloud groups as Social lists: every group a person is in that an administrator has chosen (
SectionsService::groupHasList(), empty by default, so nothing until somebody chooses) becomes a list of theirs, holding the group's members that have a Social account, kept in step three ways — the lists a viewer is missing are made when they ask for their lists (ensureForViewer(), fromGET /api/v1/lists), a group change is applied the moment it happens (GroupListListener, on the fourOCP\Group\Events), and the cron reconciles every group list against its group (reconcile(), fromCron\Cache) for what neither saw, such as an account created after the lists were. A group larger thanMAX_GROUP_SIZE(500) gets no list, and neither does one the administrator has not chosen — deselecting a group takes its lists away on the nextreconcile(), which is the only place that can see it, sinceensureForViewer()only ever looks at the groups the viewer is in. A group list is a private list like any other — same table, same timeline, same visibility — whose title and membership are the group's rather than the owner's:ListControllerrefuses to delete one or edit its members (422) and keeps its title on an update - SectionsService — what this instance offers: whether stories are taken, whether the Photos and Videos timelines appear (all three on by default), and which Nextcloud groups become lists (empty by default). Read by
NavigationControllerintoserverDataso the sidebar is drawn right the first time, byGroupListServicebefore it makes a list, and written bySectionsController(POST /admin/sections, administrators only, all of it or none) - DeliveryService — What the outbound queue looks like to an author: the rows of one post, counted as delivered / sending / waiting / failing / abandoned and listed per server, the ones that need an eye first. Only as good as the queue's retention, which the answer carries, so an old post reports nothing rather than reporting wrongly
- FederationHealthService — What the outbound queue looks like to an administrator: which instances deliveries are failing against, so an instance that has quietly stopped receiving anything is distinguishable from one nobody posted to. Both states, not one: what is still being retried (
getFailing(), STANDBY with at least one failure) and what has been given up on (getAbandoned(),STATUS_ABANDONED), each grouped per host. The second was reported nowhere — a request on its fifteenth attempt was counted as failing and the moment it was abandoned it left every count, so the queue looked healthiest exactly when a peer had been lost for good. The abandoned figures reach backRequestQueueService::RETENTION_SECONDS(seven days), which is how long a finished row is kept;occ social:queue:status, the admin settings and the dashboard widget all read this one summary, andocc social:queue:retry --instance HOSTis what acts on it - TestService — Backs the WebFinger probe of
occ social:check:install - DurableCache — Short-lived state that has to hold on an instance with no memcache.
ICacheFactory::createDistributed()hands such an instance aNullCache, which forgets every write: a counter kept there reads zero for ever and a list of what was already accepted is always empty. This is the distributed cache whenisAvailable()says there is one and thesocial_durable_cachetable when there is not, behind the part ofICachea counter or a replay record needs, with the namespace passed on each call:get($ns, $key)(null for a miss),set($ns, $key, $value, $ttl),inc($ns, $key, $ttl, $step = 1)(returns the new count; the TTL is set when the counter is created and never extended) andremove($ns, $key). Values are anythingjson_encode()round-trips; a TTL of 0 isICache::DEFAULT_TTL, never forever. On the tableinc()is read-then-write, which is fine for a ceiling and wrong for anything that must be unique. APCu configured asmemcache.localcounts as a memcache and is not shared with cron orocc, so state a background job writes for a web request does not belong here. Used byInboxLimiter(namespacesocial.inbox),SignatureService's LD-signature and signed-request replay records (social.ldsig,social.httpsig), theIdempotency-Keyrecord ofPOST /api/v1/statuses(social_idempotency) andStatisticsService's computed pages (social/statistics); theMemcacheConfiguredsetup check says which of the other caches are off without one - PushService — on a new stream, resolves the local audience through
DetailsService(home + direct viewers) and pushes asocial_timelinecustom event per user through the notify_push app when it is installed; without notify_push every call is a cheap no-op and web clients keep polling. The web client listens via@nextcloud/notify_pushand drops its 30-second poll to a 5-minute safety net when push is available
The app is an ActivityPub Server that both produces and consumes ActivityPub messages.
Each Nextcloud user that has been given a Social account gets a Person actor:
- ID: the configured social URL plus
@username, e.g.https://cloud.tld/apps/social/@username - Keys: RSA 2048-bit key pair, generated per actor by
SignatureService::generateKeys() - Endpoints:
/@{user}/inbox, the shared/inbox,/@{user}/outbox,/@{user}/followers,/@{user}/following,/@{user}/collections/featured - Profile fields:
attachmententries of typePropertyValue. A local value is stored as typed and served as the HTML the wire expects: escaped, and an address on its own linked withrel="me"the way Mastodon's own fields are, since Mastodon verifies a remote field only from a link in it. A remote actor's values are served as they arrived - Media type:
ActivityPubControlleranswers in the type the peer listed first —application/activity+jsonorapplication/ld+json; profile="https://www.w3.org/ns/activitystreams"(GoToSocial asks for the latter first and refuses any other answer). Both responders are built on the framework'sjsonresponder, so a 401, 404 or 410 reaches the peer with its status - Collections: followers, following and outbox answer with an
OrderedCollectionwhosefirstandlastpoint at?page=N, and with?page=Nthey answer with a realOrderedCollectionPageofOrderedCollection::PAGE_SIZE(40) items linked bynext/prev, so a consumer can enumerate them. Followers and following listtype = Followrows only: the acceptedLoopbackrow every actor has on itself, which the home timeline reads (getHomeCollectionPrims()), is not a follow.featured— the actor's pinned posts, which is how a remote server learns about a pin — is a single unpaged collection. The posts in the outbox and infeaturedare exported with complete details off, asdisplayPost()exports one: their rows are read with the author joined, which flags them for this app's own bookkeeping (source,actor_info,details,cache), and none of that is a peer's business
Alongside the per-user actors, the instance has one Application actor of its own at <social url>actor, with its own RSA key pair in app config. It is what signs every outbound ActivityPub GET and what ReportForwardService files a Flag as; it is not an account and appears in no directory, no client API and no statistic. Its inbox is the shared /inbox — anything addressed to the server is addressed there — and it names no outbox, followers, following or featured collection, because an actor document naming a collection no route serves is worse than one that omits it.
A story is the one object here that is neither a post nor a collection. It is published to the author's followers as an Add whose object is a Story and withdrawn with a Delete — Pixelfed's verbs, because Pixelfed is the network that has stories — and it resolves at /@{username}/stories/{id} for a peer that would rather fetch it than trust the copy it was handed, under the same rule the client API keeps: the author, or a signed reader who follows them. StoryInterface takes the incoming side and keeps a story only when somebody here follows its author, for no longer than a day whatever the sender's expiresAt says. The published shape is Pixelfed's down to the field names, because Pixelfed is the only network that has stories: its StoryFetch reads nothing out of the activity, requiring object.object as a bearcap (bear:?t=…&u=…, FEP-d8c2) that it then fetches with the token, so the Add carries the capability beside the fields, attachment is one object typed Image/Video rather than a list, and published, expiresAt and can_reply/can_react are all stated. The token is an HMAC of the story's address under an instance secret made on first use — derived rather than stored, because a story lives a day and a row per story to write and expire is a table for a value that can be recomputed — and ActivityPubController::story() accepts it as a bearer token beside the signed-follower rule, since a bearcap fetch signs nothing. What cannot be fixed from this side: Pixelfed fans stories out with FollowerService::softwareAudience($id, 'pixelfed'), so it delivers them only to instances it has identified as Pixelfed and none of its stories arrive here whatever this app does. A Create is deliberately not used: it would put a story where posts go, and a server that does not know the type ignores an Add of one, which is the outcome to want.
Every local note carries a replies collection at <post id>/replies, served paged by ActivityPub#replies. A reply reaches the instances that hold the post it answers and nowhere else, so without it a reader on a third instance sees a post with no replies. It lists the ids of the public replies only, and remote notes are not given one: their replies live on the server that holds them.
- A user action (post, edit, delete, follow, unfollow, like, boost) has a service build the activity
SignatureService::signObject()adds a Linked Data Signature — for Create, Update, Delete, Like, Announce and their Undos. Follow, Accept, Reject, Block andUndo{Block}are not LD-signed; they travel with the HTTP signature onlyActivityService::request()expands the activity's instance paths into concrete target inboxes- Targets on this instance are dropped: everyone here already has the item, because recipients are written into
social_stream_destwhen it is saved, which is what puts it in a local timeline. Posting to our own inbox would only hand us back what we wrote, and it is a request the server has to be able to make to its own public address — behind a reverse proxy, split-horizon DNS or an SSRF guard it often cannot, and the delivery then fails its way to being abandoned while remote instances queue up behind it. This holds only for activities whose effect is already applied when the item is saved. A Follow has no such path, soFollowService::followAccount()detects a local target and runsFollowInterface::processIncomingRequest()in process instead — setting the activity's origin to this host first, because that handler is the inbox's and checks it. Without that the row stayedaccepted = 0for ever and no local follow ever completed RequestQueueService::generateRequestQueue()writes onesocial_req_queuerow per remaining target- At most one row is delivered inline:
RequestQueueService::getPriorityRequest()hands back the first row only when its priority isTOP, orHIGH/MEDIUMunder narrow conditions, and otherwise throwsNoHighPriorityRequestExceptionso nothing is sent synchronously. If rows remain on standby,CurlService::asyncWithToken()fires a request at the app's own/async/request/{token}route to drain them Cron\Queue(12-minute interval) retries whatever the query says is due, with the backoff above, after returning rows a dead worker leftrunningto standby- Every delivery is an HTTP POST to the queue row's inbox URI.
SignatureService::signRequest()is given that URL and the body about to be sent and answers with the signed headers;CurlService::retrieveJson()sends both
An edit can add recipients. PostService::editPost() reparses the new text,
adds only newly named actors to the audience, mention tags and inbox paths, and
then persists the changed post. StreamRequest::update() writes those paths
back to social_stream.instances and, for a local edit, regenerates
social_stream_dest in the same transaction. The Update is built from the
reloaded post, so a newly mentioned instance is not lost between editing and
queueing. The followers path is added only when the post's visibility reaches
followers; this keeps an edit to a direct message from widening its audience.
If queue creation fails after the local transaction commits, the Mastodon API
answers 503 with an explicit message that the post was saved locally; clients
must not ask the author to submit the edit again merely because remote delivery
failed.
Reading edit history. PostDetails opens EditHistoryDialog with the
status's numeric nid and actual edited_at value. The history route resolves
that NID through StreamRequest::getStreamByNid() before reading revisions,
so visibility rules remain the same as for the post. NIDs can exceed both
32-bit integers and PHP_INT_MAX; ExtendedQueryBuilder::limitToNid() keeps
those decimal values as strings when binding the BIGINT predicate. The dialog
accepts either the Mastodon array response or Nextcloud's result wrapper,
reports malformed or failed responses as errors, lets readers retry, and
ignores a slow response when the dialog has switched to another post.
A delivery is retried when the peer's answer says it might accept the activity later — 408, 429 and any 5xx — and the row is dropped only on an answer that says it never will, or once MAX_TRIES is reached. A host that has just answered with a transient status is added to the run's failing set, so the rest of the run does not ask it once per queued activity.
Activities the app emits: Create, Update, Delete, Follow, Accept, Reject, Like, Announce, Block, Undo. Every Undo embeds the activity it takes back rather than naming it by id — PeerTube decides what is undone by object.type and ignores a bare id; an Undo{Announce} carries a fresh Announce with the id, actor, object and audience of the stored one (BoostService::undoneAnnounce()), not the stored row's export. An Update is <object id>#updates/<n>, n being the post's updated as a Unix time (the time in milliseconds for an object without one), so two edits are two activities and a redelivery of one version keeps its id.
Reject goes out when a follow request is refused (FollowInterface::rejectFollowRequest(), also used to answer a Follow from a blocked actor) and when an accepted follow is severed by a block. Block and Undo{Block} go out from RelationshipService, unless the federate_blocks app setting is 0.
The app never emits Add, Remove or Move. It can parse all three — AP::getItemFromType() constructs each one for an incoming document — but no service builds one to send: a pin is a local row that remote servers read from the featured collection, and nothing here migrates an account away.
- A remote instance POSTs to
/@{username}/inboxor the shared/inbox InboxLimiter::assertAllowed()spends the per-address bucket, before anything is read, andreadBody()reads at most 1 MiB of body — refusing more with413SignatureService::checkRequest()checks thedateheader for freshness, thatcontent-lengthmatches the body, thatdigestmatches the body, and then the HTTP signature. It returns the verified origin host, or throws — a request whose signature does not verify never reaches step 4FediverseService::authorized()is called with that origin, thenInboxLimiter::assertOriginAllowed()spends that origin's own, looser bucket. The per-origin ceiling is spent here rather than at step 2 because before step 3 the origin is only what the sender wroteImportService::importFromJson()parses the body into a typed object. A property that may be one value or a list (attachment,tag,to,cc, an actor'salsoKnownAs) is read throughACore::listOf(), so a single object or string counts as a list of one, and into/ccthe short spellings of the public collection,as:PublicandPublic, are read as its full IRI, and aninReplyTothat embeds the post it answers is read as that post's id; a document the models still cannot read — aTypeErrorinside one of them — is anActivityPubFormatException, answered 400- If the body carries a valid Linked Data Signature the origin is taken from it, otherwise the HTTP-signature origin is used — and then the key that signed the request has to be the activity's actor's own (
SignatureService::assertSignerSpeaksFor()). When it is not, the activity is a forward with nothing to vouch for it and is handled byActivityPubController::acceptForwarded()instead of steps 7–8: see Receiving a forward below ImportService::parseIncomingRequest()looks the handler up withAP::getInterfaceForItem()and callsprocessIncomingRequest(). Every exception from the handler is logged and swallowed- The controller answers 200 and then drains the inbound stream queue for that request token
What a refused delivery is answered with. The status is the only thing a peer reads to decide what to do next, and Mastodon re-queues a 5xx with backoff for about two days — so answering every rejection 500, which a catch-all used to do, turned one refused delivery into a dozen and made blocking an instance multiply its traffic. ActivityPubController::statusForRejection() maps the failure instead:
| Status | When |
|---|---|
| 401 | Nothing about the request proves who sent it: no signature, one that does not verify, a replay, a Date outside the window, a Signature header missing its parts, or an activity whose actor is not the origin that signed for it (except a forwarded Create, Update, Delete or Announce, answered 202 — see Receiving a forward) |
| 403 | The instance access list refuses this origin — a decision about who may talk to this instance, not a failure |
| 400 | The bytes could not be read as an activity, or the Date could not be parsed. Redelivering the same bytes cannot help |
| 404 | Addressed to a local actor that does not exist |
| 429 | A rate limit, either bucket |
| 503 | The signature could not be checked: the host holding the signing key was unreachable, or its last fetch failed recently enough to still be in backoff. This is the one rejection that invites a redelivery |
| 500 | A fault of this instance's own, and nothing else |
An activity that is understood but has no handler is still answered 200 (see below), as is one whose signature says the key is gone.
Receiving a forward. The other end of §7.1.2: Mastodon passes a reply on to the followers of the post it answers, signed with its own user's key, and a reply written on a server that makes no Linked Data signatures (GoToSocial, Pleroma, Pixelfed, PeerTube, Lemmy and most others) arrives signed by somebody other than its actor with nothing on it to say the actor wrote it. That used to be a 401, which is final for the forwarder, so the reply never reached this copy of the thread. The body is still not believed, and is never stored: a Create or Update whose object is on its actor's own host — and not on this one — is answered 202 and the object is queued by its id as a StreamQueue::TYPE_FETCH item under a fresh token, drained inline like any other queue item. StreamQueueService::fetchFromOrigin() fetches it signed through CurlService::retrieveObject(), requires the document to carry the id it was fetched from and to be a post, sets the origin to the URL's host, and hands it to ImportService::parseIncomingRequest() as a Create of its attributedTo — or an Update when a stored copy exists and the fetched updated differs, and nothing at all when it does not — so the author-on-the-object's-host checks of NoteInterface apply to what that server served. An unreachable origin is retried by the queue. A forwarded Delete or Announce is answered 202 and dropped, the way Mastodon drops what it cannot verify; any other activity signed by somebody else is still a 401. The same fetch takes in a Create whose object is only an id, which the vocabulary allows and CreateInterface used to drop: the id is queued when it is on the host the activity came from and its actor is on.
Incoming activities that are actually acted on:
| Activity | Effect |
|---|---|
Create (Note) |
Post is stored in social_stream, notification generated |
Update (Note) |
Stored post is updated |
Update (Person) |
Cached remote actor is refreshed |
Delete (Note, Person) |
Item is deleted; also handled when only an object id is given |
Follow |
Saved and auto-accepted — an Accept is queued straight back |
Accept (Follow) |
Local follow row marked accepted |
Reject (Follow) |
Local follow row deleted |
Undo (Follow, Like, Announce) |
The wrapped relation or action is deleted |
Like |
Stored as an action, notification generated |
Announce |
Stored as a boost, notification generated — unless the announced object is one this instance holds and is not public, in which case the activity is dropped. A boost carries the booster's audience, so storing it would republish a followers-only post to everyone the booster reaches; the local boost path has always refused to create one |
QuoteRequest |
Somebody asks to quote a local post. Answered with an Accept carrying the approval, or a Reject, according to the post's own policy — see Quote posts below |
Accept / Reject (QuoteRequest) |
The answer to a request of ours: the approval is written onto the quoting post, or the quote is marked rejected |
Move |
Actions, follows, streams and cached documents are repointed to the target actor — but only after the target actor (refreshed from its server) lists the moving actor in its alsoKnownAs; a Move whose target does not acknowledge the actor is refused |
OAuth: one authorization per account. social_client is the app registration — the name, the redirect URIs, the client id and secret — and nothing else. Everything about who authorized it is a row of social_client_auth: the code, the token, the scopes granted and the account they were granted to, unique on (client, account). Before that, the app row held one auth_user_id and one token, so an app registration belonged to exactly one person at a time and the second person to sign in with Elk or Phanpy — which register one app per instance — signed the first one out, silently. Re-authorizing replaces that account's row and nobody else's; revoking takes one authorization, where it used to take the app row's only token and sign out everybody; and the expiry sweep deletes authorizations rather than the whole social_client row, which used to take the app's registration with an idle token and make the client register again. A code is spent in the same statement that writes the token, so two requests arriving together cannot both exchange it. Every read joins the app row, so the rest of the app still sees one SocialClient carrying both halves.
Notifications that are not activities. Six of the nine notification types are an activity somebody sent — a Like, an Announce, a Mention, an Update, a Follow, a follow request — and Stream::NOTIFICATION_TYPES maps each to its Mastodon name. The other four are events this instance raises itself, with no ActivityPub verb behind them, so the subtype is this app's own name for the event: poll (swept by PollService::announceClosedPolls() from the cron, because a poll closes by its end time passing and nothing happens at the moment it does), status (the bell on a profile — a notify row in social_actor_relation, written by POST /accounts/{id}/follow with notify, and raised for local subscribers only since a remote one is told by their own server), moderation_warning (raised beside the Nextcloud notification a strike already sends, because a Mastodon client cannot see that one) and severed_relationships (raised by DomainPurgeService for the local accounts a block cuts off, counted before the purge because afterwards there is nothing left to count).
Who may moderate. Nextcloud's own settings delegation, and nothing beside
it. AdminSettings implements IDelegatedSettings, so an administrator can
hand the Social section to a group under Administration privileges; the page
then opens for that group because core gates it on the delegation, the buttons
on it work because every ModerationController and admin AnnouncementController
method carries #[AuthorizedAdminSetting(settings: AdminSettings::class)], and
the Mastodon admin API agrees because AdminApiService::isAdministrator() asks
IManager::getAllowedAdminSettings('social', $user). Before this, moderating
meant administering the whole server — a great deal of power to hand somebody
so they can act on a report — and a second list of moderators kept somewhere of
this app's own would have been one more thing to disagree with the page.
getAuthorizedAppConfig() is deliberately empty: a delegate writes the
retention period and the access list through the validating routes above, not
through core's raw app-config endpoint. The check is asked of the user id,
never of the token: a scope on an OAuth token says only that some client asked
for it, since registration stores whatever scope string arrives.
Setup checks. The OCP\SetupCheck\ISetupCheck classes in lib/SetupChecks/, registered in Application::register() and shown in Administration → Overview: WebFingerReachable (the CheckService::checkWellKnown() probe, asked about the oldest live local account rather than about the viewer, who may never have opened Social), CloudAddressMatches (the stored cloud_url against what the server now reports, and the origin of social_url, which ids are minted from, against cloud_url), CronRanRecently (Cron\Queue's last run, read off the job list), OutboundQueueNotStuck (abandoned rows, and standby rows last tried more than a day ago — past anything the retry schedule would wait on purpose), ClientApiAtRoot (whether /api and /oauth at the domain root reach the app), ProxyForwardsTheScheme (whether the proxy in front of those rules tells Nextcloud the scheme), ReachableByStrictPeers (https and a public name, which Pixelfed insists on), UploadLimitsAgree (PHP's upload ceilings against the app's own) and MemcacheConfigured (what is kept in the database, off or recomputed without a memcache). occ social:check:install runs the first five as the same objects rather than a second copy of the logic, so the console and the settings page cannot drift apart; --offline leaves out the one that goes out on the network. Each links to Admin.md, through SetupChecks\Docs so a moved guide is one edit.
The Server card. ServerSettingsService is the one place that reads and validates the instance-wide settings the page can reach. That includes the contact email and local contact account as well as media limits, inbox throttling, federation policy, and video processing. An account entered as the contact is resolved against the local Social actors table and stored by its Nextcloud user id; a remote account or a local team actor without a Nextcloud user is refused. InstanceService resolves the saved owner to its cached account on every /api/v1/instance and /api/v2/instance read, so clients see current account details, and a deleted account is omitted rather than breaking instance discovery. ServerSettingsController is deliberately not delegated: it carries no AuthorizedAdminSetting, so a group holding the Social section is refused, and AdminSettings does not render the card for one.
When public posts have no remote recipient. A Public address is a visibility declaration, not a server-wide broadcast request. Outbound delivery resolves the author's followers, explicit mentions, and accepted relays; if none resolve to a remote inbox, the queue correctly creates no delivery rows. ActivityService::request() now records a notice with the actor, object, activity, and requested path types for that zero-inbox outcome. The author's Delivery status panel explains which audiences receive public posts and why a public status is not sent to every known peer. Deletes retain the post's original saved recipient paths and add the follower and booster/replier destinations, so a retraction follows the same paths that carried the original status. The first resolved remote path on a Delete is promoted for an inline attempt before the remaining fan-out is handed to the detached queue worker; this preserves asynchronous delivery for large audiences while avoiding dependence on self-queue dispatch for every remote copy.
The audit trail. AuditService dispatches OCP\Log\Audit\CriticalActionPerformedEvent for the decisions that are worth keeping longer than social.log keeps them: suspend, silence, lift, takedown, and an instance going on or off the access list. It is called from ModerationService and from FediverseService::addAddress()/removeAddress() rather than from the controllers, so the settings page, the Mastodon admin API and occ social:fediverse all record the same thing. A lift and a takedown also write a social_strikes row now (Strike::LIFT, Strike::TAKEDOWN) naming the acting moderator; both used to be an info line and nothing else. Strike::COUNTED is what the browser's strike column counts, and a lift is not in it.
Moderation. social_moderation holds what the instance has decided about an account, as against social_actor_relation, which holds what one of its users has. Two levels: silence keeps the account reachable for its followers and drops it from the public and global timelines (StreamRequest::filterSilencedActors(), a small NOT IN rather than a join, because a moderator acts rarely); suspend deletes the account's streams and cached actor, makes ImportService::parseIncomingRequest() refuse everything it sends afterwards, and — for a local account — stops it acting at all: posting, editing, boosting, liking and following each ask ModerationService::assertNotSuspended() first, so the refusal holds for every entry point rather than for whichever controller was remembered — without that last part a suspension would undo itself the next time the account posted. Lifting removes the record; it cannot undo a deletion, and the admin panel says so before suspending.
Admin metrics. MetricsService answers Mastodon's three metric shapes — a measure (one number a day over a window), a dimension (the ranked list behind one number) and retention (how much of each month's new accounts is still posting later) — plus the three admin trend routes, which answer exactly what the public ones answer because a trend here is what the counts say and there is no review queue to report on. A key this instance cannot answer is refused with a 422 naming the ones it can, never answered with zeroes: most of Mastodon's keys describe a sign-up, an invite system, an email address or a media store this app does not own, and 0 reads as "none", which is a different claim and the one an admin acts on. The window is snapped to whole days and capped at 370, because these are full scans of a date range rather than index probes. The SQL lives in the service in protected methods, as AdminApiService's does and for the same reason: what decides which question gets asked is then testable without a database. active_users counts accounts that posted, not accounts that logged in — this app has no session of its own to count.
Blocks that are about an address. Mastodon keeps three lists for this — IP blocks, email-domain blocks, canonical email blocks — and all three exist to police a sign-up. This app has none of its own for internal users, whose account is a Nextcloud account the server decided to give them, and an optional one for external users. So two of the three are given the only meanings they can honestly have, and the third is not implemented rather than stored and never consulted. An IP block at no_access is enforced in AccessBlockMiddleware, which every request this app serves passes through — "no access" is a statement about the whole app, and a block that held on the inbox but not on the API, or on last month's routes but not on the ones added since, is not what an admin switched on; it answers 403, so a peer stops redelivering. The two sign-up severities are refused at the API rather than stored. An email-domain block is checked in AccountService::createActor(): whether a Nextcloud account gets a fediverse identity at all, which is the same question Mastodon asks one step earlier. For a self-registered external user it is asked at the registration form too (ExternalUserService::assertEmailAvailable()), which is exactly Mastodon's question. An IP block at no_access covers the registration routes like every other route of this app. A canonical email block is a hash of the address of a deleted Mastodon account, kept so the same person cannot sign up again; nothing here holds an account's address after deletion, because the address is not this app's to hold. Ranges are matched on packed bytes (inet_pton) rather than on text, which is the only way ::1 and 0:0:0:0:0:0:0:1 are the same address and the only way a prefix that falls inside a byte means anything.
The timeline switcher. TimelineSwitcher.vue sits above the posts on the three timelines that are the same place seen from three distances — the home timeline (My Feed), timeline (Local) and federated (Global) — and on no others: everywhere else it would be a switch between three places the reader is not. It routes rather than fetching, so Timeline.vue and the store go on being the single answer to "which timeline is this". The values are the route's own words rather than the labels, because timeline is what the store calls the local one and federated the global one, and a second vocabulary in a component that only routes would be one more place for the two to disagree; home is the route with no type at all, so it is pushed as the bare route rather than as type: 'home', which names a timeline nothing serves. Local and Global have left the sidebar: they are scopes of the page the switcher sets rather than places of their own, and two entries that lead to the same list while saying it is somewhere else are two too many. The Home entry stays lit while either is being read — isActive() honours a covers list on a menu entry, which is the set of type params that entry owns — so the sidebar never shows nothing chosen.
It is built from plain buttons rather than from NcCheckboxRadioSwitch, for the one thing that component cannot do: a single indicator that travels between the three. Three controls that light up tell you where you landed; one pill that slides tells you where you came from, which is what makes a switch feel like a switch. The cost is that the accessibility is hand-written rather than inherited, so it is written in full — a radiogroup of radio buttons, aria-checked, arrow keys in both axes that wrap at the ends, a roving tabindex so the control is one tab stop rather than three, and the focus following the selection the way it does in a radio group. The pill itself is aria-hidden: what it shows is already on the options. The track is 42px — 30 for an option, 3 of padding either side — which is a little under a Nextcloud button, because the control labels a timeline rather than competing with it. Every option is flex: 1 1 0 so all three are as wide as the widest, which is what lets a pill of one third of the track land exactly on one of them whatever the labels translate to; below 500px the labels go and the icons stay. The states are written as .switcher .switcher__option rather than as one class because the server styles bare button elements and its button:not(.button-vue, [class^="vs__"]):hover is the more specific selector — left alone, Nextcloud's hover colour paints over the pill on the option you just chose. The movement — the slide, the overshoot, the kick the chosen icon gives, the turn the globe makes — is all inside @media (prefers-reduced-motion: reduce), which switches every bit of it off.
It chooses between whatever it is given rather than between three timelines it knows about: each option carries a value, a label, an icon and the route it stands for, and the component routes and animates. That is why a profile carries the same control — Posts, Photos and Videos are the same shape of choice, one account seen three ways rather than three places — and why the words in those routes are the route's own (timeline, federated, image, video) rather than the labels beside them.
New post. The one thing in the sidebar that is not a place to go, so it is not a row among rows: an NcButton in the primary colour across the width of the sidebar, above the places. It carries the app's resting elevation, lifts on hover, presses in, gives its + a disc that kicks the way the switcher's icons do, and lets one band of light cross it on the way in — all of it inside prefers-reduced-motion: reduce, which switches every bit of it off. It was an NcAppNavigationItem with no to, which renders href="#" and needed a .prevent to stop the bare fragment becoming a history entry; a button has nowhere to go by construction. NcButton rather than a bare <button> because the server's own rules for bare buttons all exclude .button-vue: a hand-rolled one has to win an argument with them in every state, and loses the pressed one — Nextcloud's button:not(.button-vue, [class^="vs__"]):not(:disabled, .primary):not(.app-navigation-entry-button):active sets the background back to the page colour, and outspecifies anything a single class can say. Its class is navigation__compose rather than new-post, which the composer card already is.
Explore. One NcAppNavigationItem with allowCollapse holding three
kinds of child: the hashtags the reader follows, their lists, and what the
instance is trending. It replaced two captions that each grew without limit —
somebody who follows forty tags pushed their own feed off a laptop screen —
and, since 0.26.70, the trending section that used to sit above it as a caption
of its own. The three are not equal and the order says so: a followed tag and a
list were chosen, a trending tag is merely popular, so trending fills what is
left after the chosen ones have their places (chooseEntries() in
src/utils/explore.js) and never takes a place from them. A trending tag the
reader already follows is dropped rather than drawn twice under the same name.
How many children fit is measured from the rail rather than assumed:
measureRail() reads the space between where the children start and where the
rail ends, takes the row pitch from two consecutive rows (offsetHeight misses
the margins), and re-measures on a ResizeObserver — so it copes with browser
zoom, a denser theme and an error entry appearing. entriesThatFit() is the
answer for the first paint and for jsdom, before there is anything to measure.
The open/closed state is a localStorage key, and the entry carries no icon of
its own so that the chevron is the only thing before the word and the children
line up under it.
The account at the bottom. The way out of every app in Nextcloud is the thing at the foot of the sidebar with your face on it, so the Social sidebar ends the same way: the More menu hangs off the reader's own account — their portrait, and the name they publish under — rather than off the word "More" next to a cog. NcAppNavigationSettings renders that cog from a hard-coded path and offers no slot to replace it, so the picture is handed to the stylesheet as --social-face and set as the icon box's background with the glyph hidden inside it. The picture comes from the server's own avatar endpoint rather than from the account's avatar field, because that endpoint answers for every account — generated initials when nobody has uploaded anything — so the button is never a blank circle, and it is the same face the rest of Nextcloud shows.
The account used to be a row of its own above the footer. It is not one any more, because it would be the same face twice. My profile opens Nextcloud's native /u/{uid} page, where the Social posts section uses Nextcloud's profile layout. Social profile sits immediately below it when the reader has a Social actor; it opens the app's own @{acct} profile, whose edit dialog manages the ActivityPub display name, bio, metadata links and banner. Keeping both links makes the native profile experience and the Social-specific editing controls independently reachable.
The account used to be a row of its own above the footer. It is not one any more, because it would be the same face twice. My profile opens Nextcloud's native /u/{uid} page, where the Social posts section uses Nextcloud's profile layout. Social profile sits immediately below it when the reader has a Social actor; it opens the app's own @{acct} profile, whose edit dialog manages the ActivityPub display name, bio, metadata links and banner. Keeping both links makes the native profile experience and the Social-specific editing controls independently reachable.
Migration. A page of the app's own, in the menu behind the account, for taking your data out and putting it back. Nextcloud can already export a whole account with SocialMigrator in it, but only if the admin installed the user migration app and only from occ or that app's page; taking a copy of what you wrote should not depend on either. MigrationArchiveService therefore drives the same migrator into a zip a person can download, and reads one back — so what travels, and what deliberately does not (the private key, above all: see the class comment on SocialMigrator), is decided in one place for both. ZipExportDestination and ZipImportSource are the two adapters that make a zip look like the framework's IExportDestination and IImportSource; they implement what the migrator actually calls and refuse the rest — copyFolder() throws rather than quietly producing an archive that claims to hold files it does not. A file added as a stream is copied to a temporary file and handed to ZipArchive::addFile() rather than read into a string: what arrives that way is as often a video as an outbox, and stream_get_contents() of a two-gigabyte upload is two gigabytes of memory. The file names are the migrator's, so an archive from this page and one from occ user:export are interchangeable; the extra social/export.json names the app version, the account and the migrator version, and an archive that holds the data but no manifest is read as version 1, which is what the server's own exporter wrote.
The import is additive: the profile, follows, blocks, mutes, bookmarks and favourites are restored alongside what is already there, and the posts in outbox.json are reported rather than re-published, so importing cannot flood the timelines of people who follow you. An archive with no social/actor.json is refused by name, because the likeliest mistake is picking the wrong zip.
The third part of the page is about arriving from somewhere else, and it is deliberately honest about what that means: what travels between servers is the list of people you follow, because a follow is a relationship two servers agree on rather than a row in a file. POST /api/v1/migration/follows re-follows each handle in a following_accounts.csv — the file Mastodon, Pixelfed, GoToSocial and Akkoma all export, and the one in this app's own archive — through the ordinary follow path. The networks that do not federate are named as such rather than promised. Moving a whole account is not a button: a Move federates to every server that knows you and cannot be undone, so the page points at occ social:account:alias and occ social:account:move.
The fourth part brings the posts, which is the thing a Move has never carried, and PostImportService is where the rules of that live: each post is written as a new local post of the importing account (StreamRequest::save(), never the delivery path — not one request is queued, because re-publishing somebody's five years of posts would put five years of posts into every follower's timeline in one afternoon), dated when it was written, with its pictures through the ordinary upload path so an imported picture is stripped of its metadata like any other. The original id is remembered in social_import_post, which makes a second run of the same file a no-op and lets a reply find its parent. Five shapes are read: this app's archive, Mastodon's and GoToSocial's, a bare outbox.json, Pixelfed's pixelfed-statuses.json, and Instagram's "Download your information" — the one most people arrive at Pixelfed by. Instagram's archive has no ids, no visibility and no hashtags as data, so an id is derived from the file a post carries, the importing account's own default visibility is used for every post in the run, and the hashtags are read out of the caption; its captions are mojibake by construction (the exporter escapes each UTF-8 byte as a character) and are converted back, guarded so that a caption genuinely containing an accented character is left alone. stories.json, archived_posts.json and recently_deleted_content.json are deliberately not read: an importer that quietly republished what somebody put away would be worse than one that imported nothing.
The routes are session routes with CSRF, not client-API ones: an archive of everything an account ever wrote is not something a third-party token should be able to ask for. The export answers with a DataDisplayResponse whose Content-Disposition is set after construction — DataDownloadResponse builds that header through Symfony's HeaderUtils, a class the server has and this app does not depend on, so the download would work on a server and be untestable here.
Writing a post's recipients once. A post names the same account more than once as a matter of course: Item::getToAll() returns to alongside toArray, the author is appended to the to side, and an account addressed in both to and cc appears in each. The unique index sat is on (stream_id, actor_id, type) without the subtype, so every one of those is the same row. They used to be sent to the database one at a time and refused there. insertIgnoreConflict() keeps a refusal from failing the transaction the save runs in, but InnoDB allocates the auto-increment value before it notices the conflict, so each duplicate burned an id and dirtied the index that was about to reject it — on a development instance social_stream_dest had reached 882,837 ids for 4,976 live rows, with a 20 MB index over 1 MB of data. StreamDestRequest::uniqueRecipients() now names each account once before any of them is written, and the first subtype to name an account wins, which is the row the database kept when the duplicates were still being sent.
Reading a timeline in two queries. A page is chosen as a list of nids and the rows are fetched afterwards by id — getStreamNidsSelectSql() then streamsByNids(). The reason is the recipient join: a post can match social_stream_dest more than once, so the page query needs SELECT DISTINCT, and a DISTINCT over the full stream column set makes the database sort or hash several kilobytes a row to deduplicate integers. Two rules keep that honest. First, a page query joins the cached actor without selecting it (joinCacheActors()), because it only needs the author to constrain on, never to read. Second, DISTINCT is asked for only where a page can actually duplicate: social_stream_dest is unique on (stream_id, actor_id, type), so a query that fixes the actor and the type — public, notifications, direct messages, the marked timelines — cannot match a post twice and selects without it. The home timeline does duplicate, by design, and keeps it. getNidsFromRequest() also deduplicates the twenty integers in PHP, which covers the one case the unique index does not: the left join on expired mutes can match twice for a viewer who timed-muted both a booster and the account they boosted.
A domain block is not a join. The viewer's blocked instances are read once per request (CoreRequestBuilder::blockedDomainsOf(), memoised and dropped when DomainBlockService writes one) and compared as constants in the WHERE. It used to be a LEFT JOIN against the block table whose ON clause held four LIKEs against LOWER(attributed_to), evaluated for every candidate row of every timeline read — including for the overwhelming majority of accounts, who have blocked nothing and now add no clause at all. The patterns are still domainPatterns(), so what a block matches is unchanged: the exact host, both schemes, closed by the / that ends it. A subdomain is a separate block, which is what DomainBlockTimelineTest pins — the silenced-instance filter next door reads subdomains, and the two are easy to confuse.
The moderation panel. The Social section of the administration settings is one Vue application (src/adminSettings.js, components in src/components/admin/), built out of the same NcSettingsSection, NcTextField, NcSelect, NcCheckboxRadioSwitch, NcButton, NcNoteCard and NcDialog components as the rest of the administration settings. templates/settings/admin.php is the element it mounts on and nothing else; what the server knows when it renders — the first page of the open reports, the resolved count, the federation summary, the access list, the retention window and, for an administrator proper, the Server card — travels as initial state from AdminSettings::getForm(), in the same shape the routes answer in, so a row that arrived with the page and one fetched afterwards are the same thing to the table that draws them. It was three hand-written plain-DOM scripts, on the reasoning that mounting Vue on a server-rendered page would pull the runtime into a page that had none; the shared social-framework chunk ended that, and the page had stopped looking like the rest of the administration settings. The account browser reads GET /moderation/accounts, which is AdminApiService::accountPage() — the same read the Mastodon admin API answers — narrowed to the six fields a table draws; before it, only a reported account could be acted on from the web, and everything else needed a moderation client and a token. Nothing on the page uses v-html, and tests/js/components/admin/AdminSettings.test.js pins that: a handle, an instance name and the comment on a report are whatever a remote server sent, and this is the page whose buttons delete accounts.
Custom emoji. /api/v1/custom_emojis answered [] unconditionally and outbound posts carried no Emoji tags, so emoji from every other instance rendered here and this one could publish none — the asymmetry somebody moving here notices first, because their own instance's emoji stop working. EmojiService holds the set: the row is in social_emoji, the picture in appdata under emoji/, and both a local client and a remote server dereference the same URL (/emoji/{shortcode}, unauthenticated like /media/{uuid} and for the same reason). What a post carries is the shortcode as text plus an Emoji tag saying where the picture is, added by StreamService::addCustomEmojis() on creation and on edit — rebuilt rather than appended to, so an edit that removes a shortcode removes its tag. The scan is Mastodon's own pattern: a colon on each side, neither of them against a word character or another colon, which is what stops 12:30:45 carrying an emoji called 30. A shortcode this instance has no picture for stays the text it already was. Managed with occ social:emoji.
Strikes. social_moderation is what stands now: one row an account, replaced by the next decision and deleted when it is lifted. social_strike is the history it used to throw away. Every ModerationService::decide() writes one, warn() writes one that applies nothing — Mastodon's none, and the step the ladder was missing between doing nothing and taking an account out of the timelines — and nothing removes one. A lift says the decision no longer stands, not that it was never taken; without that, the third silence in a month looked exactly like the first, because whoever lifted the last one took the only evidence it had happened. A strike carries the moderator who took it and the report it came from, so a history names somebody. The account is told through Nextcloud's notifications (moderation_warning in Notifier), and only a local account can be: telling a remote one means telling its instance, and no ActivityPub activity says "your user has been warned". The count is what the account browser shows and the history is what opens behind it; StrikesRequest::countForActors() answers a whole page in one query, because asking per row turned a forty-account page into forty-one.
Silencing an instance. The same middle tier, applied to a whole server. A domain block (social:fediverse add, followed by social:domain:purge) cuts the instance off in both directions and deletes what it already sent, which also cuts off the local users who deliberately follow somebody there — so the tool was too blunt to reach for and the nuisance stayed. social:fediverse silence <host> adds the host to a second list (the silenced_list app value, read by FediverseService::getSilencedAddresses()) and changes exactly one thing: StreamRequest::filterSilencedInstances() drops the instance's posts from the public, global, hashtag and followed-tag timelines. Delivery, fetching, webfinger, following, and the home timeline of somebody who already follows the account are untouched — a silence is deliberately not enforced in authorized(). The clause is a LIKE on s.attributed_to rather than on a host column, because there is none: an actor id begins with the scheme and host, so the domain and everything under it is a prefix match, read the way a domain block reads subdomains. LIKE is not indexed, which is why it runs only on the timelines that need it and why the list is meant to stay an admin-written handful. Nothing is deleted, so social:fediverse unsilence brings the posts back — the difference between this and a block, whose purge does not come back.
Inbox forwarding (ActivityPub §7.1.2). A reply to a local post arrives from the replier's instance and from nowhere else, so the followers of the local post would never see it: everyone would read a different, shorter thread. ForwardService::forwardReply() therefore passes such a reply on to the followers of the post it replies to, and NoteInterface::activity() offers it every newly stored note (a re-delivery finds the note already stored and is not offered again, so nobody is sent the same reply twice).
A reply is forwarded only when all of this holds: it arrived with a valid linked-data signature (SignatureService::ORIGIN_SIGNATURE), because the recipients must be able to check the author's own signature rather than take our word for it; the post it replies to is local, since only the instance holding a post owes its followers the thread; and both the reply and the post are public or unlisted, so a private audience is never widened. The body sent is getSource() — the document exactly as it arrived, since re-encoding our model of it would drop the signature and change what was signed. Delivery goes through the normal request queue at PRIORITY_LOW via RequestQueueService::generateRequestQueueFromSource(), so nothing about it holds up the inbox response. The sender's instance and this one are left out of the recipient list.
Add and Remove are handled by FeaturedCollection, which is how a remote account's pinned posts arrive: the activity's object is resolved against the local store (Mastodon sends a bare URI, not an embedded object), target must be the actor's own featured collection, and the post must be one this instance holds and that actor authored. A pin is stored as an action row, capped at MAX_REMOTE_PINS. BlockInterface records the incoming block as an ActorRelation of type TYPE_BLOCKED_BY.
Actor types: Person, Service, Group, Organization and Application all resolve to a handler, so all five are cached and can be followed — which is what Lemmy communities, Friendica and a.gup.pe groups, and Mastodon's instance and relay actors need. OrderedCollection and Stream have no handler, being containers rather than things an activity is about.
An activity whose type this app does not implement is logged at notice with its type, activity id, object id, actor and origin, and answered 200 OK so the sender does not redeliver it forever. It used to be discarded without a word, which made "posts from that instance never arrive" impossible to diagnose from this side.
Tombstone has no interface either, and deliberately so: it names a deleted object rather than being one. DeleteInterface handles it by id — when an embedded object has no handler it looks the id up as a note, then as an actor, the same path a Delete carrying a bare id string takes. This is how a deletion from Mastodon, which sends Delete with an embedded Tombstone, is applied. Nextcloud Social sends the same shape. The note row is removed under a guard on its type, which is Question for a poll and Note for everything else a post is stored as.
An incoming Block targeting a local user is remembered as a blocked_by relation and severs the follow relationship in both directions; Undo{Block} lifts it. A Follow from an actor the target has blocked is answered with a Reject.
A Video is one of the note-like types in AP::NOTE_LIKE_TYPES — object types
other servers Create into a timeline that this app has no model of its own for
— and like the rest of them it is stored as a Note carrying its wire type in
subtype. That is what makes it storable, queryable and readable by a Mastodon
client without a second kind of post existing anywhere downstream.
It is the one of the five that is read in detail, because it is the one whose
whole point is a file to play. PeerTube writes four things where an ordinary
Note does not look, and PeerTubeService is where each is read:
-
urlis a list, not a string: the watch page (text/html), one link per transcoded resolution (video/mp4), the HLS playlist (application/x-mpegURL), a torrent and a magnet URI. The best playable file wins —video/mp4up to 1080p, by height — and the playlist is taken only when there is no file at all, since Safari is the only browser that opens one.magnet:and therel: ["metadata"]links are not something to hand a<video>and are dropped. The list is also nested, and that is not decoration: an instance transcoding to HLS — the default, and what a public PeerTube actually federates — publishes one top-level link, the playlist, and hangs the playable file for each resolution off that link'stag. Reading only the top level found a playlist and nothing else on the majority of real videos, sotagis walked as well. -
attributedTois a list of two actors, the channel (aGroup) and the account behind it (aPerson), where every other server sends one id as a string. The channel wins: it is what theCreateis signed by, what a reader follows, and what the video is listed under on PeerTube itself.Stream::import()asks for a string and got neither, so a federated video used to arrive attributed to nobody. A channel also announces its own video to its followers the moment it publishes it, so a followed channel arrived twice — once as the video, once as "the channel boosted the video". ThatAnnounceis skipped, narrowly: only a video, and only where the announcer is the account the video is already attributed to, because somebody boosting their own post to resurface it is a thing people do and this must not swallow that. -
An account's
iconand a channel'simageare lists ofImages, one per size, where Mastodon sends one.Person::largestImage()keeps the largest bywidth×height(and a single object as it is), so a PeerTube account arrives with its avatar and a channel with its banner. -
The title is in
name, which aNotehas no use for — and must not be copied into, sincenameon a note means the option a poll vote chose. So the title becomes the first paragraph of the content, linked to the watch page. -
The description is markdown, and the object says so in its own
mediaType. It is escaped and paragraph-split when the object declarestext/markdownortext/plain, and passed through as html otherwise, which is what every other object'scontentis. Believing the declaration in both directions is the point: escaping html would show somebody their own tags, and rendering markdown as html would hand a remote server a way to put markup in a post that went through no sanitiser.The little of markdown a description actually uses — links, bare urls, bold and italic — is then rendered, because left alone it reads as asterisks and brackets in the middle of a timeline (which is what Mastodon shows). The order is the safety: the text is escaped first, so every tag in the result is one this app wrote, and a link is only made of an
http(s)target. Headings, lists and code fences are deliberately not handled — rare in a video description, and each one a way to get this wrong. -
Whether replies have to be approved. PeerTube ≥ 6.2 moderates comments (FEP-5624): a video whose
commentsPolicyis 3 takes a reply in and shows it to nobody until a human has looked, then sends anApproveReplyback to the server the reply came from. Read in both spellings — the numericcommentsPolicyPeerTube sends and the FEP's owncanReply, which names who may reply without being approved — because the two arrived a version apart and instances run both. Without it a reply written here looked posted, sat in a queue on the other side, and either appeared a day later or never, with nothing anywhere to say which. It is kept on the post asStream::DETAIL_REPLY_POLICY, and a reply written to such a post carriesDETAIL_REPLY_STATE—pendinguntil theApproveReplyarrives. Both are local, derived facts, which is what the details column is for: neither is a property of the wire object, and the second is about somebody else's document. -
Dislike, which Mastodon has never had and which therefore had no model here at all: every one that arrived was logged as an unknown type and dropped, so a video whose author cared about the number showed none of them. Stored the way aLikeis — a row insocial_actionkeyed by (actor, object, type) — and counted onto the post asdislikes. It never notifies: a like tells an author somebody liked them, and a dislike arriving as a notification would be a way to needle somebody from anywhere, one activity at a time. -
View, which PeerTube sends to the owner of a video for every watch. This is the one placesocial_stream_viewtakes a number from another server, and it does so on exactly the terms it counts a local one: one row per (post, person), so it counts people rather than plays, and the person is the actor the sending server signed for — inflating it costs an actor id per view, which is the same bar a local account faces. Only ever on our own posts: a view of somebody else's video is their server's business. Outbound, aViewgoes to the origin the first time somebody here opens a federated video and not afterwards, becauseseen()is idempotent on (post, viewer) and one press of play is not a second view. An instance whose readers watch without ever saying so is a freeloader on everybody else's counters. -
Everything else the object says. A video is not a post with a rectangle in it: it has a category, a licence, a language, chapters (FEP-6f7d's
hasParts), subtitle tracks, a "support the author" line,downloadEnabledand three counters. None of it was read, so a federated video arrived as a paragraph and a player with all of that thrown away. It is kept together in one block on the post's details (Stream::DETAIL_VIDEO) rather than spread over a dozen columns — it is local, derived data about somebody else's document, and a watch page wants all of it or none — and reaches a client asvideoon the status, null for every post that is not one. The{id, label}pairs PeerTube sends are read as their label: the id means nothing off its own instance. An absentdownloadEnabledis PeerTube's own default of "yes", so only a stated refusal is recorded. -
HLS. An instance transcoding to HLS — the default — publishes an
.m3u8that only Safari opens, so those videos showed a poster and a player that did nothing. The client now loads hls.js, lazily and only when such a video is actually opened, because it is a few hundred kilobytes and almost every post is not a video. The playlist it is given is the proxied one (/media/playlist/{nid}): a playlist names its segments relative to itself, so pointing a player at the origin's copy would have every segment fetched from there — the very thing the byte proxy exists to prevent, and worse, at one request per few seconds of video. Both plain URI lines andURI="…"attributes are rewritten, because a player follows both, and the segment route refuses any address that is not on the playlist's own host.
A pasted watch page resolves too. SearchService::resolveStatus() accepted
Note and Question only, so a PeerTube address found nothing at all —
although the very same object would have been stored had it arrived by
following the channel, which is the inbox and the search disagreeing about what
a post is. It now accepts every AP::NOTE_LIKE_TYPES. A PeerTube watch page is
/w/{shortUUID}, which is the address a person copies out of their browser and
not the object's id, so the "a document is only evidence about itself" rule
is satisfied one level in: the document is trusted when it names the address it
was fetched from among its own url links.
Where somebody stopped watching is social_watch — PeerTube's
WatchAction, and the thing that makes a long video usable at all: a two-hour
talk watched in three sittings is three sittings of finding the place again. A
fact about a reader: never federated, never shown to anybody else, never
counted into anything, one row per (post, viewer) moved rather than appended. A
video watched past 95% is forgotten rather than bookmarked at the credits,
and one under ten seconds in was never really started — a "continue watching"
row that offers back either is a row nobody presses twice.
Three things decide whether this app works on an instance with a million
accounts and ten million posts, and all three were measured rather than
reasoned about — occ social:benchmark seeds a realistic instance and times the
queries against it, and EXPLAIN was read for each of the plans below.
The home timeline pages over the recipient rows' own sort key. It used to
drive from the viewer's follows, fetch every recipient row every followed
account had ever produced, join each to social_stream to find out when its
post was published, and sort the lot in a temporary table to keep twenty —
EXPLAIN said Using temporary; Using filesort in as many words. One page load
therefore cost Σ(all posts of everyone you follow), and a client asks for one
every thirty seconds. Measured on a seeded instance of 402,725 posts and 805,212
recipient rows, with both paths run against the same machine in the same pass:
the old query takes 1,661 ms and the public timeline over the same rows
4.2 ms. That figure is not a bounded read either: EXPLAIN on MariaDB shows
the public, notification and direct pages driving from their recipient rows —
one collection and one type, a constant — joining each to its post and sorting
all of them in a temporary table (Using temporary; Using filesort), so the
cost grew with every public post ever written and with every notification an
account ever received. They now page on the recipient row's nid
(SocialLimitsQueryBuilder::paginate() with the sd alias,
StreamTimelines::paginateOnRecipient()), and social_sd_atn answers the
filter and the order as one descending range that stops at the limit — once the
backfill flag below says every row carries its nid; until then they page on the
post's.
social_stream_dest.nid is the post's own nid, copied onto the recipient row
when it is written. It is safe to denormalise because a nid never changes after
the row exists — there is no update path to keep in step, only an insert — and
it is the only way to have the sort key and the filter on the same table, which
no index can span. With (actor_id, type, nid) the page is answered from the
index alone, without a row lookup: 50 ms against the same 1,661. It is not a
merge that stops at the limit — EXPLAIN says range social_sd_atn … Using index; Using filesort: across several collections the database reads every
entry the predicate admits and sorts them, so those 50 ms were the cost of
reading and sorting every index entry of the reader's followed collections, and
they grow with everything those accounts ever posted. What bounds the read is therefore
the predicate, twice over:
- The collections come from
FollowsRequest::limitToHomeCollections(): up to 500 (HOME_COLLECTIONS_IN_A_QUERY) are named in anIN (…); past that the set is anEXISTSoversocial_followcorrelated on the row, so a statement never carries one parameter per followed account — SQLite refuses one past 32,766. MariaDB turns thatEXISTSinto a semi-join driven from the follow rows, provided nothing isORed beside it, so the reader's own follower collection — how their own posts reach them — is read by a second query of one collection and merged, rather than named in the list. - The time. A nid is the publication time times 10⁹ plus a random suffix,
so "published in the last day before the cursor" is a range on the same
column (
StreamTimelines::homeRecipientNids()). The page is read within one day first and only a page that comes back short is read again, over a week, a month, a year and finally without a bound. It is exact, not a sample: every row outside a window is further from the cursor than every row inside it, so a window that fills the page holds the same rows the unbounded query would have returned, and the merge with the followed-hashtag half below keeps its guarantee. The homeETag(newestHomeNid()), which a client asks for every thirty seconds, is the same query with a limit of one.
The rows it reads over are the ones the join matched, which is every accepted
follow row of the viewer's, whatever its type — not only the ones of type
Follow. The row that makes the difference is the Loopback, the self-follow
every local actor is given, whose target is the account's own id: it is how a
post addressed to the reader by name — a mention, a reply from somebody they
do not follow — reaches their home timeline at all. Narrowing the set to
type = 'Follow' dropped every one of them; the notification still arrived and
the post was still readable at its own address, so what it looked like from
outside was a timeline that paged straight past it.
The guard on it is a flag written by the migration, not a question asked of the table. The obvious check — "is any nid still zero?" — has no index that can answer it and is a full scan of the largest table this app has, 427 ms on 800,000 rows on every request; a guard that costs more than the query it guards is worse than no guard. That one was found by measuring, which is what the seeding harness is for.
The per-viewer filters — blocks, mutes, hidden boosts — are what forced the
join, so they no longer ride in the page query. They are applied to the twenty
rows it chose, where each is a lookup against twenty ids; the page is read three
times wider than asked so that one which loses rows to a block still fills. When
that is not enough — a muted account that has just posted sixty times in a row,
a blocked instance that dominates the window — the page reads on from below
the oldest id it has already considered, up to four more windows. Empty is how
both clients read "there is nothing more" (the web app sets allLoaded on a
page of zero, and no Link: rel="next" is sent), so without it the timeline
ended in the middle while older posts the reader can see sat further down. The
bound is what keeps a reader who has muted everything they follow from turning
one request into a walk of the table.
A media or news narrowing is the exception, and the reason is worth keeping:
it is a question about the post, and the page query reads only the recipient
rows, which carry no such column. Moving it to the rows was right for blocks, which
drop a few posts, and wrong for this, which drops nearly all of them — on the
seeded instance 143 of 402,725 posts carry media, so the Photos and Videos
timelines read the newest sixty ids, discarded all sixty and came back empty
while the pictures sat further down. Those timelines take the join path, which
has the predicate in the query; they are read far less often than the home
timeline, and with media_kind indexed they are now faster than the fast path
was anyway. News is the same shape of question over news_kind and takes the
same path, for the same reason and with more of it: an article is rarer than a
photograph. The old query is kept as the fallback for an instance whose
backfill has not finished, and is slower and always correct.
Counters are added to, not counted. The three on an account lived only in
the details JSON, so moving one meant recomputing all of them — four aggregate
queries, on every post written and every follow accepted. For an account with a
million followers that is a million index entries counted so a number on a
profile can go up by one. They are columns now, because a column can be
incremented atomically; the JSON is still what is read, with the columns
overlaid on it where the row is parsed, and the counting still happens in the
cron's walk, which is where drift is reconciled. -1 means "never counted"
rather than "none".
The overlay is applied in Person::importFromDatabase(), where a row
becomes an actor, rather than in either of the two query builders that parse one
— one of them did it and did not select the columns, so the overlay had nothing
to lay on and every profile showed whatever the last cron walk had written.
Each move has to obey the recount's own rule, or the two disagree and the
number visibly wobbles between cron passes. The recount counts public
statuses, so count_posts moves only for a post that names the public
collection (Item::addressesPublic()) — and moves down when one is deleted,
which nothing did at all. count_followers is moved up by the Accept and down
by the unfollow, whichever side it came from: a local Undo sent from here, or
one that arrived in the inbox, in each case only when the follow it undoes had
been accepted. The cron's walk is the safety net for what races through, not
the thing that makes the number right.
The cron walks rather than reads. Cron\Cache refreshed every local
account on every pass — the whole table in one PHP array, twelve minutes apart,
with a dozen queries and an avatar read behind each row. At a million accounts
that array does not fit in memory, and the failure is silent: the cron simply
falls behind. It pages, stops at the pass deadline, and remembers in app config
where it got to.
Three smaller things in the same shape. social_stream.media_kind is what the
Photos and Videos timelines ask instead of searching the attachment JSON with
LIKE, which no index can serve and which was applied after the join.
social_stream.news_kind is the same idea one step further: the News timeline's
question — is this an Article, or does its text link to one — is not one a
database can be asked of stored markup at all, so there is no slow-but-correct
query to fall back to. It is decided once on the write by Stream::newsKindOf()
and read through (news_kind, nid); a row the backfill has not reached is simply
not news yet, which is the one place this differs from media_kind and is a
deliberate trade — every new post is classified from the moment the column
exists, so the top of the page, which is the end a reader looks at, is right
immediately. And a content search is bounded by search_window_days, because a
leading wildcard can never use an index and an unbounded ILIKE is a table scan
per keystroke.
Cron\Queue used to read 200 rows every twelve minutes and deliver them one
after another with a 30-second timeout each inside a 300-second budget: about a
thousand deliveries an hour at best and ten at worst, since ten unresponsive
peers filled the whole pass. It now delivers several servers at a time:
ActivityService::manageRequests() sends a batch in waves of up to PARALLEL
(20) rows, one per host, through CurlService::sendMany() (the HTTP client's
async calls, which Guzzle runs on one curl multi handle), and settles every row
exactly as a single delivery is settled. A wave costs about as long as its
slowest peer, so a dead peer costs its timeout once, beside nineteen
deliveries, instead of in front of all of them — and then the breaker below
holds its other rows back without a timeout at all. When a batch is done and
time is left, the run takes the next one (up to MAX_BATCHES, 30).
What that comes to: with peers answering within a second, a wave of twenty
takes about a second and a run delivers up to its ceiling of 6,000 rows (30
batches of 200) — some 30,000 an hour against a thousand before. When every
wave holds a peer that runs into the 30-second timeout it is ten waves, 200
deliveries a run, against ten. The async drain a new post starts
(QueueController, 90 seconds at a 10-second timeout) goes out the same way,
twenty servers at a time. Nextcloud runs one cron.php at a time, so past that
the way to scale is social:worker.
occ social:worker is the same delivery in a loop that does not stop. Claiming
a row was already atomic — setAsRunning() is an UPDATE … WHERE status = standby that throws when it loses the race — so several workers may run at once
and will not collide, which is what makes this scale by adding processes rather
than by rewriting anything. It finishes the row in hand on SIGTERM, because a
delivery abandoned halfway is one the peer may already have taken.
The circuit breaker used to be per-pass: manageInit() emptied it at the start
of every run, so a dead peer was rediscovered every twelve minutes, one
30-second timeout at a time, for every row addressed to it. It then moved to the
distributed cache — which on an instance without a memcache is a NullCache
that holds nothing, so there it was per-pass still. It is in the database now
(social_host_breaker, HostBreakerRequest), shared between the cron, the
async worker and every social:worker process, with the wait doubling per
consecutive failure up to an hour and clearing the moment the host answers. A
drain reads the failing hosts once, when it starts (manageInit() forgets the
copy), and looks every row's host up in that before it spends a timeout on it;
a healthy host costs no write at all, and one that fails costs one.
And the inbox no longer walks a whole thread inline. That walk runs after the response is flushed but while the FPM worker is still held, and each item can be a fetch from a server that is slow or gone; at a million users' worth of inbound traffic it is the pool. It does five items or three seconds, whichever comes first, and leaves the rest to the stream queue that exists for it.
A client asks for the home timeline and the unread count every thirty seconds,
and the answer is almost always the one it already holds. Both carry an ETag
built from the newest id the viewer can see — which changes exactly when the
answer does and costs an index-only read of the recipient rows: for the unread
count one descending range of the viewer's own, for the home timeline the same
windowed read the page makes, with a limit of one — so a poll that has not
changed is answered 304. Media carries the stored
file's own tag, which matters because a timeline is forty to sixty pictures a
screen and each was a full Nextcloud boot.
The tag carries a second number: the reader's timeline revision
(TimelineRevisionService), one integer per account in its user config. The
newest id answers "has anything arrived"; everything else that decides what the
page holds is a decision the reader made — following or unfollowing an account,
blocking or muting one, adding a keyword filter, following a hashtag — and none
of those moves an id. Without it, a reader who unfollows a noisy account is
answered 304 on every poll and goes on seeing it until somebody else happens
to post: the page they are looking at is the one they just asked to change. The
number is incremented where those rows are written, so it moves whether the
change came from the web app, a Mastodon client or the inbox. What it
deliberately does not cover is somebody else deleting a post further down a page
the reader already holds — making that reach every follower's tag is a write per
follower, which is the fan-out this design exists to avoid, and the page is
revalidated when anything else changes.
The two json routes hand back a JSONResponse rather than a DataResponse,
and that is what makes any of it work. A returned DataResponse is rebuilt by
Nextcloud's default json responder, which merges the fresh response's headers
over the controller's — and every response's defaults include Cache-Control: no-cache, no-store, must-revalidate. The header the route set was therefore
replaced on the way out, and no-store forbids the browser to keep the body at
all: it never sends If-None-Match, so the 304 was never asked for and the
whole thing was decoration, as the wire showed — our ETag and Nextcloud's
no-store side by side. The dispatcher rebuilds only a DataResponse, so a
route that builds the JSONResponse itself reaches the wire as it is.
tests/Controller/PollCacheHeaderTest pins both halves against the real
framework class. The media routes were never affected: a FileDisplayResponse
does not go through the responder.
The routes the sidebar reads on every page load — /api/v1/custom_emojis,
/api/v1/trends/tags, /api/v1/instance, /api/v2/instance, /api/v1/lists
and /api/v1/followed_tags — have no cheap version number to tag, and are
cheap to build, so they are tagged by what they say (Revalidation::byContent(),
a hash of the body and the paging Link) and go out the same way, as a
JSONResponse with private, no-cache. The server still builds the answer; the
body is not sent again and the browser keeps the copy it has.
With notify_push installed the client is told instead of asking, and the poll
interval drops from thirty seconds to five minutes; that is the single largest
reduction available to an administrator and it is one app install.
The page also carries the first screenful of the home timeline in its
initial state. Without it the first screen is a staircase: fetch the bundle,
mount, and only then ask the server — a second round trip and a full boot before
anything a person came to read is on screen. It is consumed once, and only by
the list it was rendered for. It goes through FilterService::apply() exactly
as the API route's page does, which is both what keeps a muted word out of the
one screenful nobody asked the server for and what makes the seeded page byte
for byte what /api/v1/timelines/home would have answered.
A ladder of sizes is social_video_rendition. A stored video used to be one
file at whatever height it was uploaded at, so a reader on a phone on a train
downloaded the 1080p of it or nothing; a ladder is the same video written two or
three more times, smaller, plus a playlist that lets the player move between
them as the connection changes. It is also the shape PeerTube publishes, so a
laddered video reaches a PeerTube reader the way a native one does.
A rung is one row and one file. HLS normally means a directory of a few
hundred segments, which would be a few hundred rows and a few hundred objects in
the store; -hls_flags single_file writes each rung as a single fragmented MP4
and the playlist addresses each segment as a byte range into it, so a
forty-minute video is three files rather than a thousand — which is, not by
coincidence, exactly the shape PeerTube's own fMP4 output has. The playlist, a
couple of kilobytes of text, is kept in the row beside it with the media
filename replaced by a placeholder: the URI it has to carry is a route on this
server, which is not known when ffmpeg writes the file and changes if the
instance is moved.
Three things it is careful about. Keyframes are forced onto the segment boundary at every rung, with the same period — without that ffmpeg cuts at the next keyframe it happens to find, the rungs end up with different boundaries, and a player switching between them stalls or skips. It never upscales: a rung at or above the source's height is dropped rather than encoded, because a 480p video written out at 1080p is a bigger file of the same picture. A half-built ladder is torn down rather than published, since a master playlist advertising a rung whose file is missing is a player that stalls rather than one that picks another; nothing is ever replaced, so the failure mode of every step is "no ladder yet" and never "a video that 404s".
Off unless an administrator turns it on (video_ladder, with
video_ladder_heights for which sizes), because it is several ffmpeg encodes
per video on somebody's server. Cron\Ladder does one video every half-hour —
its own job rather than a step of Cron\Transcode, because a transcode is one
encode and a ladder is three, and a job that did both would have an
unpredictable cost per run with the transcoder waiting behind it. Ladders are
built from video/mp4 only: a .mov goes through the transcoder first, and on
an instance with the transcoder off it gets no ladder, which is the honest
answer — there is no ladder to build from a file this server has decided not to
touch. A transcode voids whatever ladder was built from the old bytes, so
replaceVideo() resets the flag and the job rebuilds it.
It is served by uuid, never by row id: a ladder is the same video as the file,
so it must be no easier to reach than the file, and a route keyed on a small
integer would make a followers-only post's video findable by counting. Clients
see it as hls_url on the attachment — not one of Mastodon's keys, so a client
that does not know it plays url, which is the same video at one size — and
PeerTube sees it as a streaming-playlist Link with a Link tag per rung,
because PeerTube takes the resolutions from the tags rather than by fetching the
playlist and a link with no tags is one it accepts and then has no files for.
How much video one account may keep is video_quota, off by default. There
was a ceiling on how big one file could be and none at all on how many of
them one account could upload, so the only real limit was the disk — and the
administrator found out about it from the disk. It is checked once, in
CacheDocumentService::saveFromTempToCache(), which is the one place both an
upload and a fetched remote attachment pass through; a fetched one is
exempt, because charging somebody's quota for a video this instance chose to
cache on their behalf would be a limit nobody could explain. The ladders are
exempt too: they are made because an administrator asked for them, are
several times the size of the upload, and would turn a quota somebody was told
about into one several times smaller than the number they were given — but they
are counted in what an administrator is shown, because they are real disk.
Counted from the stored size rather than by walking the files, since this is
asked on every video upload. Rows written before that column existed carry 0,
so MediaUsageService — already doing a stat per stored file on the daily
cron — writes the answer down as it passes, and the quota becomes accurate
after one pass rather than never. Who is holding what is a single grouped query
on the Storage card. Where the files are is not a setting: Nextcloud has no
per-app object store, and an app that offered one would be offering something
it cannot deliver; the card says whether the instance-wide objectstore in
config.php is in force, which is the supported way to put this elsewhere and
covers Social's appdata along with everything else's.
What happens to a post somebody marked sensitive is SensitiveMediaService.
PeerTube gives an instance three NSFW policies and lets a person override the
one their instance chose; this app had one, hard-coded. The three are the same
three, under Mastodon's names rather than PeerTube's — show_all,
default, hide_all — because Mastodon already has a field for exactly this
(reading:expand:media), every client that speaks this API reads it, and a
fourth vocabulary for the same three states would be one no client could act
on. show_all is PeerTube's display, default its blur (covered, the
blurhash showing, one press away — what this app has always done, and the
default so an upgrade changes nothing), hide_all its hide (not drawn, and
no button to draw it: opening the post is what it takes).
A content warning is not subject to it: the policy is about media somebody marked sensitive, and a warning is an author saying something about the whole post in their own words — a reader who asked to see sensitive media has not asked to be shown past every warning anybody writes. An account's choice is stored against its Nextcloud user rather than its actor, because it is a fact about a person reading and not an identity other servers see, and "follow the instance" stays a state of its own: it looks the same as choosing what the instance currently does until an administrator changes the default, at which point a settings page that could not tell them apart would have silently pinned everybody to the old one. The effective policy travels in the page's own initial state rather than behind a request, because it decides what the first screenful looks like and a timeline that uncovered itself a moment after it drew would be worse than either policy.
Videos can be held for a moderator with review_videos, the third rule of
the review queue beside a new account's first post and the spam rules. Unlike
the other two it is not about the account: a trusted account with a
thousand posts behind it is held by it too, every time, because what it is
about is the video — so it is asked before the "a moderator has already decided
about this account" gate that stops the others. A moderator's own video is not
held, for the same reason their first post is not.
A forwarded report names the posts by the address the receiving server knows
them by. A client reports a post by the id this API gave it — a snowflake
nid — and those were going into the Flag as they arrived. No other server has
ever seen them, so every forwarded report named one account the receiving
moderators could find and a list of numbers they could not: a report about an
account read as a complaint with no evidence, and a report about a video,
where the video is the entire complaint, carried nothing at all — PeerTube
resolves an abuse by the video's own address and would have found nothing every
time. ReportForwardService resolves each one through social_stream now, and
leaves out anything local: the statuses in a report about a remote account are
that account's posts, and one of ours in the list is a reply somebody picked up
by mistake.
Bringing a channel over is PostImportService, which already read four
networks' archives and now reads PeerTube's. Their export carries two halves of
the same data: activity-pub/outbox.json, whose Video objects name each
file by its address on the old server, and peertube/videos.json, whose
archiveFiles name the copy inside the archive. The second is the one read,
because an import that needs the server somebody is leaving to still be running
is one that stops working exactly when it is needed — and it states the privacy
as a number rather than leaving it to be read out of an audience, and carries
the title, the tags, the category and the licence, which is most of what a video
is.
Three kinds are refused rather than translated. A private, internal or password-protected video is one its author decided not to publish, and there is no audience here that means "the people who had the password". A live has no recording to bring over; a saved replay is a video of its own and is exported as one. A video with no file in the archive would be a page with nothing to play — and an export taken without its video files is refused by name, because the JSON alone is a catalogue and the run would otherwise report "nothing imported" about a perfectly valid archive that was simply not the one to ask for. The old instance's view, like and dislike counts are not carried over: they are numbers about its readers, and a post here claiming four thousand people had watched it on this server would be false.
One video by its address is the other half, as PeerTube has it: an export is
a heavy tool for a single video, and somebody who has lost their account on the
old server cannot take one at all while the video is still there to be fetched.
The fetched document has to be a Video that names the address it was fetched
from, which is the same evidence SearchService::resolveStatus() requires and
is what stops a redirect substituting one video for another; the tallest
playable file link is stored, and an HLS playlist is passed over, since storing a
list of segments on somebody else's server and calling it a video is not an
import. A video already on this server is refused — bringing a neighbour's
post over as your own is not an import, and it is the one case the server can
actually tell. What it cannot check is whether the video is yours: neither can
PeerTube's own importer, nor the archive import, which reads a file somebody
uploaded. What stands in for it is the same thing in both places — one
deliberate act, rate-limited, producing an ordinary post of the account that
asked for it, which moderation and reporting reach like any other.
PeerTube's own client API is PeerTubeApiController and
PeerTubeApiService — the same move the Pixelfed routes are, and for the same
reason: the official PeerTube app, Tubelab and Fedilab all exist and are good,
and a client somebody already has is worth more than one nobody has written.
PeerTube's API is not Mastodon's, so this is a translation and not an alias;
the shapes live in the service so that what one client is promised can be read
in one place.
Two things decide how to judge it. It is read-only: PeerTube's upload is a
resumable protocol with a transcoding state machine behind it, and a half-built
one that took somebody's file and lost it would be worse than none — comments
are read and not posted for the same reason the Pixelfed routes do not post,
that a client posting through them would go round the review queue the composer
goes through. And it is gated on the domain root: no PeerTube client will
ask under /apps/social/, because they all build https://<host>/api/v1/…
from the address a person types. Every route is correct and none is reachable
by a real client until this instance answers at its own root — item 1 of the
Mastodon compatibility list, a decision for whoever runs the server rather than
something this app can do to a Nextcloud.
Three translations are worth stating because each could have been faked. A
video's uuid is the one this app already publishes on the wire, so a video
seen through this API and the same video seen over ActivityPub carry one uuid
rather than two nothing can tell apart — and because that uuid is a one-way hash
of the post's address, a video is addressable here by its numeric id only, which
a client that lists before it fetches already has. A category, licence or
language is {id: 0, label}: PeerTube's ids are indexes into its own lists,
which this app does not have and must not guess at, and a wrong id is a client
showing the wrong category with confidence. And total on every list is the
size of that page, because these timelines are keyed on a cursor and have no
count to give — a number invented for the shape's sake is one a client would
draw a pager from.
/oauth-clients/local is the one place the two designs genuinely disagree.
PeerTube hands out one pair per instance, the same to everybody for ever; this
app mints a pair per client and hashes the secret, deliberately, so there is no
stored plaintext to hand back twice. A fresh registration is answered instead,
which is what the route is actually for — a client fetches a pair immediately
before logging in and uses it at once — and is exactly what a Mastodon client
does through /api/v1/apps.
Playlists are collections. PeerTube's Playlist is an ordered set of a
channel's videos with a title, a description and a visibility, which is what
this app already calls a collection — so it is stored as one rather than given
a table of its own, and appears as a tab on the channel's profile. Read-only
and rebuilt: it is somebody else's document and the document is the whole
truth about it, so what arrives replaces what was there — which is the only way
a video removed from a playlist can leave this copy. Only videos this
instance already holds go in one: a playlist naming forty videos nobody here
has seen is not a reason to fetch forty videos, and it fills in as the rest
arrives by following the channel. A playlist of nothing we hold is not stored
at all, because an empty page with a title on it is worse than nothing.
The playlist's owner (attributedTo) decides whose collection it is rebuilt
into, so it is held to the actor that sent it: the owner has to be that actor,
or a channel on the actor's server that names the actor in its own
attributedTo — which is how PeerTube publishes one, from the account and
attributed to the channel. An owner that is a local account, or is not known
here, is refused (PlaylistService::checkOwner()).
A document arriving a second time — a redelivery, an Update of the post it
hangs off — describes a file on somebody else's server and knows nothing about
the copy this instance made of it. Written as it arrived it cleared
local_copy and resized_copy, orphaning the cached file and breaking every
post that showed the picture until the caching cron happened to fetch it again;
DocumentInterface::keepWhatOnlyTheRowKnows() moves the stored copies and the
row's key onto the incoming document first. That is a bug older than video —
every re-delivered Mastodon picture hit it — but a streamed row depends on it
twice over, since the key is what the media proxy is addressed by.
A picture the inbox could not fetch is stored with an empty local_copy, and
the post's own copy of its attachments (the attachments column, the client
format keyed by the document's nid) says so. When the caching cron or
occ social:media:retry fetches it later, StreamRequest::updateAttachments()
rebuilds that one entry from the document and leaves the post's other entries
as they were stored. The inbox holds the download to the federation timeout,
ten seconds for the whole transfer (the server's HTTP client is curl, and its
timeout ends a download that is still arriving), so a large original from a
slow origin is left to the caching run. The caching run and the retry command
allow the download DocumentService::BACKGROUND_FETCH_TIMEOUT (120 seconds),
which stays below the five minutes after which another run would start the
same download again; reaching the origin keeps the ten-second limit.
When a remote image is refused permanently, the post still arrives with an
image placeholder. The local attachment response now carries cache_error:
1 is over the size limit, 2 is an unsupported type, 3 could not be read
from the origin, and 4 could not be decoded. The placeholder exposes that
reason to assistive technology and on hover. Social does not load the origin
directly in a reader's browser, because that would bypass the instance's media
type and size checks. Permanent failures are intentionally skipped by the
regular cache cron; occ social:media:retry <remote_url> clears the marker for
one uncached remote row and tries it again through the same checks. The
administrator can use that after changing a limit or when the origin recovers,
without causing every rejected attachment to be fetched on every cron run.
The video is referenced, not mirrored. Every other attachment is copied into
this instance's storage on the way in; a two-hour talk is not, and the row that
represents it carries Document::COPY_STREAMED in local_copy instead of a
uuid. That sentinel does two jobs: DocumentInterface::save() skips the fetch,
and the caching cron never picks the row up, because
getNotCachedDocuments() only looks at rows whose local_copy is empty. The
thumbnail is a second, ordinary document row — it is a few dozen kilobytes
and it is mirrored, which is what lets a video timeline be scrolled without
touching another server. Two rows rather than one: hanging the still off the
video row's resized_copy would have put one uuid on two rows, and
getByCopy() would answer with whichever the database felt like.
The attachment's url is rebuilt for the instance a reader is actually on, the
way the uuid links are (MediaAttachment::onThisInstance()): it names a cache
row rather than a copy, but it was written under whichever overwrite.cli.url
the inbox request ran under, which on many instances is not the address anybody
browses.
Playing it goes through GET /media/stream/{nid} (MediaApiController::mediaStream()),
which opens the origin and copies it to the reader a chunk at a time, storing
nothing. It exists because the page cannot point a <video> at the origin
directly — Nextcloud's content security policy says media-src 'self' — and
because widening that policy would also mean every reader who pressed play
announcing themselves to a server they never chose to talk to. The cost is that
this instance carries the bandwidth. What keeps the route from being an open
proxy is that it takes a row id, not a url: only a social_cache_doc row
this app itself wrote as streamed answers, and the request still goes out
through CurlService, so the domain access list and the local-address refusal
apply as they do to every other outbound request. The reader's Range header is
forwarded and the origin's 206 comes back untouched, which is what makes
seeking in a long video cost nothing.
Publishing one. The other direction is the same shape written rather than
read, and it lives in the same class so the two halves cannot drift:
PeerTubeService::asVideo(). A local post whose attachments are exactly one
video is serialised as a Video — name (a title, derived; see below),
duration in the xsd form, icon for the poster, and url as the link list
with the web page and the file. Only the serialisation changes; the row stays a
Note, exactly as an incoming Video is stored as one.
Three things about it worth knowing:
attachmentis published as well. AVideocarries its file inurland has no need of it, but every Mastodon-family server readsattachmentand nothing else, and these posts rendered there with an inline player before any of this existed. Publishing both costs a few hundred bytes and is the difference between gaining PeerTube and trading Mastodon for it.- The title is derived, because a
Notehas none and this app does not ask for one: the first line of the post, then the video's alt text, then the wordVideo. (nameon a Note means the option a poll vote chose — seePollService::handleIncomingVote— so nothing reads that.) An explicit title field is the obvious next step and is not here yet. - There is one rendition, because this app does not transcode: the file is whatever was uploaded. A shorter list than PeerTube publishes, the same shape, and a reader takes the best playable link it finds.
It is on by default and an admin can turn it off with
occ config:app:set social publish_video_objects --value 0. The switch exists
because the one thing that cannot be proven from here is whether a
Mastodon-family server renders a Video as well as it rendered the Note; what
is proven is the round trip — everything published goes back through the
reader in the same class, in PeerTubePublishTest.
What is not done: Audio (Funkwhale), Article, Page and Event are
still read by fillNoteLikeContent() alone — title and link, no media. The
link includes a Link attachment's href, which is where a Lemmy link post
keeps the one thing it is about (it has no url); it is appended to the content
unless the content already links it, and the link preview is made from it. Nor are
there channels: a Video is attributed to the author's Person, where PeerTube
sends the [Person, Group] pair. Both are valid ActivityPub; only the second is
what PeerTube itself would send.
A quote is a post that embeds another post rather than linking to it, and the part that needs agreeing on is not the embedding — it is consent. FEP-044f, and Mastodon 4.5 with it, treats a quote as something the quoted author grants, and a quote without that grant renders as a bare link no matter what the quoting server says about it. So the feature is a handshake, and this app is on both ends of it.
Quoting. Status::import() reads a client's quote_id, PostService
stores it on the post as quote, and the post is published straight away — the
author should not wait on somebody else's server. In the same step
requestQuoteApproval() sends a QuoteRequest to the quoted author's inbox
naming the quoting post as its instrument. Until an answer comes back the
quote's state is pending; a failure to even send the request is logged and
nothing more, because the post is already out.
Being quoted. QuoteRequestInterface::processIncomingRequest() answers for
local posts. The policy is Stream::isQuotable() — public and unlisted, yes;
anything narrower, no — which is the same rule PinService::pin() and
BoostService::create() apply, and for the same reason: a quote carries the
audience of the quoter, so a narrower post would reach readers its author never
addressed. It is also exactly what interactionPolicy.canQuote advertises on
our posts, and the two have to agree, because Mastodon offers its users a quote
button on the strength of the advertisement and shows them an error if the
request is then refused. A Yes is an Accept whose result is the URI of the
approval.
Quoting a post of our own. Then this server is the authority the request
would be addressed to, and there is nobody to ask: a QuoteRequest would be the
instance delivering to its own inbox and waiting for its own answer.
PostService::applyQuote() grants the approval on the spot instead — the post
was already checked against the same policy — and stamps it onto the note before
the wire object is snapshotted, so the first delivery already carries it.
What a client is told. The quote entity's state is read from the approval,
not from whether the quoted post happens to be in the database: accepted means
the author said yes, pending means no answer yet. Holding the quoted post
answers a different question — whether we could show it — and deriving the
state from that reported every quote as accepted the moment it was written,
including ones the author went on to refuse. An accepted quote whose post is
missing here, or closed to this particular reader, is still accepted, with a
null quoted_status; calling that pending would report the author as not
having answered when they have.
The approval. That URI is <quoted post>/quote_authorizations/<stamp>,
where the stamp is the quoting post's id in base64url. Carrying the id rather
than a digest of it is what lets the endpoint be stateless: a peer that
dereferences the URI — Mastodon does, before it will render the quote inline —
gets a QuoteAuthorization document built from the stamp and the post's current
policy, with nothing stored in between. Deriving the stamp from the id also
means a request redelivered twice is answered with the same URI both times
instead of two approvals that disagree.
Answering from the current policy is deliberate, and it is the only way a
grant is taken back on this side. An author who narrows a post has withdrawn the
permission, and a peer that re-checks the approval finds the endpoint no longer
answering. Nothing pushes that news: statelessness has a price, and this is it —
approvals granted are not recorded, so there is no list of who to tell. A peer
that never re-checks goes on showing the quote. Withdrawal in the other
direction does arrive promptly: a Reject for a quote that was previously
accepted is applied as a revocation, the stamp comes off both the
quote_authorization column and the stored wire object so later deliveries stop
claiming an approval, and the client sees the quote's state as revoked rather
than rejected.
On the wire. quote is FEP-044f's name and what Mastodon 4.5 reads first;
quoteUrl and _misskey_quote are emitted beside it for the servers that
predate the FEP. quoteAuthorization carries the approval once there is one.
WellKnown/WebfingerHandler is registered as a Nextcloud well-known handler and serves three services at the server root:
- WebFinger:
/.well-known/webfinger?resource=acct:user@domain— returns theselflink to the actor. The href is the actor's stored id, not a URL built from the host the request arrived under: on an instance reachable under two names, the request-derived form handed a remote server an actor id that disagreed with the document it then fetched. Thesubjectis always the account's own handle,acct:<preferredUsername>@<social address>, whichever form found it (acct:@user@host, another letter case, noacct:scheme, the actor id), and the host part of a handle is compared case-insensitively - NodeInfo:
/.well-known/nodeinfo— returns the discovery document pointing at the app's own/apps/social/.well-known/nodeinfo/2.0route (OAuthController::nodeinfo2()), which carries the actual server metadata - host-meta:
/.well-known/host-meta
All three return the previous handler's response untouched when FediverseService::jailed() says the instance is in allow-list mode with an empty list.
A timeline is read in two queries rather than one.
The first decides which posts belong in the page: it carries all the joins
and filters (recipients, follows, hidden actors, duplicate suppression) but
projects a single column, s.nid. The second fetches the rows for exactly
those ids, joining only what is needed to render them.
The reason is SELECT DISTINCT. Joining the recipients and follows tables can
return a stream more than once, so the query has always been DISTINCT — over
eighty columns, several of them TEXT. A database cannot deduplicate that
without building and sorting the entire matching set first, which is why a
timeline used to cost the same whether twenty rows were asked for or a hundred.
Deduplicating one integer is cheap; the wide read is then a primary-key lookup
of twenty rows.
Measured with occ social:benchmark on 20 000 notes: the home timeline went
from 219 ms to 89 ms and the public timeline from 121 ms to 29 ms, returning
the same rows in the same order.
The status a row points at — the post a boost repeats, the post a notification
is about — is joined by SocialCrossQueryBuilder::leftJoinObjectStatus(), and
that join carries the viewer bound of its own: addressed to the public
collection, written by the viewer, addressed to the viewer, or written by
somebody the viewer follows, as correlated EXISTS clauses rather than as
further joins (a second FROM entry for the dest or follow table is a cartesian
product). A boost carries the audience of the booster, not of the post, so
without this a remote Announce of a followers-only status handed that status,
in full, to everyone the booster reaches. When the viewer is not entitled to the
object the joined columns come back empty, which reads downstream as "no
object": the row stays, its content does not.
The user interface is a Vue 3 front end using Vue Router, Pinia, @nextcloud/vue components, @nextcloud/axios, DOMPurify (via src/utils/sanitizeHtml.js), linkifyjs, and the Twemoji pictures of @discordapp/twemoji, which the build copies into img/twemoji/.
webpack.common.js defines these entries; the Nextcloud webpack preset prefixes the output with the app id, so they land in js/ as:
| Bundle | Source | Loaded by |
|---|---|---|
social-social.js |
src/main.js |
templates/main.php — the main SPA |
social-dashboard.js |
src/dashboard.js |
SocialWidget::load() |
social-oauth.js |
src/oauth.js |
templates/oauth2.php |
social-profilePage.js |
src/profile.js |
ProfileSectionListener |
social-ostatus.js |
src/ostatus.js |
nothing — no addScript() call references it |
social-filesAction.js |
src/filesAction.js |
FilesScriptsListener, on the Files app's LoadAdditionalScriptsEvent — alone, see below |
social-adminSettings.js |
src/adminSettings.js |
AdminSettings::getForm() — Administration → Social |
The OStatus bundle and src/views/OStatus.vue are therefore dead code today: OStatusController::subscribe() and followRemote() both render the main template, so remote-follow lands in the main SPA on its /ostatus/follow route.
None of those entries is self-contained. Vue, @nextcloud/vue and pinia used to be compiled into each of them, so opening the Dashboard and then the app downloaded the framework twice — 276 KB and then 347 KB gzipped, most of it the same bytes. The framework cache group in webpack.common.js puts what more than one entry needs into social-framework.js, which they share; minChunks: 2 leaves a library only one entry uses inside that entry, so the single-page reader pays a few KB rather than the union. Gzipped: the app alone goes 347 KB → 354 KB, the Dashboard alone 276 KB → 306 KB, the Dashboard and then the app 623 KB → 368 KB, and adding a profile page after that 949 KB → 429 KB.
Every Util::addScript() for this app therefore loads social-framework before the entry — with one deliberate exception: social-filesAction.js registers "Share to Social" in the Files app and is loaded on every Files page, most of which will never post anything, so it is excluded from the framework cache group (SELF_CONTAINED in webpack.common.js), carries its few KB of @nextcloud/files and l10n itself, and is loaded by FilesScriptsListener with addInitScript() and nothing before it. It hands the picked paths to the app as ?attach= query parameters; Navigation.vue opens the New post dialog with them and takes them off the address, and the composer attaches them through the same attachPaths() its own picker uses. The overrides entry for @nextcloud/vue in package.json exists for this dependency: @nextcloud/vue 9.12 optionally peers on a @nextcloud/files pre-release, and without the override npm ci refuses the 4.0.0 the server itself ships. Getting that wrong fails silently rather than loudly: webpack's runtime queues the startup module waiting for a chunk that never arrives, so the script runs to completion, nothing is thrown, nothing reaches the console, and the page simply stays empty. tests/js/bundles.test.js boots the built bundles in a jsdom window to pin it — an entry served alone injects no stylesheets and does nothing, and served after the framework it starts — and checks each of the five addScript() sites for the order.
social-profilePage.js is loaded on Nextcloud's profile page for every user, whether or not they have posted, so it carries the section, the timeline switcher and ProfileStatusCard but not the post renderer: the card loads TimelineEntry with defineAsyncComponent, which fetches the same chunk the app uses for it once there is a post to draw.
optimization.concatenateModules stays false. Scope hoisting is worth about a kilobyte and makes the build irreproducible: two runs over identical source emit alternating Terser manglings, and CI compares the committed bundle against a fresh build.
src/store/ holds six Pinia stores — timeline, account, settings, errors, notifications and instance — and index.js creates the Pinia every entry point installs. Components reach them through mapStores, or through a composable where the same few values are wanted together: useServerData, useCurrentUser and useAccount in src/composables/ replaced the three mixins the app used to carry.
Server-side state is not a store: it is passed through Nextcloud's initial state as serverData and read by useServerData.
The reader's own account travels the same way. NavigationController::provideViewerAccount() puts the cached actor — the one GET /api/v1/global/account/info answers with, in the same export format — into the initial state as currentAccount, and App.vue seeds the account store from it. The app used to ask for it in beforeMount(), which cost every load a second authenticated round trip before anything could render, for something the page request was already holding. A page rendered before the account exists provides nothing, and the app asks the old way.
The Mastodon and ActivityPub entities the app exchanges are described as JSDoc
typedefs in src/types/, and npm run typecheck holds the stores, services and
utilities to them (jsconfig.json). The same script runs vue-tsc over the
whole of src/, single-file components included (jsconfig.vue.json), and
fails on any error.
Each store is installed per Pinia instance rather than per module registration, which is the difference that matters for tests — two Pinias give two sets of state, where the Vuex modules shared one object literal between them.
src/router.js declares:
| Path | Route name | View |
|---|---|---|
/ |
— (redirects to timeline) |
— |
/timeline/:type? |
timeline |
Timeline |
/timeline/:type?/tags/:tag |
tags |
Timeline |
/timeline/:type?/list/:id |
list |
Timeline |
/@:account |
profile |
Profile + ProfileTimeline |
/@:account/followers |
profile.followers |
Profile + ProfileFollowers |
/@:account/following |
profile.following |
Profile + ProfileFollowers |
/@:account/portfolio |
profile.portfolio |
Profile + Portfolio |
/@:account/tagged |
profile.tagged |
Profile + ProfileTagged |
/@:account/collections |
profile.collections |
Profile + ProfileCollections |
/collections/:id |
collection |
CollectionPage |
/places/:id |
place |
PlacePage |
/discover |
discover |
Discover |
/follow_requests |
follow-requests |
FollowRequests |
/blocked |
blocked-accounts |
BlockedAccounts |
/filtered |
— (redirects to blocked-accounts) |
— |
/migration |
migration |
Migration |
/settings |
settings |
Settings |
/reels |
reels |
VideoReels |
/switch |
switch |
SwitchWizard |
/statistics |
statistics |
Statistics |
/search/:term? |
search |
Search |
/@:account/:id |
single-post |
TimelineSinglePost |
/ostatus/follow |
— | Profile + ProfileTimeline |
profile.followers and profile.following render the same ProfileFollowers component; it decides what to load from the route.
Views outside the router: Dashboard.vue (mounted by the dashboard entry), OAuth2Authorize.vue (mounted by the OAuth entry on #social-oauth2), ProfilePageIntegration.vue (registered by the profile entry as the social-profile-section custom element), and OStatus.vue (unreachable, per the table above).
The Nextcloud Profile page's classic script queue runs Social's profile.js
before the Profile app's module entry. The latter creates
OCA.Profile.ProfileSections and immediately reads the registry while mounting
the page. Registering from Social with optional chaining silently skipped the
section because the registry did not exist yet; deferring to a timer or
DOMContentLoaded runs too late, after the Profile page has already taken its
initial section snapshot. services/profileSections.js intercepts the
registry's first assignment and registers synchronously during that assignment,
then restores the ordinary data property. The profile custom element also owns
a separate Vue app, so it installs Social's normal Nextcloud globals (t, n,
OC and OCA) before rendering; without them, the section mounted but its
first translated label threw during render. It renders in the light DOM so the
Profile page's theme and Social's shared timeline component styles apply; a
shadow root isolated those styles and left posts as unformatted list items.
Profile posts use the same account entry type as Social's own profile view,
and account counts use compact theme-aware metric chips. Tests cover delayed
and immediate registry creation, the custom element's app setup, and profile
timeline data passed to Social's shared entry component.
src/components/ holds the timeline and profile UI: TimelineList, TimelineEntry, TimelinePost, TimelineAvatar, ActorAvatar, ProfileInfo, FollowButton, UserEntry, Navigation, Search, FirstRun (the four-step introduction a new account sees once, in place of the beta banner: the address, the colleagues and starter packs from the same routes Discover reads, the follows import Settings offers, and a hand-off to the composer), FirstPostCelebration, MediaAttachment, PostAttachment, Emoji, EmptyContent, QuotedPost, HashtagFollowButton, HashtagFollowedList, the Gallery group (GalleryCarousel, GalleryMedia, GalleryRatio.js), the Composer/ group (Composer, PreviewGrid, PreviewGridItem, SubmitStatusButton, LanguageSelect), ScheduledPosts (the posts waiting to go out, in Settings), the Visibility/ group (VisibilitySelect, VisibilityIcon), and MessageContent.js, a render-function component that parses a post body and rebuilds it as Vue nodes (turning mentions and hashtags into router-links and emoji into Emoji components). AltBadge, Emoji, EmptyContent, QuotedPost, HashtagFollowButton, HashtagFollowedList, the Gallery group (GalleryCarousel, GalleryMedia, GalleryRatio.js), the Composer/ group (Composer, PreviewGrid, PreviewGridItem, SubmitStatusButton), the Visibility/ group (VisibilitySelect, VisibilityIcon), and MessageContent.js, a render-function component that parses a post body and rebuilds it as Vue nodes (turning mentions and hashtags into router-links and emoji into Emojicomponents).src/components/holds the timeline and profile UI:TimelineList, TimelineEntry, TimelinePost, TimelineAvatar, ActorAvatar, ProfileInfo, FollowButton, UserEntry, Navigation, Search, FirstRun(the four-step introduction a new account sees once, in place of the beta banner, and that?welcome=1on the home timeline shows again: the address, the colleagues and starter packs from the same routes Discover reads, the follows import Settings offers, and a hand-off to the composer),FirstPostCelebration, MediaAttachment, PostAttachment, Emoji, EmptyContent, QuotedPost, HashtagFollowButton, HashtagFollowedList, the Gallery group (GalleryCarousel, GalleryMedia, GalleryRatio.js), the Composer/ group (Composer, PreviewGrid, PreviewGridItem, SubmitStatusButton), the Visibility/ group (VisibilitySelect, VisibilityIcon), the settings sections (AccountSettings, ListsSettings, MigrationSettings, IntroductionSettings— the link back to that introduction —,ShortcutList) the two account dialogs (MuteDialog, ListMembershipDialog), ShortComposerDialog(the **+** on Shorts, and withmode="story"the way a story is added from the story bar — the video then goes toPOST /api/v1/storiesinstead of becoming a post, and a picture or a text story is handed toStoryComposerDialogthrough itsotherevent: upload, drop or record a video, trim it, pick a cover and post it — the cutting, recording and frame-grabbing are insrc/utils/shortVideo.js, done in the browser with MediaRecorderand a canvas so the server stores an ordinary video attachment and knows nothing about trimming; the chosen cover goes up asthumbnailandCacheDocumentService::applyCustomPoster()puts it where the generated poster would have been), andMessageContent.js, a render-function component that parses a post body and rebuilds it as Vue nodes (turning mentions and hashtags into router-links and emoji into Emoji` components).
The sidebar and post action labels are source literals passed to t('social', …);
the German catalog ships each entry in both l10n/de_DE.json and
l10n/de_DE.js, which are the two catalog forms this app includes. The
TranslatableStringsTest checks every singular label in Navigation.vue,
TimelinePost.vue and DirectMessages.vue against both files, so adding a
menu item without its translation is caught before the interface silently
falls back to English.
DirectMessages.vue presents the direct timeline as a conversation list beside
the selected thread. The list calls the Mastodon-compatible
GET /api/v1/conversations route (40 newest conversations per request), shows
the latest status as a plain-text preview, and marks unread conversations.
The selected conversation is stored in the conversation query parameter on
the direct timeline route, so opening or refreshing a link restores the thread.
Selecting a row loads the latest status context from
GET /api/v1/statuses/{nid}/context; ancestors and descendants are combined
with that latest status and de-duplicated by status id before rendering through
TimelineEntry. A successful thread load attempts
POST /api/v1/conversations/{id}/read; failure to persist the read marker is
logged without hiding the already loaded messages.
Starting a conversation searches GET /api/v1/global/accounts/search and selects exactly one account. If that person already has a conversation in the loaded inbox, that thread is opened; duplicate API rows for the same account are collapsed to the newest row. Otherwise the right pane becomes a normal chat: it shows the selected person and a compact message field, with no audience selector, post toolbar or multi-recipient composer. sendMessage() always posts with visibility: direct to POST /api/v1/statuses, prepending the selected account's @acct mention internally because Social's ActivityPub delivery derives direct recipients from mentions. The person using the chat does not need to type that mention. Replies include the last status id as in_reply_to_id; successful sends clear the field, refresh the inbox and open the conversation returned for that peer. Failed sends retain the draft and display an error. The direct route removes the reading-column padding and announcement strip. The panes share one flat app-content surface with only a divider; empty states do not add nested cards. Incoming and outgoing messages retain TimelineEntry for content and attachments, while their post headers are hidden and the chat layout supplies date breaks and message times. The peer appears in the thread header, so each bubble does not repeat the author. Nextcloud theme variables drive the surfaces and colors; at narrow widths the list and chat become separate views with a back control. The current view loads one page of conversations; older pages are not yet appended. Conversation dismissal and pagination controls are not part of this UI.
ProfileInfo.vue keeps every control for the profile in one dialog: the banner
(a file, or the address of one), the bio and the metadata fields. The banner
buttons used to float over the picture on the owner's own profile, which put
editing chrome on a page whose job is to show somebody. Applying a banner from a
URL leaves the dialog open, because the bio and the fields may still be being
edited.
Each field row in that dialog carries its verdict, because the one thing that
makes a field worth filling in is invisible otherwise: a value naming a web page
is verified by ProfileLinkVerifier fetching the page and looking for a link
back with rel="me", and nobody finds that out by accident. The row says
verified and when, or not verified, or — for a bare example.org — that nothing
written that way can ever be verified; and as soon as any row is an address the
dialog shows the exact anchor to paste on the far end, with a copy button, and
what the check will and will not do. The verdicts come from the entity's own
fields on verify_credentials, never from source.fields, which is the
editable copy and carries no verified_at, and they are keyed by the value they
were made about, which is why editing a value drops its tick there and then.
fieldLink() in src/utils/profileFields.js is the client's copy of the
plain-text half of ProfileLinkVerifier::linkOf(), so the dialog offers a tick
for exactly the values the server would try to verify.
The verdicts themselves live on the cached actor's details, which is what
made them fragile: a local actor is rebuilt from the actors row, which has no
details column, so ActorService::cacheLocalActor() writing details whole
erased fields_verified on every write to a profile — a new bio, one flag, one
edited field — and the ticks came back only when the next Cron\Cache pass
re-fetched every linked page. It now carries the verdicts across, and only those:
fields_checked is still dropped, which is what makes that next pass look at an
edited value at once rather than waiting out RECHECK_SECONDS, and a verdict
whose value is no longer among the fields is dropped with it.
Composer.vue grows a second attach control beside the paperclip: the
@nextcloud/dialogs file picker, so a picture already in the user's Nextcloud
goes straight to POST /api/v1/media/from-file instead of being downloaded and
uploaded back. Both sources fill the same attachment map and share one ceiling,
the server's (see "What the server's limits are" below), and with anything attached the preview grid moves above the text box —
in the DOM, so the tab order follows the eye — and the box becomes a caption
field. A picture with no alt text is marked as such on its own thumbnail, and a
description is saved on leaving the field rather than only when the post goes
out, so it survives a post that is never sent.
What a post says about itself. Three controls in the same toolbar row as the visibility menu, each of them a field the client API has always taken and the composer never sent.
LanguageSelect.vue is an icon-sized NcActions wearing the current code,
defaulting to the reader's Nextcloud language without its region
(src/utils/postLanguage.js: getLanguage() says how the interface is
spelled, de-DE, while Mastodon's per-language filters are keyed by the plain
de) and remembering the last choice in localStorage the way the visibility
menu does. It is sent as language on every post: the server would fill in the
same default (PostService::languageFor()), but a guess the poster can see is
one they can correct, and a post that federates with language: null is seen
neither by the readers who filter for a language nor by the ones who filter one
out. The languages on offer are the ones Nextcloud itself is translated into,
named at run time by Intl.DisplayNames so the list carries no names to
translate.
Scheduling is the clock beside the poll button. NcDateTimePicker is a
date library and its locales, so it is a defineAsyncComponent fetched when
the clock is pressed rather than with every composer — the same treatment the
emoji picker gets. Pressing it proposes an hour from now rounded to five
minutes; the minimum is five minutes out, which is ScheduledStatusService's
MIN_LEAD_TIME, checked here as well so the refusal arrives while the time can
still be moved. With a time set the Post button reads Schedule, the payload
carries scheduled_at as ISO 8601 in UTC, and the answer is a ScheduledStatus
rather than a Status: nothing is on a timeline yet, so the composer refreshes
nothing, celebrates nothing, and says when the post will go out instead.
ScheduledPosts.vue lists what is waiting, in a section of Settings rather
than a page of its own — the list is short (an account may hold 300), what is
done with an entry is one thing, and a post-scheduled event on the bus keeps
it in step with the dialog that opens over it. Moving one to another time is
left to the API.
The focal point is the crosshair on a thumbnail in PreviewGridItem.vue,
beside the alt text and saved by the same PUT /api/v1/media/{id}. The whole
picture becomes the control while the point is being set — pressed, dragged or
moved by the arrow keys, which is the only way to set one without a pointer —
and a badge on the thumbnail says a point is set once the editor is closed.
src/utils/focalPoint.js holds the three conversions in one place: the browser
measures a pointer from the top left with y pointing down, Mastodon reads a
point from the centre with y pointing up, and CSS wants two percentages from
the top left again. ProfileMediaGrid.vue crops its tiles through the same
function, so the editor and the grid cannot disagree about which way is up.
A reply reaches the conversation. prefillMessageWithMentions() starts a
reply with a pill per participant — the author of the post being answered, then
everyone that post mentioned, deduped by full handle and never the reader
themselves. It used to insert the author alone, so an answer in a conversation
of three reached one of three: the server turns the handles in the text into
recipients, Mention tags and inboxes (PostService::fixRecipientAndHashtags()
into StreamService::addRecipient()), and it can only address the people the
text names.
The notifications page. /timeline/notifications is the ordinary
TimelineList with three things of its own, all of them in the client so that
the API stays the shape every Mastodon client expects.
The filter row is TimelineSwitcher again, with no to on any option, so the
choice is the page's own state: src/services/notifications.js holds the seven
filters and excludeTypesFor() turns one into the exclude_types the server
takes (TimelineApiController::notifications()). The choice is part of the store's
params, so it is part of getTimelineIdentity — changing it refetches rather
than hiding rows already on screen — and rememberedFilter()/rememberFilter()
keep it in localStorage under social.notificationsFilter, inside a
try/catch because reading it throws in a private window.
Grouping is groupNotifications() in the same service: consecutive favourites
or boosts of one status, and consecutive follows, become one card carrying
accounts (everyone in it) and ids (every row it stands for). Only
consecutive ones — folding across something that happened in between would
reorder what happened. TimelineEntry draws up to GROUP_FACES overlapping
avatars and notificationSummary() writes "Anna, Bob and 3 others liked your
post"; newestIdOf() is what keeps the read marker covering every folded row.
Unread has one rule: the page is read once it has been in front of the
reader for SEEN_AFTER (two seconds) with the tab visible, and it is read up
to the newest card on it at that moment. It used to be read by an
immediate: true watcher, i.e. by rendering — and the marker is the
server-side Mastodon one every client shares, so a tab opened in the background
cleared the reader's phone for notifications no human had seen. The "New" and
"Earlier" headings are drawn against notificationsStore.fetchLastRead()
(GET /api/v1/markers), read once when the page opens and then frozen in the
component: a boundary that followed the marker would rub itself out as the page
was read.
What the server's limits are, and who asks. MAX_LENGTH and
MAX_ATTACHMENTS were hard-coded in Composer.vue, TimelinePost.vue and
src/filesAction.js — the server's numbers on the day they were typed, and
nothing kept them so. src/services/instanceLimits.js reads them from
GET /api/v1/instance (configuration.statuses.max_characters,
max_media_attachments), once per page, with the old constants as the fallback
a failure leaves standing. It is framework-free — no Vue, no Pinia, no axios —
because the Files action is loaded on every Files page without any of them;
src/store/instance.js is the reactive face of it for the components.
What waits for the timeline. Every page used to fire four requests as it
mounted — the timeline, trends/tags, lists and notifications/unread_count
— and on a small server they contended for PHP workers: 2.3 to 3.4 seconds each
in parallel where the timeline alone takes 0.4. src/services/boot.js is the
one place that knows about it: the timeline store calls noteTimelineRequest()
with every request it sends and the first one on the page is the one everything
else yields to, while afterFirstTimeline() runs its callback once that has
settled — or, on a page that sends no timeline request at all, in the first idle
slot after the first paint. The decision is taken in that idle slot rather than
at mount, because the sidebar mounts before the timeline below it and at mount
time there is nothing yet to yield to. Navigation.vue and the instance store
are the callers.
The ALT badge. AltBadge.vue: a badge in the corner of a thumbnail that
carries a description, and the description under it when it is pressed. It
renders two roots (the button and the text) so that both position themselves
against whatever frame holds them, which therefore has to be
position: relative — .photo in GalleryMedia.vue, .attachment-frame in
PostAttachment.vue. It was GalleryMedia's own markup and so appeared on the
media-first mosaic only; the same picture in an ordinary two-up card had its
description in an alt attribute and nowhere else.
The foot of a card is one row: the reactions somebody left at the near end, the counts and their controls at the far one, wrapping to a second line only when a post has collected enough reactions to need it. It was two rows while the second was revealed by the pointer and had to live in the card's bottom padding, which cost every card in the timeline the height of both.
The action row. How many replies, boosts and likes a post has is a fact about the post, so it is always drawn — as type: no button, no border, no surface, the glyphs dimmed to a hairline and the whole row out of the tab order's way until it is wanted. What arrives on hover (and on :focus-within, which is the entire keyboard path) is the controls: a pill fades in behind the row at the size the row already occupies, the glyphs come up to full, and the overflow menu appears. Nothing moves — no track widens, no digit shifts, so a pointer is never chasing a button that is still travelling, and a reader scanning a timeline can see which posts landed without pointing at each one in turn. The surface is a ::before rather than the row's own background, border and shadow, so the whole thing arrives as one opacity — one compositor property on a page that can be showing a hundred of these — and it leaves in 100ms flat, because a pointer crossing four cards on its way somewhere else must not leave four pills fading behind it. The one thing lit at rest is a like or a boost this reader has already given ([aria-pressed="true"]), which is state rather than chrome. Touch gets none of it: the @media (hover: none) block turns the row back into a row with everything visible, since no pointer will ever arrive to reveal anything.
Sound and touch. src/services/senses.js is the one place that makes a
sound or vibrates: play(moment), buzz(moment), and feel(moment) for both,
over a fixed list of moments (like, boost, react, post, follow, dm,
heart, roll). The sounds are synthesised with the Web Audio API — a few
oscillators and an envelope each, a band-passed noise sweep for post — so
nothing is downloaded or decoded, and one AudioContext is made on the first
press, which is the only moment a browser lets one start. The two switches are
per device and per account in localStorage (userKey()), not on the server:
sound is off until turned on, vibration on unless turned off (the app vibrated
on a like before the switch existed), and buzz() also answers
prefers-reduced-motion. The DM chime rides on
fetchUnreadDirectMessages(): it sounds when the count rises, never for the
first count a page reads. SensesSettings.vue is the Settings section.
Words and stickers drawn to a picture. src/utils/textCard.js draws a text
card (a story at 1080×1920, a post at 1080×1080) and bakes stickers into a
picture, on a canvas, and hands back a File that goes up through the ordinary
/api/v1/media like any attachment. So nothing new federates: a Mastodon or
Pixelfed reader gets a picture. The words travel as the picture's description
(and, for a card post, in the post itself). wrapLines() and fitText() are
the layout, kept pure for the tests; the drawing returns null, or the original
file for stickers, where a browser cannot draw, and the caller says so rather
than posting something other than what was on screen.
The games. src/utils/composerCommands.js resolves /dice [n], /roll,
/flip and /pick a, b in Composer::createPost() before anything is sent, so
the result is plain text in the post and cannot be re-rolled. A command is one
only at the start of a line or after a space (a URL containing /dice is left
alone), and only /pick runs to the end of its line. The tumble the writer sees
is tumble() on a timer; the result was decided before it started.
Arrivals. timelineStore.markArrived(id) names the post this reader has just
published for 2.4 seconds; TimelinePost plays post-arrive and a ring in the
author's hue while it matches, so a reload afterwards does not replay it.
The constellation. src/utils/constellation.js is a small force layout in a
unit square — repulsion softened so two nodes on one spot part gently, springs
along the ties, a pull to the middle, a speed cap, and a node that meets an edge
stops there so the whole thing comes to rest. FollowConstellation.vue draws it
(an async chunk, constellation, so the Discover page does not carry it until
Constellation is pressed) with a star per suggestion as a real button whose
label says who and why; prefers-reduced-motion gets settle() at once instead
of the animation.
Phone layout. One breakpoint, 600px, stated twice on purpose: as PHONE_WIDTH in src/services/phone.js (a shared matchMedia query with isPhone() and onPhoneChange()) and as the @media (max-width: 600px) rule in the stylesheets that lay themselves out differently on a phone — TimelineEntry.vue (the avatar column goes; the face, 36px, sits inside the card over the corner .post-header leaves for it, which is why TimelineAvatar takes a size), TimelinePost.vue (less padding), TimelineSinglePost.vue (the 64px the fine print and the spine kept for the avatar column), and Composer.vue (the toolbar wraps, the visibility menu is icon-only, Post keeps the end of its row). Nextcloud's own mobile breakpoint, 1024px, is where the sidebar collapses; the only rule at that width is Timeline.vue's, which starts the page's first element below the sidebar toggle. A tablet in portrait is between the two and keeps the avatar column.
Composer.vue carries a full tributeOptions config for @ account and # hashtag completion. tributejs is a plain DOM library rather than a component: it is attached to the contenteditable in mounted() and detached in unmounted(), and it appends its menu to the body, which the unscoped .tribute-container rule at the end of the file styles. The account collection searches /api/v1/global/accounts/search and the hashtag collection /api/v1/global/tags/search, both debounced. The composer's HTML-to-text conversion inserts one newline at each block boundary, including before a block that follows an unwrapped text node; browser contenteditables commonly encode the first Enter in that mixed form, so missing the leading separator concatenates the first two lines in a new post. The shared htmlToPlainText() applies the same rule when editing/redrafting and when presenting portfolio captions. The emoji picker is a separate NcEmojiPicker; it is loaded on first use and portals to #content, outside the composer toolbar and reaction modal's clipping/stacking containers while retaining Nextcloud theme variables inherited from the app root. The reaction picker shares that portal so the popover does not participate in its centered flex panel's sizing or sit beneath its backdrop. Portfolio captions are reduced to plain text by htmlToPlainText() and use white-space: pre-line so paragraph boundaries remain visible without rendering untrusted HTML.
The Settings page, and what is on it. src/views/Settings.vue is a list of
sections, each with an id — #account, #lists, #scheduled, #migration,
#shortcuts — because other pages link to one of them: the Follow requests
page's empty state sends the reader to #account for the switch it talks about.
An id names the section it is on, which the scheduled posts' did not: it said
#migration, so that link scrolled to the wrong section and the migration tools
had no anchor at all. The order is the things done to the account first and the
keyboard shortcuts at the end, under them: they are reference rather than a
setting, nothing on them is changed, and only the account deletion is below them
— last, and on its own, because it is the one thing on the page that cannot be
undone. The two large
sections are defineAsyncComponent imports in a settings chunk, since nobody
loads them until they open the page, and a section that arrives after the page
did is why the scroll to the hash is retried in updated().
AccountSettings.vue is PATCH /api/v1/accounts/update_credentials as a form:
the display name, locked, discoverable, indexable, bot and
source[privacy]. It sends only the fields that changed, which is not an
optimisation: the route writes only what it is given, and a form that posted the
whole of itself back would re-save a display name into a backend that owns it
(LDAP, SAML) and be refused for a switch it never meant to touch. The bio, the
banner and the metadata fields stay in the profile's own editor, because they are
what a visitor reads and are edited where they are seen.
The default audience. source.privacy lives on verify_credentials and
nowhere else — the account the initial state seeds the store with is the plain
Account entity and has no source — so the account store keeps the
CredentialAccount separately as credentials and exposes
defaultPostVisibility, which translates the wire's private into the
composer's followers and answers '' for anything it does not know. The
composer's chain is: what the caller passed, the post being replied to, the
account's default, the last visibility the reader used, and failing all of those
followers. Because verify_credentials can land after a composer is already on
screen — the timeline draws one as the page opens — a watcher takes the default
when it arrives, unless the audience has been settled by a caller, a reply or the
reader.
ListsSettings.vue is the whole of ListController in one section: create,
rename, delete with a confirmation, and members added through the same
/api/v1/global/accounts/search the composer's mention autocomplete uses.
Lists carrying nextcloud_group are shown — a reader looks here when they wonder
who is in "Design" — with their members and without any of the three controls,
because the group decides all of it and the server answers 422 to anybody who
tries. The sidebar draws the lists from a fetch of its own, once per page, so
every change here is announced on the event bus as LISTS_CHANGED (exported by
src/services/eventBus.js, because two unrelated components have to agree on the
name) and Navigation.vue asks again.
Muting asks two questions. MuteDialog.vue — whether the notifications go
quiet too, and for how long: indefinitely, an hour, a day, seven days or thirty —
is opened from a profile and from the overflow menu of any post its author did
not write, in an account-dialogs chunk shared with ListMembershipDialog. It
mutes itself rather than handing the answers back, because both callers would do
exactly the same with them. Blocking is a plain confirmation in the same two
places. A relationship reports mute_expires_at while a timed mute is running,
which is what the profile writes out under the name.
The Statistics page. src/views/Statistics.vue behind the account menu, and
StatisticsService behind that. Everything is counted from this instance's own
rows when the page is opened — nothing stored, nothing precomputed by a cron —
which is what keeps it from showing a total a deletion has already made false,
and the walk is bounded at MAX_POSTS with the answer saying how far it got.
The engagement figures come from each post's details, which is a JSON blob:
that is why the sum is a walk in PHP rather than a SUM(), because the three
databases this app supports do not agree on how to reach inside one. A boost the
account made is counted as something it did and then left out of everything
else, because the likes on a boosted post belong to whoever wrote it. The bar
charts are CSS — a chart library would cost more than the page it draws — and
every bar carries its own figure in a title, because a bar whose only value is
its height says nothing to a reader who cannot see it.
What the page reports beyond the totals is what somebody running an account professionally asks of it: engagement per post and per follower (the second is the industry's "engagement rate", against followers because this app has no impressions to divide by and says so rather than inventing a denominator), the median beside the mean because one viral post makes a mean meaningless, the share of posts that got no answer at all, which kind of post averages best (media, hashtags, originals against replies, each visibility), which weekday and hour do, which hashtags are worth using as opposed to merely used, and where the audience is — the hosts the followers are on, which is what a Fediverse account has instead of a geography. Two sample-size rules keep those from being noise: an hour is not named until three posts fall in it, and a hashtag's average is not reported until it has been used twice.
And the way back out. A post opened from a timeline is somewhere the reader
went into, so TimelineSinglePost carries a Back button above the thread. It
uses history.state.back — which the router writes whenever it navigates inside
the app — to tell a post opened from a timeline from one opened from a link
somebody sent: the first goes back, the second goes to the home timeline, because
a button inside the app should not be the thing that leaves it.
Which is only a way back if the timeline is still there. The store holds one
list at a time, and changeTimelineType() used to clear it on every call — which
includes the call Timeline.vue makes in beforeMount() when the view is
mounted again on the way back out of a post. A reader four pages into a timeline
came back to fifteen posts. switchTimeline() compares the list being asked for
with the one being held (getTimelineIdentity: type, account and params, the
same value that tells a page in flight it is no longer wanted) and clears only
when they genuinely differ, and it keeps one list aside — remembered, the
one just left — so that coming straight back to it finds it whole. One and not a
cache of all of them: each entry holds a full status index, and keeping every
timeline ever opened is the leak resetTimeline() was written to stop. A
restored list also sets restored, which is how TimelineList knows not to ask
for another page on top of the ones it already has.
Putting the reader back where they were then needs the page to exist first. Vue
Router applies the offset Back remembers as soon as the route has changed, and at
that moment the timeline is one screen tall, so the browser clamped a four-page
offset to the bottom of what was there. scrollBehavior returns a promise
instead: TimelineList emits timeline:rendered on the event bus once its
entries are on the page, and the router waits for that, or for two seconds,
whichever comes first — a view that never says anything still scrolls. The offset restored is the content column's, not the window's: a Nextcloud app is given a fixed viewport and the column scrolls inside it, so the position Vue Router remembers is always zero and applying it moved nothing. A beforeEach guard records #app-content-vue's scrollTop for the view being left, at most ten views deep.
The "N new posts" pill that polling puts up is sticky, and so is the box the
reader writes in: same stacking context, and the composer both taller and above.
The pill only appears once the reader is a screen or so down, which is exactly
when the composer is stuck to the top — so it was painted behind it every time.
TimelineList measures the composer, which is a sibling above it rather than a
parent, and sets the pill's top below it; measured and not assumed, because a
content warning, a row of attachments or a poll all make the composer taller.
The watching lasts only as long as the pill is on screen.
One card, and a line only when there is a conversation. The post a page is
about used to be drawn inside two boxes: TimelinePost's own card, and around
it a second card in TimelineSinglePost — white ground, padding, rounded
corners, a shadow, and a border in the accent colour on top of that. What marks
the post the page is about is that it is the one at the top with the thread
hanging off it, so .main-post is spacing and stacking now and the card inside
does the drawing. The 24px it is indented by is the 16 of margin plus the 8 of
padding the lists above and below take, which is what puts every avatar in the
conversation on one line — the line the spine runs down. And the spine itself
is drawn only when hasThread holds, meaning there is a parent or a reply:
beside a post with neither, it was a line from nothing to nothing.
Under it, "No replies yet" is a small drawing over a line of muted text rather
than a heading. EmptyContent takes an optional illustration name alongside
the image the timelines use, resolved through a map of components that draw
themselves in markup — NoReplies is two speech bubbles in currentColor over
the page's own background, so it follows the theme with no filter to correct it
on dark. It is a component and not another file in img/undraw because those
eight illustrations are licensed for this app by permission covering those eight
and nothing else (see img/undraw/readme.md); anything new has to be ours. A
state with a small drawing keeps the compact layout — the 60vh of height is room
for the full-size ones only.
A link to a post, opened cold. /@{username} and /@{username}/{token} are
ActivityPub addresses first, so ActivityPubController owns them; a request
whose Accept header asks for HTML is handed to SocialPubController, which is
the browser half of those two routes and nothing else — it has no routes of its
own. Who is asking decides what they get. A reader with a session gets the app,
the very page NavigationController::navigate() serves at /, because the
client-side router has a view for each of these paths. This used to serve the
public page to everybody, so a link to a post or a profile landed a logged-in
reader on a page with a blue header, a "Get your own free account" banner and a
Follow button that started the remote-follow flow for an account they could have
followed with one click. An ActivityPub request is untouched by any of it.
For an anonymous reader, both the profile shell and the resolved single-post
shell are PublicTemplateResponses. A profile whose well-formed user@host
handle is not cached here still receives that shell; the browser resolves the
account through GET /api/v1/global/account/info, whose anonymous rate limit
bounds remote lookups. The HTML request itself remains cache-only and never
WebFingers an arbitrary name. The profile view uses the local account endpoint
for a bare local username and the global resolver for a federated handle. Both
account-info routes are read-only public GETs and carry NoCSRFRequired, so a
signed-out browser can load the profile without a session token; the remote
resolver remains protected by its anonymous rate limit. This
detail matters after the post has been resolved: an ordinary TemplateResponse
is private by default, so Nextcloud redirects the visitor to /login during
the document request and throws away the already-rendered public status. The
public single-post shell also receives the safe serverData state and
serialized status before Vue starts; the guest timeline does not need a session
to fetch the surrounding public context. Authenticated readers still get the
normal navigate() shell.
The app writes its own links to a post as /@acct/<nid> — the numeric id its
client API uses — while the address a post is published under ends in a
different token, and the post used to be looked up by that address alone. So a
link opened in a new tab, a reload, or a link somebody sent found nothing,
provided no item, and the page said the post did not exist.
SocialPubController::resolvePost() falls back to getStreamByNid() for a
numeric token, on the browser branch only: an ActivityPub request asks for a
post by its address, and answering a second identifier there would invent a
second canonical id for every post. The fallback goes through the same viewer
filter every other read does, so it shows what the reader may see and nothing
more. The other end of that is in TimelineSinglePost: the post the server
rendered into the page carries its client id, and the view goes on that rather
than on what is in the address, so /context and the store are asked in the one
identifier they speak. The initial-state post is only used when the address
names it — by that id, or by the last segment of its uri — since the page is
rendered once and the reader goes on reading, and a second post used to be
answered with the first.
An address that names nothing is a 404 rather than a 500. Looking an account
up used to try to fetch it, and a bare username is not an account anybody can
resolve, so the exception saying so came out as a server error; the lookup is of
what this instance already has now (getFromAccount($username, false)) — an
anonymous page request is no reason to webfinger whatever is in the address. A
visitor gets templates/notfound.php, a small guest page shaped like the
server's own; a reader with a session gets the app with a 404 status, and its
views say "User not found" and "This post is not available" once they have
asked.
The portfolio URL is a separate browser route under /@{username}/portfolio.
It uses the same public page shell for anonymous readers and the client router
loads the portfolio through the public portfolio/{handle} API. The published
portfolio API resolves posts as the anonymous internet, so followers-only posts
cannot leak onto a public page. The route is covered alongside the public actor,
followers, and following pages. Captions are plain text and white-space: pre-line preserves paragraph breaks without interpreting user HTML.
The post route in ActivityPubController also matches any one-segment token
under /@{username}/…; because controllers contribute attribute routes in
filesystem order, it explicitly forwards the reserved portfolio token to
SocialPubController::portfolio() before attempting post resolution. This
keeps the public portfolio working on both cold anonymous loads and logged-in
navigation regardless of route registration order.
On the client, TimelineSinglePost asks for the post itself when nothing has
loaded it — timelineStore.fetchStatus(), which is GET /api/v1/statuses/{id}.
/context answers with what is around a post and never with the post, so a
page reached from anywhere outside a timeline had nothing to draw. A tile on
Discover is exactly that: those posts belong to the Discover view and never
reach the timeline store.
The route parameter for /context is a string even when its contents are a
numeric status id. StreamService::getContextByNid() normalizes that decimal
string with Nid::fromStorage() before it reaches the query builder, whose
integer predicate rejects numeric strings under strict typing. Without that
boundary conversion, the public post shell could render its initial status, but
the follow-up context request returned 500 and the page displayed a timeline
load error.
The pictures on Discover were not drawn at all, and had not been since the
tab was added. ProfileMediaGrid builds each tile's route with the grid's
account prop, which Discover leaves empty because the grid is everybody's —
and account is a required route param, so router-link threw while resolving
and took every tile with it. A tile links at the account that wrote the post it
draws, falling back to the grid's own account where a post carries none.
The hashtags on Discover. TrendingHashtags is a ranking rather than a
list of names: a row carries its position, the tag, how often it was used in the
window asked for, and a bar showing its share of the busiest tag on the list —
which is the only comparison these numbers support, since history from this
server is a single bucket for the chosen window rather than a series (the
accounts field is always 0, so "how many people" is not something the page
may claim). The window itself is the useful part: /api/v1/trends/tags takes a
period of 1h, 12h, 1d, 3d or 10d and orders by that window's own
column, so choosing one re-ranks rather than relabels, and an answer that
arrives after the reader has moved to another window is dropped rather than
drawn. The empty state says which of the two emptinesses it is: nothing tagged
in this stretch of time, or — over ten days — an instance where hashtags are not
used.
Beside it, what other servers say. The same tab carries a search box and, under
the ranking, a Busy elsewhere in the fediverse section; both draw PeerTagRows,
because a tag found by searching and a tag that is busy somewhere else are the same
row and differ only in what put it on screen. A row names the servers — "busy on
mastodon.social, misskey.io" — rather than counting them, since a named server is
checkable and "1 server" is not, and it counts only once there is no room left to
name. Typing replaces the ranking rather than filtering it: what is on screen
otherwise is one window on one server, and narrowing that to a word answers a
question nobody asked, so the box asks /api/v1/directories/hashtags and shows what
the servers said. A tag already in the ranking above is not repeated below it — the
same tag twice reads as a second opinion rather than as the answer to a different
question. A failure out there is quiet: the ranking is this instance's own and is
already drawn, and an error card about strangers' servers on a working page would be
this app apologising for somebody else.
Following is answered once for the page. HashtagFollowButton looks a tag up
for itself when nobody has told it, which is right for the one button on a
hashtag timeline and wrong for twenty on a ranking: it takes an optional
known list and reads its state from that instead, so the page costs one
/api/v1/followed_tags call rather than twenty lookups before anything can be
drawn. null means nobody has said, which is not the same as "not followed" —
hence a list rather than a boolean, since a Boolean prop cannot carry the
third state.
What else a post's own page says. Four things that belong to a post being read rather than to a post being scrolled past.
PostReactedBy puts the faces behind the two counts under the post, through
GET /api/v1/statuses/{nid}/reblogged_by and /favourited_by — both of which
the server has answered since the moderation tier and neither of which the web
client called until now. Nothing is requested when a count is zero, which is
most posts, so the page costs the two requests only when there is something to
answer with; a refusal draws nothing, since the counts are still on the post and
this row is only the elaboration. A dozen faces at most, with the count saying
how many there are in all, and each row reloads on its own when its own count
changes — the reader's own boost lands in the row it just changed.
PostDetails is the fine print: the full date, the audience in the words the
composer uses, the language named rather than coded (Intl.DisplayNames, the
code itself when nothing can name it), when it was last edited, and — for a post
from another server — a link to where it actually lives. A local post gets no
such link, its own address being the page the reader is on. Mastodon's
application is not among these: the entity this app builds does not carry one,
and inventing a value would be worse than the absence.
The composer sits under the post, pointed at it, rather than at the top of the
page waiting to be summoned by a reply button. Composer takes inReplyTo for
this: it seeds replyTo, and it is a default rather than a fixed target — pressing reply on
another post in the thread retargets the box as it always did, and sending or
dismissing that reply comes back to the anchor rather than leaving the box
pointed at nothing. The header naming who is being replied to is hidden while
the target is the post directly above the box, where it would be the page
repeating itself. There is no box at all on the public page, where there is no
account to send from.
The anchor is the one thing that does not open the box. Everywhere else a
replyTo means a reply in progress, which is why it counts as expanded; a box
that is under every post would then be eight controls and a text area under
every post. So expanded ignores the anchored target, the box is a line of
placeholder until somebody clicks into it, and it closes again once the reply
has been sent. Pressing reply on the post it sits under sets openedByHand
directly — the target does not change there, so opening is the whole of what
that button can do.
And it closes by hand, without losing anything. A click elsewhere collapses
an idle composer and may do no more than that — a stray click must not take a
half-written post with it — so a box with a single word in it stayed open, and
the only way back to a line of placeholder was to delete the word. The close
button in the composer's header is the way out, and it throws nothing away:
closedByHand wins over everything expanded would otherwise stay open for,
the text stays in the box, and the draft stays on disk. Opening it again — a
click, a reply, the compose shortcut, or the next visit to the page — puts the
reader back where they were. A half-written post is not something to ask
somebody about at the moment they are trying to get it out of their way; it is
something to still be there when they come back. The button is not offered where
there is nothing to close to, which is the New post dialog, whose own way out
is the dialog's. Posting still empties the box, through one clearComposer(), so
a field added to the composer cannot survive a post by being missed off one of
two lists.
Every panel the toolbar opens closes from itself. The poll, the content
warning, the preview and the place picker were opened by a button in the toolbar
and could be shut only by finding that same button and pressing it again — a row
further down, and a thing to work out rather than see. Each carries its own close
now, beside the schedule editor's, which has had one all along; the button in the
toolbar keeps working and still says which state it is in. Closing takes what
belongs to the panel with it, as pressing the toolbar button always did: the
poll's options, the warning's text, the place. The place picker keeps its
narrower Remove the place as well — that one clears the choice and leaves the
search open, where the close puts the panel away and leaves the post without a
place, which is what an unpressed pin means.
The draft on disk (src/services/draft.js) is the same promise across a reload:
the text, the content warning, the audience and the team account it was being
written as — which the store took and dropped until the field was added to it,
so the line in the composer that reads it back could never fire. Attachments are
deliberately not in it: they are uploaded media the server holds a handle to, and
restoring an id whose upload has been reaped would fail on the next send. They
survive a close, because nothing is cleared; they do not survive a reload.
And when the thread is shorter than the post says it is, the page says so.
replies_count is what the post's own instance reported plus what has arrived
here, so the two differ honestly: a reply from a muted or blocked account is
filtered out of the thread but still counted, and a remote thread is only ever
as complete as what has reached this server. The note counts against the
direct replies on screen, since that is what the number on the post counts,
and it waits for TimelineList to emit settled — before that, every reply is
one this page has not drawn, and saying so would be counting the loading.
Saying it out loud found a counter that was wrong. Deleting a reply removed its
row and left its parent's replies detail alone, so a post whose reply had been
deleted claimed one that no page could ever show — invisible until something
compared the number with the thread. Both deletes now recount:
NoteInterface::delete() for a Delete that arrives from another server, and
StreamService::deleteLocalItem() for your own post, which never passes through
that interface at all. The arithmetic itself is StreamRequest::recountReplies()
— remote_replies (what the post's own instance reported, which nothing here can
see) plus countRepliesTo() — which is a recount rather than a bump, because the
two things that move the number cannot both be expressed as one, and a recount
repairs a count that has already drifted instead of tracking one. It runs after
the row is gone, or it counts the reply it has just removed.
A post is a link to itself. Pressing anywhere on a post in a timeline opens
the post with its replies — the card, its picture, its video. TimelinePost
works out postRoute, which is null for exactly one post: the one whose page
the reader is already on ($route.params.id). A reply on that page is another
post and links to its own page like any other. onPostClick is what keeps the
card from swallowing everything else: a link, a button, a control, a modified
click asking for a tab, or a press that ended a text selection all keep their
meaning. Below the card, PostAttachment takes the same route as a to prop —
where it is set, a press on the media routes; where it is null, it opens the
viewer — and MediaAttachment takes an interactive flag that decides whether
a video gets controls at all. A player in a timeline would put a play button
in the way of the post and swallow the press meant to open it; the poster is
what the reader is choosing from. On the post's own page the media is the
subject again: the picture opens full size, the video plays.
An avatar is a link. ActorAvatar and TimelineAvatar wrap the face in a
link to the account, so the most obvious thing on screen to click does the
obvious thing wherever either is used. Three cases, because a link has to go
somewhere that exists: the router where there is one; the account's own address
where there is not, which is the profile section this app adds to a Nextcloud
user page — a custom element running an app of its own, where a router-link
resolves to nothing and takes the avatar with it; and nothing at all for an
actor with neither a handle nor an address. Each link is named ("Open the
profile of @alice"), because its only content is an avatar with empty alt text.
link: false turns it off where the avatar already sits inside a link — two
nested anchors is invalid and the browser resolves it by dropping content — and
on the composer's reply and quote lines, where following one would abandon a
draft.
The sidebar's “My profile” shortcut leaves the Social router. The Social
posts section is registered on Nextcloud's user profile page by
ProfileSectionListener; its custom element fetches the selected user's Social
account summary and statuses and renders them in that page. Above the posts, the
section shows localized post, following, and follower totals from the same
account response used by Social's profile header. The My profile shortcut therefore uses Nextcloud's generated /u/{uid} URL. The adjacent Social profile shortcut remains inside Social's router at @{acct}, preserving the app's own profile editor for ActivityPub fields, links, biography and banner. generateUrl() preserves the installation web root and
encodeURIComponent() keeps unusual user IDs inside one path segment. Because
this destination belongs to another Nextcloud page, its navigation row is a
normal browser link: it works with modified clicks and is not marked active by
Social's router. The Navigation test covers the generated destination and
external navigation behavior; the profile integration test checks the three
counts and fetched posts.
Account previews. AccountHoverCard.vue is the card that opens when the
pointer rests on an avatar or a mention, fetched once per handle and cached in
the account store. It answers "who is this?" without opening the profile, so it
carries what the profile header does: display name and handle, when the account
joined, the bio, up to four of its metadata fields (src/utils/profileFields.js
parses those out of the HTML Mastodon sends them as, and the profile page uses
the same function so the two cannot disagree about what a link is), and the
three counts. Badges say what is true of the account rather than about it —
that it follows you, that it approves its followers, that it is automated — each
of which is something somebody deciding whether to follow wants before they
click. Everything is conditional: a card built from what a status carried knows
less than one built from a lookup, and a gap is better than an invented value. Two rules keep it consistent. Every avatar that stands for
somebody goes through ActorAvatar (or TimelineAvatar, which adds the
instance ring) rather than a bare <img>, so the small ones preview too — the
face on a "X boosted" line, the one on a notification, the ones in Discover.
And every NcAvatar in the app passes disableMenu, without exception,
because for a local account it otherwise hangs Nextcloud's own profile card
off the same hover and opens it over the top of this one: the reader would get
a different card depending on which instance the account was on. There is one
account preview in this app and it is this app's. The avatars that are not a
reference to somebody else — the reader's own face in the composer, the profile
header of the profile you are already reading, the two on the remote-follow
page — simply have no card rather than Nextcloud's.
That rule is the kind a new avatar added next year cannot be expected to
remember, so tests/js/hoverCard.test.js reads the templates and fails with the
file and line of any <NcAvatar> that is missing it.
QuotedPost.vue renders a status's quote. Only an accepted quote whose
quoted_status came back becomes a card; pending, rejected, revoked and
an accepted quote the reader may not see each get a line saying which, because
a quote that silently renders as nothing is indistinguishable from a bug. A
quoted post that itself quotes something is not nested a second time — the
component prints one line and stops, so no chain and no cycle can recurse.
The Photos view. The sidebar's Photos, directly under Home, is a timeline
with media_type=image — what people showed rather than what they said. It is
the same query and the same filters, one predicate narrower, so nothing about
visibility, blocks, mutes or silencing is decided twice.
It asked only_media alone until 0.26.70, which is Mastodon's question — does
this post carry an attachment — and is not the one a page called Photos is
asking: measured against a live instance, forty posts of that timeline carried
twenty-five video attachments. media_type is the app's own narrowing and the
profile's Photos tab and Explore's Pictures tab were both already sending it;
this one page was not. only_media still goes out beside it, so a server that
has not been upgraded yet answers with media rather than with everything. A post
carrying both a picture and a video is mixed and is in both pages, which is
what limitToMediaType() already said.
Which people is the same switcher: Photos carries it too, so the photos of the
people you follow, of this instance and of everywhere are one control apart.
The scope rides in the query (/timeline/photos?scope=timeline) rather than
in a type of its own, for two reasons: the sidebar's Photos entry stays lit
whichever scope is being read, and the page is one page with a filter on it
rather than three pages that happen to look alike. It is the route's own three
words there as well (timeline, federated, and nothing at all for My Feed),
so no part of this translates between two vocabularies. The scope arrives from
the address bar, so Timeline.vue reads it rather than trusting it: anything
that is not one of the two named scopes is the default. It is also part of what
Timeline.vue reports as the timeline's params, which is what makes changing it
refetch instead of leaving the previous photos on screen.
Posts, Photos and Videos on a profile. The same switcher, above the account's posts, asking the server a different question rather than filtering the page on screen — a page filtered in the client is a page that can come back empty while there are still videos to find. The tab rides in the query (/@alice?media=video) so a profile stays one route and every link to it still names the same one, and the word is the API's own.
Behind it: only_media is Mastodon's own parameter on /api/v1/accounts/{id}/statuses and had never been passed on; media_type is a Social extension that narrows it to one kind, because Mastodon has nothing finer and two tabs need the difference. ProbeOptions::setMediaType() takes only the three kinds an attachment can be — image, video, audio, which are the first half of its MIME type and so the only values the column can hold — and reads anything else as no preference, since the value arrives from a query string. media_type implies only_media: a post with no attachments cannot be one carrying a video. The predicate is SocialLimitsQueryBuilder::limitToMediaType(), a LIKE on "type":"video" in the stored attachments — the column holds them as the client sees them, there is no column to compare and no JSON support to rely on across the three databases this app supports, and a description containing the same text is stored with its quotes escaped so it cannot collide. Unindexed, like the silenced-instance filter and for the same reason: it runs on a list something else has already narrowed to one account. Pinned posts are left out of a filtered tab, being about the account rather than about a kind of attachment.
The profile page is for reading. The Social section embedded in Nextcloud's /u/{uid} profile never mounts a post composer; new posts are written from the Social app. It shows the account's posts for any visitor, and adds My Feed only when the signed-in account owns the profile. Local and Global show the viewer's public Social timelines. The account banner is fetched from Social's account endpoint and shown only when it is a custom header rather than the avatar fallback. When Nextcloud's native profile header exists, the component teleports the decorative banner into that header, behind its avatar and name; the image stays at the top of the Social section as a fallback when the header is absent. The native header has a top: -40px offset; the teleport makes it positioned and activates that offset, so the Social banner explicitly resets top and bottom to align with the content and avoid a 40px coloured strip beneath it. Public local post links use the native /u/{uid} page with a status anchor; remote and non-public posts keep their Social or ActivityPub destination. The native Profile app remains responsible for its follower and post totals.
Federated media recovery. A remote Note can have source.attachment while its stored attachments column is [], for example when an earlier import could not persist a CDN image. The normal renderer only reads the latter, so the post text remains visible while every image disappears. social:media:recover walks remote rows with an empty attachment list by nid in pages of 50, decodes the original ActivityPub object, and feeds its attachments through the same Stream::importAttachments() and DocumentInterface path used by an incoming post. It reads attachment in either shape ActivityPub writes it, a list or a single object on its own. It writes only the recovered attachment list and its derived media_kind; a conditional update leaves a concurrent import intact, and the summary counts those separately (already filled) so a run that changed nothing can be told from one that failed. Documents without an ActivityPub id receive a generated id on each import, so DocumentInterface::save() first looks for the same URL under the same parent post and reuses that row's id, nid and stored copy. This avoids both duplicate downloads and a newly generated /media/{uuid} URL that has no cache row behind it. The command has a read-only --dry-run and an examination --limit. Existing posts with media, local posts, and originals without an attachment list are never changed. A media failure does not rewrite the post's text, recipients, or publication metadata, and another run can retry a post that remained empty.
Direct messages are one private chat per person. DirectMessages.vue uses Nextcloud Vue controls and the full Social app-content width for a two-pane layout: the left column owns conversation search, compact all/unread filters and one conversation row per person; the right column owns the selected conversation, its scrollable message lane and a private-message field. The new-chat pane starts with deduplicated people from existing conversations and the signed-in account's following list, then searches cached and resolvable accounts by name or handle through /api/v1/accounts/search. This endpoint returns a plain array of Mastodon-compatible account objects. The older /api/v1/global/accounts/search instead returns ActivityPub actor objects inside result; their missing acct field caused every result to be filtered out by the chat picker. The search delay and request counters keep stale responses from overwriting a later query; an explicit error state distinguishes failed searches from no matches. Selecting somebody reuses that person's existing conversation where possible; the new-chat header and composer then use the same visual structure as a thread rather than a separate contact card. The composer has no audience selector. Each send sets direct visibility and adds the protocol-required recipient mention internally; the chat removes the leading recipient routing mention from message bubbles and inbox previews whether the server returns it as an h-card, a mention link, or plain text; later mentions remain in the message. Context-loaded messages receive saved link-preview cards through the same batched attachment service used by timelines, so previews render inside the chat without fetching arbitrary URLs in the browser. The native conversation action dismisses the thread from this viewer's inbox; it preserves the statuses and a later message makes the conversation reappear. Duplicate API threads for the same peer are collapsed in the inbox. Incoming and outgoing posts render as separate bubbles aligned to opposite sides, while consecutive messages from the same account are grouped without repeating the sender label. Opening a thread fetches its context and marks it read; sending a reply targets the latest message and stays private. At mobile width the view becomes a single pane whose Back button returns to the list.
The Social section embedded in Nextcloud's /u/{uid} profile deliberately
leaves post/follower/following totals to the native Profile app, which already
renders those numbers. Each status card adds its Likes and Comments controls
inside the native card footer: likes load the existing favourited_by and
reblogged_by data only when opened; comments load the status context on demand
and display direct replies only. The Open post link uses the status's public
URL, so it works in the custom element without relying on Social's main Vue
Router instance. The profile owner also sees a separate My Feed section,
loaded from the authenticated home-timeline endpoint with cursor pagination;
it is never requested on another person's profile, so followed/private posts
cannot leak into a public page.
The tab also decides how it is drawn, and there is nothing beside it to say otherwise: Posts is what somebody wrote, so it is a list of posts; Photos and Videos are what they showed, so they are grids. There used to be a grid/list switch here, remembered across profiles, and it could disagree with the tab — ProfileMediaGrid kept only the posts carrying a picture, so Posts showed sixteen of them as a list and three as a grid, with nothing to say where the other thirteen had gone. One question, one answer. The empty state comes from TimelineList in both views for the same reason: the grid carried one of its own that said "No photos yet" whatever the tab was, so an account with no videos was told it had no photos.
The Videos view. The sidebar's Videos, directly under Photos, is the same
page again with only_video — this app's own narrowing of only_media, because
a video timeline that asked Mastodon's question would answer with every holiday
photo on the instance. It carries the same switcher, in the same query
(/timeline/videos?scope=federated), through the same isScopedPage branch in
Timeline.vue: Photos and Videos differ in one predicate and in nothing else,
which is why TimelineSwitcher takes the page it is scoping as a prop rather
than a photos flag.
Two things make a post a video, and the query asks both (limitToVideo()): an
attachment whose Mastodon type is video, or a post that arrived as a PeerTube
Video. The second counts whether or not this instance found a playable file in
it — the post is a video either way, and a timeline that hid the ones it could
not play would be hiding exactly the videos worth reporting.
News, on the server only. The web interface used to have a News timeline
in the sidebar, a News tab in Discover and a switch for it in the Sections card.
All three are gone, and /timeline/news redirects to My Feed. What stays is
what a client can still ask for: the only_news parameter on the home and
public timelines, /api/v1/trends/links, and /api/v1/timelines/link — the
last is also still how the web interface opens everything said here about one
article. section_news is no longer read or written.
What counts as news is two things, and the app takes both rather than choosing:
a post that is an article — an Article or a Page, which is what Plume,
WriteFreely, Ghost and the WordPress plugin publish, and which arrives here as a
Note carrying that word in subtype (AP::NOTE_LIKE_TYPES) — and a post that
points at one, meaning an ordinary note whose text carries a link to
somewhere else. The second is where the volume is: most news on the fediverse
travels as somebody's sentence with a URL after it.
The question "does this text link anywhere" is asked by Stream::firstLinkIn(),
which is the same code LinkPreviewService asks before it fetches a preview.
That is deliberate rather than tidy: if the two disagreed, a post could be in the
News timeline with no card to draw, or carry a card and not be there. A mention
and a hashtag are anchors too, and both are dropped whole before anything is
looked for — counting them would have made News a copy of the home timeline.
What is deliberately not asked is whether the preview was actually fetched. Cards are read from the linked page after the post is stored and may never arrive; a post that dropped out of the News timeline hours later because somebody else's web server was down would be worse than one that sits there without a picture.
Boosts are not in it. A boost is a row of its own with no content of its own, so
news_kind is '' for it — which on this page is the right answer rather than a
gap: a news page whose top five entries are the same article five times is a
worse page.
Where the links themselves are. /api/v1/trends/links counts the URLs this
instance shares, and /api/v1/timelines/link?url=… is everything said here
about one of them. Both are API only now.
In the player, a video attachment with a preview that is not the video itself
gets that preview as its poster and preload="none". Only a federated video
has one, and it is what lets a page of twenty of them be scrolled without opening
twenty connections to other servers: preload="metadata" on a proxied video is
not free the way it is on a local one. A video uploaded here has preview_url
pointing at the file, which is no use as a poster — a browser handed a video for
one downloads it to find a frame — so those keep metadata and no poster.
One column, one owner. --social-column in App.vue is the width of the
timeline — 900px — and every view that shows the same column reads it from
there: the profile, the followers and follow-request lists, the blocked
accounts, search, the welcome banner and the composer. They each used to carry
their own 600px, so widening the timeline alone would have made every other
page snap back on navigation. (The OAuth consent card keeps its own narrow
width; it is a dialog, not the column.) The list keeps --social-column-gutter inside that, so a
post is narrower than the column by a gutter on each side; the composer takes the
column whole and therefore stands that much proud of the posts beneath it, which
is deliberate — the box you write in should read as the thing that makes them
rather than as one of them. Both used to carry the same max-width independently
while only one of them had a gutter, which is how they came to disagree.
TimelineList owns its own layout and the views that render it do not touch
it. That is not style: .social__timeline is a child component's root, and
a scoped rule still reaches a child's root — so a view styling it lands beside
the list's own rule at the same specificity and wins or loses on whatever order
the bundle puts them in. Timeline.vue set margin: 0 there, which beat the
list's margin: 0 auto and left the timeline flush to one side while the
composer beside it stayed centred. Where a view genuinely needs to shift the
list — the reply spine in TimelineSinglePost — it says so through its own
element (.thread .social__timeline), which wins on specificity rather than on
luck.
Every entry in the list has the same edges for the same reason. A notification is a card, because it is a thing that happened and the post inside it is quoted evidence; a boost is not, because it is somebody else's post with a line saying who passed it on. Giving a boost a card put a box inside a box and inset the post by the outer padding, so boosted posts were narrower than their neighbours.
Posts that are pictures. A post carrying attachments and no content
warning is laid out around them: TimelinePost.vue's mediaLeads puts
PostAttachment above the text, which then reads as a caption. A warning wins
over that — its cover has to come before anything it covers — and so does edit
mode, where the text is the thing being worked on. One or two pictures are a
mosaic; from three (CAROUSEL_FROM) they become a GalleryCarousel paged with
the arrow keys, Home and End, because eight thumbnails side by side are eight
pictures in which nothing can be made out. The older thumbnail grid stays for
every place the text still leads.
GalleryRatio.js reserves each picture's box from meta.original before it
loads, clamped between 3:4 and 16:9, so a photo timeline does not jump under the
reader's thumb as images arrive; MediaAttachment.vue paints the blurhash
into that box meanwhile. GalleryMedia.vue carries the ALT badge — the
description is what the picture is, and a reader who cannot see it is not the
only one who wants it — and sets the alt attribute from the same value.
HashtagFollowButton.vue reads /api/v1/tags/{tag} on mount and whenever the
route's tag changes, and takes its state from what the server answers rather
than from what was asked, so a refused follow does not leave the button lying.
It renders nothing on the public page, where there is no viewer to follow
anything. HashtagFollowedList.vue is the disclosure beneath it.
MessageContent.js turns both Unicode and custom emoji into inline image
elements while walking sanitized post text. TimelinePost.vue gives actual
content images a block layout; its image rule excludes .emoji and
.custom-emoji, which stay inline with the surrounding text. QuotedPost.vue
applies the same inline sizing so a quoted remote status preserves the same
word flow. Direct-message bubbles clone a direct status for display and strip
only its leading ActivityPub recipient h-card; the stored status remains
unchanged, and mentions later in the message still render normally.
| Integration | Class | Registered in | Description |
|---|---|---|---|
| Dashboard | SocialWidget |
Application::register() |
Recent Social notifications, rendered client-side; loads social-dashboard |
| Dashboard | SocialTimelineWidget |
Application::register() |
Home timeline, 300-second reload interval |
| Dashboard | SocialMentionsWidget |
Application::register() |
The notifications probe filtered to mention |
| Dashboard | SocialDirectWidget |
Application::register() |
Direct messages |
| Dashboard | SocialBookmarksWidget |
Application::register() |
Saved posts |
| Dashboard | SocialFollowRequestsWidget |
Application::register() |
Follow requests awaiting an answer; conditional — offered to a locked account, or one that still has requests waiting |
| Dashboard | SocialTrendingWidget |
Application::register() |
Trending hashtags over one day, 900-second reload interval; needs no viewer |
| Dashboard | SocialReportsWidget |
Application::register() |
Open moderation reports; conditional — admins only |
| Dashboard | SocialFederationHealthWidget |
Application::register() |
Instances the outbound queue is failing to reach; conditional — admins only |
| Unified Search | UnifiedSearchProvider |
Application::register() |
Searches URIs, accounts, hashtags and status content (case-insensitive substring over the statuses the viewer may see: own posts, public/unlisted, and what is addressed to them — the timeline viewer bound). Local hits link to the post page, remote hits to their origin. Honours the query's cursor and limit — each source is asked for one entry past the end of the page, and a further page is offered only when one of them supplied it. It used to advertise a next cursor unconditionally while reading neither, so "load more" served the first page for ever |
| Notifications | Notifier |
Application::register() |
Prepares Social notifications for the NC notification system: every subject in NotificationService::SUBJECTS is worded, linked into this app's own pages (NotificationService::emit() builds the link from the post's nid or the account's handle), and a follow_request carries Accept/Decline actions that POST to /api/v1/follow_requests/{id}/authorize and /reject; the answer dismisses the stored row and withdraws the bell entry (onFollowRequestAnswered()) |
| References | Reference\PostReferenceProvider |
Application::register() |
Unfurls links to this app's posts and profiles (/@acct/{nid}, /@acct/{id tail}, /@acct) into a card wherever Nextcloud renders references — Talk, Text, Deck — and on public shares too (IPublicReferenceProvider). Renders only public and unlisted posts and profiles, because the card is cached per link for everyone (getCacheKey() is null, the prefix is the link); anything narrower resolves to nothing and stays a link. Never fetches from another server: a remote account not cached here is not looked up |
| Activity | Activity\Publisher, Activity\Provider, Activity\Setting, Activity\Filter |
appinfo/info.xml (Publisher is called from NotificationService::emit()) |
Every bell entry is also an Activity entry with the same subject (NotificationService::SUBJECTS), worded by Provider with the actor as a rich user (local) or highlight (remote) and the post as the message. Setting puts it in the stream by default, the digest mail off by default, and Activity's own notifications off for good — this app has a bell. Filter is the "Social" entry in Activity's sidebar |
| User migration | UserMigration\SocialMigrator |
Application::register() |
Puts the user's Social data in a Nextcloud account export, and reads it back on import. See "Account export and import" below |
| Profile Page | ProfileSectionListener |
Application::register() (on BeforeTemplateRenderedEvent) |
Adds the social-profilePage script to the user profile page |
| Files | FilesScriptsListener |
Application::register() (on OCA\Files\Event\LoadAdditionalScriptsEvent) |
Adds the self-contained social-filesAction init script, which registers "Share to Social" on pictures and videos |
| User Events | UserAccountListener |
Application::register() (on UserUpdatedEvent) |
Re-caches the local actor when the NC account changes |
The app listens to Nextcloud's events and, until now, published none of its own — so nothing else on the server could know that somebody had posted. An Activity entry, a Talk message, a Flow rule or an integration of somebody's own had nothing to subscribe to, and would have had to poll this app's client API from inside the same server to find out.
| Event | Dispatched from | Carries | When |
|---|---|---|---|
OCA\Social\Events\PostPublishedEvent |
PostService::createPost() |
the Stream as stored, and getAuthorId() |
after the post is stored and addressed, before it is delivered |
OCA\Social\Events\PostDeletedEvent |
StreamService::deleteLocalItem() |
the post as it last was | after the row is gone and the Delete is queued |
Both are local posts only. Everything that arrives from elsewhere arrives through the inbox, in volume, and an event per federated post would be a firehose nobody asked for — that is a separate event with a separate name if anybody ever wants one.
A listener must not wait for delivery: delivery is a queue and other people's servers, and a listener that waited on it would be waiting on the internet. The publish event says the post exists and has been addressed, which is the thing a listener can act on.
Subscribe as usual:
$context->registerEventListener(PostPublishedEvent::class, MyListener::class);| User Events | UserDeletedListener | Application::register() (on UserDeletedEvent) | Deletes the Social account of a deleted Nextcloud user through AccountService::deleteActor(): the actor is tombstoned, what belongs to it is dropped and a Delete is federated. A user who never opened Social has nothing here and is skipped; a failure is logged rather than thrown, since the Nextcloud user is already gone |
| Group Events | GroupListListener | Application::register() (on UserAddedEvent, UserRemovedEvent, GroupDeletedEvent, GroupChangedEvent) | Keeps the group lists in step with the groups; never fails the group operation, a failure is logged and the cron's reconcile settles it |
| WebFinger / NodeInfo / host-meta | WebfingerHandler | Application::register() | ActivityPub discovery at the server root |
| Contacts Menu | ContactsMenuProvider | appinfo/info.xml | "Follow %s on Social" entry linking to the actor page |
| Background Jobs | Cron\Cache | appinfo/info.xml | 12-minute interval, MAX_DURATION of 300 seconds: reaps deleted actors, refreshes local and remote actor caches, caches documents, recomputes hashtag trends, closes polls, prunes remote statuses past retention (bounded to 5000 per run), sweeps cached remote actors nobody refers to (bounded to 500), syncs remote timelines (getRemoteActorsToSync(), its own batch — the refresh now stamps what it touched, so a shared selection would leave the sync nothing to do on a small instance), verifies profile links, reconciles group lists, finds out which servers the Discover page may ask (FediverseDirectoryService::refresh()) and deletes the expired rows of social_durable_cache. The budget is threaded through the steps and into the three loops that make a request per remote actor — the actor refresh and the details refresh each get at most REMOTE_ACTOR_SECONDS (90) of it and take batch after batch of SYNC_BATCH until then (CacheActorService::walkDue()), where one batch of fifty a pass could not keep the ten-day lifetime past about 60,000 cached actors; a step that has no time left is skipped and named in the log, and the next run starts with the first step it skipped (cache_cron_start), so the tail of the list is not the part that never runs |
| Background Jobs | Cron\Queue | appinfo/info.xml | 12-minute interval: drains the outbound request queue and the inbound stream queue |
| Background Jobs | Cron\BlocklistSync | appinfo/info.xml | Daily: re-reads the block lists an administrator follows (BlocklistSubscriptionService::fetchEnabled()); one app-setting read and nothing else while no source is on |
| Background Jobs | Cron\ActorCleanup | queued by PersonInterface::delete() | Finishes detaching a deleted account from the posts that addressed it, when there are more of them than one inbox request should rewrite. Not in appinfo/info.xml, for the same reason Cron\DomainPurge is not: it is meaningless without an argument. Re-queues itself while rows remain |
| Background Jobs | Cron\DomainPurge | queued by FediverseService::addAddress() | Queued with a domain when one is added to the deny list, never registered in appinfo/info.xml — a job listed there is added once at install time with no argument, and this one is meaningless without a domain. Runs 10 batches of DomainPurgeService per pass and re-queues itself while anything of the domain is left |
| Background Jobs | Cron\InterestHides | appinfo/info.xml | Daily: forgets the "less like this" hides older than the My interests window, which can no longer keep anything out of the feed |
| Background Jobs | Cron\ExpiredStories | appinfo/info.xml | Hourly: deletes the stories whose day is up, at most 500 per run. The read filter on expires_at is the other half of the guarantee; this is what stops the rows and pictures outliving it |
| Background Jobs | Cron\ScheduledPosts | appinfo/info.xml | 5-minute interval: publishes the scheduled posts whose time has come, at most 50 per run. Shorter than the other two jobs on purpose — a post may be published up to one cron period late, and a period longer than the five minutes' notice the API demands would promise a precision the app cannot keep |
| Repair step | Migration\EncryptPrivateKeys | appinfo/info.xml | Seals legacy plaintext actor private keys with ICrypto, once. A row it cannot process is named and skipped rather than aborting occ upgrade with the instance in maintenance mode |
| Repair step | Migration\HashClientSecrets | appinfo/info.xml | Rewrites legacy plaintext client secrets/codes/tokens as sha256 digests, once. Asks the database for the rows that still need converting instead of hydrating the whole client table, and isolates a row it cannot process |
| Repair step | Migration\BackfillRemoteVisibility | appinfo/info.xml | Backfills the empty visibility of remote statuses stored before estimation landed (public/unlisted set-based, followers/direct per author), idempotent |
| Repair step | Migration\CacheFeaturedCollections | appinfo/info.xml | Rebuilds the cached copy of every local actor when something the cache carries has changed — the featured URL, the display name. Gated on a VERSION marker rather than re-running on every upgrade, and it counts the local actors before loading any |
| Repair step | Migration\BackfillStreamPostFields | appinfo/info.xml | Fills in social_stream.tags, language, updated, quote and quote_authorization for the rows stored before those columns existed, by re-reading each row's wire object through Stream::importFromDatabase() — one parser, not a second copy of it. Pages on the primary key, writes only the rows that disagree, and is gated on a marker so it is not a full scan of the largest table on every later upgrade |
The four timeline tiles (home, mentions, direct, bookmarks) extend
Dashboard\TimelineWidget, which resolves the viewer, builds the ProbeOptions
and turns a stream row into a tile row; a subclass supplies the probe, the
/timeline/{path} it opens and its empty-state wording. A row's sinceId is
the stream nid, which is what the widget feeds back to ProbeOptions::setSince()
on the next poll — setMinId() would return the oldest matching rows instead of
the newest. A boost renders as the post it repeats, subtitled with who boosted
it; a boost or notification whose subject did not resolve has no row.
The occ commands are registered in appinfo/info.xml. lib/Command/ also holds SocialCommand.php, the base class all of them extend — it declares --output and the writers that honour it, in place of the server's private OC\Core\Command\Base — and ExtendedBase.php, a shared base several of them extend. Neither calls setName(), so neither registers a command of its own. See docs/OCC-Commands.md.
lib/UserMigration/SocialMigrator.php implements the server's
OCP\UserMigration\IMigrator (and ISizeEstimationMigrator), registered in
Application::register(). It is what makes a user's Fediverse identity part of
occ user:export / occ user:import and of the account-transfer UI; before it
existed, an exported account carried nothing of this app at all.
The migrator id is social and the export format version is 1. Everything it
writes lives under social/ in the archive:
| File | What it holds |
|---|---|
social/actor.json |
The actor: id, handle, display name, bio, profile fields, locked, discoverable, indexable, bot, sensitive, default privacy, language, avatar and header URLs, alsoKnownAs, movedTo, the public key and the creation date |
social/following_accounts.csv |
Who the account follows, in Mastodon's following_accounts.csv shape (Account address,Show boosts,Notify on new posts,Languages) — written by MigrationService::exportFollowsCsv(), read by MigrationService::parseFollowsCsv(), and accepted by Mastodon's own "Import follows" |
social/followers.csv |
Who follows the account, same shape. A record for the user; nothing imports it, because a follower is somebody else's decision |
social/blocked_accounts.csv |
Blocked handles, one per line (the shape Mastodon exports) |
social/muted_accounts.csv |
Muted handles with the Hide notifications column |
social/bookmarks.csv |
The URLs of the bookmarked posts |
social/likes.csv |
The URLs of the favourited posts |
social/interests.json |
My interests as InterestService::export() writes them: every row (tag, score, scored_at, manual, position) and the reader's learning switch, languages and reading pace. Imported rows keep their times, so decay carries on rather than restarting; out-of-range values are clamped and a row that is not a hashtag is left out. The posts hidden with "less like this" do not travel: they name posts on this server |
social/outbox.json |
The user's own posts as an ActivityPub OrderedCollection, written a page at a time through a temporary file so that an account with years of posts never has to fit in memory |
social/media_attachments/files/<id>/original.<ext> |
The file of one attachment of one of those posts, in the layout Mastodon's own export uses. <id> is the attachment id the post carries |
social/media_attachments/header.<ext>, avatar.<ext> |
The profile banner, and a profile picture where this app has one of its own. Named in actor.json as headerFile and avatarFile |
Reads are paged everywhere (SocialMigrator::PAGE, 50 rows); the block and mute
lists come from one capped query (RELATIONS_LIMIT, 5000), and reaching the cap
is reported on the console rather than silently truncating. A follow whose
account this server never cached is left out of the CSV instead of being written
as a bare actor URL, which no reader of the format accepts.
The files travel with the posts. As the outbox is walked, each attachment
that names a copy this instance stored is copied into the archive and its url
is rewritten to that path — relative, because an absolute one names the server
the archive is leaving. The address it had here is kept beside it as
originalUrl, so nothing the archive knew is lost and a reader that cannot use
the copy still knows where the picture was served from. The bytes go through
IExportDestination::addFileAsStream() from the stored copy's own stream: a
two-gigabyte video costs the export no more memory than a sentence does. An
attachment this instance has no bytes of — a streamed PeerTube video, a copy a
retention sweep removed — keeps the URL it had, which is all there ever was of
it. The size estimate (ISizeEstimationMigrator) counts the media too, per kind
(SIZE_IMAGE/SIZE_AUDIO/SIZE_VIDEO, from one grouped count in
CacheDocumentsRequest::countLocalCopiesByType()): the table stores no file
size, and an estimate that left the files out was wrong by orders of magnitude
for any account with a video on it.
- The actor's private key. It is the only secret that lets anything speak as
that account, ActivityPub has no revocation for it, and the app encrypts it at
rest (
PrivateKeyCipher, see Security above) precisely so that a copy of the database is not enough to impersonate a local actor. An export archive is an ordinary file the user downloads and keeps, so a plaintext key in it would undo that — and for nothing: an account imported elsewhere is a new actor with a new id, andAccountService::createActor()gives it a fresh pair. Identity continuity is carried byalsoKnownAsplus aMovefrom the old server (MigrationService::move()), which is why the import records the old actor id as an alias. The public key is exported, because it is public and says which actor this was. - Other people's posts. The local copies of remote statuses are a cache of somebody else's content, re-fetched wherever they are needed.
- Moderation decisions taken against the account, and reports. A suspension
deliberately outlives even the deletion of an actor (
PersonInterface::delete()), so it must not be something a user can shed by exporting and re-importing. - Tokens, OAuth clients and client secrets, and the outbound request queue. A credential that survived a move would be one nobody can revoke.
- Other people's media. Only the files of the user's own posts are copied,
and only where this instance stored them. A streamed video is deliberately
never mirrored here (
Document::COPY_STREAMED) and there is nothing to put in the archive; the Nextcloud account's avatar is the account's rather than this app's, and core's own migrator carries it.
An import is safe on a server where the account already exists: the actor is
taken as found and only created when there is none
(AccountService::getActorFromUserId($uid, create: true)), every write is
idempotent, and nothing is ever deleted. A missing file is not a failure — an
archive from an older version, or one assembled by hand, imports whatever it
does carry, and an archive with no version for this migrator is skipped
entirely (the migrator is not mandatory). An archive that carries a version but
none of the files above — the export of a user who never used this app — creates
no account either: a Fediverse identity is something a user asks for.
- the profile —
locked,discoverable/indexable, the fields and the bio — goes back throughAccountService, the same path the API uses. The display name does not: it belongs to the Nextcloud account, the coreaccountmigrator carries it, andAccountServicere-derives the actor's name from it. - the old actor id is recorded in
alsoKnownAs, so aMovefrom the old account is accepted here.movedTois not imported: it would point the new account's own followers somewhere else. - the follows are re-created through the ordinary follow path
(
MigrationService::importFollows()), one handle at a time, and a handle whose server is unreachable is reported without stopping the rest. - blocks and mutes are written straight to the relation table. Resolving a
handle may fetch the remote actor (a signed GET), but no
Blockis federated: the account on the other end was already blocked, and was never told about the move. - bookmarks and favourites are re-marked on the posts this server already has.
A post nobody here has seen is skipped rather than fetched from its origin,
and a favourite is not re-federated as a
Like. - the posts in
outbox.jsonare not replayed into the timeline. Their ids belong to the server they were written on, the threads around them are not here, and minting new ids would either publish years of posts to the Fediverse again or fill the timeline with statuses no remote server can resolve. Mastodon's own import does not restore statuses either. - their files are, onto the posts this server does have: an attachment whose
URL names a
media_attachments/…path in the archive is stored throughDocumentService::storeLocalAttachment()— which is the path the composer's own upload takes, so the type is sniffed from the bytes, anythingfilterMimeTypes()refuses is refused here too, and the metadata is stripped — and the post's stored copy of its attachments is re-pointed at it. Nothing is transcoded: the file in the archive is the one that was stored here, already converted and resized when it was first uploaded. Where the post's picture is still here nothing happens; where the post itself is not here the file stays in the archive rather than becoming a row nothing can show; and where the archive names a file it does not hold, the attachment keeps the address it had, which is whatoriginalUrlis for. This is the case of an archive read back after the files were lost and the rows were not. - the banner goes back through
BannerService::setFromTempFile(), the path that owns it, so the actor cache and the followers are told exactly as they are when it is set by hand. The avatar is the Nextcloud account's, soAvatarService::restoreFromArchive()decides: a picture the account already has is never written over, and only an account still showing its generated initials gets the one out of the archive — the order the migrators of an account import run in is not this app's to depend on.
So the only thing an import sends to other servers is a Follow per followed
account — which is the only way a follow can exist at all — plus the single
Update{Person} that any bio change sends to the account's followers, of which
a freshly imported account has none. No Delete, no Move, no Like, no
Block.
Internal Nextcloud users use Social as they always have. An administrator can also open Social to a limited number of external users: people without a Nextcloud account who register one themselves, log in through the normal login form and reach nothing but Social. The settings are on the Social settings page, in the External users cards, and are all off by default.
They are real Nextcloud users. External\ExternalUserBackend is a user
backend of this app, holding the logins in social_ext_user. Because they are
ordinary users on it, the login form, sessions, remember-me, password reset,
password confirmation, two-factor authentication, the Users page (disable,
delete, the backend column says "Social") and the Mastodon OAuth flow all work
unchanged. The backend does not implement ICreateUserBackend, so nobody
creates one from the Users page or occ user:add; they are made by
ExternalUserService::createAccount() only. The user id is the handle: lower
case letters, digits and underscore, with dot and dash inside, at most 64
characters, not reserved (a built-in list and the administrator's), not
anybody's user id on any backend, not held by a Social actor, deleted ones in
retention included, and not reserved by a pending registration.
External\ExternalGroupBackend exposes one read-only group, social-external,
whose members are exactly the external users. It is hidden from the share
dialog. Core's mandatory two-factor setting is group-based, and an
administrator who restricts Social to groups names the externals with it.
Loaded before login. appinfo/info.xml declares the app
extended_authentication, which every entry point loads before it
authenticates anybody — index.php, ocs/, remote.php and public.php —
and which, unlike authentication, still lets Social be restricted to groups.
Application::boot() registers both backends there. Where Social is
restricted to groups, switching external users on adds social-external to
them, and the settings card says when the restriction leaves them out.
Registration (ExternalSignupService, SignupController, the
social-signup entry). The login page shows a "Create an account" button,
through SignupLoginProvider, while there is room and registration is not by
invitation only. The mode decides who is admitted: open admits anybody,
invite only the holder of an invitation link, approval puts the
registration in a queue on the settings page. A valid invitation admits its
holder in every mode, approval included, and a use is counted when the form is
sent. With email confirmation on, which is the default, the account is made
only when the link in the confirmation email is followed, within 24 hours.
The form asks for the handle, the email, a password (8 characters at least,
and the instance's password policy), acceptance of the server rules and, when
a minimum age is set, confirmation of it. It carries a honeypot field and two
rate limits, 10 an hour per address on the route and 5 registrations an hour
per address in all, and it stops taking registrations while 1000 are
waiting. A pending registration reserves its handle and its email; the
unconfirmed ones are forgotten by Cron\ExternalSignups after a day. Email
domains on the access block list are refused, as they are for every account.
ExternalUserService::createAccount() takes a lock, then checks the room left
under the maximum, the handle and the email again, because the person may have
registered while there was room. It writes the login, dispatches
BeforeUserCreatedEvent and UserCreatedEvent itself, sets the email, sets
every account property to at most v2-local (display name, email and avatar)
or v2-private (everything else) and turns the core profile off, so nothing
about the person is published to trusted servers or the lookup server, makes
Social their default app, and creates the Social actor. When any of that
fails, the user is deleted again. ExternalFirstLoginListener stops the
first-login event for external users before other apps see it, so Files does
not copy the skeleton into their home and no default calendar is made.
What they reach (ExternalScope, Middleware\ExternalScopeMiddleware).
The middleware is registered as a global middleware, so it runs before the
controllers of every app and of core, and it refuses whatever ExternalScope
does not allow: a page is answered with a redirect to Social, anything else
with 403. Allowed are this app, theming, notifications and the two-factor
providers; logging in and out, two-factor challenges, password reset and
confirmation; avatars and the assets every page loads; the core capabilities;
their own user status; and the personal settings sections for their account
details, language, security, appearance and notifications, whose navigation is
cut down to those sections. Everything else is refused, including every app
installed later. Core's profile page, /u/{userId}, is sent to the Social
profile for everybody when it is an external user's, and for an external user
whoever's it is. The app menu, unified search, the contacts menu and the
account menu entries that lead elsewhere are taken off the page by
css/external-scope.css (ExternalPageListener); on a server that dispatches
NavigationEntriesFilterEvent, ExternalNavigationListener also removes the
navigation entries themselves.
WebDAV is not a controller. External\ExternalDavGuard runs in
Application::boot(), which remote.php calls right before it hands the
request to the DAV server, and ends a request with 403 when its session or its
HTTP basic credentials are an external user's: this covers the legacy
/remote.php/caldav and /remote.php/carddav endpoints, which have no hook
of their own and would list the system address book. ExternalDavListener
adds a beforeMethod:* handler to the DAV server of /remote.php/dav and
/remote.php/webdav at priority 15, after Sabre's authentication, which
refuses an external user whatever the credential was. Nextcloud's desktop and
mobile clients, Talk and every CalDAV and CardDAV client are therefore
refused; Mastodon apps work through the OAuth flow of this app.
Who sees them. Inside Social they are local accounts like any other.
Outside it they are invisible: ExternalCollaboratorPlugin, a collaborator
search plugin for user shares, removes them from every people search that
goes through core — the share dialog, mentions, Talk;
ExternalUserStatusListener removes them from the user lists
UserEnumerationFilterEvent filters; ExternalAddressBookListener deletes
their card from the system address book as soon as it is written (the card is
named Social:<uid>.vcf), which keeps them out of the contacts menu and of
every CardDAV client. Administrators still see them on the Users page.
Quota. ExternalMediaQuota is one number for every external user, in MB,
checked in CacheDocumentService::filterQuota() — the one place every local
upload passes — against the stored size of every local document the account
holds. The video quota every account has still applies on top. Media that
federated in is not charged to anybody. The person sees what they use on
their Social settings page (serverData.externalMedia). Social stores media in
app data, so the core per-user quota is not involved.
Two-factor authentication. ExternalTwoFactorService reads and writes
core's mandatory two-factor setting for social-external and keeps its
shape: enforcement for a list of groups gains or loses the group, enforcement
for everybody gains or loses it as an excluded group, and taking the last
group off a list turns enforcement off rather than leaving an empty list,
which core would read as everybody.
Promotion. ExternalUserService::promote() moves an external user to
Nextcloud's database backend with the same user id and password hash, in one
transaction, and removes the login here. Their two-factor setup, preferences
and Social account are keyed by the user id and stay. Cron\ExternalPromoted
writes their system address book card in a later request, which sees them on
their new backend.
Removal. Deleting an external user goes through IUserManager, so the
Social account goes the way it goes for any deleted user. occ social:reset
refuses to run while external users exist, because emptying social_ext_user
would leave Nextcloud users nobody can log in as.
What is enforced
-
Authorized fetch (inbound) — a signature on a GET is verified by
SignatureService::checkGetRequest()and resolved to the account behind it byAuthorizedFetchService::reader(), so what this instance serves can depend on who asked:displayPost()reads the object as that remote account, which is what lets a followers-only post reach the people who follow it from another server. Before this, verification ran on inbox POSTs only, every GET served what an anonymous reader gets, and a follower elsewhere saw a profile with nothing on it — safe, and also wrong. A GET has no body, so the digest and content-length checks that bind one are not asked for; everything else is the POST path's, including the requirement that(request-target),hostanddatebe inside the signature and the replay window on the date. An unsigned GET is not an error — it is the ordinary case, and it gets what it always got. A signature that is present and fails leaves the reader anonymous rather than answering 401: a peer whose clock has drifted, or whose key cannot be fetched at that moment, should still see the public object the route exists to serve. A signer on an instance the access list excludes, and a local actor's key signing an inbound fetch (this instance talking to itself, or a replay of one of our own requests), are both refused as readers. Secure mode —secure_mode, off by default — turns the other half on: an unsigned ActivityPub GET is a 401. It is off by default because switching it on makes this instance invisible to every peer that does not sign, which is a decision about who to federate with rather than something to arrive at by upgrading -
HTTP Signatures on outbound requests — every queued delivery is signed with the sending actor's RSA private key over
(request-target),content-length,date,hostanddigest. Outbound ActivityPub GETs are signed too, byHttpSignatureService::signFetch(), over(request-target) host date— there is no body to digest. Without this, any peer running Mastodon's authorized-fetch or GoToSocial's secure mode answers 401 to every actor, object and collection fetch, which reads as "user not found" when following and as threads that stop at the first remote reply. The signing identity is one fixed local actor rather than whoever is reading, so a remote instance is not told which of our accounts read which of its posts; a peer that rejects a signed GET is retried once unsigned, so nobody becomes less reachable than before. WebFinger, host-meta and NodeInfo stay unsigned -
HTTP Signature verification on inbound requests —
SignatureService::checkRequest()requires(request-target),host,dateanddigestto all be within the signed header set, so the signature binds the body and cannot be replayed against another host; it rejects a missingdate, one older thanDATE_PAST(12 h) or more thanDATE_FUTURE(1 h) ahead — Mastodon's bounds, so a peer with a drifting clock or a slow queue is not refused for good — and, since a window that wide would let a captured delivery be sent again, a request whose exact signature was already taken in, whichActivityPubControlleranswers 200 without processing (isReplayed();rememberRequest()records it in the distributed cache once it has been taken in, so identical bytes resent after a failure are still processed); acontent-lengththat disagrees with the body when the header is sent (a chunked sender omits it, and refusing those outright cost interoperability for nothing), and adigestthat does not match.DigestandContent-Digestare parsed rather than byte-compared, so a lowercase algorithm token, a multi-value digest or an RFC 9530 header is accepted as long as one algorithm we can compute matches. A signature algorithm that is neitherhs2019nor absent is refused by name instead of being assumed to be sha256, which used to fail an Ed25519 key with a misleading message. Thehostverified is the configured one as a peer addresses it,ConfigService::getCloudAuthority()— with the port when the cloud URL names a non-default one, since that is theHosta peer connects to and signs. When the signedhostdiffers from it — which fails every inbound delivery on a multi-domain install or one reached on another port than it is configured with — the log names both. A signature that does not verify, or whose key cannot be retrieved, is refused bycheckRequest()itself (it throws), rather than returning an empty origin for a later check to catch -
Inbound inbox deliveries are rate-limited —
InboxLimitercaps deliveries in two places (inbox_throttleapp setting, default 300 per minute, 0 disables). Before any signature work,assertAllowed()spends a bucket keyed on the source address, which is the one thing about an unauthenticated request the sender cannot choose. After the signature has been verified,assertOriginAllowed()spends a second, looser bucket (HOST_LIMIT_FACTOR, 4×) keyed on the verified origin, which bounds what one instance can send from however many addresses. The second bucket used to be spent up front on the host named in the sender's own unverifiedkeyId— which meant four cheap addresses could fill a large instance's bucket every minute and have its genuine deliveries answered 429, cutting this server off from it. Only a peer that can sign for a host now spends that host's budget -
A delivery is bounded in size —
InboxLimiter::readBody()refuses a body overMAX_BODY(1 MiB) with413, by the declaredContent-Lengthwhere there is one and by a bounded read where there is not. Both inboxes are public and unauthenticated, and theDigestis hashed over the body before the signature is verified — a signature over a body nobody has hashed proves nothing about the body — so until this the only ceiling was PHP'spost_max_size, which a Nextcloud sets to hundreds of megabytes so that file uploads work. Anyone at all could hand every worker in the pool that much to allocate and SHA-256; the rate limit bounds requests, not bytes, and one request was enough to hold a worker for seconds. An activity is a few kilobytes, and a sender with more to say hasCollectionpages to say it in -
LD signatures are bounded in time and replay-checked —
checkObject()refuses a signature whosecreatedlies more thanLD_WINDOW(24 h) from now, and remembers accepted signatures in a distributed cache for twice the window, so a captured activity cannot be re-POSTed indefinitely by an instance that once saw it -
Linked Data Signatures — outgoing Create, Update, Delete, Like, Announce and Undo carry an RsaSignature2017 signature; incoming ones are verified by
SignatureService::checkObject(), which also retries against a refreshed public key. Follow and Accept are not LD-signed -
Instance access control —
FediverseService::authorized()is checked on both inbox routes and on every outgoingCurlServicerequest. It reads one app config value,access_type, which is eitherall_but(the default: everything is allowed unless the host is in the list) ornone_but(only listed hosts, plus the local host, are allowed), together with a single host list inaccess_list. Hosts are compared case-insensitively and without the trailing dot of the absolute form, and the two modes read the list differently on purpose: a deny-list entry covers the domain and everything under it (isListed()), because blockingevil.testwhilewww.evil.testwalks straight back in is not a block; an allow-list entry matches exactly (isExactlyListed()), because a subdomain of an allowed domain is a different instance and whoever runs the parent was never asked.occ social:fediversemanages both -
Outbound requests cannot be steered at the local network. A host that is, or resolves to, a private, loopback, link-local, multicast or otherwise reserved address is refused before anything is sent, unless the instance has set
allow_local_remote_servers(lib/Security/RemoteAddress.php; a name that resolves to nothing is refused too — failing closed). The server's HTTP client then enforces the same rule itself, and re-checks it on every redirect it follows, which is whyCurlServiceleaves redirect handling to it; onlyhttp/httpsare ever followed, so afile://orgopher://location cannot be reached. The banner-by-URL endpoint applies the same rules and a size ceiling before fetching -
JSON-LD contexts are served only from the copies shipped in
context/. Signature normalisation never resolves a document's@contextover the network, so a remote activity cannot make the server open an arbitrary URL and cannot substitute the bytes a signature is computed over; an unrecognised context makes the LD signature unverifiable rather than triggering a fetch -
HTML sanitisation — remote HTML reaches local timelines, so
ACorerunslib/Security/HtmlSanitizer.phpover everyAS_CONTENTfield it imports, and strips tags from string, username and account fields. The frontend sanitises again with DOMPurify insrc/utils/sanitizeHtml.js -
Actor private keys are encrypted at rest —
social_actor.private_keyholds the PEM encrypted with the instance secret (ICrypto, viaPrivateKeyCipher), so a database dump alone is not enough to impersonate a local actor — it also takes thesecretfromconfig.php. Rows written before encryption existed (recognisable by their-----BEGINprefix) are still readable and are rewritten once by theEncryptPrivateKeysrepair step on upgrade -
Client secrets, authorization codes and access tokens are stored hashed —
sha256:<hex>digests (SecretHasher). A presented secret that already carries that prefix is never looked up as a legacy plaintext row, so the stored digest is not itself a working credential: offering it for lookup made a database dump, a backup or a read-only SQL flaw hand out usable tokens, which is the one thing hashing them is for -
Self-signed certificates — TLS peer verification is skipped only when the
allow_self_signedapp config value is1 -
My interests learns only from what the reader may read, and shows it to nobody else. A signal names a post by id and nothing else; the hashtags are read from the stored post through the same visibility, block and mute filter the timelines use (
StreamRequest::getVisibleByNids()), because the interests it produces are shown back on the reader's settings page — a signal naming a stranger's followers-only post would otherwise put that post's hashtags there. No raw signal is kept, nothing is federated, and every route is the viewer's own. Learning is on by default for a reader who has not chosen (interests_default), with a one-time notice in the web interface; an administrator for whom profiling from reading needs consent turns the default off -
External users reach Social only — every controller of every app is checked against
ExternalScope's allowlist by a global middleware before it runs, and WebDAV byExternalDavGuardandExternalDavListener; hiding the menus is not what keeps them out. See External users.
Known gaps — these are real and deliberate to record
- The federation endpoints are readable by anyone.
ActivityPubController::actor(),actorAlias(),outbox(),followers(),following()anddisplayPost()all carry#[PublicPage]with#[NoCSRFRequired]. This instance does not require a signed fetch of its own collections, so any anonymous caller can read a local actor's profile, outbox, follower and following collections and individual posts. (Outbound fetches this app makes are signed — see above; the two directions are independent.) - The older dual blacklist/whitelist implementation in
FediverseServiceis commented out. What remains is the single-listaccess_type/access_listmechanism described above FediverseService::getKnownAddresses()returns an empty array unconditionally- The base URL is set once.
ConfigService::setCloudUrl()will overwrite it, but stored actor and stream ids embed the old URL, so changing it in practice requiresocc social:reset
appinfo/info.xml claims 35 and 36, and both are meant, so a change has to
hold on either.
- Analysis runs against the newer one.
nextcloud/ocpis a dev dependency pinned todev-master, which tracks the unreleased server — 36 until it branches off asstable36. Psalm therefore sees the API the app will meet next, not the one it met last. - The unit suite runs against whichever OCP is installed. It is standalone:
tests/bootstrap.phploads the interfaces from that package rather than booting a server. Where the two versions differ in a way a test can see, the test asks rather than assumes —ApiControllerTest::userFolderMock()is the example, because 36 narrowedIRootFolder::getUserFolder()fromFoldertoIUserFolderand PHPUnit checks a stubbed return value against the declared type. - CI runs both servers. The version matrix is derived from
info.xml, so the PHPUnit workflows install the app on each supported server and run the integration suite there, and the browser tests run twice, once per version. - Deprecations are treated as breakage. 36 logs
Calling IQueryBuilder::orX without parameters is deprecated and will throw soon; the conditions that are assembled in a loop (limitToQueueDue(),limitToSyncDue(), the trend windows) build their parts into an array and pass them in one call.tests/Db/FakeExpressions.phprefuses an empty composite outright, so the old shape fails the suite.
This file, docs/API.md and docs/OCC-Commands.md describe the current implementation. They must be updated in the same change as the code they describe.
tests/DocumentationTest.php mechanically enforces the parts that can be checked, in both directions where that is possible:
- the registered occ commands, and every option and argument each of them declares — a documented flag that does not exist, and an existing flag nobody documented, both fail;
- the HTTP routes the app registers — the
#[FrontpageRoute]attributes on the controllers, read by reflection the way the server reads them, plus what is left inappinfo/routes.php— against the route tables ofdocs/API.md; - the repair steps of
appinfo/info.xmlagainst the integration table above; - the tables declared in
CoreRequestBuilderagainst the schema table above; - the supported Nextcloud and PHP version ranges, and the app version stated at the top of this file, against
appinfo/info.xml; - two claims of absence, which is the direction the rest of it is blind in: a sentence saying there is no
/some/routemust be true, and a symbol the docs call "commented out" may not be called by live code. The false claim that key-pair rotation was unavailable, published six lines after the flag that performs it, is what these were written for.
Everything else is on the author of the change. In particular nothing can check a paragraph of prose against the behaviour it describes, so a feature described in words the "not implemented" guard does not recognise, or a mechanism described plausibly and wrongly, still gets through.