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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user