v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
This commit is contained in:
@@ -30,6 +30,7 @@ func (p *blockProvider) GetStackComposePath(string) (string, bool) { return "",
|
||||
func (p *blockProvider) ListDeployedStacks() []backup.StackSummary { return nil }
|
||||
func (p *blockProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *blockProvider) GetStackHDDPath(string) string { return p.hdd }
|
||||
func (p *blockProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *blockProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *blockProvider) StopStack(string) error {
|
||||
atomic.AddInt32(&p.stops, 1)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// „Hova tegyem a fájlokat?" — the app-page folder card (R-75).
|
||||
//
|
||||
// Rendered only for DEPLOYED apps that declare data_paths (Fork-2: all catalog apps get the
|
||||
// skeleton, but only deployed apps get a UI affordance — an empty folder for an app nobody installed
|
||||
// is the thing that would actually confuse someone).
|
||||
|
||||
// DataPathCard is one folder row on the app page.
|
||||
type DataPathCard struct {
|
||||
Label string // the catalog's Hungarian label
|
||||
Link string // FileBrowser deep link
|
||||
Consequence string // class-DRIVEN copy (never hand-written per app)
|
||||
IsImport bool // drives the free-space line
|
||||
FreeSpace string // system-drive headroom, import rows only ("" when unreadable)
|
||||
}
|
||||
|
||||
// consequenceFor maps a folder's DERIVED BACKUP CLASS to the sentence the customer reads (Fork-4).
|
||||
//
|
||||
// It is driven by the class, not by the role and not by a per-app string, so the promise the UI makes
|
||||
// can never drift from what the backup engines actually do. `excluded` means the tier filter drops it
|
||||
// at EVERY tier — so a drop-zone must say, in the customer's own language, that the folder is
|
||||
// temporary and unbacked. Saying anything softer would be a false promise about their files.
|
||||
func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole) string {
|
||||
switch class {
|
||||
case appbackup.ClassExcluded:
|
||||
if role == stacks.RoleImport {
|
||||
return "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — " +
|
||||
"ez a mappa átmeneti, és nem készül róla biztonsági mentés."
|
||||
}
|
||||
return "Ez a mappa átmeneti, és nem készül róla biztonsági mentés."
|
||||
case appbackup.ClassMandatory, appbackup.ClassOptional:
|
||||
return "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla."
|
||||
default:
|
||||
// No classification (legacy app, or a bind with no backup block). Say nothing rather than
|
||||
// guess — an unverified backup promise is worse than no sentence at all.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// buildDataPathCards turns an app's validated data_paths into rendered rows.
|
||||
//
|
||||
// classOf resolves a (root, relpath) to its backup class; missing ⇒ empty class ⇒ no consequence
|
||||
// line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead.
|
||||
func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard {
|
||||
if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
domain := s.cfg.Customer.Domain
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
classOf := map[string]appbackup.BindClass{}
|
||||
if binds, has := s.stackMgr.ClassifiedBinds(st.Name); has {
|
||||
for _, b := range binds {
|
||||
classOf[string(b.Root)+"\x00"+b.RelPath] = b.Class
|
||||
}
|
||||
}
|
||||
|
||||
var freeSpace string
|
||||
if s.stackMgr != nil {
|
||||
if root := s.stackMgr.GetImportRoot(); root != "" {
|
||||
if du := system.GetDiskUsage(root); du != nil {
|
||||
freeSpace = fmt.Sprintf("%.1f GB szabad", du.AvailGB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cards := make([]DataPathCard, 0, len(st.Meta.DataPaths))
|
||||
for _, dp := range st.Meta.DataPaths {
|
||||
var link string
|
||||
switch dp.Root {
|
||||
case appbackup.RootImport:
|
||||
link = importFolderLink(domain, dp.Path)
|
||||
case appbackup.RootUserdata:
|
||||
// A userdata folder lives on the app's OWN drive, so its FileBrowser source is that
|
||||
// drive's sidebar entry. Resolve it from the app's HDD_PATH; skip the row if we cannot.
|
||||
src := s.fbSourceNameForApp(st.Name)
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
link = fileBrowserLink(domain, src, dp.Path)
|
||||
default:
|
||||
// hdd: app-internal (appdata/) — NOT customer-browsable, FileBrowser does not mount it.
|
||||
// Surfacing a link here would 404. Skipped deliberately; the catalog should not annotate
|
||||
// an hdd path with a customer-facing role.
|
||||
continue
|
||||
}
|
||||
isImport := dp.Role == stacks.RoleImport
|
||||
card := DataPathCard{
|
||||
Label: dp.Label,
|
||||
Link: link,
|
||||
Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role),
|
||||
IsImport: isImport,
|
||||
}
|
||||
if isImport {
|
||||
// The system SSD filling is a different severity from a data drive filling — it can take
|
||||
// the whole guest down — and the customer has no other signal that the drop-zone is not
|
||||
// bottomless.
|
||||
card.FreeSpace = freeSpace
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// fbSourceNameForApp returns the FileBrowser sidebar source name for the drive an app is deployed
|
||||
// on: the storage path's label when it has one, else the mount basename — exactly what
|
||||
// RenderFileBrowserConfig emits, so the deep link and the sidebar can never disagree.
|
||||
func (s *Server) fbSourceNameForApp(stackName string) string {
|
||||
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
|
||||
if appCfg == nil {
|
||||
return ""
|
||||
}
|
||||
hdd := appCfg.Env["HDD_PATH"]
|
||||
if hdd == "" {
|
||||
return ""
|
||||
}
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path != hdd || sp.Decommissioned {
|
||||
continue
|
||||
}
|
||||
if sp.Label != "" {
|
||||
return sp.Label
|
||||
}
|
||||
return filepath.Base(sp.Path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// appInfoWithCards overlays the R-75 folder card onto the shared app_info fixture. It reuses
|
||||
// appInfoData (lifecycle_test.go) deliberately — that helper passes a *Metadata for a reason
|
||||
// documented there (value vs pointer receivers at render time); do not fork it.
|
||||
func appInfoWithCards(st stacks.Stack, cards []DataPathCard) map[string]interface{} {
|
||||
d := appInfoData(st)
|
||||
if len(cards) > 0 {
|
||||
d["DataPathCards"] = cards
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// SEAM-WIRING RULE: a conditional affordance ships with a render test PER BRANCH of its gate.
|
||||
// Branch 1 — cards present: the block renders, with the label, the deep link and the copy.
|
||||
func TestAppInfo_DataPathCardRenders(t *testing.T) {
|
||||
st := stacks.Stack{Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless-ngx"}}
|
||||
cards := []DataPathCard{{
|
||||
Label: "Beolvasandó dokumentumok",
|
||||
Link: importFolderLink("demo-felhom.eu", "paperless"),
|
||||
Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport),
|
||||
IsImport: true,
|
||||
FreeSpace: "44.8 GB szabad",
|
||||
}}
|
||||
html := renderBackupPage(t, "app_info", appInfoWithCards(st, cards))
|
||||
|
||||
if !strings.Contains(html, "Hova tegyem a fájlokat?") {
|
||||
t.Error("the folder card heading must render")
|
||||
}
|
||||
if !strings.Contains(html, "Beolvasandó dokumentumok") {
|
||||
t.Error("the catalog label must render")
|
||||
}
|
||||
// The deep link must reach the page INTACT — this is the assertion the brief asks for, and it is
|
||||
// what would have caught the v0.150.0 class of bug (a key rendered where another belonged).
|
||||
if !strings.Contains(html, "https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless") {
|
||||
t.Errorf("the FileBrowser deep link must render intact; body:\n%s", excerpt(html, "datapath"))
|
||||
}
|
||||
if !strings.Contains(html, "nem készül róla biztonsági mentés") {
|
||||
t.Error("an excluded drop-zone must say it is unbacked")
|
||||
}
|
||||
if !strings.Contains(html, "44.8 GB szabad") {
|
||||
t.Error("the system-drive free space must render on an import row")
|
||||
}
|
||||
// The copy must not promise a single click — a cold deep link goes through the FileBrowser login.
|
||||
if !strings.Contains(html, "be kell jelentkezned") {
|
||||
t.Error("the card must warn that a FileBrowser sign-in may be needed")
|
||||
}
|
||||
}
|
||||
|
||||
// Branch 2 — no cards: nothing renders, and in particular no empty heading.
|
||||
func TestAppInfo_NoDataPathCardsRendersNothing(t *testing.T) {
|
||||
st := stacks.Stack{Name: "docmost", Deployed: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "docmost", DisplayName: "Docmost"}}
|
||||
html := renderBackupPage(t, "app_info", appInfoWithCards(st, nil))
|
||||
if strings.Contains(html, "Hova tegyem a fájlokat?") {
|
||||
t.Error("an app with no data_paths must not render the folder card")
|
||||
}
|
||||
if strings.Contains(html, "datapath-row") {
|
||||
t.Error("no folder rows must render")
|
||||
}
|
||||
}
|
||||
|
||||
// Fork-4: the consequence line is CLASS-driven, so the UI can never promise a backup the engines do
|
||||
// not make. `excluded` is dropped at every tier — it must say so.
|
||||
func TestConsequenceIsClassDriven(t *testing.T) {
|
||||
imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport)
|
||||
if !strings.Contains(imp, "törli innen") || !strings.Contains(imp, "nem készül róla biztonsági mentés") {
|
||||
t.Errorf("an excluded import folder must say it is temporary AND unbacked: %q", imp)
|
||||
}
|
||||
for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} {
|
||||
lib := consequenceFor(cls, stacks.RoleLibrary)
|
||||
if !strings.Contains(lib, "Biztonsági mentés készül") {
|
||||
t.Errorf("class %q must promise a backup: %q", cls, lib)
|
||||
}
|
||||
if strings.Contains(lib, "nem készül") {
|
||||
t.Errorf("class %q must NOT say unbacked: %q", cls, lib)
|
||||
}
|
||||
}
|
||||
// An unclassified bind says NOTHING rather than guessing — an unverified backup promise about a
|
||||
// customer's files is worse than no sentence.
|
||||
if got := consequenceFor("", stacks.RoleLibrary); got != "" {
|
||||
t.Errorf("an unclassified path must produce no promise, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A card is only built for a DEPLOYED app (Fork-2: deployed-only in the UI).
|
||||
func TestBuildDataPathCards_UndeployedYieldsNothing(t *testing.T) {
|
||||
s := testServer(t)
|
||||
st := &stacks.Stack{Name: "paperless-ngx", Deployed: false,
|
||||
Meta: stacks.Metadata{Slug: "paperless-ngx", DataPaths: []stacks.DataPath{
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: stacks.RoleImport, Label: "x"},
|
||||
}}}
|
||||
if got := s.buildDataPathCards(st); got != nil {
|
||||
t.Errorf("an undeployed app must get no folder card, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func excerpt(html, needle string) string {
|
||||
i := strings.Index(html, needle)
|
||||
if i < 0 {
|
||||
return "(needle not found)"
|
||||
}
|
||||
end := i + 400
|
||||
if end > len(html) {
|
||||
end = len(html)
|
||||
}
|
||||
return html[i:end]
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func (p *fabWebProvider) GetStackHDDMounts(string) []string {
|
||||
return []string{appbackup.UserdataDir(p.hddPath)}
|
||||
}
|
||||
func (p *fabWebProvider) GetStackHDDPath(string) string { return p.hddPath }
|
||||
func (p *fabWebProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *fabWebProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||
return p.binds, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
)
|
||||
|
||||
// FileBrowser Quantum deep links (R-75).
|
||||
//
|
||||
// The template is taken VERBATIM from the shipped frontend router (SPIKE P2, read out of the
|
||||
// bundle), not guessed:
|
||||
//
|
||||
// function Ms(n,t,…){ … let a=q2(t), s=`/files/${encodeURIComponent(n)}${a}` … }
|
||||
// q2 = … .map(r=>encodeURIComponent(r)).join("/") // per-SEGMENT encoding
|
||||
// function H2(n){ t=i.split("/")[2]; … } // source is the 3rd path segment
|
||||
//
|
||||
// So a link is constructible server-side from (sourceName, relPath) alone — no internal id, no
|
||||
// index, no client-side state.
|
||||
//
|
||||
// A cold link (no FileBrowser session) is NOT lost: the router guard redirects to
|
||||
// /login?redirect=<fullPath> and the login handler navigates back to it. The customer may still have
|
||||
// to sign in, which is why the UI copy must not promise one click.
|
||||
|
||||
// fbSourceRoot is the FileBrowser origin for a customer domain. FileBrowser is published at
|
||||
// files.<domain> by the base-infra traefik labels.
|
||||
func fbSourceRoot(domain string) string {
|
||||
return "https://files." + domain
|
||||
}
|
||||
|
||||
// fileBrowserLink builds a deep link into a named FileBrowser source at relPath.
|
||||
//
|
||||
// ENCODING TRAP, measured in SPIKE P2 — use url.PathEscape, NEVER url.QueryEscape:
|
||||
//
|
||||
// "Média & könyvtár" PathEscape=M%C3%A9dia%20&%20k%C3%B6nyvt%C3%A1r QueryEscape=M%C3%A9dia+%26+k%C3%B6nyvt%C3%A1r
|
||||
// "a+b" PathEscape=a+b QueryEscape=a%2Bb
|
||||
//
|
||||
// QueryEscape encodes a space as "+", which inside a PATH segment means a literal plus and breaks
|
||||
// the link. PathEscape leaves "&" unescaped, which is correct here: "&" is a legal path sub-delim,
|
||||
// and html/template escapes it to "&" in the href attribute, which the browser decodes back to
|
||||
// "&". The two escapings compose — so do NOT pre-escape for HTML here.
|
||||
//
|
||||
// Source names cannot contain "/" (they come from filepath.Base or the ASCII import constant), which
|
||||
// is what keeps the router's split("/")[2] round-trip intact.
|
||||
func fileBrowserLink(domain, sourceName, relPath string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fbSourceRoot(domain))
|
||||
b.WriteString("/files/")
|
||||
b.WriteString(url.PathEscape(sourceName))
|
||||
for _, seg := range strings.Split(strings.Trim(relPath, "/"), "/") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("/")
|
||||
b.WriteString(url.PathEscape(seg))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// importFolderLink is the deep link to a drop-zone app's folder inside the canonical import source.
|
||||
func importFolderLink(domain, appDir string) string {
|
||||
return fileBrowserLink(domain, infra.FileBrowserImportLabel, appDir)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
)
|
||||
|
||||
// The deep-link template, pinned against the shipped FileBrowser Quantum router (SPIKE P2).
|
||||
func TestFileBrowserLink_Template(t *testing.T) {
|
||||
const dom = "demo-felhom.eu"
|
||||
for _, tc := range []struct{ name, source, rel, want string }{
|
||||
{"ascii source, one segment", "hdd_1", "media/movies",
|
||||
"https://files.demo-felhom.eu/files/hdd_1/media/movies"},
|
||||
{"accented source (the import label)", infra.FileBrowserImportLabel, "paperless",
|
||||
"https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless"},
|
||||
{"source root, empty relpath", infra.FileBrowserImportLabel, "",
|
||||
"https://files.demo-felhom.eu/files/Beolvas%C3%A1s"},
|
||||
{"leading/trailing slashes are ignored", "hdd_1", "/media/books/",
|
||||
"https://files.demo-felhom.eu/files/hdd_1/media/books"},
|
||||
{"accented path segment", "hdd_1", "media/könyvek",
|
||||
"https://files.demo-felhom.eu/files/hdd_1/media/k%C3%B6nyvek"},
|
||||
} {
|
||||
if got := fileBrowserLink(dom, tc.source, tc.rel); got != tc.want {
|
||||
t.Errorf("%s:\n got %q\n want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE trap, measured in SPIKE P2. QueryEscape turns a space into "+", which in a path segment is a
|
||||
// literal plus and lands the customer on a folder that does not exist. This test fails if anyone
|
||||
// swaps the escaper.
|
||||
func TestFileBrowserLink_UsesPathEscapeNotQueryEscape(t *testing.T) {
|
||||
const spaced = "Média & könyvtár"
|
||||
got := fileBrowserLink("x.eu", spaced, "a b")
|
||||
|
||||
if strings.Contains(got, "+") {
|
||||
t.Errorf("link contains '+' — QueryEscape was used somewhere; a '+' in a path segment is a literal plus, not a space: %q", got)
|
||||
}
|
||||
if want := url.PathEscape(spaced); !strings.Contains(got, want) {
|
||||
t.Errorf("source not PathEscape'd: got %q, want it to contain %q", got, want)
|
||||
}
|
||||
// Guard the exact divergence the spike measured, so the two escapers can never be confused here.
|
||||
if url.PathEscape(spaced) == url.QueryEscape(spaced) {
|
||||
t.Fatal("fixture no longer distinguishes the two escapers — pick a name where they differ")
|
||||
}
|
||||
if strings.Contains(got, url.QueryEscape(spaced)) {
|
||||
t.Errorf("link used QueryEscape: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The link is embedded in HTML. Percent-encoding and attribute-escaping must COMPOSE: PathEscape
|
||||
// leaves "&" bare, html/template turns it into "&", and the browser decodes it back to "&".
|
||||
// Anything that double-encodes (a hand-rolled HTML escape before the template) breaks the path.
|
||||
func TestFileBrowserLink_ComposesWithHTMLEscaping(t *testing.T) {
|
||||
link := fileBrowserLink("x.eu", "Média & könyvtár", "docs")
|
||||
var sb strings.Builder
|
||||
tmpl := template.Must(template.New("a").Parse(`<a href="{{.}}">x</a>`))
|
||||
if err := tmpl.Execute(&sb, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := sb.String()
|
||||
if !strings.Contains(out, "&") {
|
||||
t.Errorf("html/template should escape the bare & in the href: %q", out)
|
||||
}
|
||||
// It must NOT have been percent-encoded a second time (%2526 etc. would be double-encoding).
|
||||
if strings.Contains(out, "%25") {
|
||||
t.Errorf("double percent-encoding detected — do not pre-escape before the template: %q", out)
|
||||
}
|
||||
// And no raw quote/angle escaped into the attribute.
|
||||
if strings.Contains(out, `href=""`) {
|
||||
t.Errorf("html/template refused the URL (would render an empty href): %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// importFolderLink targets the CANONICAL source, so a drop-zone link is identical regardless of
|
||||
// which drive the app itself sits on.
|
||||
func TestImportFolderLink_IsCanonical(t *testing.T) {
|
||||
a := importFolderLink("demo-felhom.eu", "paperless")
|
||||
b := importFolderLink("demo-felhom.eu", "calibre")
|
||||
for _, l := range []string{a, b} {
|
||||
if !strings.Contains(l, "/files/"+url.PathEscape(infra.FileBrowserImportLabel)+"/") {
|
||||
t.Errorf("import link must go through the canonical source: %q", l)
|
||||
}
|
||||
if strings.Contains(l, "hdd_1") || strings.Contains(l, "felhom-drives") {
|
||||
t.Errorf("import link must never name a data drive: %q", l)
|
||||
}
|
||||
}
|
||||
if a == b {
|
||||
t.Error("different apps must get different folders")
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func TestFileBrowserNetworkShareIncluded(t *testing.T) {
|
||||
t.Errorf("skeleton calls = %v, want exactly [%s] — a skeleton toward the NAS writes Felhom convention dirs onto the customer's own NAS", calls, drive.Path)
|
||||
}
|
||||
// Config has both sources, share named by its display label.
|
||||
cfg := infra.RenderFileBrowserConfig(cfgPaths)
|
||||
cfg := infra.RenderFileBrowserConfig(cfgPaths, false)
|
||||
for _, m := range []string{`- path: "/srv/hdd_1"`, `- path: "/srv/Felhom-Share"`, `name: "Felhom Share"`} {
|
||||
if !strings.Contains(cfg, m) {
|
||||
t.Errorf("config missing %q:\n%s", m, cfg)
|
||||
@@ -104,7 +104,7 @@ func TestFileBrowserNetworkStubExcluded(t *testing.T) {
|
||||
if len(mounts) != 1 || strings.Contains(mounts[0], "Felhom-Share") {
|
||||
t.Errorf("stub share leaked into mounts: %v", mounts)
|
||||
}
|
||||
cfg := infra.RenderFileBrowserConfig(cfgPaths)
|
||||
cfg := infra.RenderFileBrowserConfig(cfgPaths, false)
|
||||
if strings.Contains(cfg, "Felhom-Share") {
|
||||
t.Errorf("stub share leaked into the source list:\n%s", cfg)
|
||||
}
|
||||
@@ -150,8 +150,8 @@ func TestFileBrowserNetworkRemoval(t *testing.T) {
|
||||
|
||||
oldCompose := infra.RenderFileBrowserCompose("example.hu", withMounts)
|
||||
newCompose := infra.RenderFileBrowserCompose("example.hu", withoutMounts)
|
||||
oldCfg := infra.RenderFileBrowserConfig(withCfg)
|
||||
newCfg := infra.RenderFileBrowserConfig(withoutCfg)
|
||||
oldCfg := infra.RenderFileBrowserConfig(withCfg, false)
|
||||
newCfg := infra.RenderFileBrowserConfig(withoutCfg, false)
|
||||
|
||||
if strings.Contains(newCompose, "Felhom-Share") || strings.Contains(newCfg, "Felhom-Share") {
|
||||
t.Error("removed share left a trace in the renders")
|
||||
|
||||
@@ -636,6 +636,13 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||
data["EffectiveSubdomain"] = effectiveSubdomain
|
||||
|
||||
// „Hova tegyem a fájlokat?" (R-75) — deployed apps that declare data_paths only. Set EXPLICITLY,
|
||||
// like every other key here: appDetailHandler does not funnel through baseData, and the v0.150.0
|
||||
// app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did.
|
||||
if cards := s.buildDataPathCards(found); len(cards) > 0 {
|
||||
data["DataPathCards"] = cards
|
||||
}
|
||||
|
||||
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
|
||||
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
|
||||
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
|
||||
@@ -2217,13 +2224,29 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{
|
||||
isMount: system.IsMountPoint,
|
||||
classify: s.classifyFSPath,
|
||||
ensureSkeleton: appbackup.EnsureUserdataSkeleton,
|
||||
ensureSkeleton: s.ensureUserdataSkeleton,
|
||||
logger: s.logger,
|
||||
})
|
||||
|
||||
// R-75: the canonical drop-zone is an EXTRA bind, outside the registered-storage-path loop above.
|
||||
// The system drive is deliberately not a registered StoragePath (it would become a customer-visible
|
||||
// drive, a deploy target and a wipe candidate), so it is mounted here explicitly. Ensure the root
|
||||
// first — a source whose path does not exist renders a broken sidebar entry.
|
||||
importSource := false
|
||||
if s.stackMgr != nil {
|
||||
if importRoot := s.stackMgr.GetImportRoot(); importRoot != "" {
|
||||
if err := s.stackMgr.EnsureImportRoot(); err != nil {
|
||||
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure the import root %s: %v", importRoot, err)
|
||||
}
|
||||
storageMounts = append(storageMounts,
|
||||
fmt.Sprintf(" - %s:/srv/%s", importRoot, infra.FileBrowserImportMount))
|
||||
importSource = true
|
||||
}
|
||||
}
|
||||
|
||||
// Generate and write config.yaml (sources + sidebar entries per drive/share)
|
||||
configPath := stackDir + "/config.yaml"
|
||||
fbConfig := generateFileBrowserConfig(configPaths)
|
||||
fbConfig := generateFileBrowserConfig(configPaths, importSource)
|
||||
|
||||
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
|
||||
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
|
||||
@@ -2382,6 +2405,6 @@ func generateFileBrowserCompose(domain string, storageMounts []string) string {
|
||||
|
||||
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
|
||||
// registered storage path. Delegates to internal/infra (single source of truth).
|
||||
func generateFileBrowserConfig(paths []settings.StoragePath) string {
|
||||
return infra.RenderFileBrowserConfig(paths)
|
||||
func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
|
||||
return infra.RenderFileBrowserConfig(paths, importSource)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-75 Scenario E — the system share's delete refusal is SERVER-SIDE.
|
||||
//
|
||||
// Two independent checks, tested independently on purpose (the v0.70.1 lesson): a handler test that
|
||||
// POSTs directly proves nothing about UI reachability, and a render gate proves nothing about
|
||||
// enforcement. Both are required; neither substitutes for the other.
|
||||
|
||||
func serverWithImportShare(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s := testServer(t)
|
||||
// A real Manager so the handler's post-delete ReconcileSamba has a receiver. Sharing is left
|
||||
// DISABLED, so reconcileSambaAt early-returns and no docker call is made.
|
||||
s.cfg.Paths.SystemDataPath = "/mnt/sys_drive"
|
||||
s.cfg.Paths.StacksDir = t.TempDir()
|
||||
mgr, err := stacks.NewManager(s.cfg, s.logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mgr.SetMigrationDeps(s.settings, func() bool { return false })
|
||||
s.stackMgr = mgr
|
||||
if err := s.settings.AddSMBShare(settings.SMBShare{
|
||||
Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import",
|
||||
System: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.settings.AddSMBShare(settings.SMBShare{
|
||||
Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Scenario E, enforcement half: POST the delete endpoint directly. The share must survive.
|
||||
func TestScenarioE_SystemShareDeleteRefusedServerSide(t *testing.T) {
|
||||
s := serverWithImportShare(t)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/sharing/shares/delete",
|
||||
strings.NewReader(url.Values{"name": {settings.SystemImportShareName}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.sharingShareDeleteHandler(rr, req)
|
||||
|
||||
found := false
|
||||
for _, sh := range s.settings.GetSMBShares() {
|
||||
if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the system share was DELETED by a direct POST — the refusal is not server-side")
|
||||
}
|
||||
|
||||
// And a non-system share is still deletable, so this is a targeted refusal and not a broken
|
||||
// endpoint that happens to refuse everything.
|
||||
rr2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("POST", "/sharing/shares/delete",
|
||||
strings.NewReader(url.Values{"name": {"csalad"}}.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.sharingShareDeleteHandler(rr2, req2)
|
||||
for _, sh := range s.settings.GetSMBShares() {
|
||||
if sh.Name == "csalad" {
|
||||
t.Error("an ordinary share must still be deletable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The store layer refuses too, so no future caller can bypass the handler.
|
||||
func TestScenarioE_StoreLayerRefusesSystemShare(t *testing.T) {
|
||||
s := serverWithImportShare(t)
|
||||
if err := s.settings.RemoveSMBShare(settings.SystemImportShareName); err == nil {
|
||||
t.Error("RemoveSMBShare must refuse a System share")
|
||||
}
|
||||
if err := s.settings.RemoveSMBShare("csalad"); err != nil {
|
||||
t.Errorf("RemoveSMBShare must still delete an ordinary share: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E, reachability half: the template must not render a delete button for a system share —
|
||||
// and must still render one for an ordinary share.
|
||||
func TestScenarioE_SharingTemplateOmitsSystemDeleteButton(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "sharing", "Title": "Hálózati megosztás",
|
||||
"SMBEnabled": true,
|
||||
// The SAME type the handler passes — see ShareRow's comment.
|
||||
"SMBShares": []ShareRow{
|
||||
{Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import", System: true, Available: true},
|
||||
{Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad", Available: true},
|
||||
},
|
||||
"CSRFField": "",
|
||||
}
|
||||
html := renderBackupPage(t, "sharing", data)
|
||||
|
||||
rows := strings.Split(html, "<tr>")
|
||||
var sysRow, normalRow string
|
||||
for _, row := range rows {
|
||||
if strings.Contains(row, settings.SystemImportShareName) {
|
||||
sysRow = row
|
||||
}
|
||||
if strings.Contains(row, "csalad") {
|
||||
normalRow = row
|
||||
}
|
||||
}
|
||||
if sysRow == "" || normalRow == "" {
|
||||
t.Fatalf("both share rows must render; sys=%v normal=%v", sysRow != "", normalRow != "")
|
||||
}
|
||||
if strings.Contains(sysRow, "/sharing/shares/delete") {
|
||||
t.Error("the system share row must NOT carry a delete form")
|
||||
}
|
||||
if !strings.Contains(normalRow, "/sharing/shares/delete") {
|
||||
t.Error("an ordinary share row must still carry its delete form")
|
||||
}
|
||||
}
|
||||
|
||||
// R-75 Scenario F — sharing stays OPT-IN. Deploying a drop-zone app must not put SMB on the LAN.
|
||||
func TestScenarioF_SharingStaysOptIn(t *testing.T) {
|
||||
s := testServer(t)
|
||||
if s.settings.GetSMBSettings().Enabled {
|
||||
t.Fatal("precondition: sharing must start disabled")
|
||||
}
|
||||
// The auto-create is wired to the ENABLE handler only; nothing in the deploy path calls it.
|
||||
// Assert the state a fresh box is in: no shares at all.
|
||||
if got := s.settings.GetSMBShares(); len(got) != 0 {
|
||||
t.Errorf("a fresh box must have no shares before sharing is enabled, got %v", got)
|
||||
}
|
||||
if s.settings.GetSMBSettings().Enabled {
|
||||
t.Error("sharing must not have been switched on")
|
||||
}
|
||||
}
|
||||
|
||||
// ensureImportShare is idempotent and correctly shaped.
|
||||
func TestEnsureImportShare_IdempotentAndCorrect(t *testing.T) {
|
||||
s := testServer(t)
|
||||
// A writable stand-in for /mnt/sys_drive so EnsureImportRoot really creates the dir (the test
|
||||
// user is not root, so the real path is not writable).
|
||||
s.cfg.Paths.SystemDataPath = t.TempDir()
|
||||
s.cfg.Paths.StacksDir = t.TempDir()
|
||||
mgr, err := stacks.NewManager(s.cfg, s.logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.stackMgr = mgr
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.ensureImportShare(); err != nil {
|
||||
t.Fatalf("call %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
shares := s.settings.GetSMBShares()
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("expected exactly 1 share after 3 calls (idempotent), got %d: %v", len(shares), shares)
|
||||
}
|
||||
sh := shares[0]
|
||||
if sh.Name != settings.SystemImportShareName {
|
||||
t.Errorf("share name = %q, want %q", sh.Name, settings.SystemImportShareName)
|
||||
}
|
||||
if !sh.System {
|
||||
t.Error("the import share must be marked System")
|
||||
}
|
||||
if sh.Offsite {
|
||||
t.Error("the drop-zone is class `excluded` — Offsite must be false, or the UI would contradict the backup engines")
|
||||
}
|
||||
if sh.ReadOnly {
|
||||
t.Error("a drop-zone the customer copies INTO must be writable")
|
||||
}
|
||||
if want := mgr.GetImportRoot(); sh.Path != want {
|
||||
t.Errorf("share path = %q, want the canonical import root %q", sh.Path, want)
|
||||
}
|
||||
// The name must be NetBIOS-safe — it is an SMB share name.
|
||||
if err := settings.ValidateSMBShareName(sh.Name); err != nil {
|
||||
t.Errorf("share name is not NetBIOS-safe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The brief's re-assertion: SPIKE P4 proved <root>/userdata/import is shareable against a GENERIC
|
||||
// registered root. This pins the SYSTEM-root shape specifically, because ProtectedHDDPaths has a
|
||||
// legacy felhom-data double-nest branch that only fires there.
|
||||
//
|
||||
// It documents the actual live shape, which is why ensureImportShare does not route through the
|
||||
// picker guard: the system drive is NOT a registered storage path on either demo box (verified
|
||||
// 2026-07-26), so sharingResolvePath — whose job is to validate CUSTOMER-supplied paths — refuses it.
|
||||
// A controller-generated constant is a different trust class.
|
||||
func TestImportRoot_NotReachableViaTheCustomerPicker(t *testing.T) {
|
||||
s := testServer(t)
|
||||
root := t.TempDir() // stands in for the system drive; deliberately NOT registered
|
||||
importRoot := filepath.Join(root, "felhom-data", "userdata", "import")
|
||||
if err := os.MkdirAll(importRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A registered data drive exists, so this is not "the registry is empty" trivially refusing.
|
||||
dataDrive := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dataDrive, "userdata", "import"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.settings.AddStoragePath(settings.StoragePath{Path: dataDrive, Label: "hdd_1", IsDefault: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.sharingResolvePath(importRoot); err == nil {
|
||||
t.Error("the customer picker must NOT accept the unregistered system-drive import root")
|
||||
}
|
||||
// Control: the data drive's own userdata subtree IS pickable, so the guard is not refusing all.
|
||||
if _, err := s.sharingResolvePath(filepath.Join(dataDrive, "userdata", "import")); err != nil {
|
||||
t.Errorf("a registered drive's userdata/import must stay shareable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,19 @@ func (s *Server) sharingResolveStorageRoot(raw string) (string, error) {
|
||||
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")
|
||||
@@ -134,18 +147,12 @@ func (s *Server) sharingPageData() map[string]interface{} {
|
||||
data["SMBDirectAddress"] = s.sambaLANAddress()
|
||||
}
|
||||
|
||||
type shareRow struct {
|
||||
Name string
|
||||
Path string
|
||||
ReadOnly bool
|
||||
Offsite bool
|
||||
Available bool
|
||||
}
|
||||
var rows []shareRow
|
||||
var rows []ShareRow
|
||||
for _, sh := range s.settings.GetSMBShares() {
|
||||
fi, err := os.Stat(sh.Path)
|
||||
rows = append(rows, shareRow{
|
||||
rows = append(rows, ShareRow{
|
||||
Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly, Offsite: sh.Offsite,
|
||||
System: sh.System,
|
||||
Available: err == nil && fi.IsDir(),
|
||||
})
|
||||
}
|
||||
@@ -272,6 +279,14 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
|
||||
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.
|
||||
@@ -411,6 +426,15 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
|
||||
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, "Ez a megosztás a rendszer része, nem törölhető.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.settings.RemoveSMBShare(name); err != nil {
|
||||
sharingRedirect(w, r, err.Error())
|
||||
return
|
||||
@@ -524,3 +548,40 @@ func writeSharingJSON(w http.ResponseWriter, code int, v interface{}) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error
|
||||
// v0.66.0: create the full userdata skeleton with the shared-storage convention (2775 setgid,
|
||||
// gid 1000) the moment a drive is registered — system drive AND additional drives. Idempotent;
|
||||
// best-effort (a perms hiccup shouldn't block registration).
|
||||
if err := appbackup.EnsureUserdataSkeleton(where); err != nil {
|
||||
if err := s.ensureUserdataSkeleton(where); err != nil {
|
||||
s.logger.Printf("[WARN] [web] userdata skeleton on %s: %v", where, err)
|
||||
}
|
||||
// Change 4: re-enrolling a previously-DECOMMISSIONED drive must un-retire it. AddStoragePath
|
||||
@@ -890,3 +890,19 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", res)
|
||||
}
|
||||
|
||||
// ensureUserdataSkeleton applies the CATALOG-DERIVED userdata skeleton (R-75) to a storage path.
|
||||
//
|
||||
// It replaces the direct appbackup.EnsureUserdataSkeleton call, which used a hardcoded Go list, so a
|
||||
// new catalog app with a folder no longer needs a controller release. The derived set is merged with
|
||||
// appbackup.UserdataSkeletonCarry (which is the old hardcoded list verbatim), so the result can only
|
||||
// ever ADD — no directory this arc touches is ever removed.
|
||||
//
|
||||
// Falls back to the carry-list alone when the stack manager is not wired (setup mode / tests), which
|
||||
// is exactly the pre-R-75 behaviour.
|
||||
func (s *Server) ensureUserdataSkeleton(nsRoot string) error {
|
||||
if s.stackMgr == nil {
|
||||
return appbackup.EnsureUserdataSkeleton(nsRoot, appbackup.BuildUserdataSkeleton(nil))
|
||||
}
|
||||
return s.stackMgr.EnsureUserdataSkeleton(nsRoot)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,24 @@
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
|
||||
{{if .DataPathCards}}
|
||||
<div class="app-info-card" style="margin-top:1rem">
|
||||
<h3>Hova tegyem a fájlokat?</h3>
|
||||
<p class="form-hint">Ezeket a mappákat a Fájlkezelőben éred el. Előfordulhat, hogy először be kell jelentkezned a Fájlkezelőbe.</p>
|
||||
<div class="datapath-list">
|
||||
{{range .DataPathCards}}
|
||||
<div class="datapath-row">
|
||||
<div class="datapath-head">
|
||||
<a href="{{.Link}}" target="_blank" rel="noopener" class="datapath-link">{{.Label}} ↗</a>
|
||||
{{if .FreeSpace}}<span class="datapath-space">{{.FreeSpace}}</span>{{end}}
|
||||
</div>
|
||||
{{if .Consequence}}<p class="datapath-note">{{.Consequence}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and .Stack.Deployed .MigrateTargets}}
|
||||
<div class="app-info-card" style="margin-top:1rem">
|
||||
<h3>Áthelyezés másik tárhelyre</h3>
|
||||
|
||||
@@ -146,11 +146,15 @@
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{{if .System}}
|
||||
<span class="form-hint">rendszer</span>
|
||||
{{else}}
|
||||
<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>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -1317,6 +1317,44 @@ a.stat-card:hover {
|
||||
font-size: .95rem;
|
||||
color: var(--text-1);
|
||||
}
|
||||
/* „Hova tegyem a fájlokat?" — R-75 folder rows on the app page. */
|
||||
.datapath-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
}
|
||||
.datapath-row {
|
||||
border-left: 3px solid var(--blue);
|
||||
background: var(--blue-dim);
|
||||
padding: .6rem .75rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.datapath-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: .75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.datapath-link {
|
||||
color: var(--blue-bright);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.datapath-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.datapath-space {
|
||||
color: var(--text-2);
|
||||
font-size: .8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.datapath-note {
|
||||
margin: .35rem 0 0 0;
|
||||
color: var(--text-2);
|
||||
font-size: .85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.app-info-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
|
||||
Reference in New Issue
Block a user