Skip to content

omnibase/drive

The drive application for omnibase: a site's own cloud of folders and files - who each folder is for, sharing by name, workspaces with their tasks, search, previews, zips, download counts - on the site's storage or its Google Drive

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/drive:dev-main

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.

Propose a change 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

Propose a change 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's lets();
  • 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), with drive.retention on.

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.

Propose a change 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.

Propose a change 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

Propose a change 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):

  1. 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).
  2. GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env.local or the secrets vault.
  3. « 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.

Propose a change 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

Propose a change 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).

Propose a change docs/testing.md