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))
+ }
+}