Architecture
The code is organized to explicitly separate the local application domain
from the ActivityPub representation and federation mechanics, as required by
the project design:
app/
Domain/ # Local application domain
Accounts/
Profiles/
Posts/ # Posts, attachments, hashtags, mentions, text rendering
Comments/ # Comments (top-level and nested replies)
Events/ # Federated events, locations, RSVP and comment threads
Reactions/ # Likes and shares (Like/Announce at the local level)
SocialGraph/ # Follows between Actors (local and remote)
Notifications/ # Local notifications (not federated)
Federation/ # Everything related to ActivityPub
Actors/ # Actors (local and remote), RemoteActorResolver (fetch + WebFinger)
Inbox/ # Raw InboxItem + InboxActivityProcessor (semantic processing)
Resolution/ # ObjectResolver: ActivityPub URI -> local Actor/content
Delivery/ # ActivityDelivery: fan-out of outgoing activities to remote inboxes
Serialization/ # ActorSerializer, NoteSerializer, CollectionSerializer, ActivitySerializer
Jobs/
Federation/ # ProcessInboxActivityJob, DeliverActivityJob ("inbox"/"delivery" queues)
Infrastructure/ # Cross-cutting technical details (DB, security, installation, media)
Database/
Installation/
Security/
Http/ # SsrfGuard, SafeHttpClient, DnsResolver: remote fetch protected from SSRF
Media/ # Upload, validation, thumbnails (Media, MediaVariant, MediaUploader)
Application/ # Application services that orchestrate the domain
Services/
Queries/ # Complex read queries (e.g. FeedQuery)
Http/ # Controllers, HTTP requests, middleware
Policies/ # Centralized authorization (PostPolicy, CommentPolicy)
Controllers contain no domain logic: they validate the request, check
authorization/authentication, call an application service (in
app/Application/Services), and return the response. For example, complete
account creation (user + profile + settings + ActivityPub Actor + RSA key pair
+ endpoints) is wrapped in a single transaction by
App\Application\Services\AccountRegistrar, used by the registration
controller, the installer, and the openbook:make-admin CLI command. In the
same way, PostComposer, CommentComposer, FollowManager, ReactionManager,
and AnnounceManager each wrap a single domain operation in a transaction,
updating denormalized counters and creating the relevant notifications via
NotificationCreator. From Phase 4 onward, each of these services also calls
ActivityDelivery after the transaction commit, when the actor performing
the action is local and the recipient (or the followers) involve at least one
remote Actor: that is the only point where domain logic "knows" about
federation, and it remains an after-the-fact addition, never a condition for
the local action to succeed.
Every local account has from the start an ActivityPub Actor of type Person
(tables actors, actor_keys, actor_endpoints), exposed to the Fediverse
(see Federation below). The local social domain is
modeled with federation in mind: follows and likes connect Actors (not
users), so remote actors can be accepted without schema changes.
Actor private keys are encrypted at rest (Eloquent encrypted cast, based on
APP_KEY) and are never exposed by APIs, logs, or error messages.
Posts, comments, and reactions
- The body of posts and comments is text with broad Markdown (GFM), rendered to
safe HTML by App\Domain\Posts\PostBodyRenderer (raw HTML stripped, external
links with restrictive rel, Markdown images ignored). Hashtags and mentions
are linkified after conversion; HTML content of remote posts (Phase 4) is
reduced to plain text before entering the same pipeline (see
- Comments live in a dedicated table (
comments), separate fromposts, with
parent_comment_id for nested replies: a post's entire tree is loaded with a
single query and rebuilt in memory. They can have image attachments like
posts (comment_attachments, same MIME/size limits).
- Likes (
likes) and mentions (mentions) are polymorphic relations, already
ready to apply to both posts and comments.
- Shares (
announces) never duplicate the original post: they are a simple
"actor shared this post" reference, which the feed uses to show the content
also to people who follow the sharer (not the original author). They also
appear in the personal feed and on the sharer's profile (FeedQuery), with
the "shared this post" label above the card — ordered by the share time, not
the original post's publication date, so a recent share of an old post still
appears at the top. Sharing your own post does not add the label (it would be
redundant: it already appears as your own post).
- Counters (
likes_count,comments_count,announces_count) are
denormalized on post/comment rows and updated transactionally, to avoid heavy
counts on every feed request.
- The feed (
App\Application\Queries\FeedQuery) unions your own posts, posts
from people you follow, and shares made by people you follow, respecting
visibility (public, unlisted, followers-only, direct) and with no
recommendation algorithm: always reverse-chronological order.
Profile customization and account settings
From the earliest phases the database already had columns for account
customization (profiles.avatar_path/cover_path/bio/links,
user_settings.locale/default_post_visibility/manually_approves_followers
/discoverable); the Settings page (/impostazioni, link in the user menu
and "Edit profile" button on your own profile) makes them editable:
- Public profile: display name, biography (max 500 characters), up to 4
labeled links, avatar and cover image. Image upload (ProfileImageUploader)
validates the file's actual type (never the extension alone), strips EXIF
metadata, and resizes with GD when available (avatar max 512px, cover max
1600px on the longest side), reusing the same base logic already used for
post attachments (ManipulatesImagesWithGd, trait shared with
MediaUploader). The previous file is always removed when a new one is
uploaded, so orphan copies do not accumulate on quota-limited hosting.
Changing the display name also updates the local ActivityPub Actor's name
field, which is the value actually exposed to remote servers
(ActorSerializer). Every change to the public profile (name, biography,
links, avatar, cover) or to the "Protected account" option also sends an
ActivityPub Update to all remote followers
(ProfileUpdater/AccountPreferencesUpdater +
ActivityDelivery::deliverToFollowers()), with the full updated Actor
document as the embedded object: without this step remote servers would keep
showing a stale copy of the profile until their local cache expired (up to
openbook.federation.actor_cache_ttl_hours, 24 hours by default).
Symmetrically, an Update with a Person/Group object received from
another instance (because a remote user changed their profile) immediately
updates the cached copy of their Actor
(InboxActivityProcessor::handleUpdateActor()), applying the embedded
document directly instead of waiting for a new fetch; it is accepted only if
the id declared in the document matches the Actor that signed the request, so
nobody can update someone else's profile. Before saving, the chosen file is
shown immediately as a preview (client-side, via FileReader, with no
upload). Profile::avatarUrl()/coverUrl() build the public URL through the
configured "public" disk (Storage::disk('public')->url()), as already
happens for post attachments (Media::url()), instead of relying on the
asset() helper: the latter depends on the scheme/host detected on the
individual request and can produce inconsistent URLs (e.g. http:// instead
of https://) behind a proxy or load balancer that does not correctly report
the original scheme.
- Interface language: each user can choose among the languages listed in
config('openbook.locales') (Italian and English at the moment). The
SetUserLocale middleware, applied to all web requests, sets the app
language from user_settings.locale for authenticated users; visitors who
have not signed in yet see the language deduced from the browser's
Accept-Language header (Italian if preferred, English in every other case),
so the public homepage already appears in the right language before
registration. A request without that header (never a real browser, typical of
crawlers/monitoring) is not forced and stays on the instance default language
(app.locale).
- Default visibility of new posts: the visibility selector in the composer
now uses user_settings.default_post_visibility as the initial value (the
panel opens automatically if the default is not "public"), remaining
changeable post by post.
- Protected account: the "Protected account" checkbox updates both
user_settings.manually_approves_followers and (the column actually read by
FollowManager) actors.manually_approves_followers, so the two stay
consistent.
- Presence in suggestions: turning off "Include my account in suggestions
and search results" (user_settings.discoverable and actors.discoverable)
stops the account from appearing in the sidebar "People to follow" box, and
the federated Actor document declares discoverable: false (Mastodon
directories and similar). It remains reachable directly, for example via
federated search of the exact address.
- Search indexing (FEP-5feb): the "Allow my public posts to be indexed for
search" checkbox (user_settings.indexable / actors.indexable, off by
default) is the ActivityPub profile indexable consent. Other people's
public posts and comments appear in local search only if the author enabled
the option; the author still finds their own content.
- "This instance" box: it no longer shows the subscriber count (a figure
that needlessly exposes the instance's real size), but the most used tags
recently by the local community (App\Application\Queries\PopularHashtagsQuery):
only hashtags on posts published by *local* Actors, with public or unlisted
visibility, never from remote content merely in cache or from posts reserved
to followers/direct recipients.
- Followed hashtags: authenticated users can follow or unfollow tags from
a hashtag page or the full trends page. Public posts already known to the
instance then join the personal Home feed, with the existing timeline
deduplication and cursor pagination. This is a local preference rather than
a federated ActivityPub follow: it does not discover content the instance
has not received. Followed tags stay private and are shown only to their
owner, mixed chronologically with Actors in the owner's "Following" list.
- Image lightbox: clicking an image attached to a post or comment opens a
full-screen overlay with the original at full resolution (previous/next
arrows if the post has more than one, close with Esc, click outside the image,
or a dedicated button). No external library: shared markup in layouts.app
and a single script (public/assets/js/lightbox.js) that delegates events
across the page, so it works the same on the feed, profile, single-post page,
and World section. This was also the chance to finally use in the feed the
thumbnail already generated at upload (MediaUploader, unused until now):
the in-post preview shows the thumbnail in full (never cropped: `object-fit:
contain` with a neutral background filling any side/top bands when the
aspect ratio does not match the box); the lightbox instead loads the original
at full resolution via the data-full-src attribute and shows it as large as
possible without ever enlarging it beyond its natural size.
- Static asset versioning (`App\Support\Assets`):
app.cssand
lightbox.js are served from public/ with no build pipeline (no
Vite/webpack, to stay compatible with shared hosting): without a query string
that changes on every modification, the browser can keep serving a stale copy
of the file even after a software update (typical cause of "I updated but
nothing changes", or worse of new markup paired with old CSS/JS behaving
inconsistently). Views now reference these two files through
App\Support\Assets::url(), which automatically appends
?v=<last modification of the file> to the URL.
- Infinite scroll instead of numbered pagination: feed, World, profile
(local or remote), and hashtag pages no longer show page arrows/numbers at
the bottom of the post list. When the user nears the end of the page,
public/assets/js/infinite-scroll.js downloads the next page in the
background (the same ?page=N URL as always) and appends only its posts to
the current list, with no dedicated route/API and no external library.
Classic pagination remains available inside a <noscript>, for people
browsing without JavaScript. Setting data-infinite-scroll and
data-next-url on a post container is enough for the script to activate: see
resources/views/posts/_feed.blade.php, the partial shared by all these
pages. This was also the chance to give FeedQuery/HashtagController a
truly deterministic sort (ORDER BY ... , id DESC): without a tie-breaker,
two posts published in the same second could end up duplicated or skipped
when moving from one page to the next, a defect already present with classic
pagination but much more visible with continuous scrolling.
- Post card: like / comment / share are icons only (with the numeric
counter beside them; the texts remain as aria-label for accessibility).
Deletion is no longer in line with the other actions: it appears only for
*your own local posts* (never for cached remote Notes, nor for an admin:
PostPolicy::delete rejects posts with uri set) and lives in a
three-vertical-dots menu at the top right of the card
(<details class="ob-post__menu">, with post-menu.js to close on outside
click). On remote posts, clicking the timestamp opens the original
ActivityPub uri in a new tab (target="_blank" rel="noopener noreferrer");
on local posts it still goes to the Openbook post page. The same pattern
(icons + three-dot menu for Delete, never on remotes) is applied to comments
too (comments/_comment.blade.php, CommentPolicy).
- Navbar: the bell icon opens a dropdown with recent notifications (the
full page remains in the left sidebar); the search icon opens an inline input
instead of going straight to /cerca (submitting the form still uses the
same local/federated search). Dedicated script:
public/assets/js/header-panels.js. On desktop, after scrolling past the
composer, a + button appears in the center of the header that returns
focus to the composer (or to Home if you are elsewhere); on mobile the same
control is a discreet FAB at the bottom right (compose-shortcut.js).
- Emoji: in post and comment composers (including replies) a smile icon
opens a local Mastodon-style picker (categories, search, recents in
localStorage). System-native Unicode only, no CDN / Twemoji
(emoji-data.js + emoji-picker.js).
- Reports: from the three-dot menu of every post that is not yours (local
or remote) you can open a local report (reason + optional details), stored in
reports and handled by the control panel (/admin/segnalazioni). It is not
federated; you cannot report your own post. Throttle on
POST /posts/{post}/segnala.
- Control panel (
/admin, v0.5.0–0.6.0): accessible to administrators and
moderators (is_admin / is_moderator). Includes dashboard, report queue
for posts and comments (review / archive / action, with optional soft-delete
of local content only), local user management (suspension / disable;
promoting moderators and admins), instance settings (site_name,
registration_open, Markdown rules and privacy policy, post/comment/media
limits), federated domain blocks, federation queue inspection, and an action
log. CLI still available: openbook:make-admin / openbook:make-moderator.
- Video embeds: if a post body contains a YouTube link (
youtube.com,
youtu.be, Shorts, ...) or PeerTube (/w/..., /videos/watch/...), an
iframe player is shown under the text (only the *first* video link in the
post). YouTube uses youtube-nocookie.com; PeerTube is recognized from the
path shape typical of instances (VideoEmbedFinder).
- Hashtags in bios: hashtags (and URLs/mentions) in local and remote
profile biographies are linkified with the same PostBodyRenderer used for
posts and comments; this also applies to the bio snippet in search results.
On remotes the HTML summary is first reduced to plain text
(RemoteContentSanitizer::toPlainText).
- Quote sharing: the share icon on the card opens a menu with *Share
directly* (Announce / boost, as before) or *Share with quote*. The quote
takes you to the Home composer with the original post nested under the text;
on publish a new post is created (quoted_post_id) that in the feed shows
the original card inside its own. The quote also increments the original's
share counter (the same announces row as a direct share; if the user had
already shared, it is not doubled). Outgoing federation: quoteUrl on the
Note plus a fallback link in content, and Announce to followers. Comments
have no share (only like/reply).
"World" section
A new "World" item in the left sidebar (/mondo) gives a window onto what
arrives from the rest of the fediverse toward this instance. **It is not and
cannot be a complete index of the fediverse**: Openbook neither crawls nor
actively indexes it, so this page shows only what has already been cached
locally because it is relevant (InboxActivityProcessor::isRelevant() —
author followed by a local Actor, reply to already-known content, or mention
of a local Actor). That limit is stated in the interface, not a hidden
implementation detail.
- Timeline: all public posts of *remote* Actors already in cache
(FeedQuery::world()), ordered by descending publication date, regardless of
who follows them (unlike the personal feed). Local posts do not appear: they
already live on Home.
- Accounts to discover: a small list of proposed remote Actors
(PopularRemoteActorsQuery), with the same "Follow" button used elsewhere
(actors.follow). If there are more than five, a "See more" link opens
/mondo/scopri with the paginated list and infinite scroll. There being no
authoritative follower count for a remote Actor (nor an index of "real
popularity" in the fediverse), the ranking uses only signals visible from
this instance, in order: how many local Actors already follow them
(follows.status = accepted), then the date of their most recent public post
in cache. An Actor with neither signal (never followed locally, never a
public post in cache) is not proposed, and anyone already followed by the
visitor is excluded.