feat(browse): "Show hidden" toggle — list .-prefixed and _-prefixed entries

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:

  • <dir>/.zddc                ACL YAML — already exposed via /.profile/zddc
  • <dir>/.converted/<base>    cached MD→DOCX/HTML/PDF, same sensitivity as source
  • <root>/.zddc.d/tokens/     per-token metadata; filename = sha256(token)
                               so not bearer-usable. Default root ACL
                               restricts to admins; matches /.tokens UI.
  • <root>/.zddc.d/logs/       access logs; same admins-only audience
  • <root>/_app/               cached upstream tool HTML (public)
  • <root>/_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.
This commit is contained in:
ZDDC 2026-05-13 14:45:41 -05:00
parent 9a5b293590
commit 72c0552750
10 changed files with 126 additions and 26 deletions

View file

@ -700,5 +700,14 @@ html, body {
outline-offset: -1px; 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 /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
by the .md-shell BEM block above. */ by the .md-shell BEM block above. */

View file

@ -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 // No view-mode buttons; mode is derived from the URL on every
// scope change (resolveViewMode below). Pass-through for the // scope change (resolveViewMode below). Pass-through for the
// initial path. // initial path.

View file

@ -81,7 +81,15 @@
// the same UX for anything else and for non-zddc-server backends. // the same UX for anything else and for non-zddc-server backends.
async function fetchServerChildren(path) { async function fetchServerChildren(path) {
if (!path.endsWith('/')) 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' }, headers: { 'Accept': 'application/json' },
credentials: 'same-origin' credentials: 'same-origin'
}); });

View file

@ -70,6 +70,12 @@
<option value="ext:asc">Type (A→Z)</option> <option value="ext:asc">Type (A→Z)</option>
</select> </select>
</label> </label>
<label class="sort-control" for="showHidden"
title="Surface .-prefixed and _-prefixed entries (.zddc, .converted/, _app/, …). ACL still applies — you only see what you'd already be allowed to read.">
<input type="checkbox" id="showHidden" class="sort-control__checkbox"
aria-label="Show hidden files">
<span class="sort-control__label">Show hidden</span>
</label>
</div> </div>
<!-- Browse mode (default): two-pane tree + preview --> <!-- Browse mode (default): two-pane tree + preview -->

View file

@ -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 // 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 // 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 // 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 // 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 // Also reserve the apps cache directory (`_app`): the cached HTML files
// there must be served via the apps resolver (with proper headers and // 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 { for _, seg := range segments {
if seg == "" { if seg == "" {
continue continue
@ -806,12 +817,15 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if !strings.HasPrefix(seg, ".") { if !strings.HasPrefix(seg, ".") && !strings.HasPrefix(seg, "_") {
continue continue
} }
if seg == cfg.IndexPath { if seg == cfg.IndexPath {
continue continue
} }
if hiddenOK {
continue
}
http.NotFound(w, r) http.NotFound(w, r)
return return
} }

View file

@ -26,7 +26,9 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
// filepath.Join(fsRoot, dirPath), filtered by ACL for userEmail. // filepath.Join(fsRoot, dirPath), filtered by ACL for userEmail.
// //
// Rules: // 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 + "/") // - *.portfolio files appear as virtual directories (stem + "/")
// - Subdirectories for which the user lacks access are omitted (not 403'd inline) // - Subdirectories for which the user lacks access are omitted (not 403'd inline)
// - dirPath="" means the root of the served tree // - 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 // The decider is queried per subdirectory; nil falls back to the internal
// Go evaluator (policy.InternalDecider) for tests that don't wire up // Go evaluator (policy.InternalDecider) for tests that don't wire up
// an explicit decider. // 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 { if decider == nil {
decider = &policy.InternalDecider{} decider = &policy.InternalDecider{}
} }
@ -83,8 +85,13 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
// Skip hidden files and dotfiles (including .zddc) // Hidden file filter. '.' marks system/internal state (.zddc,
if strings.HasPrefix(name, ".") { // .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 continue
} }

View file

@ -28,7 +28,7 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
} }
zddc.InvalidateCache(root) 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 { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -56,7 +56,7 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
} }
zddc.InvalidateCache(root) 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 { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -74,7 +74,7 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
} }
zddc.InvalidateCache(root) 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 { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -92,7 +92,7 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
} }
zddc.InvalidateCache(root) 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 { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -113,7 +113,7 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
got, err := ListDirectory(context.Background(), nil, root, got, err := ListDirectory(context.Background(), nil, root,
"Proj/working/alice@example.com", "alice@example.com", "Proj/working/alice@example.com", "alice@example.com",
"/Proj/working/alice@example.com/") "/Proj/working/alice@example.com/", false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -132,7 +132,7 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
} }
zddc.InvalidateCache(root) 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 { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -165,7 +165,7 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
for _, stage := range []string{"working", "staging", "reviewing", "archive"} { for _, stage := range []string{"working", "staging", "reviewing", "archive"} {
got, err := ListDirectory(context.Background(), nil, root, 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 { if err != nil {
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err) t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err)
continue continue
@ -199,7 +199,7 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
_, err := ListDirectory(context.Background(), nil, root, _, err := ListDirectory(context.Background(), nil, root,
"Proj/random-folder-that-doesnt-exist", "alice@example.com", "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) { if !os.IsNotExist(err) {
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err) t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
} }

View file

@ -110,7 +110,14 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
// Build base URL for listing entries // Build base URL for listing entries
baseURL := urlPath // relative URLs suffice for JSON listings 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 err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)

View file

@ -7,18 +7,25 @@ import (
// FromDirEntries converts os.DirEntry slice to []FileInfo. // FromDirEntries converts os.DirEntry slice to []FileInfo.
// baseURL is the URL prefix for this directory (must end with "/"). // baseURL is the URL prefix for this directory (must end with "/").
// Entries starting with "." or "_" are excluded (see filter below). // When includeHidden is false (the default for normal listings),
func FromDirEntries(entries []os.DirEntry, baseURL string) ([]FileInfo, error) { // 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 var result []FileInfo
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
// Skip hidden entries. '.' and '_' are both reserved prefixes: // Skip empty names always. '.' and '_' prefixes mark hidden
// '.' marks system/internal state (.zddc files, .archive virtual // entries — system/internal state (.zddc, .converted/,
// path, .admin debug page, dev-shell home dirs); '_' marks operator // .zddc.d/) and operator scaffolding (_app, _template). These
// scaffolding like install.zip's _template/ directory that's // are filtered by default; pass includeHidden=true to expose.
// reachable by direct URL but should not appear in browse listings. if len(name) == 0 {
if len(name) == 0 || name[0] == '.' || name[0] == '_' { continue
}
if !includeHidden && (name[0] == '.' || name[0] == '_') {
continue continue
} }

View file

@ -35,7 +35,7 @@ func TestFromDirEntriesFiltersHidden(t *testing.T) {
t.Fatalf("ReadDir: %v", err) t.Fatalf("ReadDir: %v", err)
} }
got, err := FromDirEntries(entries, "/") got, err := FromDirEntries(entries, "/", false)
if err != nil { if err != nil {
t.Fatalf("FromDirEntries: %v", err) 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))
}
}