diff --git a/controller/internal/stacks/samba.go b/controller/internal/stacks/samba.go index 404f750..aaaf116 100644 --- a/controller/internal/stacks/samba.go +++ b/controller/internal/stacks/samba.go @@ -29,6 +29,39 @@ const ( sambaUID = 1000 ) +// SharingDeniedRoots returns the SYSTEM subset of ProtectedHDDPaths(root) whose SUBTREES may never +// be exported over SMB: appdata/ (live app databases — writable SMB access to them is the [R3] +// corruption foot-gun), backups/, and the legacy felhom-data nest. +// +// The drive root itself is deliberately NOT in this set: it is denied by an EXACT-match check at the +// call site ("a whole drive is not shareable"). Putting it here would make every path under the drive +// — i.e. every legitimate share — match the subtree rule and be refused. +// +// It is DERIVED from ProtectedHDDPaths, never a parallel list: each candidate is emitted only if that +// guard already contains it, so this set can only ever SHRINK relative to the delete guard — it can +// never drift into a stale second source of truth. media/ and Dokumentumok/ are protected THERE as +// delete targets but are customer data and stay shareable (Scenario B shares media/filmek). +func SharingDeniedRoots(root string) []string { + if root == "" { + return nil + } + protected := ProtectedHDDPaths(root) + candidates := []string{ + filepath.Join(root, "appdata"), + filepath.Join(root, "backups"), + filepath.Join(root, felhomDataDir), + filepath.Join(root, felhomDataDir, "appdata"), + filepath.Join(root, felhomDataDir, "backups"), + } + var out []string + for _, c := range candidates { + if protected[c] { + out = append(out, c) + } + } + return out +} + // sambaDir is the samba stack directory. func (m *Manager) sambaDir() string { return filepath.Join(m.cfg.Paths.StacksDir, SambaStackName) diff --git a/controller/internal/web/inframeta.go b/controller/internal/web/inframeta.go index 1e17082..0db05b1 100644 --- a/controller/internal/web/inframeta.go +++ b/controller/internal/web/inframeta.go @@ -31,6 +31,12 @@ var infraMetaMap = map[string]InfraMeta{ Description: "Fájlkezelő — a tárhely fájljainak böngészése a böngészőből.", Linked: true, }, + // R-7: the LAN sharing stack has no web UI of its own (it is reached from Windows Intéző / + // Mac Finder), so Linked stays false — it is configured on the „Megosztás" page. + "samba": { + DisplayName: "Hálózati megosztás", + Description: "Fájlmegosztás a helyi hálózaton — a mappák a Windows Intézőben és a Mac Finderben jelennek meg.", + }, } // infraMetaFor returns the curated metadata for a protected infra stack, nil for diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index e32715f..96bd110 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -354,6 +354,21 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.storagePageHandler(w, r) case path == "/storage/network" && r.Method == http.MethodGet: s.storageNetworkPageHandler(w, r) + // „Megosztás" — LAN network sharing (R-7 slice 1) + case path == "/sharing" && r.Method == http.MethodGet: + s.sharingPageHandler(w, r) + case path == "/sharing/enable" && r.Method == http.MethodPost: + s.sharingEnableHandler(w, r) + case path == "/sharing/password" && r.Method == http.MethodPost: + s.sharingPasswordHandler(w, r) + case path == "/sharing/shares" && r.Method == http.MethodPost: + s.sharingShareCreateHandler(w, r) + case path == "/sharing/shares/delete" && r.Method == http.MethodPost: + s.sharingShareDeleteHandler(w, r) + case path == "/sharing/shares/offsite" && r.Method == http.MethodPost: + s.sharingShareOffsiteHandler(w, r) + case path == "/api/sharing/browse" && r.Method == http.MethodGet: + s.sharingBrowseHandler(w, r) case path == "/settings/notifications" && r.Method == http.MethodGet: s.settingsNotificationsPageHandler(w, r) case path == "/settings/security" && r.Method == http.MethodGet: diff --git a/controller/internal/web/sharing_handlers.go b/controller/internal/web/sharing_handlers.go new file mode 100644 index 0000000..e51a010 --- /dev/null +++ b/controller/internal/web/sharing_handlers.go @@ -0,0 +1,375 @@ +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"}`) + } +} diff --git a/controller/internal/web/sharing_handlers_test.go b/controller/internal/web/sharing_handlers_test.go new file mode 100644 index 0000000..45daa76 --- /dev/null +++ b/controller/internal/web/sharing_handlers_test.go @@ -0,0 +1,129 @@ +package web + +import ( + "io" + "log" + "os" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// newSharingServer returns a Server with ONE registered storage root laid out like a real drive +// (appdata/, backups/, media/filmek, shares/) so the guard runs against a realistic tree. +func newSharingServer(t *testing.T) (*Server, string) { + t.Helper() + lg := log.New(io.Discard, "", 0) + root := t.TempDir() + drive := filepath.Join(root, "drive") + for _, d := range []string{"appdata/paperless", "backups/unit", "media/filmek", "shares"} { + if err := os.MkdirAll(filepath.Join(drive, filepath.FromSlash(d)), 0o755); err != nil { + t.Fatal(err) + } + } + sett, err := settings.Load(filepath.Join(root, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "teszt", Schedulable: true}); err != nil { + t.Fatal(err) + } + return &Server{settings: sett, logger: lg, cfg: &config.Config{}}, drive +} + +// Scenario C: the picker/create guard matrix. Every refusal must ALSO be a non-effect. +func TestSharingResolvePath_GuardMatrix(t *testing.T) { + s, drive := newSharingServer(t) + outside := t.TempDir() // a real dir, but under no registered storage root + + refused := []struct{ name, path string }{ + {"appdata subtree (live app DB)", filepath.Join(drive, "appdata", "paperless")}, + {"appdata root", filepath.Join(drive, "appdata")}, + {"backups subtree", filepath.Join(drive, "backups", "unit")}, + {"the drive root itself", drive}, + {"outside every registered root", outside}, + {"relative path", "nem/abszolut"}, + {"empty", ""}, + {"traversal out of the root", filepath.Join(drive, "..", "escape")}, + {"nonexistent", filepath.Join(drive, "nincs-ilyen")}, + } + for _, tc := range refused { + if got, err := s.sharingResolvePath(tc.path); err == nil { + t.Errorf("%s: must be refused, got %q", tc.name, got) + } + } + + // The guard is not "deny everything": a real user-data folder IS shareable (Scenario B shares + // media/filmek). Without this the refusal tests above would pass on a broken always-deny guard. + good := filepath.Join(drive, "media", "filmek") + if _, err := s.sharingResolvePath(good); err != nil { + t.Errorf("a user-data folder must be shareable, got %v", err) + } + if _, err := s.sharingResolvePath(filepath.Join(drive, "shares")); err != nil { + t.Errorf("the shares dir must be shareable, got %v", err) + } +} + +// A symlink planted INSIDE a registered root that points OUTSIDE it must not escape the guard — +// this is why EvalSymlinks runs before the containment assert. +func TestSharingResolvePath_SymlinkEscapeRefused(t *testing.T) { + s, drive := newSharingServer(t) + outside := t.TempDir() + link := filepath.Join(drive, "media", "escape-link") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlinks unavailable on this platform/privilege level: %v", err) + } + if got, err := s.sharingResolvePath(link); err == nil { + t.Errorf("a symlink escaping the storage root must be refused, resolved to %q", got) + } +} + +// A decommissioned storage root stops being shareable. +func TestSharingResolvePath_DecommissionedRootRefused(t *testing.T) { + s, drive := newSharingServer(t) + good := filepath.Join(drive, "media", "filmek") + if _, err := s.sharingResolvePath(good); err != nil { + t.Fatalf("precondition: %v", err) + } + if err := s.settings.SetDecommissioned(drive, ""); err != nil { + t.Fatal(err) + } + if _, err := s.sharingResolvePath(good); err == nil { + t.Error("a decommissioned storage root must not be shareable") + } +} + +// Every refusal returns the SAME message — a per-reason message would make the picker an oracle +// for the existence/contents of paths outside the customer's storage. +func TestSharingResolvePath_UniformRefusal(t *testing.T) { + s, drive := newSharingServer(t) + outside := t.TempDir() + for _, p := range []string{ + filepath.Join(drive, "appdata"), + outside, + filepath.Join(drive, "nincs-ilyen"), + } { + _, err := s.sharingResolvePath(p) + if err == nil { + t.Fatalf("%s should refuse", p) + } + if err.Error() != errNotShareable.Error() { + t.Errorf("refusal message must be uniform, got %q for %s", err.Error(), p) + } + } +} + +// pathWithin must be segment-wise: a sibling directory sharing a name PREFIX is not containment. +func TestPathWithin_SiblingPrefixIsNotContainment(t *testing.T) { + if pathWithin("/mnt/drive-evil/x", "/mnt/drive") { + t.Error("/mnt/drive-evil must NOT count as inside /mnt/drive") + } + if !pathWithin("/mnt/drive/x", "/mnt/drive") { + t.Error("/mnt/drive/x must count as inside /mnt/drive") + } + if !pathWithin("/mnt/drive", "/mnt/drive") { + t.Error("a root is within itself") + } +} diff --git a/controller/internal/web/templates/icons.html b/controller/internal/web/templates/icons.html index 04b0d32..df0ac88 100644 --- a/controller/internal/web/templates/icons.html +++ b/controller/internal/web/templates/icons.html @@ -12,6 +12,7 @@ + diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index fe4b260..ed71186 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -67,6 +67,11 @@
  • Visszaállítás
  • +
  • Megosztás + +
  • Rendszermonitor
  • Debug
  • diff --git a/controller/internal/web/templates/sharing.html b/controller/internal/web/templates/sharing.html new file mode 100644 index 0000000..b5af093 --- /dev/null +++ b/controller/internal/web/templates/sharing.html @@ -0,0 +1,243 @@ +{{define "sharing"}} +{{template "layout_start" .}} + + + +{{if .Flash}} +
    {{.Flash}}
    +{{end}} + +
    +

    + A megosztott mappák a Windows Intézőben és a Mac Finderben jelennek meg, mintha egy hálózati + meghajtó lennének. A megosztás csak az otthoni hálózaton érhető el — az + internetről nem. +

    + +
    + {{.CSRFField}} +
    + +
    +
    + + +
    + Ezen a néven jelenik meg a gép a hálózaton (\\{{.SMBServerName}}). + Legfeljebb 15 karakter: betű, szám, kötőjel, aláhúzás. +
    +
    +
    + +
    +
    + +
    + Állapot: + {{if .SMBRunning}} + fut + {{else}} + áll + {{end}} + {{if and .SMBEnabled (not .SMBUserSet)}} + először adj meg jelszót + {{end}} +
    +
    + Egyes hálózatokon a gép késve jelenik meg az eszközlistában. A + \\{{.SMBServerName}} cím beírása a címsorba ilyenkor is működik. +
    +
    + +
    +

    Megosztási jelszó beállítása

    +

    + Egy közös jelszó tartozik a háztartáshoz. A csatlakozáskor felhasználónévnek add meg: + felhom. +

    +
    + {{.CSRFField}} +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + +
    +

    Megosztott mappák

    + {{if .SMBShares}} +
    + + + + + + + + + + + + {{range .SMBShares}} + + + + + + + + {{end}} + +
    NévMappaÍrásvédettFelhőmentésTörlés
    {{.Name}}{{.Path}} + {{if not .Available}}A meghajtó nem elérhető.{{end}} + {{if .ReadOnly}}igen{{else}}nem{{end}} +
    + {{$.CSRFField}} + + + +
    +
    +
    + {{$.CSRFField}} + + +
    +
    +
    +
    + A megosztás törlésekor a mappa és a fájlok megmaradnak — csak a hálózati + elérés szűnik meg. +
    + {{else}} +
    Még nincs megosztott mappa.
    + {{end}} +
    + +
    +

    Új megosztás

    +
    + {{.CSRFField}} +
    + + +
    Legfeljebb 15 karakter: betű, szám, kötőjel, aláhúzás.
    +
    + +
    + + +
    + +
    + + +
    A mappa a kiválasztott tárhely shares/ könyvtárában jön létre.
    +
    + + + +
    + +
    +
    + +
    +
    +
    + + + + + +{{template "layout_end" .}} +{{end}}