diff --git a/go.mod b/go.mod index b3e356f5..8a1e898e 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( github.com/rs/cors v1.10.1 github.com/rs/xid v1.5.0 github.com/rs/zerolog v1.32.0 - github.com/sftpgo/sdk v0.1.6-0.20240216180841-c13afec62842 + github.com/sftpgo/sdk v0.1.6-0.20240317102632-f6eb95ea55c3 github.com/shirou/gopsutil/v3 v3.24.2 github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.0 diff --git a/go.sum b/go.sum index fa6ffdd2..31f04fbe 100644 --- a/go.sum +++ b/go.sum @@ -355,8 +355,8 @@ github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJ github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sftpgo/sdk v0.1.6-0.20240216180841-c13afec62842 h1:Rqh/TYkMX6UmUWvgXrsOBoG7ee2GH1AJXBFlszIzKT0= -github.com/sftpgo/sdk v0.1.6-0.20240216180841-c13afec62842/go.mod h1:AWoY2YYe/P1ymfTlRER/meERQjCcZZTbgVPGcPQgaqc= +github.com/sftpgo/sdk v0.1.6-0.20240317102632-f6eb95ea55c3 h1:svxTNm3r2kRlpuVSUKi0WKQlsAq8VI0EzDWPNqeNn/o= +github.com/sftpgo/sdk v0.1.6-0.20240317102632-f6eb95ea55c3/go.mod h1:AWoY2YYe/P1ymfTlRER/meERQjCcZZTbgVPGcPQgaqc= github.com/shirou/gopsutil/v3 v3.24.2 h1:kcR0erMbLg5/3LcInpw0X/rrPSqq4CDPyI6A6ZRC18Y= github.com/shirou/gopsutil/v3 v3.24.2/go.mod h1:tSg/594BcA+8UdQU2XcW803GWYgdtauFFPgJCJKZlVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= diff --git a/internal/common/common.go b/internal/common/common.go index 17e31ddd..b7fb5473 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -449,6 +449,7 @@ type ActiveConnection interface { GetTransfers() []ConnectionTransfer SignalTransferClose(transferID int64, err error) CloseFS() error + isAccessAllowed() bool } // StatAttributes defines the attributes for set stat commands @@ -1081,9 +1082,15 @@ func (conns *ActiveConnections) checkIdles() { if idleTime > Config.idleTimeoutAsDuration || (isUnauthenticatedFTPUser && idleTime > Config.idleLoginTimeout) { defer func(conn ActiveConnection) { err := conn.Disconnect() - logger.Debug(conn.GetProtocol(), conn.GetID(), "close idle connection, idle time: %v, username: %q close err: %v", + logger.Debug(conn.GetProtocol(), conn.GetID(), "close idle connection, idle time: %s, username: %q close err: %v", time.Since(conn.GetLastActivity()), conn.GetUsername(), err) }(c) + } else if !c.isAccessAllowed() { + defer func(conn ActiveConnection) { + err := conn.Disconnect() + logger.Info(conn.GetProtocol(), conn.GetID(), "access conditions not met for user: %q close connection err: %v", + conn.GetUsername(), err) + }(c) } } diff --git a/internal/common/common_test.go b/internal/common/common_test.go index 00c2f304..d57b0a66 100644 --- a/internal/common/common_test.go +++ b/internal/common/common_test.go @@ -748,6 +748,7 @@ func TestIdleConnections(t *testing.T) { user := dataprovider.User{ BaseUser: sdk.BaseUser{ Username: username, + Status: 1, }, } c := NewBaseConnection(sshConn1.id+"_1", ProtocolSFTP, "", "", user) @@ -772,15 +773,34 @@ func TestIdleConnections(t *testing.T) { assert.NoError(t, err) assert.Equal(t, Connections.GetActiveSessions(username), 2) - cFTP := NewBaseConnection("id2", ProtocolFTP, "", "", dataprovider.User{}) + cFTP := NewBaseConnection("id2", ProtocolFTP, "", "", dataprovider.User{ + BaseUser: sdk.BaseUser{ + Status: 1, + }, + }) cFTP.lastActivity.Store(time.Now().UnixNano()) fakeConn = &fakeConnection{ BaseConnection: cFTP, } err = Connections.Add(fakeConn) assert.NoError(t, err) - assert.Equal(t, Connections.GetActiveSessions(username), 2) - assert.Len(t, Connections.GetStats(""), 3) + // the user is expired, this connection will be removed + cDAV := NewBaseConnection("id3", ProtocolWebDAV, "", "", dataprovider.User{ + BaseUser: sdk.BaseUser{ + Username: username + "_2", + Status: 1, + ExpirationDate: util.GetTimeAsMsSinceEpoch(time.Now().Add(-24 * time.Hour)), + }, + }) + cDAV.lastActivity.Store(time.Now().UnixNano()) + fakeConn = &fakeConnection{ + BaseConnection: cDAV, + } + err = Connections.Add(fakeConn) + assert.NoError(t, err) + + assert.Equal(t, 2, Connections.GetActiveSessions(username)) + assert.Len(t, Connections.GetStats(""), 4) Connections.RLock() assert.Len(t, Connections.sshConnections, 2) Connections.RUnlock() diff --git a/internal/common/connection.go b/internal/common/connection.go index 09c59e0e..52d987ef 100644 --- a/internal/common/connection.go +++ b/internal/common/connection.go @@ -109,6 +109,14 @@ func (c *BaseConnection) GetMaxSessions() int { return c.User.MaxSessions } +// isAccessAllowed returns true if the user's access conditions are met +func (c *BaseConnection) isAccessAllowed() bool { + if err := c.User.CheckLoginConditions(); err != nil { + return false + } + return true +} + // GetProtocol returns the protocol for the connection func (c *BaseConnection) GetProtocol() string { return c.protocol diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index d7f504cb..9171f95c 100644 --- a/internal/common/protocol_test.go +++ b/internal/common/protocol_test.go @@ -531,6 +531,45 @@ func TestCheckFsAfterUpdate(t *testing.T) { assert.NoError(t, err) } +func TestLoginAccessTime(t *testing.T) { + u := getTestUser() + u.Filters.AccessTime = []sdk.TimePeriod{ + { + DayOfWeek: int(time.Now().Add(-25 * time.Hour).UTC().Weekday()), + From: "00:00", + To: "23:59", + }, + } + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + _, _, err = getSftpClient(user) + assert.Error(t, err) + + user.Filters.AccessTime = []sdk.TimePeriod{ + { + DayOfWeek: int(time.Now().UTC().Weekday()), + From: "00:00", + To: "23:59", + }, + } + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + err := checkBasicSFTP(client) + assert.NoError(t, err) + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestSetStat(t *testing.T) { u := getTestUser() user, _, err := httpdtest.AddUser(u, http.StatusCreated) diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go index 61bf7089..26d42efb 100644 --- a/internal/dataprovider/dataprovider.go +++ b/internal/dataprovider/dataprovider.go @@ -2650,6 +2650,14 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters { copy(bwLimit.Sources, limit.Sources) filters.BandwidthLimits = append(filters.BandwidthLimits, bwLimit) } + filters.AccessTime = make([]sdk.TimePeriod, 0, len(in.AccessTime)) + for _, period := range in.AccessTime { + filters.AccessTime = append(filters.AccessTime, sdk.TimePeriod{ + DayOfWeek: period.DayOfWeek, + From: period.From, + To: period.To, + }) + } return filters } @@ -3129,9 +3137,60 @@ func validateBaseFilters(filters *sdk.BaseUserFilters) error { } updateFiltersValues(filters) + if err := validateAccessTimeFilters(filters); err != nil { + return err + } + return validateFiltersPatternExtensions(filters) } +func isTimeOfDayValid(value string) bool { + if len(value) != 5 { + return false + } + parts := strings.Split(value, ":") + if len(parts) != 2 { + return false + } + hour, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + if hour < 0 || hour > 23 { + return false + } + minute, err := strconv.Atoi(parts[1]) + if err != nil { + return false + } + if minute < 0 || minute > 59 { + return false + } + return true +} + +func validateAccessTimeFilters(filters *sdk.BaseUserFilters) error { + for _, period := range filters.AccessTime { + if period.DayOfWeek < int(time.Sunday) || period.DayOfWeek > int(time.Saturday) { + return util.NewValidationError(fmt.Sprintf("invalid day of week: %d", period.DayOfWeek)) + } + if !isTimeOfDayValid(period.From) || !isTimeOfDayValid(period.To) { + return util.NewI18nError( + util.NewValidationError("invalid time of day. Supported format: HH:MM"), + util.I18nErrorTimeOfDayInvalid, + ) + } + if period.To <= period.From { + return util.NewI18nError( + util.NewValidationError("invalid time of day. The end time cannot be earlier than the start time"), + util.I18nErrorTimeOfDayConflict, + ) + } + } + + return nil +} + func validateCombinedUserFilters(user *User) error { if user.Filters.TOTPConfig.Enabled && util.Contains(user.Filters.WebClient, sdk.WebClientMFADisabled) { return util.NewI18nError( diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go index d4b30a1a..2fe673ec 100644 --- a/internal/dataprovider/user.go +++ b/internal/dataprovider/user.go @@ -335,7 +335,27 @@ func (u *User) isFsEqual(other *User) bool { return true } -// CheckLoginConditions checks if the user is active and not expired +func (u *User) isTimeBasedAccessAllowed(when time.Time) bool { + if len(u.Filters.AccessTime) == 0 { + return true + } + if when.IsZero() { + when = time.Now() + } + when = when.UTC() + weekDay := when.Weekday() + hhMM := when.Format("15:04") + for _, p := range u.Filters.AccessTime { + if p.DayOfWeek == int(weekDay) { + if hhMM >= p.From && hhMM <= p.To { + return true + } + } + } + return false +} + +// CheckLoginConditions checks user access restrictions func (u *User) CheckLoginConditions() error { if u.Status < 1 { return fmt.Errorf("user %q is disabled", u.Username) @@ -344,7 +364,10 @@ func (u *User) CheckLoginConditions() error { return fmt.Errorf("user %q is expired, expiration timestamp: %v current timestamp: %v", u.Username, u.ExpirationDate, util.GetTimeAsMsSinceEpoch(time.Now())) } - return nil + if u.isTimeBasedAccessAllowed(time.Now()) { + return nil + } + return errors.New("access is not allowed at this time") } // hideConfidentialData hides user confidential data @@ -1834,6 +1857,7 @@ func (u *User) mergeAdditiveProperties(group *Group, groupType int, replacer *st u.Filters.DeniedProtocols = append(u.Filters.DeniedProtocols, group.UserSettings.Filters.DeniedProtocols...) u.Filters.WebClient = append(u.Filters.WebClient, group.UserSettings.Filters.WebClient...) u.Filters.TwoFactorAuthProtocols = append(u.Filters.TwoFactorAuthProtocols, group.UserSettings.Filters.TwoFactorAuthProtocols...) + u.Filters.AccessTime = append(u.Filters.AccessTime, group.UserSettings.Filters.AccessTime...) } func (u *User) mergeVirtualFolders(group *Group, groupType int, replacer *strings.Replacer) { diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 784129d3..c06b2e64 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -1269,6 +1269,13 @@ func TestGroupSettingsOverride(t *testing.T) { }, VirtualPath: "/vdir4", }) + g2.UserSettings.Filters.AccessTime = []sdk.TimePeriod{ + { + DayOfWeek: int(time.Now().UTC().Weekday()), + From: "10:00", + To: "18:00", + }, + } f1 := vfs.BaseVirtualFolder{ Name: folderName1, MappedPath: mappedPath1, @@ -1363,10 +1370,12 @@ func TestGroupSettingsOverride(t *testing.T) { assert.Equal(t, g2.UserSettings.Permissions["/dir3"], user.Permissions["/dir3"]) assert.Equal(t, g1.UserSettings.FsConfig.OSConfig.ReadBufferSize, user.FsConfig.OSConfig.ReadBufferSize) assert.Equal(t, g1.UserSettings.FsConfig.OSConfig.WriteBufferSize, user.FsConfig.OSConfig.WriteBufferSize) + assert.Len(t, user.Filters.AccessTime, 1) user, err = dataprovider.GetUserAfterIDPAuth(defaultUsername, "", common.ProtocolOIDC, nil) assert.NoError(t, err) assert.Len(t, user.VirtualFolders, 4) + assert.Len(t, user.Filters.AccessTime, 1) user1, user2, err := dataprovider.GetUserVariants(defaultUsername, "") assert.NoError(t, err) @@ -1374,6 +1383,8 @@ func TestGroupSettingsOverride(t *testing.T) { assert.Len(t, user2.VirtualFolders, 4) assert.Equal(t, int64(0), user1.ExpirationDate) assert.Equal(t, int64(0), user2.ExpirationDate) + assert.Len(t, user1.Filters.AccessTime, 0) + assert.Len(t, user2.Filters.AccessTime, 1) group2.UserSettings.FsConfig = vfs.Filesystem{ Provider: sdk.SFTPFilesystemProvider, @@ -2846,6 +2857,40 @@ func TestUserBandwidthLimits(t *testing.T) { assert.NoError(t, err) } +func TestAccessTimeValidation(t *testing.T) { + u := getTestUser() + u.Filters.AccessTime = []sdk.TimePeriod{ + { + DayOfWeek: 8, + From: "10:00", + To: "18:00", + }, + } + _, resp, err := httpdtest.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err, string(resp)) + assert.Contains(t, string(resp), "invalid day of week") + u.Filters.AccessTime = []sdk.TimePeriod{ + { + DayOfWeek: 6, + From: "10:00", + To: "18", + }, + } + _, resp, err = httpdtest.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err, string(resp)) + assert.Contains(t, string(resp), "invalid time of day") + u.Filters.AccessTime = []sdk.TimePeriod{ + { + DayOfWeek: 6, + From: "11:00", + To: "10:58", + }, + } + _, resp, err = httpdtest.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err, string(resp)) + assert.Contains(t, string(resp), "The end time cannot be earlier than the start time") +} + func TestUserTimestamps(t *testing.T) { user, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err, string(resp)) @@ -20648,6 +20693,11 @@ func TestWebUserAddMock(t *testing.T) { form.Set("directory_patterns[4][pattern_path]", "/dir2") form.Set("directory_patterns[4][patterns]", "*.mkv") form.Set("directory_patterns[4][pattern_type]", "denied") + form.Set("access_time_restrictions[0][access_time_day_of_week]", "2") + form.Set("access_time_restrictions[0][access_time_start]", "10") // invalid and no end, ignored + form.Set("access_time_restrictions[1][access_time_day_of_week]", "3") + form.Set("access_time_restrictions[1][access_time_start]", "12:00") + form.Set("access_time_restrictions[1][access_time_end]", "14:09") form.Set("additional_info", user.AdditionalInfo) form.Set("description", user.Description) form.Add("hooks", "external_auth_disabled") @@ -20997,6 +21047,11 @@ func TestWebUserAddMock(t *testing.T) { } } } + if assert.Len(t, newUser.Filters.AccessTime, 1) { + assert.Equal(t, 3, newUser.Filters.AccessTime[0].DayOfWeek) + assert.Equal(t, "12:00", newUser.Filters.AccessTime[0].From) + assert.Equal(t, "14:09", newUser.Filters.AccessTime[0].To) + } assert.Len(t, newUser.Groups, 3) assert.Equal(t, sdk.TLSUsernameNone, newUser.Filters.TLSUsername) req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, newUser.Username), nil) diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index bbfd7994..1188018d 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -1284,6 +1284,36 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string { return permissions } +func getAccessTimeRestrictionsFromPostFields(r *http.Request) []sdk.TimePeriod { + var result []sdk.TimePeriod + + dayOfWeeks := r.Form["access_time_day_of_week"] + starts := r.Form["access_time_start"] + ends := r.Form["access_time_end"] + + for idx, dayOfWeek := range dayOfWeeks { + dayOfWeek = strings.TrimSpace(dayOfWeek) + start := "" + if len(starts) > idx { + start = strings.TrimSpace(starts[idx]) + } + end := "" + if len(ends) > idx { + end = strings.TrimSpace(ends[idx]) + } + dayNumber, err := strconv.Atoi(dayOfWeek) + if err == nil && start != "" && end != "" { + result = append(result, sdk.TimePeriod{ + DayOfWeek: dayNumber, + From: start, + To: end, + }) + } + } + + return result +} + func getBandwidthLimitsFromPostFields(r *http.Request) ([]sdk.BandwidthLimit, error) { var result []sdk.BandwidthLimit bwSources := r.Form["bandwidth_limit_sources"] @@ -1463,6 +1493,7 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error) filters.MaxSharesExpiration = maxSharesExpiration filters.PasswordExpiration = passwordExpiration filters.PasswordStrength = passwordStrength + filters.AccessTime = getAccessTimeRestrictionsFromPostFields(r) hooks := r.Form["hooks"] if util.Contains(hooks, "external_auth_disabled") { filters.Hooks.ExternalAuthDisabled = true @@ -1969,6 +2000,13 @@ func updateRepeaterFormFields(r *http.Request) { r.Form.Add("pattern_policy", strings.TrimSpace(r.Form.Get(base+"[pattern_policy]"))) continue } + if hasPrefixAndSuffix(k, "access_time_restrictions[", "][access_time_day_of_week]") { + base, _ := strings.CutSuffix(k, "[access_time_day_of_week]") + r.Form.Add("access_time_day_of_week", strings.TrimSpace(r.Form.Get(k))) + r.Form.Add("access_time_start", strings.TrimSpace(r.Form.Get(base+"[access_time_start]"))) + r.Form.Add("access_time_end", strings.TrimSpace(r.Form.Get(base+"[access_time_end]"))) + continue + } if hasPrefixAndSuffix(k, "src_bandwidth_limits[", "][bandwidth_limit_sources]") { base, _ := strings.CutSuffix(k, "[bandwidth_limit_sources]") r.Form.Add("bandwidth_limit_sources", r.Form.Get(k)) diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go index 0db93a8e..d495a2d0 100644 --- a/internal/httpdtest/httpdtest.go +++ b/internal/httpdtest/httpdtest.go @@ -2516,6 +2516,9 @@ func compareUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters if err := compareUserBandwidthLimitFilters(expected, actual); err != nil { return err } + if err := compareAccessTimeFilters(expected, actual); err != nil { + return err + } return compareUserFilePatternsFilters(expected, actual) } @@ -2531,6 +2534,26 @@ func checkFilterMatch(expected []string, actual []string) bool { return true } +func compareAccessTimeFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error { + if len(expected.AccessTime) != len(actual.AccessTime) { + return errors.New("access time filters mismatch") + } + + for idx, p := range expected.AccessTime { + if actual.AccessTime[idx].DayOfWeek != p.DayOfWeek { + return errors.New("access time day of week mismatch") + } + if actual.AccessTime[idx].From != p.From { + return errors.New("access time from mismatch") + } + if actual.AccessTime[idx].To != p.To { + return errors.New("access time to mismatch") + } + } + + return nil +} + func compareUserBandwidthLimitFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error { if len(expected.BandwidthLimits) != len(actual.BandwidthLimits) { return errors.New("bandwidth limits filters mismatch") diff --git a/internal/util/i18n.go b/internal/util/i18n.go index b2c760d7..c15f6b84 100644 --- a/internal/util/i18n.go +++ b/internal/util/i18n.go @@ -191,6 +191,8 @@ const ( I18nStorageSFTP = "storage.sftp" I18nStorageHTTP = "storage.http" I18nErrorInvalidQuotaSize = "user.invalid_quota_size" + I18nErrorTimeOfDayInvalid = "user.time_of_day_invalid" + I18nErrorTimeOfDayConflict = "user.time_of_day_conflict" I18nErrorInvalidMaxFilesize = "filters.max_upload_size_invalid" I18nErrorInvalidHomeDir = "storage.home_dir_invalid" I18nErrorBucketRequired = "storage.bucket_required" diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index da447cce..e019fdce 100644 --- a/static/locales/en/translation.json +++ b/static/locales/en/translation.json @@ -263,7 +263,16 @@ "month": "Month", "options": "Options", "expired": "Expired", - "unsupported": "Feature no longer supported" + "unsupported": "Feature no longer supported", + "start": "Start (HH:MM)", + "end": "End (HH:MM)", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" }, "fs": { "view_file": "View file \"{{- path}}\"", @@ -537,7 +546,9 @@ "template_password_placeholder": "replaced with the specified password", "template_help1": "Placeholders will be replaced in paths and credentials of the configured storage backend.", "template_help2": "The generated users can be saved or exported. Exported users can be imported from the \"Maintenance\" section of this SFTPGo instance or another.", - "template_no_user": "No valid user defined, unable to complete the requested action" + "template_no_user": "No valid user defined, unable to complete the requested action", + "time_of_day_invalid": "Invalid time of day. Supported format HH:MM", + "time_of_day_conflict": "Invalid time of day. The end time cannot be earlier than the start time" }, "group": { "view_manage": "View and manage groups", @@ -715,7 +726,9 @@ "disable_fs_checks_help": "Disable checks for existence and automatic creation of home directory and virtual folders", "api_key_auth_help": "Allow to impersonate the user, in REST API, with an API key", "external_auth_cache_time": "External auth cache time", - "external_auth_cache_time_help": "Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache" + "external_auth_cache_time_help": "Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache", + "access_time": "Access time restrictions", + "access_time_help": "No restrictions means access is always allowed, the time must be set in the format HH:MM. Use UTC time" }, "admin": { "role_permissions": "A role admin cannot have the following permissions: {{val}}", diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index cf253c1b..216fc1ad 100644 --- a/static/locales/it/translation.json +++ b/static/locales/it/translation.json @@ -263,7 +263,16 @@ "month": "Mese", "options": "Opzioni", "expired": "Scaduto", - "unsupported": "Funzionalità non più supportata" + "unsupported": "Funzionalità non più supportata", + "start": "Inizio (HH:MM)", + "end": "Fine (HH:MM)", + "monday": "Lunedì", + "tuesday": "Martedì", + "wednesday": "Mercoledì", + "thursday": "Giovedì", + "friday": "Venerdì", + "saturday": "Sabato", + "sunday": "Domenica" }, "fs": { "view_file": "Visualizza file \"{{- path}}\"", @@ -537,7 +546,9 @@ "template_password_placeholder": "sostituito con la password specificata", "template_help1": "I segnaposto verranno sostituiti nei percorsi e nelle credenziali del backend di archiviazione configurato.", "template_help2": "Gli utenti generati possono essere salvati o esportati. Gli utenti esportati possono essere importati dalla sezione \"Manutenzione\" di questa istanza SFTPGo o di un'altra.", - "template_no_user": "Nessun utente valido definito. Impossibile completare l'azione richiesta" + "template_no_user": "Nessun utente valido definito. Impossibile completare l'azione richiesta", + "time_of_day_invalid": "Ora del giorno non valida. Formato supportato HH:MM", + "time_of_day_conflict": "Ora del giorno non valida. L'ora di fine non può essere precedente all'ora di inizio" }, "group": { "view_manage": "Visualizza e gestisci gruppi", @@ -715,7 +726,9 @@ "disable_fs_checks_help": "Disabilita i controlli sull'esistenza e la creazione automatica della directory home e delle cartelle virtuali", "api_key_auth_help": "Permetti di impersonare l'utente nelle API REST utilizzando una chiave API", "external_auth_cache_time": "Cache per autenticazione esterna", - "external_auth_cache_time_help": "Tempo di memorizzazione nella cache, in secondi, per gli utenti autenticati utilizzando un hook di autenticazione esterno. 0 significa nessuna cache" + "external_auth_cache_time_help": "Tempo di memorizzazione nella cache, in secondi, per gli utenti autenticati utilizzando un hook di autenticazione esterno. 0 significa nessuna cache", + "access_time": "Limitazioni temporali all'accesso", + "access_time_help": "Nessuna restrizione significa che l'accesso è sempre consentito, l'ora deve essere impostata nel formato HH:MM. Utilizzare l'ora UTC" }, "admin": { "role_permissions": "Un amministratore di ruolo non può avere le seguenti autorizzazioni: {{val}}", diff --git a/templates/webadmin/fsconfig.html b/templates/webadmin/fsconfig.html index 4ea5412c..6853f890 100644 --- a/templates/webadmin/fsconfig.html +++ b/templates/webadmin/fsconfig.html @@ -605,6 +605,101 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). {{- end}} +{{- define "user_group_access_time"}} +