package settings import ( "fmt" "regexp" "strings" "time" ) // LAN network-sharing (Samba) settings — R-7 slice 1. The household SMB password is NEVER persisted // here (it lives in the samba container's passdb volume); SMBSettings.UserSet only records that one // exists. Everything in this file is customer-modifiable state behind the „Megosztás” page. // DefaultSMBServerName is the NetBIOS name shown in Windows Explorer's Network view when the customer // hasn't chosen one. Kept short + uppercase (NetBIOS is case-folded). const DefaultSMBServerName = "FELHOM" // SMBSettings holds the network-sharing feature toggle + server identity. type SMBSettings struct { Enabled bool `json:"enabled"` ServerName string `json:"server_name"` // NetBIOS name (≤15, NetBIOS-safe); "" ⇒ DefaultSMBServerName UserSet bool `json:"user_set,omitempty"` // the household SMB password has been set at least once } // SMBShare is one exported folder. Path is an absolute host path under a registered storage root // (validated by the web layer against the storage registry + deny-list before it ever reaches here). type SMBShare struct { Name string `json:"name"` // share name (NetBIOS-safe, ≤15); the [section] in smb.conf and the \\SERVER\ path Path string `json:"path"` // absolute host path ReadOnly bool `json:"read_only,omitempty"` // smb.conf `read only = yes` + a :ro compose bind Offsite bool `json:"offsite"` // [R4] true (default) → backup class mandatory; false → optional (tier-2 only) CreatedAt string `json:"created_at"` // RFC3339 // System marks a controller-OWNED share the customer may not delete (R-75). Today that is the // canonical drop-zone („beolvasas"), which is auto-created whenever sharing is enabled and whose // absence would silently break the documented „drop a file in Beolvasás over the network" flow. // Its Path is a controller-generated constant derived from config — never customer input — which // is why it does NOT go through sharingResolvePath (that guard exists to validate the paths a // CUSTOMER picks, a different trust class). System bool `json:"system,omitempty"` } // SystemImportShareName is the fixed name of the canonical drop-zone share (R-75). ASCII and // nbNameRe-safe on purpose: it is a NetBIOS share name and appears in \\SERVER\. const SystemImportShareName = "beolvasas" // nbNameRe matches a NetBIOS-safe name: 1–15 chars, letters/digits/hyphen/underscore, not starting // or ending with a hyphen. Deliberately stricter than SMB share-name rules (slice 1 keeps the flat // name and the share name in the same safe space; slice 2 may relax share names). var nbNameRe = regexp.MustCompile(`^[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,13}[A-Za-z0-9_])?$`) // ValidateSMBServerName checks a proposed server (NetBIOS) name, returning a Hungarian error on defect. func ValidateSMBServerName(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("a kiszolgáló neve nem lehet üres") } if len(name) > 15 { return fmt.Errorf("a kiszolgáló neve legfeljebb 15 karakter lehet") } if !nbNameRe.MatchString(name) { return fmt.Errorf("a kiszolgáló neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat") } return nil } // ValidateSMBShareName checks a proposed share name, returning a Hungarian error on defect. It rejects // path traversal (slashes/backslashes/dots), whitespace, over-length, and any non-NetBIOS-safe char — // so a name can never turn into a path segment or a second [section] header. func ValidateSMBShareName(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("a megosztás neve nem lehet üres") } if len(name) > 15 { return fmt.Errorf("a megosztás neve legfeljebb 15 karakter lehet") } if strings.ContainsAny(name, `/\.`) { return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot") } // RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares" // — a restic tag, a tier-2 dest root and a status record. nbNameRe below starts with [A-Za-z0-9_], // so before this guard „_shares" was an ACCEPTED share name and the underscore namespace was not in // fact reserved (the R-7b task's assumption to the contrary was verified false here). Reserving the // whole leading-underscore space keeps future system keys collision-free too. Validation runs on // ADD only, so an already-registered share is never invalidated retroactively. if strings.HasPrefix(name, "_") { return fmt.Errorf("a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva") } if !nbNameRe.MatchString(name) { return fmt.Errorf("a megosztás neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat") } return nil } // EffectiveServerName returns the configured server name or the default when unset. func (s *SMBSettings) EffectiveServerName() string { if s == nil || strings.TrimSpace(s.ServerName) == "" { return DefaultSMBServerName } return s.ServerName } // ---- accessors (thread-safe, mirror the OffboxTarget getter/setter shape) -------------------------- // GetSMBSettings returns a copy of the SMB feature settings (never nil; a zero-value disabled struct // with the default server name when unconfigured). func (s *Settings) GetSMBSettings() SMBSettings { s.mu.RLock() defer s.mu.RUnlock() if s.SMB == nil { return SMBSettings{Enabled: false, ServerName: DefaultSMBServerName} } cp := *s.SMB if strings.TrimSpace(cp.ServerName) == "" { cp.ServerName = DefaultSMBServerName } return cp } // SetSMBEnabled toggles the feature and saves. func (s *Settings) SetSMBEnabled(on bool) error { s.mu.Lock() defer s.mu.Unlock() if s.SMB == nil { s.SMB = &SMBSettings{ServerName: DefaultSMBServerName} } s.SMB.Enabled = on return s.save() } // SetSMBServerName updates the server (NetBIOS) name and saves. Caller validates. func (s *Settings) SetSMBServerName(name string) error { s.mu.Lock() defer s.mu.Unlock() if s.SMB == nil { s.SMB = &SMBSettings{} } s.SMB.ServerName = strings.TrimSpace(name) return s.save() } // SetSMBUserSet records that the household SMB password has been set at least once. func (s *Settings) SetSMBUserSet(set bool) error { s.mu.Lock() defer s.mu.Unlock() if s.SMB == nil { s.SMB = &SMBSettings{ServerName: DefaultSMBServerName} } s.SMB.UserSet = set return s.save() } // GetSMBShares returns a copy of the share registry. func (s *Settings) GetSMBShares() []SMBShare { s.mu.RLock() defer s.mu.RUnlock() if len(s.SMBShares) == 0 { return nil } out := make([]SMBShare, len(s.SMBShares)) copy(out, s.SMBShares) return out } // AddSMBShare appends a share, refusing a case-insensitive name collision. Caller has already // validated the name (ValidateSMBShareName) and the path (against the storage registry + deny-list). func (s *Settings) AddSMBShare(share SMBShare) error { s.mu.Lock() defer s.mu.Unlock() for _, ex := range s.SMBShares { if strings.EqualFold(ex.Name, share.Name) { return fmt.Errorf("már létezik „%s” nevű megosztás", share.Name) } } if share.CreatedAt == "" { share.CreatedAt = time.Now().UTC().Format(time.RFC3339) } s.SMBShares = append(s.SMBShares, share) if s.log != nil { s.log.Printf("[INFO] [settings] Added SMB share: %s", share.Name) } return s.save() } // RemoveSMBShare deletes a share by name (case-insensitive). Config-only: never touches the folder. // // A System share is REFUSED here, at the store layer, so every caller inherits the rule — the web // handler has its own check too, and that duplication is deliberate: a handler test that POSTs // directly proves nothing about UI reachability, and a hidden button proves nothing about // enforcement (the v0.70.1 lesson). They are separate concerns. func (s *Settings) RemoveSMBShare(name string) error { s.mu.Lock() defer s.mu.Unlock() for _, ex := range s.SMBShares { if strings.EqualFold(ex.Name, name) && ex.System { return fmt.Errorf("a(z) „%s” megosztás a rendszer része, nem törölhető", ex.Name) } } var kept []SMBShare found := false for _, ex := range s.SMBShares { if strings.EqualFold(ex.Name, name) { found = true continue } kept = append(kept, ex) } if !found { return fmt.Errorf("nincs „%s” nevű megosztás", name) } s.SMBShares = kept if s.log != nil { s.log.Printf("[INFO] [settings] Removed SMB share: %s", name) } return s.save() } // SetSMBShareOffsite flips a share's „Felhőmentés” (offsite/mandatory) toggle [R4]. func (s *Settings) SetSMBShareOffsite(name string, offsite bool) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.SMBShares { if strings.EqualFold(s.SMBShares[i].Name, name) { s.SMBShares[i].Offsite = offsite return s.save() } } return fmt.Errorf("nincs „%s” nevű megosztás", name) }