Compare commits
2 commits
9a5b293590
...
a62960b712
| Author | SHA1 | Date | |
|---|---|---|---|
| a62960b712 | |||
| 72c0552750 |
17 changed files with 178 additions and 40 deletions
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2470,7 +2470,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 18:48:41 · f7f018c</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1639,6 +1639,15 @@ 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. */
|
||||||
|
|
||||||
|
|
@ -1657,7 +1666,7 @@ html, body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 18:48:42 · f7f018c</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
@ -1705,6 +1714,12 @@ html, body {
|
||||||
<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 -->
|
||||||
|
|
@ -5417,7 +5432,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
// 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'
|
||||||
});
|
});
|
||||||
|
|
@ -7610,6 +7633,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "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.
|
||||||
|
|
|
||||||
|
|
@ -1681,7 +1681,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 18:48:41 · f7f018c</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1424,7 +1424,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 18:48:42 · f7f018c</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -2523,7 +2523,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 18:48:41 · f7f018c</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.17-beta · 2026-05-13 18:48:41 · f7f018c
|
archive=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||||
transmittal=v0.0.17-beta · 2026-05-13 18:48:41 · f7f018c
|
transmittal=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||||
classifier=v0.0.17-beta · 2026-05-13 18:48:41 · f7f018c
|
classifier=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||||
landing=v0.0.17-beta · 2026-05-13 18:48:42 · f7f018c
|
landing=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||||
form=v0.0.17-beta · 2026-05-13 18:48:42 · f7f018c
|
form=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||||
tables=v0.0.17-beta · 2026-05-13 18:48:42 · f7f018c
|
tables=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||||
browse=v0.0.17-beta · 2026-05-13 18:48:42 · f7f018c
|
browse=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 18:48:42 · f7f018c</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue