From 72c055275040e38de356036d398a91abe64b4ed0 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 13 May 2026 14:45:41 -0500 Subject: [PATCH] =?UTF-8?q?feat(browse):=20"Show=20hidden"=20toggle=20?= =?UTF-8?q?=E2=80=94=20list=20.-prefixed=20and=20=5F-prefixed=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a UI checkbox next to the existing Sort dropdown that surfaces hidden entries when ACL would otherwise allow read. Default off (matches today's filtered behavior). On toggle, browse re-fetches the current directory with ?hidden=1 and re-renders. ┌─ browse toolbar ─────────────────────────────────────────────┐ │ Sort: [Name (A→Z) ▾] ☐ Show hidden │ └──────────────────────────────────────────────────────────────┘ Server-side surface: - internal/fs/tree.go ListDirectory gains an `includeHidden bool` parameter. The .-prefix filter (previously hard-coded) now also drops _-prefix entries (matches dispatch's reserved-prefix guard) and honors the new flag. - internal/handler/directory.go reads `?hidden=1` from the request and threads it through. - cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and _-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking a hidden entry's link works. `_app/` (apps cache) stays unconditionally reserved — those bytes must go through the apps resolver. Writes to hidden paths stay blocked (the file API has its own segment check that the flag does NOT relax). - internal/listing/listing.go: signature parity (the lower-level helper that's used by tests + non-cascade listing paths). Security model unchanged: the ACL chain on the parent dir is the only real gate. Whoever can read the dir can see its contents — toggling "Show hidden" just stops the client-side filter from masking .-prefixed and _-prefixed entries. Hidden paths today: • /.zddc ACL YAML — already exposed via /.profile/zddc • /.converted/ cached MD→DOCX/HTML/PDF, same sensitivity as source • /.zddc.d/tokens/ per-token metadata; filename = sha256(token) so not bearer-usable. Default root ACL restricts to admins; matches /.tokens UI. • /.zddc.d/logs/ access logs; same admins-only audience • /_app/ cached upstream tool HTML (public) • /_template/ install.zip scaffolding (public) None of these contain bearer credentials or secret material that the existing ACL doesn't already gate. The walls are still the cascade. --- browse/css/tree.css | 9 +++++++++ browse/js/events.js | 15 ++++++++++++++ browse/js/loader.js | 10 ++++++++- browse/template.html | 6 ++++++ zddc/cmd/zddc-server/main.go | 20 +++++++++++++++--- zddc/internal/fs/tree.go | 15 ++++++++++---- zddc/internal/fs/tree_test.go | 16 +++++++-------- zddc/internal/handler/directory.go | 9 ++++++++- zddc/internal/listing/listing.go | 23 +++++++++++++-------- zddc/internal/listing/listing_test.go | 29 ++++++++++++++++++++++++++- 10 files changed, 126 insertions(+), 26 deletions(-) diff --git a/browse/css/tree.css b/browse/css/tree.css index 4f4994a..56ee0f5 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -700,5 +700,14 @@ html, body { outline-offset: -1px; } +.sort-control__checkbox { + /* Pair with the "Show hidden" label as a unified control. The + parent .sort-control already does horizontal flex + gap, so the + checkbox just needs sensible vertical alignment + a clickable + hit target. */ + margin: 0; + cursor: pointer; +} + /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced by the .md-shell BEM block above. */ diff --git a/browse/js/events.js b/browse/js/events.js index 02a0c61..87b02d6 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -152,6 +152,21 @@ }); } + // "Show hidden" checkbox — toggles state.showHidden, which the + // loader reads to append ?hidden=1 to listing requests. Re-uses + // the existing refreshListing flow so the tree pulls a fresh + // listing. ACL is still server-side; this just relaxes the + // client-visible filter for entries the user is already + // allowed to read. + var hiddenCb = document.getElementById('showHidden'); + if (hiddenCb) { + hiddenCb.checked = !!state.showHidden; + hiddenCb.addEventListener('change', function () { + state.showHidden = hiddenCb.checked; + refreshListing(); + }); + } + // No view-mode buttons; mode is derived from the URL on every // scope change (resolveViewMode below). Pass-through for the // initial path. diff --git a/browse/js/loader.js b/browse/js/loader.js index a9c0fb7..834a6c0 100644 --- a/browse/js/loader.js +++ b/browse/js/loader.js @@ -81,7 +81,15 @@ // the same UX for anything else and for non-zddc-server backends. async function fetchServerChildren(path) { if (!path.endsWith('/')) path += '/'; - var resp = await fetch(path, { + // ?hidden=1 surfaces .-prefixed and _-prefixed entries when the + // user has flipped the "Show hidden" toggle. The server still + // ACL-gates per-entry, so this is purely additive — anyone + // without read on the parent dir already sees nothing. + var url = path; + if (window.app && window.app.state && window.app.state.showHidden) { + url += (url.indexOf('?') === -1 ? '?' : '&') + 'hidden=1'; + } + var resp = await fetch(url, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }); diff --git a/browse/template.html b/browse/template.html index 148665d..7d71903 100644 --- a/browse/template.html +++ b/browse/template.html @@ -70,6 +70,12 @@ + diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index d1ac292..6a42dd0 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -788,7 +788,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } // Reserve dot-prefixed path segments. The listing pipeline already hides - // hidden entries (internal/listing/listing.go:17, projectshandler.go:40), + // hidden entries (internal/fs/tree.go:90, projectshandler.go:40), // but direct URL access would still serve them. 404 here so hidden trees // like /srv/.devshell (the in-image dev-shell's persistent home dir on // the same Azure Files PVC as served data) cannot be fetched. The @@ -797,7 +797,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // // Also reserve the apps cache directory (`_app`): the cached HTML files // there must be served via the apps resolver (with proper headers and - // ACL), never raw at /_app/...html. + // ACL), never raw at /_app/...html. The apps cache stays reserved + // even with ?hidden=1 — its files must go through the resolver for + // proper ETag/MIME/X-ZDDC-Source headers. + // + // ?hidden=1 on a GET/HEAD relaxes the dot-prefix guard for everything + // EXCEPT _app. The ACL chain on the resolved path is still the gate; + // anyone who couldn't list this hidden file via fs.ListDirectory + // can't reach it via direct URL either. Write methods stay blocked + // from hidden paths (the file API has its own segment check that + // the ?hidden flag does NOT relax). + hiddenOK := r.URL.Query().Has("hidden") && + (r.Method == http.MethodGet || r.Method == http.MethodHead) for _, seg := range segments { if seg == "" { continue @@ -806,12 +817,15 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps http.NotFound(w, r) return } - if !strings.HasPrefix(seg, ".") { + if !strings.HasPrefix(seg, ".") && !strings.HasPrefix(seg, "_") { continue } if seg == cfg.IndexPath { continue } + if hiddenOK { + continue + } http.NotFound(w, r) return } diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 69433d5..9edceea 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -26,7 +26,9 @@ func safeJoin(fsRoot, relPath string) (string, bool) { // filepath.Join(fsRoot, dirPath), filtered by ACL for userEmail. // // Rules: -// - Hidden files and .zddc files are excluded +// - Hidden files (.-prefixed and _-prefixed) are excluded by default +// unless includeHidden is true (typically driven by ?hidden=1 on +// the request). // - *.portfolio files appear as virtual directories (stem + "/") // - Subdirectories for which the user lacks access are omitted (not 403'd inline) // - dirPath="" means the root of the served tree @@ -36,7 +38,7 @@ func safeJoin(fsRoot, relPath string) (string, bool) { // The decider is queried per subdirectory; nil falls back to the internal // Go evaluator (policy.InternalDecider) for tests that don't wire up // an explicit decider. -func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileInfo, error) { +func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden bool) ([]listing.FileInfo, error) { if decider == nil { decider = &policy.InternalDecider{} } @@ -83,8 +85,13 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, for _, entry := range entries { name := entry.Name() - // Skip hidden files and dotfiles (including .zddc) - if strings.HasPrefix(name, ".") { + // Hidden file filter. '.' marks system/internal state (.zddc, + // .converted/, .zddc.d/) and '_' marks operator scaffolding + // (_app, _template). Both prefixes are hidden by default; + // includeHidden=true (set via ?hidden=1 in the request) surfaces + // them. The ACL chain still applies — anyone who can't read + // the parent directory sees nothing regardless of this flag. + if !includeHidden && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")) { continue } diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go index 57a7f54..31e74e8 100644 --- a/zddc/internal/fs/tree_test.go +++ b/zddc/internal/fs/tree_test.go @@ -28,7 +28,7 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) { } zddc.InvalidateCache(root) - got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/") + got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false) if err != nil { t.Fatalf("list: %v", err) } @@ -56,7 +56,7 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) { } zddc.InvalidateCache(root) - got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/") + got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false) if err != nil { t.Fatalf("list: %v", err) } @@ -74,7 +74,7 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) { } zddc.InvalidateCache(root) - got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/") + got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false) if err != nil { t.Fatalf("list: %v", err) } @@ -92,7 +92,7 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) { } zddc.InvalidateCache(root) - got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/") + got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false) if err != nil { t.Fatalf("list: %v", err) } @@ -113,7 +113,7 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) { got, err := ListDirectory(context.Background(), nil, root, "Proj/working/alice@example.com", "alice@example.com", - "/Proj/working/alice@example.com/") + "/Proj/working/alice@example.com/", false) if err != nil { t.Fatalf("list: %v", err) } @@ -132,7 +132,7 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) { } zddc.InvalidateCache(root) - got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/") + got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false) if err != nil { t.Fatalf("list: %v", err) } @@ -165,7 +165,7 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) { for _, stage := range []string{"working", "staging", "reviewing", "archive"} { got, err := ListDirectory(context.Background(), nil, root, - "Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/") + "Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false) if err != nil { t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err) continue @@ -199,7 +199,7 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) { _, err := ListDirectory(context.Background(), nil, root, "Proj/random-folder-that-doesnt-exist", "alice@example.com", - "/Proj/random-folder-that-doesnt-exist/") + "/Proj/random-folder-that-doesnt-exist/", false) if !os.IsNotExist(err) { t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err) } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index ff69691..0394028 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -110,7 +110,14 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit // Build base URL for listing entries baseURL := urlPath // relative URLs suffice for JSON listings - entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL) + // ?hidden=1 surfaces dot- and underscore-prefixed entries. ACL is + // still the only real gate — anyone who can't read this dir sees + // nothing regardless. browse pipes the flag through when its + // "Show hidden" toggle is on. Matches the bare-flag convention + // used by ?zip and ?convert= elsewhere in the dispatcher. + includeHidden := r.URL.Query().Has("hidden") + + entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden) if err != nil { if os.IsNotExist(err) { http.Error(w, "Not Found", http.StatusNotFound) diff --git a/zddc/internal/listing/listing.go b/zddc/internal/listing/listing.go index 251dc83..4b4c05c 100644 --- a/zddc/internal/listing/listing.go +++ b/zddc/internal/listing/listing.go @@ -7,18 +7,25 @@ import ( // FromDirEntries converts os.DirEntry slice to []FileInfo. // baseURL is the URL prefix for this directory (must end with "/"). -// Entries starting with "." or "_" are excluded (see filter below). -func FromDirEntries(entries []os.DirEntry, baseURL string) ([]FileInfo, error) { +// When includeHidden is false (the default for normal listings), +// entries starting with "." or "_" are excluded. When true (the +// dispatcher passes ?hidden=1 through to here), they're surfaced; +// the caller is responsible for any further policy gating, but +// in practice the existing ACL chain on the parent directory is +// the only gate that matters. +func FromDirEntries(entries []os.DirEntry, baseURL string, includeHidden bool) ([]FileInfo, error) { var result []FileInfo for _, entry := range entries { name := entry.Name() - // Skip hidden entries. '.' and '_' are both reserved prefixes: - // '.' marks system/internal state (.zddc files, .archive virtual - // path, .admin debug page, dev-shell home dirs); '_' marks operator - // scaffolding like install.zip's _template/ directory that's - // reachable by direct URL but should not appear in browse listings. - if len(name) == 0 || name[0] == '.' || name[0] == '_' { + // Skip empty names always. '.' and '_' prefixes mark hidden + // entries — system/internal state (.zddc, .converted/, + // .zddc.d/) and operator scaffolding (_app, _template). These + // are filtered by default; pass includeHidden=true to expose. + if len(name) == 0 { + continue + } + if !includeHidden && (name[0] == '.' || name[0] == '_') { continue } diff --git a/zddc/internal/listing/listing_test.go b/zddc/internal/listing/listing_test.go index a916fd4..a5819ef 100644 --- a/zddc/internal/listing/listing_test.go +++ b/zddc/internal/listing/listing_test.go @@ -35,7 +35,7 @@ func TestFromDirEntriesFiltersHidden(t *testing.T) { t.Fatalf("ReadDir: %v", err) } - got, err := FromDirEntries(entries, "/") + got, err := FromDirEntries(entries, "/", false) if err != nil { t.Fatalf("FromDirEntries: %v", err) } @@ -58,3 +58,30 @@ func TestFromDirEntriesFiltersHidden(t *testing.T) { } } } + +// TestFromDirEntriesIncludeHidden verifies the includeHidden=true path +// surfaces dot- and underscore-prefixed entries. +func TestFromDirEntriesIncludeHidden(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{".zddc", "_template", "normal.txt"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + got, err := FromDirEntries(entries, "/", true) + if err != nil { + t.Fatalf("FromDirEntries: %v", err) + } + want := map[string]bool{".zddc": true, "_template": true, "normal.txt": true} + if len(got) != len(want) { + var names []string + for _, e := range got { + names = append(names, e.Name) + } + t.Fatalf("got %d entries (%v), want %d", len(got), names, len(want)) + } +}