Skip to content

feat: allow mounting team folders below a configurable parent folder - #5205

Open
valueerrorx wants to merge 9 commits into
nextcloud:masterfrom
valueerrorx:feat/team-folders-parent-folder
Open

valueerrorx wants to merge 9 commits into
nextcloud:masterfrom
valueerrorx:feat/team-folders-parent-folder

Conversation

@valueerrorx

@valueerrorx valueerrorx commented Nov 3, 2026 •

Copy link
Copy Markdown

Summary

Since team folders can be created by any team owner (Teams → "create team folder"), the root of every user's files quickly fills up with team folders. This PR adds an optional, system-wide parent folder in which team folders are mounted for every user, e.g. TeamFolders/Marketing instead of Marketing.

  • Configurable in the admin settings (new section "Parent folder for team folders" above the folder list) or via
    occ config:app:set groupfolders mount_prefix --value="TeamFolders"
  • Nested paths like Shared/Teams are supported.
  • Scope: by default only folders that belong to a team (created or linked from the Teams app, i.e. team_circle_id is set) are moved. Admin created folders keep their path, so existing WebDAV paths, desktop client sync paths and links don't change. A switch in the settings (mount_prefix_scope = all) applies the parent folder to all folders.
  • Default is empty, so existing installations keep the current behaviour.
  • The stored mount point in the database is unchanged; the prefix is only applied when mounting. Changing the setting moves the folders for all users without any data migration.
  • Folders mounted at / (replacing the user's home folder) are never moved.

Mount path collisions

Because the prefix is applied at mount time, two folders can end up at the same path for a user (e.g. the team folder Project and an admin folder stored as TeamFolders/Project). They are resolved per user with one deterministic rule, used by both the full and the partial setup:

  1. Folders that are not moved below the parent folder always keep their stored mount point, so existing paths never change because of the parent folder.
  2. A moved folder whose path below the parent folder is taken falls back to its stored mount point (logged at info level).
  3. A folder that still has no free path is not mounted and a warning is logged (only possible when the same mount point is stored twice). Within each step the lower folder id wins.

Nothing is overwritten silently. New collisions are prevented: FolderManager::mountPointExists() also checks the resulting paths, so the admin UI and occ reject colliding names and new team folders get a unique name (Lab (1)).

If a group folder is mounted at the parent folder or at a part of it, team folders are nested inside it like any other nested group folder, and the parent folder is not created in the home storage.

Implementation

  • MountPathResolver (new) translates between the stored mount point and the per-user mount path, depending on the folder (isTeamSpace()) and the scope, and sanitizes the setting (.., ., empty segments, backslashes).
  • MountProvider: applies the resolved paths in getMountsForUser() and getMountsForPath(). For partial setups MountPathResolver::getMountPointCandidates() loads every folder that can be mounted at or collide with the requested path (<path>, <path without prefix>, <prefix>/<path>), and only folders whose resolved path matches the requested one are returned. It also makes sure the parent folder(s) exist in the user's home, so deep paths can be reached directly without listing the parent first. A file blocking the path is renamed, like existing mount point conflicts.
  • TeamSpaceProvider: the Teams app uses TeamFolder::getMountPoint() as the path inside the current user's files (folder widget, team pages, Files links), so it returns the resolved path of the current user. The Files links in getSharedWith() use it as well; labels keep the stored name.
  • TrashBackend: the original location and restore target use the resolved path.
  • GroupFoldersHome (DAV): resolves and nests nodes via the resolved paths.
  • New admin settings section (AdminMountPrefix, TeamFoldersMountPrefix.vue), saved through the provisioning API like the encryption setting.

Testing

  • MountProviderTest::testFullAndPartialSetupAgree runs 7 scenarios (regular, root mount, team vs. admin collision, admin folder at / nested in the parent folder, fallback also taken, duplicate mount points) with no prefix, scope teams and scope all, and asserts that the full setup never mounts two folders at one path and that the partial setup returns the same folder for every path. 12 of these cases fail without the collision handling.
  • Tests for MountPathResolver, FolderManager::mountPointExists() and TeamSpaceProvider.
  • Full suite passes on SQLite, MySQL 8.4 and PostgreSQL 16 against a server master checkout (except CircleDestroyedEventListenerTest, which needs the circles app). phpstan and php-cs-fixer are clean.
  • Manually tested a backport of this change on Nextcloud 35.0.2 (Docker, SQLite):
    • group-based folder and a team created with "createTeamFolder" appear under the parent folder for the owner and for a member added later; uploading works
    • direct WebDAV access to a deep path for a fresh user, without listing the parent first
    • trash shows TeamFolders/Marketing/test.txt as original location; restore works
    • switching TeamFolders → Schule/Teams → TeamFolders → empty, no errors in the log
    • scope teams: an admin created folder stays at Marketing while a team folder is at TeamFolders/Projekt X; scope all: both are below the parent folder
    • getMountsForPath() called directly (as SetupManager does) with Redis as distributed cache, for both scopes, single and nested prefixes and an empty prefix
    • collisions end-to-end with Redis and the Teams app: a team folder and an admin folder TeamFolders/Docs are resolved the same way in the full and the partial setup, occ and the admin UI reject colliding names, team pages are created in the right folder, trash restore works
  • Not tested on master/NC 36 in a running instance yet.

Known limitations

  • When the setting is changed, the old (then empty) parent folder stays in the users' homes.
  • With scope teams, a folder that is unlinked from its team but still shared with a group moves back to the root of the users' files.
  • With a distributed cache, users may see the new path only after the setup cache expires (fs_mount_cache_duration, default 5 min).
  • A setting changed via occ may be picked up by the web server only after its local app config cache expires.
  • If an admin later creates a folder named like the parent folder, the existing parent folder in the users' homes is renamed once (TeamFolders (1)), like any other mount point conflict.
  • If two folders with the same name are mounted for a user (e.g. Docs and TeamFolders/Docs), the groupfolders WebDAV home lists both as Docs, as it already does for nested group folders.

Checklist

  • Code is properly formatted
  • Sign-off message is added to all commits
  • Tests (unit) are included
  • Documentation (README section "Parent folder for team folders")

This change was developed with AI assistance (Claude Code) and reviewed and tested by me.

🤖 Generated with Claude Code

valueerrorx and others added 2 commits November 3, 2026 08:39
Admins can now configure a system-wide parent folder (e.g. "TeamFolders")
in which all team folders are mounted for every user, instead of
cluttering the root of the users' files.

- New MountPathResolver translates between the stored mount point and
  the per-user mount path; configured via app config
  `groupfolders mount_prefix`. Empty (default) keeps the current
  behaviour.
- MountProvider applies the prefix for full and partial setups and
  creates the parent folder(s) in the user's home, renaming a file that
  would block the path.
- Trash (original location / restore) and the groupfolders DAV home use
  the prefixed path.
- New admin settings section to change the parent folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Thomas Weissel <valueerror@gmail.com>
The folder list has a min-height of the full viewport, which pushed the
section far down the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Thomas Weissel <valueerror@gmail.com>
@valueerrorx

valueerrorx commented Nov 3, 2026 •

Copy link
Copy Markdown
Author
Screenshot_20261103_085053

that's how it looks like in the settings page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Thomas Weissel <valueerror@gmail.com>
@gseilheimer

Copy link
Copy Markdown

Thanks for working on this. One consideration: the prefix applies to every group folder, including admin-created ones such as a main shared folder, which would change all existing paths (WebDAV, share targets, scripts). Would it be possible to apply the prefix only to Team-linked (auto-created) folders, or make it opt-in per folder?

valueerrorx and others added 2 commits November 3, 2026 18:22
Admin created folders keep their path unless mount_prefix_scope is set
to "all", so existing paths (WebDAV, desktop clients, links) don't
change unexpectedly when the parent folder is configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Thomas Weissel <valueerror@gmail.com>
The provisioning API requires a recent password confirmation, so saving
failed when the admin had not confirmed their password in the last 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Thomas Weissel <valueerror@gmail.com>
@valueerrorx

Copy link
Copy Markdown
Author

Updated: the prefix now only applies to Team-linked folders by default, admin folders keep their paths unless the scope is set to all.

Comment thread lib/Mount/MountProvider.php Resolved
getMountsForPath() no longer mapped the root of the user's files to the
"/" mount point, so a folder mounted at "/" (see
FolderManager::trimMountpoint()) was not found by the partial mount
setup, regardless of the parent folder setting. Such folders are also
never moved below the parent folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Thomas Weissel <valueerror@gmail.com>
Comment on lines +64 to +69
foreach ($folders as $folder) {
if ($this->mountPathResolver->appliesTo($folder)) {
$this->ensurePrefixFolderExists($user);
break;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefix directory is created in the wrong storage when a root-mounted GroupFolder exists. Consequently, the prefix directory creation has no effect on the visible root-mounted filesystem. The nested team folder is still registered, but its expected parent directory is missing from the storage underneath it.

Handle prefix-directory creation against the effective filesystem storage, including the root-mounted GroupFolder case.

Add an integration test that:

  1. Creates a root-mounted GroupFolder.
  2. Creates a team-linked GroupFolder for the same user.
  3. Enables a non-empty prefix.
  4. Performs full filesystem setup.
  5. Verifies that the prefix directory is actually accessible and listable and that the nested team folder works.

Comment on lines +87 to +108
/**
* Whether the parent folder is used for the given folder.
*/
public function appliesTo(FolderDefinition $folder): bool {
// A folder mounted at "/" replaces the user's home folder and is never moved
if ($this->getPrefix() === '' || $folder->mountPoint === self::ROOT_MOUNT_POINT) {
return false;
}

return $this->getScope() === self::SCOPE_ALL || $folder->isTeamSpace();
}

/**
* Path relative to the user's "files" folder where the given folder is mounted.
*/
public function getUserMountPath(FolderDefinition $folder): string {
if (!$this->appliesTo($folder)) {
return $folder->mountPoint;
}

return $this->getPrefix() . '/' . $folder->mountPoint;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effective mount-point collisions are not detected. The resolver determines paths independently for each folder, but does not guarantee that the resulting paths are unique.

Validate effective mount paths per user before returning mounts.

At minimum:

  • Detect duplicate resolved paths.
  • Do not silently overwrite a mount.
  • Define deterministic handling for collisions between prefixed team folders and existing nested admin folders.
  • Ensure that TeamResource URLs use the effective user-facing mount path rather than the stored database mount point.
  • Add tests covering both full filesystem setup and partial setup.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, fixed in 3079df9.

  • Collisions: paths are now resolved per user with one deterministic rule, used by both the full and the partial setup. Folders that are not moved keep their stored mount point. A team folder whose path below the parent folder is taken falls back to its stored mount point. If no path is free, the folder is skipped with a warning; the lower id wins. Nothing is overwritten silently.
  • Prevention: mountPointExists() also checks the resulting paths, so colliding names are rejected and new team folders get a unique name.
  • TeamResource: TeamSpaceProvider and the getSharedWith() links return the effective path of the current user.
  • Tests: MountProviderTest checks that the full and the partial setup give the same result across several scenarios (root mount, collisions, nested admin folders, duplicates) and prefix configs.

Collision rules are documented in the README.

valueerrorx and others added 2 commits November 8, 2026 14:32
The parent folder is added at mount time, so two folders can end up at
the same path for a user, e.g. the team folder "Project" and an admin
folder stored as "TeamFolders/Project". Such mounts silently replaced
each other.

- MountPathResolver::resolveMountPaths() resolves the paths of all
  folders of a user deterministically: folders that are not moved keep
  their stored mount point, a moved folder whose path is taken falls back
  to its stored mount point, and a folder without a free path is not
  mounted. Fallbacks and skipped folders are logged.
- The full and the partial filesystem setup use the same resolution, the
  partial setup also looks up the folders a requested folder can collide
  with, so both mount every folder at the same path.
- The parent folder is not created in the home storage when a group
  folder is mounted at (a part of) it or at "/".
- FolderManager::mountPointExists() also detects collisions of the
  resulting paths, so new admin and team folders cannot create them.
- The Teams integration reports the path of the current user as the
  team folder mount point and uses it for the Files links, the Teams app
  uses it as path inside the user's files.
- Trash and the groupfolders WebDAV home use the resolved paths.
- Document the parent folder and the collision handling in the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Thomas Weissel <valueerror@gmail.com>
@valueerrorx

Copy link
Copy Markdown
Author

Thanks, fixed in 3079df9.

  • Collisions: paths are now resolved per user with one deterministic rule, used by both the full and the partial setup. Folders that are not moved keep their stored mount point. A team folder whose path below the parent folder is taken falls back to its stored mount point. If no path is free, the folder is skipped with a warning; the lower id wins. Nothing is overwritten silently.
  • Prevention: mountPointExists() also checks the resulting paths, so colliding names are rejected and new team folders get a unique name.
  • TeamResource: TeamSpaceProvider and the getSharedWith() links return the effective path of the current user.
  • Tests: MountProviderTest checks that the full and the partial setup give the same result across several scenarios (root mount, collisions, nested admin folders, duplicates) and prefix configs.

Collision rules are documented in the README.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants