feat(samba): Megosztas page + guarded folder picker (R-7 slice 1, Part 3)

New top-nav category with the Halozati megosztas page: enable/server-name card,
household password, shares table (Nev/Mappa/Irasvedett/Felhomentes/Torles), and
a create flow (new folder under <storage>/shares or an existing folder via the
browse modal). Every customer path goes through sharingResolvePath: absolute ->
EvalSymlinks -> containment in a registered live storage root -> deny-listed
system subtree check -> is-a-directory. Refusals are UNIFORM so the picker is
never a filesystem oracle. Deny-list derived from ProtectedHDDPaths (provably a
subset); the drive root is an exact-match denial so user-data folders under it
stay shareable. samba infra metadata + i-share icon. Gates green.
This commit is contained in:
2026-07-18 11:45:18 +02:00
parent 1d26a69dd4
commit 4f08e5e7c3
8 changed files with 807 additions and 0 deletions
+33
View File
@@ -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)
+6
View File
@@ -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
+15
View File
@@ -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:
+375
View File
@@ -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
// <storage>/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"}`)
}
}
@@ -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")
}
}
@@ -12,6 +12,7 @@
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
@@ -67,6 +67,11 @@
<li><a href="/backups/restore" class="{{if eq .Page "backups-restore"}}active{{end}}">Visszaállítás</a></li>
</ul>
</li>
<li><a href="/sharing" class="{{if eq .Page "sharing"}}active{{end}}"><svg class="ico"><use href="#i-share"/></svg>Megosztás</a>
<ul class="nav-links nav-links-sub nav-links-nested">
<li><a href="/sharing" class="{{if eq .Page "sharing"}}active{{end}}">Hálózati megosztás</a></li>
</ul>
</li>
<li><a href="/monitoring" class="{{if eq .Page "monitoring"}}active{{end}}"><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
<li><a href="/debug" class="{{if eq .Page "debug"}}active{{end}}"><svg class="ico"><use href="#i-wrench"/></svg>Debug</a></li>
</ul>
@@ -0,0 +1,243 @@
{{define "sharing"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Hálózati megosztás</h2>
</div>
{{if .Flash}}
<div class="alert alert-success">{{.Flash}}</div>
{{end}}
<div class="settings-card">
<p class="settings-card-desc">
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 <strong>csak az otthoni hálózaton</strong> érhető el — az
internetről nem.
</p>
<form method="POST" action="/sharing/enable">
{{.CSRFField}}
<div class="form-group">
<label>
<input type="checkbox" name="enabled" {{if .SMBEnabled}}checked{{end}}>
Megosztás engedélyezése
</label>
</div>
<div class="form-group">
<label for="server_name">Kiszolgáló neve</label>
<input type="text" id="server_name" name="server_name" class="form-control mono"
value="{{.SMBServerName}}" maxlength="15" autocomplete="off">
<div class="form-hint">
Ezen a néven jelenik meg a gép a hálózaton (<span class="mono">\\{{.SMBServerName}}</span>).
Legfeljebb 15 karakter: betű, szám, kötőjel, aláhúzás.
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Mentés</button>
</div>
</form>
<div class="form-group">
<span>Állapot:</span>
{{if .SMBRunning}}
<span class="badge badge-ok">fut</span>
{{else}}
<span class="badge badge-neutral">áll</span>
{{end}}
{{if and .SMBEnabled (not .SMBUserSet)}}
<span class="badge badge-warn">először adj meg jelszót</span>
{{end}}
</div>
<div class="form-hint">
Egyes hálózatokon a gép késve jelenik meg az eszközlistában. A
<span class="mono">\\{{.SMBServerName}}</span> cím beírása a címsorba ilyenkor is működik.
</div>
</div>
<div class="settings-card">
<h3>Megosztási jelszó beállítása</h3>
<p class="settings-card-desc">
Egy közös jelszó tartozik a háztartáshoz. A csatlakozáskor felhasználónévnek add meg:
<span class="mono">felhom</span>.
</p>
<form method="POST" action="/sharing/password">
{{.CSRFField}}
<div class="form-group">
<label for="smb_password">Új jelszó</label>
<input type="password" id="smb_password" name="smb_password" class="form-control"
autocomplete="new-password" minlength="8" required>
</div>
<div class="form-group">
<label for="smb_password_confirm">Jelszó megerősítése</label>
<input type="password" id="smb_password_confirm" name="smb_password_confirm" class="form-control"
autocomplete="new-password" minlength="8" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Jelszó mentése</button>
</div>
</form>
</div>
<div class="settings-card">
<h3>Megosztott mappák</h3>
{{if .SMBShares}}
<div class="backup-table-wrap">
<table class="db-table">
<thead>
<tr>
<th>Név</th>
<th>Mappa</th>
<th>Írásvédett</th>
<th>Felhőmentés</th>
<th>Törlés</th>
</tr>
</thead>
<tbody>
{{range .SMBShares}}
<tr>
<td class="mono">{{.Name}}</td>
<td class="mono">{{.Path}}
{{if not .Available}}<span class="badge badge-warn">A meghajtó nem elérhető.</span>{{end}}
</td>
<td>{{if .ReadOnly}}<span class="badge badge-neutral">igen</span>{{else}}nem{{end}}</td>
<td>
<form method="POST" action="/sharing/shares/offsite">
{{$.CSRFField}}
<input type="hidden" name="name" value="{{.Name}}">
<input type="hidden" name="offsite" value="{{if .Offsite}}off{{else}}on{{end}}">
<button type="submit" class="btn btn-xs btn-outline">
{{if .Offsite}}bekapcsolva{{else}}kikapcsolva{{end}}
</button>
</form>
</td>
<td>
<form method="POST" action="/sharing/shares/delete">
{{$.CSRFField}}
<input type="hidden" name="name" value="{{.Name}}">
<button type="submit" class="btn btn-xs btn-danger-outline">Törlés</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="form-hint">
A megosztás törlésekor <strong>a mappa és a fájlok megmaradnak</strong> — csak a hálózati
elérés szűnik meg.
</div>
{{else}}
<div class="backup-table-empty">Még nincs megosztott mappa.</div>
{{end}}
</div>
<div class="settings-card">
<h3>Új megosztás</h3>
<form method="POST" action="/sharing/shares" id="share-create-form">
{{.CSRFField}}
<div class="form-group">
<label for="share_name">Megosztás neve <span class="required">*</span></label>
<input type="text" id="share_name" name="name" class="form-control mono"
maxlength="15" required autocomplete="off">
<div class="form-hint">Legfeljebb 15 karakter: betű, szám, kötőjel, aláhúzás.</div>
</div>
<div class="form-group">
<label>
<input type="radio" name="mode" value="new" checked onclick="shareModeSwitch()">
Új mappa a tárhelyen
</label>
<label>
<input type="radio" name="mode" value="existing" onclick="shareModeSwitch()">
Meglévő mappa kiválasztása
</label>
</div>
<div class="form-group" id="share-mode-new">
<label for="storage_root">Tárhely</label>
<select id="storage_root" name="storage_root" class="form-control">
{{range .StorageRoots}}
<option value="{{.Path}}">{{.Label}}</option>
{{end}}
</select>
<div class="form-hint">A mappa a kiválasztott tárhely <span class="mono">shares/</span> könyvtárában jön létre.</div>
</div>
<div class="form-group" id="share-mode-existing" style="display:none">
<label>Kiválasztott mappa</label>
<input type="text" id="share_path" name="path" class="form-control mono" readonly>
<div class="form-actions">
<button type="button" class="btn btn-sm btn-outline" onclick="shareBrowseOpen()">Tallózás</button>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="read_only">
Írásvédett (csak olvasható)
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Megosztás létrehozása</button>
</div>
</form>
</div>
<div class="confirm-overlay" id="share-browse-overlay" style="display:none">
<div class="confirm-box">
<h3>Mappa kiválasztása</h3>
<div class="mono" id="share-browse-path"></div>
<div id="share-browse-list"></div>
<div class="form-actions">
<button type="button" class="btn btn-sm btn-primary" onclick="shareBrowsePick()">Ezt választom</button>
<button type="button" class="btn btn-sm btn-outline" onclick="shareBrowseClose()">Mégse</button>
</div>
</div>
</div>
<script>
var shareBrowseCurrent = "";
function shareModeSwitch(){
var mode = document.querySelector('input[name="mode"]:checked').value;
document.getElementById('share-mode-new').style.display = (mode === 'new') ? '' : 'none';
document.getElementById('share-mode-existing').style.display = (mode === 'existing') ? '' : 'none';
}
function shareBrowseOpen(){
document.getElementById('share-browse-overlay').style.display = '';
shareBrowseLoad('');
}
function shareBrowseClose(){
document.getElementById('share-browse-overlay').style.display = 'none';
}
function shareBrowsePick(){
if(shareBrowseCurrent){ document.getElementById('share_path').value = shareBrowseCurrent; }
shareBrowseClose();
}
function shareBrowseLoad(p){
fetch('/api/sharing/browse?path=' + encodeURIComponent(p))
.then(function(r){ return r.json(); })
.then(function(d){
shareBrowseCurrent = d.path || "";
document.getElementById('share-browse-path').textContent = d.error ? d.error : (d.path || 'Tárhelyek');
var list = document.getElementById('share-browse-list');
list.innerHTML = '';
if(d.parent){
var up = document.createElement('div');
up.innerHTML = '<button type="button" class="btn btn-xs btn-outline">.. vissza</button>';
up.firstChild.onclick = function(){ shareBrowseLoad(d.parent); };
list.appendChild(up);
}
(d.entries || []).forEach(function(e){
var row = document.createElement('div');
row.innerHTML = '<button type="button" class="btn btn-xs btn-outline"></button>';
row.firstChild.textContent = e.name;
row.firstChild.onclick = function(){ shareBrowseLoad(e.path); };
list.appendChild(row);
});
});
}
</script>
{{template "layout_end" .}}
{{end}}