A site's cloud (« le Partage », « Cloud » on the pages): an index of folders and files, as a web
server's "Index of /", with a little more - who each folder is for, whom it is shared with by
name, a few words above it, its README under it, tags to filter by, a search through every folder,
previews, a folder as one zip, download counts, workspaces with their people and tasks - managed by
the site's team on the page itself. It is a Symfony bundle (Base\Drive\DriveBundle) on the core
3 or 4.
Proposer une modification docs/index.md
Installation
The bundle
composer require omnibase/drive
// config/bundles.php
return [
// ...
Base\BaseBundle::class => ['all' => true],
Base\Drive\DriveBundle::class => ['all' => true],
];
DriveBundle aliases Base\Drive\Entity\* to App\Entity\Drive\* and the repositories to
App\Repository\Drive\* unless the application declares its own classes there; its templates/
is @Drive, its translations/ add the share.* and drive.admin.* keys to the messages
domain.
The routes
# config/routes.yaml
drive_controller:
resource: "@DriveBundle/src/Controller"
type: attribute
prefix: /
The page's path is the site's: the key app.share of config/routes_paths.yaml (omnibase's
localized route paths), which the bundle's controller requires. Its routes are named app_share
(GET: a folder, a file, ?preview, ?download, ?zip, ?q) and app_share_manage (POST: the
team's actions and a workspace's). The back office's Google Drive page is /admin/drive
(app_admin_drive, with omnibase/admin and google/apiclient).
# config/routes_paths.yaml
app.share:
fr: /cloud/{path}
en: /cloud/{path}
The storage
The files are on a flysystem storage of the site's, local.share by default
(drive.local_storage), never under public/: they are read through the controller, which asks
the voter. Give the tests a folder of their own:
flysystem:
storages:
local.share:
local:
directory: '%kernel.project_dir%/var/storage/share'
when@test:
flysystem:
storages:
local.share:
local:
directory: '%kernel.project_dir%/var/storage/share-test'
The service drive.storage (alias documents.storage) is that storage, or the site's Google
Drive folder once connected (Google Drive).
The tables
sharedPath (what the team says of a path), sharedPathMember (its people), shareTask (a
workspace's tasks), shareDownload (download counts), shareGroup and shareGroupMember,
sharedPathGroup, sharePersonalTag (with drive.personal_tags), shareRetention (the retention
log, with drive.retention). The names are fixed (#[ORM\Table]): a site
that had its own copy of these entities keeps its tables and their rows. The people are accounts
of App\Entity\User, as in the core's own entities. Generate the application's migration:
bin/console make:migration
bin/console doctrine:migrations:migrate
Proposer une modification docs/installation.md
Concepts
Paths
A path is relative to the drive's root, /-separated, without slashes at its ends; "" is the
root. FileShare::normalize() refuses .., . and any part beginning with a dot: hidden files
(.git, .htaccess) are never listed, served, searched nor zipped. FileShare::name() makes a
typed or uploaded name fit (its last part, without what a path cannot hold).
What the team says of a path
Entity\SharedPath, one row per path the team said something of: its audience (a key), a few
words, its tags, whether it is listed, whether it is a workspace (its people, its tasks), whether
its people may bring files (deposit). A path without a row is as its nearest folder with one; the
root as drive.default_audience says. Tags add up: a path has its own and all its folders'.
Audiences
Who a path is for, by key, stored as a string (sharedPath.audience, 16 characters). The bundle's:
| Key | Lets in |
|---|---|
readers |
everyone, signed in or not |
users |
the signed-in accounts |
members |
the people of the workspace the path is in |
staff |
nobody else (the staff see everything anyway) |
A site adds its own (customising). A key no audience answers to any more is read
as staff.
Who sees what
Security\Voter\ShareVoter:
SHARE_MANAGE: the staff (drive.staff_role), who also see everything;SHARE_VIEW(the path as subject): the people the path is shared with - the members of a workspace in it or above it -, whatever its audience; otherwise its audience'slets();SHARE_WORK(the path as subject): a workspace's people - its tasks, and the files they bring when the team allowed it;SHARE_ALTER(the path as subject): delete, rename or move it - the staff, and nobody where the retention lock keeps it;SHARE_RETAIN(the folder as subject): lift its retention lock or put it back - the superadmin (drive.retention_role), withdrive.retentionon.
Someone signed in whom the root is not for has a root of their own: the highest folders they see
(FileShare::tops()), and no crumb of a folder above them.
Sharing by name
« Partager » (the team): a folder shared with an account becomes a workspace if it was not one,
keeps its audience, and is theirs to see from their own access, read only unless its people may
bring files (FileShare::shareWith(), isSharedWith()).
Each person's space, and sharing on upload
A person's space is the folder their role gives them - a tenant's, an owner's, a partner's. Where
it is, a bundle says (Space\SpaceProviderInterface, tagged drive.space: the first provider to
answer wins - omnibase/estate's tenants, owners and co-owners); nobody answering, it is
drive.spaces_root/<their name>, the team's and theirs. Space\Spaces::of() makes it when
missing, reuses it otherwise, and shares it with them, read only.
Files the team adds - the upload form, a drop on the page, on a folder's line or on the empty
folder's line - may be shared at once with named people and groups (share_with[]: user ids, or
group:<id>): besides the folder they were added to, a copy goes into each person's space (a
group: each of its people's), beside what is there, never over it, and none where the file already
is in their space (Spaces::deliver()). Read only for them, unless the uploader ticks
share_deposit: they may then bring files into their space too (a right given, never taken back
by sharing again). Only the team shares on upload; a workspace's people only bring files.
The retention lock
With drive.retention on, every folder and what it holds is kept: nothing deleted, overwritten,
renamed nor moved, whoever asks - the page, the commands (drive:move refuses, drive:import
replaces nothing and says so) and the bundles filing into the drive all go through FileShare,
which throws Share\LockedException; ShareVoter answers SHARE_ALTER no, and the pages offer
no delete, rename nor move on what is kept. Adding files stays open.
The superadmin lifts it per folder - until it is put back, for a while (a number of hours or
days), or until a date (that day included) - and puts it back, from the folder's access dialog
(retention_lift, retention_lock), where the log is listed. Each lift and each lock is a line of
shareRetention (Entity\RetentionEvent: who, when, which folder, until when, and why - a reason
is required), only ever added to. A folder is as the nearest of itself and its folders whose
latest line says something: a lock, or a lift still running. A lift whose time is up says nothing
any more: the lock is back by itself, read so when asked, with no task to run. A folder goes
(deleted, moved) only with all it holds: a lock put back on a folder inside a lifted one keeps
both. A lift is on a path: a lifted folder renamed or moved is kept again at its new place.
Listed or not
A folder listed is shown on the root and open to search engines; not listed (the default), its
pages and files answer X-Robots-Tag: noindex. With drive.listed_root, the root and the search show
whoever is not of the team only what is listed (and the workspaces they are of): the rest opens by
its link only.
Downloads
A file served counts once (not a Range request, nor a ?preview); a folder's zip counts for the
folder. A folder's total is everything under it; the root's total is the team's to see.
Proposer une modification docs/concepts.md
The site's own
Its look: the templates
The bundle's pages are plain: @Drive/share/index.html.twig (the drive) and
@Drive/admin/drive.html.twig (the back office's Google Drive page), each made of blocks
(drive_header, drive_toolbar, drive_deposit, drive_list, drive_readme, drive_workspace,
drive_manage; drive_where, drive_connect, drive_tree). A site gives them its look:
- by overriding the file:
templates/bundles/DriveBundle/share/index.html.twig; - or by extending it (
{% extends '@!Drive/share/index.html.twig' %}) and redefining blocks.
Its styles and its Stimulus controllers (drag and drop, in-place navigation, list or cards, the
preview window) stay the site's. The variables a page receives are listed at the top of the
bundle's template; audience, each entry's audience and each of audiences is an
AudienceInterface (key, label, icon), row.audience a key.
Its words and its name
The words are translation keys of the messages domain: share.* on the page, drive.admin.*
in the back office, French and English. A site rewrites any of them in its own
translations/messages+intl-icu.<lang>.yaml - only what it says its own way - or through its back
office's texts when it has some: the drive's name is share.title (« Cloud »).
Its audiences
An audience is a service implementing Base\Drive\Audience\AudienceInterface, autoconfigured
(tag drive.audience): its key (what the column holds), its label (a translation key), its icon,
and whom it lets in.
final class AssociatesAudience implements AudienceInterface
{
public function __construct(private readonly Spaces $spaces) {}
public function key(): string { return 'associates'; }
public function label(): string { return 'share.audience.associates'; }
public function icon(): string { return 'fa-solid fa-people-group'; }
public function lets(?UserInterface $user, string $path, FileShare $share): bool
{
return $user instanceof User && [] !== $this->spaces->associateships($user);
}
}
« Pour qui » lists the bundle's readers, users and members, then the site's (by
#[AsTaggedItem(priority: ...)]), then staff last.
Its people
Whom a folder may be shared with: Base\Drive\People\PeopleInterface - groups of
user id => label, each group's name a key share.people_group.<group>. The bundle's lists every
account; a site says who its people are:
#[AsAlias(PeopleInterface::class)]
final class People implements PeopleInterface { /* tenants, associates, contractors, team... */ }
Its people's spaces
Where a person's space is - the folder a file shared with them on upload goes into:
Base\Drive\Space\SpaceProviderInterface::spaceOf(User): ?string, the path, the folder made if
missing and shared with them; null for someone who is none of its people. Autoconfigured
(drive.space), asked by priority (#[AsTaggedItem(priority: ...)]); nobody answering,
drive.spaces_root/<name>. omnibase/estate's Drive\EstateSpaces answers for tenants
(Locataires/<name>), owners under management (Propriétaires/<name>) and co-owners
(Indivisions/<ownership>).
The page gets people and share_groups to offer on upload (share_with[], share_deposit, on
the upload form - meyers ties its checkboxes to it with form="...", and its drop controller sends
them with each file dropped).
Its retention lock
The page gets retention, locked (this folder), lift (the RetentionEvent it is open by, or
null), retain (the superadmin) and retention_log, and each entry locked (it, or something it
holds): hide delete, rename and move on what is locked, and give the superadmin the lift and lock
forms (reason; until: open, duration with duration and unit - hours or days -, or
date, Y-m-d). The words are share.retention.*.
Its folders
Base\Drive\Tree\TreeInterface: a layout of folders (path => audience key or null) the back office
creates where the files are (« Créer les dossiers »), or the site's fixtures through
Tree\DocumentsTree::create(). Without one, the page offers none.
Proposer une modification docs/customising.md
Configuration reference
drive:
drive:
user_class: App\Entity\User
staff_role: ROLE_STAFF
default_audience: staff
local_storage: local.share
listed_root: false
team_tags: everyone
personal_tags: false
slugs: false
spaces_root: Spaces
retention: false
retention_role: ROLE_SUPERADMIN
zip_name: drive
| Option | Default | Read by |
|---|---|---|
user_class |
App\Entity\User |
the accounts a folder is shared with (ShareController, People\AccountPeople) |
staff_role |
ROLE_STAFF |
ShareVoter: who manages the drive and sees all of it |
default_audience |
staff |
FileShare::audience(): who a path is for when neither it nor a folder above it says - staff keeps everything private until a folder is opened, readers opens the drive to everyone |
local_storage |
local.share |
Storage\DocumentsStorage: the flysystem storage the files are on until a Google Drive is connected, and always in the tests |
listed_root |
false |
ShareController: the root lists, to whoever is not of the team, only what the team listed and the workspaces they are of, and a search finds only that; the rest opens by its link (a studio's public drive) |
team_tags |
everyone |
ShareController: who sees the team's tags on folders and files, and filters or finds things by them - everyone who sees the folder (a public drive's categories), or team: the team's own way of sorting, which nobody else knows of |
personal_tags |
false |
Share\PersonalTags: whoever is signed in puts tags of their own on the folders they see (my_tag_add, my_tag_remove, my_tag_color), nobody else seeing them, the team neither; they need the sharePersonalTag table (a migration in the site) |
slugs |
false |
ShareController and |drive_slug: addresses by slugs, /cloud/assemblees-generales/2026/proces-verbal.pdf, a file keeping its extension; an address by names still leads there (FileShare::resolve()), redirected (301) to its slugs. A site's template writes its links path('app_share', {path: entry.path|drive_slug}) |
spaces_root |
Spaces |
Space\Spaces: the folder a person's space is made in when no provider says where it is (<spaces_root>/<their name>); a file shared on upload with them goes there |
retention |
false |
Share\Retention: the retention lock - nothing in the drive deleted, overwritten, renamed nor moved, by anyone, the team included, until the superadmin lifts it for a folder; it needs the shareRetention table (a migration in the site) |
retention_role |
ROLE_SUPERADMIN |
ShareVoter's SHARE_RETAIN: who lifts the lock and puts it back |
zip_name |
drive |
the name of a zip of the root |
Each option is also a container parameter (%drive.default_audience%...).
Services a site may replace
| Interface | The bundle's | A site's own |
|---|---|---|
Base\Drive\People\PeopleInterface |
AccountPeople: every account, one group |
#[AsAlias(PeopleInterface::class)] on its class |
Base\Drive\Tree\TreeInterface |
none: the back office offers no layout | #[AsAlias(TreeInterface::class)] on its class |
Base\Drive\Audience\AudienceInterface |
readers, users, members, staff | any number, autoconfigured (drive.audience) |
Base\Drive\Space\SpaceProviderInterface |
none: drive.spaces_root/<name> |
any number, autoconfigured (drive.space), asked by priority - omnibase/estate's Drive\EstateSpaces |
Proposer une modification docs/configuration.md
Google Drive
With google/apiclient and masbug/flysystem-google-drive-ext installed, the files may live in a
folder of the site's Google Drive, connected from the back office (/admin/drive, omnibase/admin,
ROLE_ADMIN):
- Google Cloud Console: enable the Google Drive API, create an OAuth client "Web application",
its redirect URI the page's
app_admin_drive_callback, and publish the consent screen (Google drops the token after seven days otherwise). GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETin.env.localor the secrets vault.- « Connecter Google Drive », then pick or create the folder.
Storage\DriveSettings keeps the refresh token in omnibase's settings, sealed with a key derived
from the app secret, with the folder and the account (app.drive.*: the names sites stored them
under before the bundle). Storage\DocumentsStorage answers the Drive folder once connected, the
site's storage otherwise - and always in the tests; Google unreachable, it falls back and logs why.
Nothing moves by itself between the two.
Proposer une modification docs/google-drive.md
Commands
Each keeps the name sites knew it by as an alias. With the retention lock on (drive.retention),
nothing kept is moved nor replaced: drive:move fails, drive:import leaves a file of another size
as it is, says so and ends in failure.
| Command | Alias | Does |
|---|---|---|
drive:import <directory> [target] |
app:share:import |
a folder of files into the drive as it is; a file already there with the same size is left |
drive:move <destination> <paths>... [--drop-tags] |
app:share:move |
files and folders into another folder, what was said of them and their downloads following |
drive:workspace <name> [--audience=members] [--description=] [--tag=]... |
app:share:workspace |
a workspace at the root, created or a folder made one |
drive:count <path> <count> [--add] |
app:share:count |
a path's downloads set from statistics kept elsewhere |
Proposer une modification docs/commands.md
Testing
The bundle's tests are units - no kernel, no database: FileShare on a temporary local storage
(paths, listing, audiences and their inheritance, listed, tags, search, each person's root,
sharing, zips), the audiences, the voter; Space\SpacesTest (an upload shared with a person and a
group: their spaces made, then reused, read only unless they may bring files); Share\RetentionTest
(a lock refusing delete, overwrite, rename and move, the team included; a lift by the superadmin
with its log, refused to anyone else; a lift for a while expiring by itself, on a MockClock; a
lock put back inside a lifted folder).
composer install && vendor/bin/phpunit
Inside a host's container, with its vendor (/srv/app/vendor, or DRIVE_AUTOLOAD):
docker exec -w /srv/omnibase/drive meyers-web-1 php /srv/app/vendor/bin/phpunit --do-not-cache-result
The pages are proven by the sites' functional tests (meyers' tests/Functional/ShareTest.php).