Skip to content

Commit 2f7546e

Browse files
committed
Add subdirectory option to virtual folders
Allow virtual folders to map to subdirectories within shared storage, enabling better user isolation and multi-tenant setups. When configuring a virtual folder, users can now specify a 'virtual_subdirectory' that automatically isolates their access within a shared storage location. For example, multiple users can share the same base folder while each accessing their own subdirectory. Signed-off-by: TEC <git@tecosaur.net>
1 parent 19d1a0e commit 2f7546e

36 files changed

+1025
-203
lines changed

examples/ldapauthserver/httpd/models.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ type SFTPGoFilesystem struct {
5858
}
5959

6060
type virtualFolder struct {
61-
VirtualPath string `json:"virtual_path"`
62-
MappedPath string `json:"mapped_path"`
61+
VirtualPath string `json:"virtual_path"`
62+
VirtualSubdirectory string `json:"virtual_subdirectory,omitempty"`
63+
MappedPath string `json:"mapped_path"`
6364
}
6465

6566
// SFTPGoUser defines an SFTPGo user

examples/php-activedirectory-http-server/README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,26 @@ $virtual_folders['example'] = [
4949
"quota_files" => -1
5050
]
5151
];
52+
53+
// Alternative example using virtual_subdirectory for multi-tenant shared storage:
54+
$virtual_folders['shared_example'] = [
55+
[
56+
"name" => "shared-documents",
57+
"mapped_path" => 'F:\files\shared\documents',
58+
"virtual_path" => "/documents",
59+
"virtual_subdirectory" => "#USERNAME#", // Each user gets their own subdirectory
60+
"quota_size" => 1073741824, // 1GB limit per user
61+
"quota_files" => 1000
62+
],
63+
[
64+
"name" => "shared-projects",
65+
"mapped_path" => 'F:\files\shared\projects',
66+
"virtual_path" => "/projects",
67+
"virtual_subdirectory" => "#DEPARTMENT#", // Users in same department share space
68+
"quota_size" => 0, // Unlimited
69+
"quota_files" => 0
70+
]
71+
];
5272
```
5373

5474
## Example Connection "Output Object" Allowing For No Files in the User's Home Directory ("Root Directory") but Allowing for Files in the Public/Private Virtual Folders
@@ -99,8 +119,20 @@ $auto_groups_mode_virtual_folder_template = [
99119
//"used_quota_files" => 0,
100120
//"last_quota_update" => 0,
101121
"virtual_path" => "/groups/#GROUP#",
102-
"quota_size" => 0,
103-
"quota_files" => 100000
122+
"quota_size" => -1,
123+
"quota_files" => -1
124+
]
125+
];
126+
127+
// Alternative group template using virtual_subdirectory for shared group storage:
128+
$auto_groups_mode_shared_template = [
129+
[
130+
"name" => "shared-group-storage",
131+
"mapped_path" => 'F:\files\shared\groups',
132+
"virtual_path" => "/group_shared",
133+
"virtual_subdirectory" => "#GROUP#", // Each group gets isolated subdirectory
134+
"quota_size" => 5368709120, // 5GB per group
135+
"quota_files" => 0
104136
]
105137
];
106138

internal/common/actions_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func TestPreDeleteAction(t *testing.T) {
263263
}
264264
user.Permissions = make(map[string][]string)
265265
user.Permissions["/"] = []string{dataprovider.PermAny}
266-
fs := vfs.NewOsFs("id", homeDir, "", nil)
266+
fs := vfs.NewOsFs("id", homeDir, "", "", nil)
267267
c := NewBaseConnection("id", ProtocolSFTP, "", "", user)
268268

269269
testfile := filepath.Join(user.HomeDir, "testfile")

internal/common/common_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,7 @@ func TestConnectionStatus(t *testing.T) {
968968
Username: username,
969969
},
970970
}
971-
fs := vfs.NewOsFs("", os.TempDir(), "", nil)
971+
fs := vfs.NewOsFs("", os.TempDir(), "", "", nil)
972972
c1 := NewBaseConnection("id1", ProtocolSFTP, "", "", user)
973973
fakeConn1 := &fakeConnection{
974974
BaseConnection: c1,
@@ -1271,7 +1271,7 @@ func TestPostConnectHook(t *testing.T) {
12711271

12721272
func TestCryptoConvertFileInfo(t *testing.T) {
12731273
name := "name"
1274-
fs, err := vfs.NewCryptFs("connID1", os.TempDir(), "", vfs.CryptFsConfig{
1274+
fs, err := vfs.NewCryptFs("connID1", os.TempDir(), "", "", vfs.CryptFsConfig{
12751275
Passphrase: kms.NewPlainSecret("secret"),
12761276
})
12771277
require.NoError(t, err)

internal/common/connection_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (fs *MockOsFs) Walk(_ string, walkFn filepath.WalkFunc) error {
9191

9292
func newMockOsFs(hasVirtualFolders bool, connectionID, rootDir, name string, err error) vfs.Fs {
9393
return &MockOsFs{
94-
Fs: vfs.NewOsFs(connectionID, rootDir, "", nil),
94+
Fs: vfs.NewOsFs(connectionID, rootDir, "", "", nil),
9595
name: name,
9696
hasVirtualFolders: hasVirtualFolders,
9797
err: err,
@@ -119,7 +119,7 @@ func TestRemoveErrors(t *testing.T) {
119119
}
120120
user.Permissions = make(map[string][]string)
121121
user.Permissions["/"] = []string{dataprovider.PermAny}
122-
fs := vfs.NewOsFs("", os.TempDir(), "", nil)
122+
fs := vfs.NewOsFs("", os.TempDir(), "", "", nil)
123123
conn := NewBaseConnection("", ProtocolFTP, "", "", user)
124124
err := conn.IsRemoveDirAllowed(fs, mappedPath, "/virtualpath1")
125125
if assert.Error(t, err) {
@@ -164,7 +164,7 @@ func TestSetStatMode(t *testing.T) {
164164
}
165165

166166
func TestRecursiveRenameWalkError(t *testing.T) {
167-
fs := vfs.NewOsFs("", filepath.Clean(os.TempDir()), "", nil)
167+
fs := vfs.NewOsFs("", filepath.Clean(os.TempDir()), "", "", nil)
168168
conn := NewBaseConnection("", ProtocolWebDAV, "", "", dataprovider.User{
169169
BaseUser: sdk.BaseUser{
170170
Permissions: map[string][]string{
@@ -201,7 +201,7 @@ func TestCrossRenameFsErrors(t *testing.T) {
201201
if runtime.GOOS == osWindows {
202202
t.Skip("this test is not available on Windows")
203203
}
204-
fs := vfs.NewOsFs("", os.TempDir(), "", nil)
204+
fs := vfs.NewOsFs("", os.TempDir(), "", "", nil)
205205
conn := NewBaseConnection("", ProtocolWebDAV, "", "", dataprovider.User{})
206206
dirPath := filepath.Join(os.TempDir(), "d")
207207
err := os.Mkdir(dirPath, os.ModePerm)
@@ -228,7 +228,7 @@ func TestRenameVirtualFolders(t *testing.T) {
228228
},
229229
VirtualPath: vdir,
230230
})
231-
fs := vfs.NewOsFs("", os.TempDir(), "", nil)
231+
fs := vfs.NewOsFs("", os.TempDir(), "", "", nil)
232232
conn := NewBaseConnection("", ProtocolFTP, "", "", u)
233233
res := conn.isRenamePermitted(fs, fs, "source", "target", vdir, "vdirtarget", nil)
234234
assert.False(t, res)
@@ -380,7 +380,7 @@ func TestUpdateQuotaAfterRename(t *testing.T) {
380380
}
381381

382382
func TestErrorsMapping(t *testing.T) {
383-
fs := vfs.NewOsFs("", os.TempDir(), "", nil)
383+
fs := vfs.NewOsFs("", os.TempDir(), "", "", nil)
384384
conn := NewBaseConnection("", ProtocolSFTP, "", "", dataprovider.User{BaseUser: sdk.BaseUser{HomeDir: os.TempDir()}})
385385
osErrorsProtocols := []string{ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare,
386386
ProtocolDataRetention, ProtocolOIDC, protocolEventAction}

internal/common/transfer_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func TestTransferUpdateQuota(t *testing.T) {
3737
transfer := BaseTransfer{
3838
Connection: conn,
3939
transferType: TransferUpload,
40-
Fs: vfs.NewOsFs("", os.TempDir(), "", nil),
40+
Fs: vfs.NewOsFs("", os.TempDir(), "", "", nil),
4141
}
4242
transfer.BytesReceived.Store(123)
4343
errFake := errors.New("fake error")
@@ -76,7 +76,7 @@ func TestTransferThrottling(t *testing.T) {
7676
DownloadBandwidth: 40,
7777
},
7878
}
79-
fs := vfs.NewOsFs("", os.TempDir(), "", nil)
79+
fs := vfs.NewOsFs("", os.TempDir(), "", "", nil)
8080
testFileSize := int64(131072)
8181
wantedUploadElapsed := 1000 * (testFileSize / 1024) / u.UploadBandwidth
8282
wantedDownloadElapsed := 1000 * (testFileSize / 1024) / u.DownloadBandwidth
@@ -108,7 +108,7 @@ func TestTransferThrottling(t *testing.T) {
108108

109109
func TestRealPath(t *testing.T) {
110110
testFile := filepath.Join(os.TempDir(), "afile.txt")
111-
fs := vfs.NewOsFs("123", os.TempDir(), "", nil)
111+
fs := vfs.NewOsFs("123", os.TempDir(), "", "", nil)
112112
u := dataprovider.User{
113113
BaseUser: sdk.BaseUser{
114114
Username: "user",
@@ -142,7 +142,7 @@ func TestRealPath(t *testing.T) {
142142

143143
func TestTruncate(t *testing.T) {
144144
testFile := filepath.Join(os.TempDir(), "transfer_test_file")
145-
fs := vfs.NewOsFs("123", os.TempDir(), "", nil)
145+
fs := vfs.NewOsFs("123", os.TempDir(), "", "", nil)
146146
u := dataprovider.User{
147147
BaseUser: sdk.BaseUser{
148148
Username: "user",
@@ -211,7 +211,7 @@ func TestTransferErrors(t *testing.T) {
211211
isCancelled = true
212212
}
213213
testFile := filepath.Join(os.TempDir(), "transfer_test_file")
214-
fs := vfs.NewOsFs("id", os.TempDir(), "", nil)
214+
fs := vfs.NewOsFs("id", os.TempDir(), "", "", nil)
215215
u := dataprovider.User{
216216
BaseUser: sdk.BaseUser{
217217
Username: "test",
@@ -302,7 +302,7 @@ func TestTransferErrors(t *testing.T) {
302302

303303
func TestRemovePartialCryptoFile(t *testing.T) {
304304
testFile := filepath.Join(os.TempDir(), "transfer_test_file")
305-
fs, err := vfs.NewCryptFs("id", os.TempDir(), "", vfs.CryptFsConfig{Passphrase: kms.NewPlainSecret("secret")})
305+
fs, err := vfs.NewCryptFs("id", os.TempDir(), "", "", vfs.CryptFsConfig{Passphrase: kms.NewPlainSecret("secret")})
306306
require.NoError(t, err)
307307
u := dataprovider.User{
308308
BaseUser: sdk.BaseUser{
@@ -334,7 +334,7 @@ func TestFTPMode(t *testing.T) {
334334
transfer := BaseTransfer{
335335
Connection: conn,
336336
transferType: TransferUpload,
337-
Fs: vfs.NewOsFs("", os.TempDir(), "", nil),
337+
Fs: vfs.NewOsFs("", os.TempDir(), "", "", nil),
338338
}
339339
transfer.BytesReceived.Store(123)
340340
assert.Empty(t, transfer.ftpMode)
@@ -393,7 +393,7 @@ func TestTransferQuota(t *testing.T) {
393393

394394
conn := NewBaseConnection("", ProtocolSFTP, "", "", user)
395395
transfer := NewBaseTransfer(nil, conn, nil, "file.txt", "file.txt", "/transfer_test_file", TransferUpload,
396-
0, 0, 0, 0, true, vfs.NewOsFs("", os.TempDir(), "", nil), dataprovider.TransferQuota{})
396+
0, 0, 0, 0, true, vfs.NewOsFs("", os.TempDir(), "", "", nil), dataprovider.TransferQuota{})
397397
err := transfer.CheckRead()
398398
assert.NoError(t, err)
399399
err = transfer.CheckWrite()
@@ -452,7 +452,7 @@ func TestUploadOutsideHomeRenameError(t *testing.T) {
452452
transfer := BaseTransfer{
453453
Connection: conn,
454454
transferType: TransferUpload,
455-
Fs: vfs.NewOsFs("", filepath.Join(os.TempDir(), "home"), "", nil),
455+
Fs: vfs.NewOsFs("", filepath.Join(os.TempDir(), "home"), "", "", nil),
456456
}
457457
transfer.BytesReceived.Store(123)
458458

internal/dataprovider/bolt.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import (
3939
)
4040

4141
const (
42-
boltDatabaseVersion = 33
42+
boltDatabaseVersion = 34
4343
)
4444

4545
var (
@@ -3167,15 +3167,15 @@ func (p *BoltProvider) migrateDatabase() error {
31673167
providerLog(logger.LevelError, "%v", err)
31683168
logger.ErrorToConsole("%v", err)
31693169
return err
3170-
case version == 29, version == 30, version == 31, version == 32:
3171-
logger.InfoToConsole("updating database schema version: %d -> 33", version)
3172-
providerLog(logger.LevelInfo, "updating database schema version: %d -> 33", version)
3170+
case version == 29, version == 30, version == 31, version == 32, version == 33:
3171+
logger.InfoToConsole("updating database schema version: %d -> 34", version)
3172+
providerLog(logger.LevelInfo, "updating database schema version: %d -> 34", version)
31733173
if version <= 31 {
31743174
if err := updateEventActions(); err != nil {
31753175
return err
31763176
}
31773177
}
3178-
return updateBoltDatabaseVersion(p.dbHandle, 33)
3178+
return updateBoltDatabaseVersion(p.dbHandle, 34)
31793179
default:
31803180
if version > boltDatabaseVersion {
31813181
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -3197,7 +3197,7 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error { //nolint:gocycl
31973197
return errors.New("current version match target version, nothing to do")
31983198
}
31993199
switch dbVersion.Version {
3200-
case 30, 31, 32, 33:
3200+
case 30, 31, 32, 33, 34:
32013201
logger.InfoToConsole("downgrading database schema version: %d -> 29", dbVersion.Version)
32023202
providerLog(logger.LevelInfo, "downgrading database schema version: %d -> 29", dbVersion.Version)
32033203
if dbVersion.Version >= 32 {

internal/dataprovider/dataprovider.go

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2760,7 +2760,7 @@ func validateAssociatedVirtualFolders(vfolders []vfs.VirtualFolder) ([]vfs.Virtu
27602760
return []vfs.VirtualFolder{}, nil
27612761
}
27622762
var virtualFolders []vfs.VirtualFolder
2763-
folderNames := make(map[string]bool)
2763+
folderKeys := make(map[[2]string]bool)
27642764

27652765
for _, v := range vfolders {
27662766
if v.VirtualPath == "" {
@@ -2776,11 +2776,22 @@ func validateAssociatedVirtualFolders(vfolders []vfs.VirtualFolder) ([]vfs.Virtu
27762776
if v.Name == "" {
27772777
return nil, util.NewI18nError(util.NewValidationError("folder name is mandatory"), util.I18nErrorFolderNameRequired)
27782778
}
2779-
if folderNames[v.Name] {
2780-
return nil, util.NewI18nError(
2781-
util.NewValidationError(fmt.Sprintf("the folder %q is duplicated", v.Name)),
2782-
util.I18nErrorDuplicatedFolders,
2783-
)
2779+
if err := validateVirtualSubdirectory(v.VirtualSubdirectory); err != nil {
2780+
return nil, err
2781+
}
2782+
folderKey := [2]string{v.Name, v.VirtualSubdirectory}
2783+
if folderKeys[folderKey] {
2784+
if v.VirtualSubdirectory != "" {
2785+
return nil, util.NewI18nError(
2786+
util.NewValidationError(fmt.Sprintf("the folder %q with subdirectory %q is duplicated", v.Name, v.VirtualSubdirectory)),
2787+
util.I18nErrorDuplicatedFolders,
2788+
)
2789+
} else {
2790+
return nil, util.NewI18nError(
2791+
util.NewValidationError(fmt.Sprintf("the folder %q is duplicated", v.Name)),
2792+
util.I18nErrorDuplicatedFolders,
2793+
)
2794+
}
27842795
}
27852796
for _, vFolder := range virtualFolders {
27862797
if util.IsDirOverlapped(vFolder.VirtualPath, cleanedVPath, false, "/") {
@@ -2792,18 +2803,27 @@ func validateAssociatedVirtualFolders(vfolders []vfs.VirtualFolder) ([]vfs.Virtu
27922803
}
27932804
}
27942805
virtualFolders = append(virtualFolders, vfs.VirtualFolder{
2795-
BaseVirtualFolder: vfs.BaseVirtualFolder{
2796-
Name: v.Name,
2797-
},
2798-
VirtualPath: cleanedVPath,
2799-
QuotaSize: v.QuotaSize,
2800-
QuotaFiles: v.QuotaFiles,
2806+
BaseVirtualFolder: v.BaseVirtualFolder,
2807+
VirtualPath: cleanedVPath,
2808+
VirtualSubdirectory: v.VirtualSubdirectory,
2809+
QuotaSize: v.QuotaSize,
2810+
QuotaFiles: v.QuotaFiles,
28012811
})
2802-
folderNames[v.Name] = true
2812+
folderKeys[folderKey] = true
28032813
}
28042814
return virtualFolders, nil
28052815
}
28062816

2817+
func validateVirtualSubdirectory(subdirectory string) error {
2818+
if path.IsAbs(subdirectory) || strings.Contains(subdirectory, "..") {
2819+
return util.NewI18nError(
2820+
util.NewValidationError("invalid virtual subdirectory path"),
2821+
util.I18nErrorPathInvalid,
2822+
)
2823+
}
2824+
return nil
2825+
}
2826+
28072827
func validateUserTOTPConfig(c *UserTOTPConfig, username string) error {
28082828
if !c.Enabled {
28092829
c.ConfigName = ""
@@ -4844,10 +4864,19 @@ func downgradeSQLDatabaseFrom32To31(dbHandle *sql.DB) error {
48444864
if err := restoreEventActions(); err != nil {
48454865
return err
48464866
}
4847-
ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
4848-
defer cancel()
48494867

4850-
return sqlCommonUpdateDatabaseVersion(ctx, dbHandle, 31)
4868+
return sqlCommonUpdateDatabaseVersion(context.Background(), dbHandle, 31)
4869+
}
4870+
4871+
func downgradeSQLDatabaseFrom33To32(dbHandle *sql.DB) error {
4872+
logger.InfoToConsole("downgrading database schema version: 33 -> 32")
4873+
providerLog(logger.LevelInfo, "downgrading database schema version: 33 -> 32")
4874+
4875+
sql := fmt.Sprintf("ALTER TABLE %s DROP COLUMN subdirectory;"+
4876+
"ALTER TABLE %s DROP COLUMN subdirectory;",
4877+
sqlTableUsersFoldersMapping, sqlTableGroupsFoldersMapping)
4878+
4879+
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 32, false)
48514880
}
48524881

48534882
func getConfigPath(name, configDir string) string {

0 commit comments

Comments
 (0)