5270bad76e
gates / gates (push) Successful in 23s
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still Hungarian literals in Go, so an English household clicked an English button and was answered in Hungarian. 226 of them move into the bundle here. A flash was the hard part: it travels inside the redirect URL and is rendered by a DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by an older controller carries prose and is shown verbatim — never a raw key, never dropped. Also converted: page data and view-model text, the internal/api JSON answers, the alert banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at display, and the four page titles built around an app name (R-566 closed). Hungarian is byte-identical, and that is measured rather than read: scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict. Its own first version filtered the capture through an ASCII-Hungarian word list and missed seven real literals — the R-565 class. The filter is gone. Nothing on the wire moved, and wire goldens now hold it there: the report's health warnings and every notify event message stay Hungarian, because the hub MAILS the controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
589 lines
22 KiB
Go
589 lines
22 KiB
Go
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
|
|
}
|
|
|
|
// sharingResolveStorageRoot validates a storage ROOT chosen for the "new folder" flow.
|
|
//
|
|
// This is deliberately NOT sharingResolvePath: that one validates a SHARE TARGET and therefore
|
|
// refuses the drive root itself (a whole drive is never shareable). Here the root is not the share —
|
|
// the new folder is created UNDER it — so the accept condition is "is EXACTLY a registered, live
|
|
// storage root", which is strictly tighter than the share-target guard.
|
|
func (s *Server) sharingResolveStorageRoot(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
|
|
}
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Decommissioned || sp.Disconnected {
|
|
continue
|
|
}
|
|
spResolved, serr := filepath.EvalSymlinks(sp.Path)
|
|
if serr != nil {
|
|
spResolved = filepath.Clean(sp.Path)
|
|
}
|
|
if filepath.Clean(resolved) == filepath.Clean(spResolved) {
|
|
return resolved, nil
|
|
}
|
|
}
|
|
return "", errNotShareable
|
|
}
|
|
|
|
// ShareRow is one row of the shares table. It is a PACKAGE-LEVEL type, not a function-local struct,
|
|
// so the render test constructs the exact shape the handler passes: this template reads .System and
|
|
// .Available, and a field present in one and missing from the other is a render-time 500 that no
|
|
// handler test would catch (the template-gate class this project has hit four times).
|
|
type ShareRow struct {
|
|
Name string
|
|
Path string
|
|
ReadOnly bool
|
|
Offsite bool
|
|
System bool // controller-owned (R-75): no delete button, and the handler refuses it anyway
|
|
Available bool
|
|
}
|
|
|
|
// sharingPageData assembles the „Megosztás" page state.
|
|
func (s *Server) sharingPageData() map[string]interface{} {
|
|
data := s.settingsBaseData("sharing", "Hálózati megosztás")
|
|
data["TitleKey"] = "page.title.sharing" // i18n: the Hungarian title above is what hu renders
|
|
smb := s.settings.GetSMBSettings()
|
|
data["SMBEnabled"] = smb.Enabled
|
|
data["SMBServerName"] = smb.EffectiveServerName()
|
|
data["SMBUserSet"] = smb.UserSet
|
|
data["SMBRunning"] = s.stackMgr != nil && s.stackMgr.SambaRunning()
|
|
|
|
// „Csatlakozás a megosztáshoz" (v0.151.0, S-2): the page has always shown the configured NAME and
|
|
// never an address, so a customer whose network does not resolve the name had nothing to fall
|
|
// back on and guessed — which is how this diagnosis started, with the Proxmox HOST's IP typed
|
|
// into Finder (DIAG-sharing-2026-07-20.md). Derived FRESH on every render and cached nowhere:
|
|
// the guest holds this address by DHCP, so a stored copy is a copy that eventually misdirects
|
|
// people (S-5). "" simply omits the line — an address-less page beats a wrong address.
|
|
if smb.Enabled {
|
|
data["SMBDirectAddress"] = s.sambaLANAddress()
|
|
}
|
|
|
|
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,
|
|
System: sh.System,
|
|
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
|
|
|
|
// R-7b: per-tier backup truth. Until R-7b the „Felhőmentés" toggle promised a protection the
|
|
// engines did not deliver; these two lines are what makes the promise checkable by the customer
|
|
// rather than taken on faith. Amber ONLY on deviation — a green tier says nothing at all beyond
|
|
// its timestamp, so the page stays quiet when everything is fine.
|
|
if s.backupMgr != nil {
|
|
if cd := s.backupMgr.SharesTier2Status(); cd != nil {
|
|
data["SharesTier2Status"] = cd.LastStatus
|
|
data["SharesTier2LastRun"] = cd.LastRun
|
|
data["SharesTier2Warning"] = cd.LastWarning
|
|
data["SharesTier2Error"] = cd.LastError
|
|
data["SharesTier2Dest"] = cd.DestinationPath
|
|
}
|
|
if lastRun, status, count, ok := s.backupMgr.SharesOffsiteStatus(); ok {
|
|
data["SharesOffsiteStatus"] = status
|
|
data["SharesOffsiteLastRun"] = lastRun
|
|
data["SharesOffsiteCount"] = count
|
|
}
|
|
data["SharesRestoreReady"] = s.backupMgr.SharesScratchReady()
|
|
}
|
|
return data
|
|
}
|
|
|
|
// sambaLANAddress resolves the connect-address seam. Never cached at this level either — the seam
|
|
// exists so tests can supply an address without docker, not so anyone can memoize one.
|
|
func (s *Server) sambaLANAddress() string {
|
|
if s.sambaAddrFn != nil {
|
|
return s.sambaAddrFn()
|
|
}
|
|
if s.stackMgr == nil {
|
|
return ""
|
|
}
|
|
return s.stackMgr.SambaLANAddress()
|
|
}
|
|
|
|
// guestGateway resolves the guest's default gateway for the Hálózat card (R-66) — the sibling of
|
|
// sambaLANAddress with the identical contract: live per render, never stored, "" = the row shows
|
|
// „—". The read goes through the samba-container netns door (stacks/guestnet.go) because the
|
|
// controller's OWN /proc/net/route answers for the docker bridge (172.x) — the S-2 wrong answer.
|
|
func (s *Server) guestGateway() string {
|
|
if s.guestGatewayFn != nil {
|
|
return s.guestGatewayFn()
|
|
}
|
|
if s.stackMgr == nil {
|
|
return ""
|
|
}
|
|
return s.stackMgr.GuestGateway()
|
|
}
|
|
|
|
// guestNetSnapshot resolves the Debug dump's network section (R-66); same seam shape.
|
|
func (s *Server) guestNetSnapshot() stacks.GuestNetSnapshot {
|
|
if s.guestNetFn != nil {
|
|
return s.guestNetFn()
|
|
}
|
|
if s.stackMgr == nil {
|
|
return stacks.GuestNetSnapshot{Errors: map[string]string{
|
|
"interfaces": "stack manager unavailable",
|
|
"route": "stack manager unavailable",
|
|
"dns": "stack manager unavailable",
|
|
}}
|
|
}
|
|
return s.stackMgr.GuestNetSnapshot()
|
|
}
|
|
|
|
func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
|
|
data := s.sharingPageData()
|
|
if f := s.flashFrom(r, "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?"+flashQuery("flash", 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, "flash.sharing.save_failed")
|
|
return
|
|
}
|
|
}
|
|
if err := s.settings.SetSMBEnabled(enable); err != nil {
|
|
sharingRedirect(w, r, "flash.sharing.save_failed")
|
|
return
|
|
}
|
|
if !enable {
|
|
if err := s.stackMgr.DisableSamba(); err != nil {
|
|
s.logger.Printf("[WARN] [sharing] disable failed: %v", err)
|
|
}
|
|
sharingRedirect(w, r, "flash.sharing.disabled")
|
|
return
|
|
}
|
|
// R-75: the canonical drop-zone share exists whenever sharing is ON — and NEVER before. Enabling
|
|
// sharing is the customer's decision (it puts SMB on the household LAN and demands a household
|
|
// password); deploying a drop-zone app must not trigger it. "Mandatory" here means "always present
|
|
// once sharing is on", not "turns sharing on".
|
|
if err := s.ensureImportShare(); err != nil {
|
|
s.logger.Printf("[WARN] [sharing] could not ensure the import share: %v", err)
|
|
}
|
|
|
|
// v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form
|
|
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
|
|
// regardless of whether anything actually came up.
|
|
if !s.startSambaEnsure() {
|
|
sharingRedirect(w, r, "flash.sharing.prepare_running")
|
|
return
|
|
}
|
|
sharingRedirect(w, r, "flash.sharing.saved_preparing")
|
|
}
|
|
|
|
// sharingStatusHandler is the 4b poll target (GET /sharing/status). It carries TWO independent
|
|
// channels in one envelope, and keeping them apart is the whole point of this handler:
|
|
//
|
|
// phase — the ensure JOB. The client treats a terminal `running` as an EDGE ("the bring-up I was
|
|
// watching just succeeded") and reloads once to repaint the server-rendered badge.
|
|
// running — the service LEVEL, straight from the liveness probe. True whenever the container is up,
|
|
// with or without a job, and it is what makes a page loaded after the job finished (or
|
|
// after a controller restart, when the in-memory job is gone) still show the truth.
|
|
//
|
|
// v0.147.0 coerced `idle` → `running` here so that liveness could never be contradicted by a missing
|
|
// job. That duty belongs to — and was already discharged by — the `running` field beside it; on the
|
|
// phase channel the same value reads as a fresh terminal edge, so the client reloaded on the FIRST
|
|
// poll of every steady-state page load and the page looped at ~1.2s forever
|
|
// (felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md, S-1). The coercion is gone: no job,
|
|
// no edge. See consumeIfRunning for the other half — a REAL bring-up must be reported exactly once.
|
|
func (s *Server) sharingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
phase := sambaPhaseIdle
|
|
errMsg := ""
|
|
if job := s.sambaEnsure.consumeIfRunning(); job != nil {
|
|
phase, errMsg = job.Phase, job.Error
|
|
}
|
|
running := s.stackMgr != nil && s.stackMgr.SambaRunning()
|
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
|
"phase": phase, "error": errMsg, "running": running,
|
|
})
|
|
}
|
|
|
|
// 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, "flash.sharing.pw_too_short")
|
|
return
|
|
}
|
|
if pw != pw2 {
|
|
sharingRedirect(w, r, "flash.sharing.pw_mismatch")
|
|
return
|
|
}
|
|
if err := s.stackMgr.SetSMBPassword(pw); err != nil {
|
|
s.logger.Printf("[ERROR] [sharing] password apply failed: %v", err)
|
|
sharingRedirect(w, r, "flash.sharing.pw_set_failed")
|
|
return
|
|
}
|
|
// Setting the password is the moment the stack ACTUALLY first comes up: with UserSet false,
|
|
// reconcile deliberately deploys nothing, so on a fresh box this — not the enable toggle — is
|
|
// where the image pull happens. Same detached job, same card.
|
|
if !s.startSambaEnsure() {
|
|
sharingRedirect(w, r, "flash.sharing.pw_set_preparing_already")
|
|
return
|
|
}
|
|
sharingRedirect(w, r, "flash.sharing.pw_set_preparing")
|
|
}
|
|
|
|
// 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.sharingResolveStorageRoot(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, "flash.sharing.folder_failed")
|
|
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, "flash.sharing.created")
|
|
}
|
|
|
|
// 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"))
|
|
// SERVER-SIDE refusal for controller-owned shares (R-75), BEFORE any mutation. The template also
|
|
// omits the button; both are required and they prove different things — a render gate is not
|
|
// enforcement, and a handler check is not reachability (the v0.70.1 ghost-delete lesson).
|
|
for _, sh := range s.settings.GetSMBShares() {
|
|
if strings.EqualFold(sh.Name, name) && sh.System {
|
|
sharingRedirect(w, r, "flash.sharing.protected")
|
|
return
|
|
}
|
|
}
|
|
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, "flash.sharing.deleted")
|
|
}
|
|
|
|
// 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, "flash.sharing.saved")
|
|
}
|
|
|
|
// ServeSharingAPI dispatches the /api/sharing/* XHR endpoints. Registered on the mux in main.go
|
|
// behind RequireAuth+CsrfProtect (the /api/ subtree is claimed there, NOT in the web ServeHTTP
|
|
// switch — a case added there would be shadowed by the apiRouter catch-all and 401).
|
|
func (s *Server) ServeSharingAPI(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/api/sharing/browse" && r.Method == http.MethodGet:
|
|
s.sharingBrowseHandler(w, r)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// 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"}`)
|
|
}
|
|
}
|
|
|
|
// ensureImportShare creates the controller-owned drop-zone share (R-75) if it is not already there.
|
|
// Idempotent, and a no-op when the import root is unresolvable.
|
|
//
|
|
// It writes to the store DIRECTLY rather than going through sharingResolvePath: that guard validates
|
|
// paths a CUSTOMER supplied through the picker, and refuses anything outside a registered storage
|
|
// root. The system drive is deliberately not registered (registering it would make a 50 GB volume
|
|
// holding the recovery units a customer-visible drive, a deploy target and a wipe candidate), so the
|
|
// guard would refuse this path — correctly, for customer input. A controller-generated constant is a
|
|
// different trust class.
|
|
//
|
|
// Offsite is FALSE: the drop-zone is class `excluded` data, and shipping an inbox offsite would
|
|
// contradict the class the backup engines already act on.
|
|
func (s *Server) ensureImportShare() error {
|
|
if s.stackMgr == nil {
|
|
return nil
|
|
}
|
|
root := s.stackMgr.GetImportRoot()
|
|
if root == "" {
|
|
return nil
|
|
}
|
|
for _, sh := range s.settings.GetSMBShares() {
|
|
if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
|
|
return nil // already present
|
|
}
|
|
}
|
|
if err := s.stackMgr.EnsureImportRoot(); err != nil {
|
|
return err
|
|
}
|
|
return s.settings.AddSMBShare(settings.SMBShare{
|
|
Name: settings.SystemImportShareName,
|
|
Path: root,
|
|
ReadOnly: false,
|
|
Offsite: false,
|
|
System: true,
|
|
})
|
|
}
|