package web import ( "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "sort" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // „Megosztás" — LAN network sharing (R-7 slice 1). The customer enables sharing, sets one household // SMB password, and exports folders. Every customer-supplied path goes through sharingResolvePath, // which is load-bearing security, not defence in depth. // errNotShareable is the single refusal surfaced for every deny-listed / out-of-root path. It is // deliberately uniform: a distinct message per reason would let the picker act as a filesystem oracle. var errNotShareable = errors.New("Ez a mappa nem osztható meg.") // pathWithin reports whether path IS root or lives under it. Segment-wise and separator-agnostic, so // neither a "/mnt/drive-evil" sibling nor a non-POSIX separator can pass as containment. func pathWithin(path, root string) bool { p := filepath.ToSlash(filepath.Clean(path)) r := filepath.ToSlash(filepath.Clean(root)) return p == r || strings.HasPrefix(p, r+"/") } // sharingOwningRoot returns the registered, non-decommissioned storage root that contains path. func (s *Server) sharingOwningRoot(path string) (string, bool) { for _, sp := range s.settings.GetStoragePaths() { if sp.Decommissioned { continue } if pathWithin(path, sp.Path) { return sp.Path, true } } return "", false } // sharingResolvePath is THE security gate for this feature. A path is accepted only when it // (1) is absolute, (2) resolves — SYMLINKS INCLUDED — into a registered live storage root, (3) is not // inside a deny-listed system subtree (appdata/, backups/, the drive root itself, felhom-data), and // (4) is a real directory. EvalSymlinks runs BEFORE the containment assert so a symlink planted inside // a root cannot point outside it. func (s *Server) sharingResolvePath(raw string) (string, error) { if strings.TrimSpace(raw) == "" { return "", errNotShareable } clean := filepath.Clean(raw) if !filepath.IsAbs(clean) { return "", errNotShareable } resolved, err := filepath.EvalSymlinks(clean) if err != nil { return "", errNotShareable } root, ok := s.sharingOwningRoot(resolved) if !ok { return "", errNotShareable } // A whole drive is never shareable (exact match — the subtree rule below must NOT include the // root, or every legitimate share under it would be refused). if filepath.Clean(resolved) == filepath.Clean(root) { return "", errNotShareable } for _, denied := range stacks.SharingDeniedRoots(root) { if pathWithin(resolved, denied) { return "", errNotShareable } } fi, err := os.Stat(resolved) if err != nil || !fi.IsDir() { return "", errNotShareable } return resolved, nil } // sharingPageData assembles the „Megosztás" page state. func (s *Server) sharingPageData() map[string]interface{} { data := s.settingsBaseData("sharing", "Hálózati megosztás") smb := s.settings.GetSMBSettings() data["SMBEnabled"] = smb.Enabled data["SMBServerName"] = smb.EffectiveServerName() data["SMBUserSet"] = smb.UserSet data["SMBRunning"] = s.stackMgr.SambaRunning() type shareRow struct { Name string Path string ReadOnly bool Offsite bool Available bool } var rows []shareRow for _, sh := range s.settings.GetSMBShares() { fi, err := os.Stat(sh.Path) rows = append(rows, shareRow{ Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly, Offsite: sh.Offsite, Available: err == nil && fi.IsDir(), }) } data["SMBShares"] = rows // Storage roots offered for the "new folder on a drive" flow. var roots []map[string]string for _, sp := range s.settings.GetStoragePaths() { if sp.Decommissioned || sp.Disconnected { continue } label := sp.Label if label == "" { label = filepath.Base(sp.Path) } roots = append(roots, map[string]string{"Path": sp.Path, "Label": label}) } data["StorageRoots"] = roots return data } func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) { data := s.sharingPageData() if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" { data["Flash"] = f } s.executeTemplate(w, r, "sharing", data) } // sharingRedirect returns to the page with a Hungarian flash. func sharingRedirect(w http.ResponseWriter, r *http.Request, flash string) { http.Redirect(w, r, "/sharing?flash="+urlQueryEscape(flash), http.StatusSeeOther) } func urlQueryEscape(s string) string { return strings.NewReplacer(" ", "+", "&", "%26", "?", "%3F", "#", "%23").Replace(s) } // sharingEnableHandler toggles the feature and updates the server name (POST /sharing/enable). func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() enable := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true" name := strings.TrimSpace(r.FormValue("server_name")) if name != "" { if err := settings.ValidateSMBServerName(name); err != nil { sharingRedirect(w, r, err.Error()) return } if err := s.settings.SetSMBServerName(name); err != nil { sharingRedirect(w, r, "A mentés nem sikerült.") return } } if err := s.settings.SetSMBEnabled(enable); err != nil { sharingRedirect(w, r, "A mentés nem sikerült.") return } if !enable { if err := s.stackMgr.DisableSamba(); err != nil { s.logger.Printf("[WARN] [sharing] disable failed: %v", err) } sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.") return } if err := s.stackMgr.ReconcileSamba(); err != nil { s.logger.Printf("[WARN] [sharing] reconcile failed: %v", err) } sharingRedirect(w, r, "Beállítás mentve.") } // sharingPasswordHandler sets the household SMB password (POST /sharing/password). // SECRET: the password is read from the form and handed straight to the stacks layer, which puts it // on smbpasswd's stdin. It is never logged and never persisted. func (s *Server) sharingPasswordHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() pw := r.FormValue("smb_password") pw2 := r.FormValue("smb_password_confirm") if len(pw) < 8 { sharingRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.") return } if pw != pw2 { sharingRedirect(w, r, "A két jelszó nem egyezik.") return } if err := s.stackMgr.SetSMBPassword(pw); err != nil { s.logger.Printf("[ERROR] [sharing] password apply failed: %v", err) sharingRedirect(w, r, "A jelszó beállítása nem sikerült.") return } if err := s.stackMgr.ReconcileSamba(); err != nil { s.logger.Printf("[WARN] [sharing] reconcile after password failed: %v", err) } sharingRedirect(w, r, "Megosztási jelszó beállítva.") } // sharingShareCreateHandler creates a share (POST /sharing/shares). Either a NEW folder under // /shares/ or an EXISTING folder picked in the browser — both go through the same guard. func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() name := strings.TrimSpace(r.FormValue("name")) mode := r.FormValue("mode") // "new" | "existing" readOnly := r.FormValue("read_only") == "on" if err := settings.ValidateSMBShareName(name); err != nil { sharingRedirect(w, r, err.Error()) return } var target string switch mode { case "new": root, err := s.sharingResolvePath(r.FormValue("storage_root")) if err != nil { sharingRedirect(w, r, errNotShareable.Error()) return } dir := filepath.Join(root, "shares", name) // The name is already NetBIOS-validated (no slash/dot), but assert containment anyway — // a join that escaped its root must never reach MkdirAll. if !pathWithin(dir, root) { sharingRedirect(w, r, errNotShareable.Error()) return } if err := os.MkdirAll(dir, 0o775); err != nil { s.logger.Printf("[ERROR] [sharing] mkdir failed: %v", err) sharingRedirect(w, r, "A mappa létrehozása nem sikerült.") return } // uid:gid 1000 so apps and both backup tiers see the same ownership as SMB writes. if err := os.Chown(dir, 1000, 1000); err != nil { s.logger.Printf("[WARN] [sharing] chown 1000:1000 failed: %v", err) } target = dir case "existing": resolved, err := s.sharingResolvePath(r.FormValue("path")) if err != nil { sharingRedirect(w, r, errNotShareable.Error()) return } target = resolved default: sharingRedirect(w, r, errNotShareable.Error()) return } share := settings.SMBShare{ Name: name, Path: target, ReadOnly: readOnly, Offsite: true, // [R4] new shares default to mandatory (offsite + tier-2) CreatedAt: time.Now().UTC().Format(time.RFC3339), } if err := s.settings.AddSMBShare(share); err != nil { sharingRedirect(w, r, err.Error()) return } if err := s.stackMgr.ReconcileSamba(); err != nil { s.logger.Printf("[WARN] [sharing] reconcile after share create failed: %v", err) } sharingRedirect(w, r, "A megosztás létrehozva.") } // sharingShareDeleteHandler removes a share (POST /sharing/shares/delete). CONFIG ONLY — by // construction there is no filesystem removal anywhere in this feature. func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() name := strings.TrimSpace(r.FormValue("name")) if err := s.settings.RemoveSMBShare(name); err != nil { sharingRedirect(w, r, err.Error()) return } if err := s.stackMgr.ReconcileSamba(); err != nil { s.logger.Printf("[WARN] [sharing] reconcile after share delete failed: %v", err) } sharingRedirect(w, r, "A megosztás törölve — a mappa és a fájlok megmaradtak.") } // sharingShareOffsiteHandler flips a share's „Felhőmentés" toggle (POST /sharing/shares/offsite). func (s *Server) sharingShareOffsiteHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() name := strings.TrimSpace(r.FormValue("name")) on := r.FormValue("offsite") == "on" || r.FormValue("offsite") == "true" if err := s.settings.SetSMBShareOffsite(name, on); err != nil { sharingRedirect(w, r, err.Error()) return } sharingRedirect(w, r, "Beállítás mentve.") } // sharingBrowseHandler is the folder picker (GET /api/sharing/browse?path=). With no path it lists // the registered live storage roots; otherwise the sub-DIRECTORIES of a guard-approved path, sorted. // Deny-listed children are omitted so the picker never offers an unshareable folder. func (s *Server) sharingBrowseHandler(w http.ResponseWriter, r *http.Request) { type entry struct { Name string `json:"name"` Path string `json:"path"` } resp := struct { Path string `json:"path"` Parent string `json:"parent,omitempty"` Entries []entry `json:"entries"` Error string `json:"error,omitempty"` }{Entries: []entry{}} raw := r.URL.Query().Get("path") if strings.TrimSpace(raw) == "" { for _, sp := range s.settings.GetStoragePaths() { if sp.Decommissioned || sp.Disconnected { continue } label := sp.Label if label == "" { label = filepath.Base(sp.Path) } resp.Entries = append(resp.Entries, entry{Name: label, Path: sp.Path}) } sort.Slice(resp.Entries, func(i, j int) bool { return resp.Entries[i].Name < resp.Entries[j].Name }) writeSharingJSON(w, http.StatusOK, resp) return } dir, err := s.sharingResolvePath(raw) if err != nil { resp.Error = errNotShareable.Error() writeSharingJSON(w, http.StatusBadRequest, resp) return } resp.Path = dir if root, ok := s.sharingOwningRoot(dir); ok && dir != root { resp.Parent = filepath.Dir(dir) } items, rerr := os.ReadDir(dir) if rerr != nil { resp.Error = "A mappa nem érhető el." writeSharingJSON(w, http.StatusBadRequest, resp) return } root, _ := s.sharingOwningRoot(dir) denied := stacks.SharingDeniedRoots(root) for _, it := range items { if !it.IsDir() || strings.HasPrefix(it.Name(), ".") { continue } child := filepath.Join(dir, it.Name()) skip := false for _, d := range denied { if pathWithin(child, d) { skip = true break } } if skip { continue } resp.Entries = append(resp.Entries, entry{Name: it.Name(), Path: child}) } sort.Slice(resp.Entries, func(i, j int) bool { return resp.Entries[i].Name < resp.Entries[j].Name }) writeSharingJSON(w, http.StatusOK, resp) } func writeSharingJSON(w http.ResponseWriter, code int, v interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) if err := json.NewEncoder(w).Encode(v); err != nil { fmt.Fprintf(w, `{"error":"encode"}`) } }