2958946517
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
230 lines
8.8 KiB
Go
230 lines
8.8 KiB
Go
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\<name> 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\<name>.
|
||
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)
|
||
}
|