Files
felhom-controller/controller/internal/settings/smb.go
T
admin 7c05b59708
gates / gates (push) Successful in 24s
v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +02:00

230 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package settings
import (
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"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 util.MsgError("err.settings.a_kiszolgalo_neve_nem_lehet_ures")
}
if len(name) > 15 {
return util.MsgError("err.settings.a_kiszolgalo_neve_legfeljebb_15_karakter")
}
if !nbNameRe.MatchString(name) {
return util.MsgError("err.settings.a_kiszolgalo_neve_csak_betut_szamot")
}
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 util.MsgError("err.settings.a_megosztas_neve_nem_lehet_ures")
}
if len(name) > 15 {
return util.MsgError("err.settings.a_megosztas_neve_legfeljebb_15_karakter")
}
if strings.ContainsAny(name, `/\.`) {
return util.MsgError("err.settings.a_megosztas_neve_nem_tartalmazhat_perjelet")
}
// 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 util.MsgError("err.settings.a_megosztas_neve_nem_kezdodhet_alahuzassal")
}
if !nbNameRe.MatchString(name) {
return util.MsgError("err.settings.a_megosztas_neve_csak_betut_szamot")
}
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 util.MsgError("err.settings.mar_letezik_nevu_megosztas", 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 util.MsgError("err.settings.a_z_megosztas_a_rendszer_resze", 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 util.MsgError("err.settings.nincs_nevu_megosztas", 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 util.MsgError("err.settings.nincs_nevu_megosztas", name)
}