diff --git a/browse/css/base.css b/browse/css/base.css index 1d2b5ff..38f7de4 100644 --- a/browse/css/base.css +++ b/browse/css/base.css @@ -40,3 +40,19 @@ body { .status-bar--error { color: #b00020; } .status-bar--info { color: var(--primary); } + +/* Read-only banners — surfaced by preview-markdown.js / preview-yaml.js + when the listing's `writable` bit was false. Inline at the top of + the editor host so the user can't miss it; muted styling so it + doesn't fight the editor chrome. */ +.md-readonly-banner, +.yaml-readonly-banner { + background: rgba(220, 53, 69, 0.10); + color: var(--text); + border-bottom: 1px solid rgba(220, 53, 69, 0.35); + padding: 0.4rem 0.7rem; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.4rem; +} diff --git a/browse/js/loader.js b/browse/js/loader.js index 0621aa0..dd51bf6 100644 --- a/browse/js/loader.js +++ b/browse/js/loader.js @@ -37,6 +37,11 @@ modTime: e.mod_time ? new Date(e.mod_time) : null, ext: e.is_dir ? '' : splitExt(name), url: e.url || null, + // Server-computed write authority — true if the policy + // decider would allow a PUT for the calling principal. + // Absent / false means "save will 403"; preview editors + // read this to mount in read-only mode. + writable: !!e.writable, // FS-API specific (null in server mode): handle: null }; diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index fc4a996..d23e29f 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -304,6 +304,11 @@ function canSave(node) { if (isZipMemberNode(node)) return false; + // Server-computed authority gate. The listing's `writable` + // bit reflects what a PUT would do — false here means the + // file API would 403 the save, so we mount in read-only + // mode rather than letting the user type and lose changes. + if (node.url && window.app.state.source === 'server' && !node.writable) return false; if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; @@ -510,24 +515,50 @@ var bodyText = initialParsed.body; var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText)); - var editor = new window.toastui.Editor({ + var writableMode = canSave(node); + // autofocus:false keeps the keyboard caret in the tree pane — + // arrow-key nav can continue through markdown files without + // diverting into the editor. The user clicks into the editor + // (or tabs to it) when they actually want to type. + var editorOpts = { el: editorHost, height: '100%', - // WYSIWYG by default — most users want the rendered view - // out of the gate; the markdown/WYSIWYG toggle in the - // Toast UI toolbar still flips to source mode in one click. - initialEditType: 'wysiwyg', - previewStyle: 'vertical', - initialValue: bodyText, usageStatistics: false, - toolbarItems: [ - ['heading', 'bold', 'italic', 'strike'], - ['hr', 'quote'], - ['ul', 'ol', 'task', 'indent', 'outdent'], - ['table', 'image', 'link'], - ['code', 'codeblock'] - ] - }); + autofocus: false, + initialValue: bodyText, + }; + var editor; + if (!writableMode) { + // Read-only mount uses Toast UI's Viewer (rendered markdown, + // no edit toolbar, no caret). Clear visual signal that the + // file isn't editable — much better than letting the user + // type into an Editor whose Save button is disabled. + editor = window.toastui.Editor.factory(Object.assign({}, editorOpts, { + viewer: true, + })); + // Read-only banner above the editor explains why. + var roBanner = document.createElement('div'); + roBanner.className = 'md-readonly-banner'; + roBanner.innerHTML = '' + + ' Read-only — you don\'t have write access to this file.' + + ' Save is disabled; changes won\'t persist.'; + editorHost.insertBefore(roBanner, editorHost.firstChild); + } else { + editor = new window.toastui.Editor(Object.assign({}, editorOpts, { + // WYSIWYG by default — most users want the rendered view + // out of the gate; the markdown/WYSIWYG toggle in the + // Toast UI toolbar still flips to source mode in one click. + initialEditType: 'wysiwyg', + previewStyle: 'vertical', + toolbarItems: [ + ['heading', 'bold', 'italic', 'strike'], + ['hr', 'quote'], + ['ul', 'ol', 'task', 'indent', 'outdent'], + ['table', 'image', 'link'], + ['code', 'codeblock'] + ] + })); + } currentInstance = { editor: editor, @@ -539,8 +570,7 @@ fmEl: fmTextarea }; - var writable = canSave(node); - if (!writable) { + if (!writableMode) { saveBtn.disabled = true; saveBtn.title = 'Save not available — read-only source.'; fmTextarea.readOnly = true; diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index f2568d3..47b9ac1 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -77,6 +77,10 @@ function canSave(node) { if (isZipMemberNode(node)) return false; if (node.virtual) return false; + // Server-computed authority gate. Mirrors the markdown editor's + // check — listing's `writable` bit is the same decision the + // file API would reach on PUT. + if (node.url && window.app.state.source === 'server' && !node.writable) return false; if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; @@ -454,6 +458,7 @@ window.CodeMirror.__zddcYamlLinterReady = true; } + var writable = canSave(node); var editor = window.CodeMirror(editorHost, { value: text, mode: 'yaml', @@ -463,7 +468,15 @@ indentWithTabs: false, lineWrapping: false, gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'], - lint: { hasGutters: true } + lint: { hasGutters: true }, + // autofocus:false keeps the keyboard caret in the browse + // tree pane so arrow-key nav can continue through yaml / + // .zddc files without diverting into the editor. User + // clicks (or tabs) into the editor when they want to type. + autofocus: false, + // CodeMirror's "nocursor" mode is the truest read-only: + // selection allowed for copy, no caret, no edit affordances. + readOnly: !writable ? 'nocursor' : false, }); // Stash the node on the editor so the lint helper can decide // whether to apply the .zddc schema layer. @@ -472,11 +485,15 @@ editor.performLint(); currentEditor = editor; - var writable = canSave(node); if (!writable) { saveBtn.disabled = true; saveBtn.title = 'Save not available — read-only source.'; - editor.setOption('readOnly', true); + // Read-only banner above the editor explains why. + var roBanner = document.createElement('div'); + roBanner.className = 'yaml-readonly-banner'; + roBanner.innerHTML = '' + + ' Read-only — you don\'t have write access to this file.'; + editorHost.insertBefore(roBanner, editorHost.firstChild); } var initialHash = await hashContent(text); diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index fa9c93d..db15ecc 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -38,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, includeHidden bool) ([]listing.FileInfo, error) { +func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden, elevated bool) ([]listing.FileInfo, error) { if decider == nil { decider = &policy.InternalDecider{} } @@ -93,6 +93,16 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, declaredSet[strings.ToLower(name)] = true } + // Parent-dir chain + active-admin status. Files in this directory + // inherit authorization from this chain, so we compute it once + // and reuse for every file entry's Writable bit. Subdirectories + // build their own chain (the child cascade can differ — e.g. a + // per-user fenced home). + parentChain, _ := zddc.EffectivePolicy(fsRoot, absDir) + principal := zddc.Principal{Email: userEmail, Elevated: elevated} + parentActiveAdmin := elevated && userEmail != "" && + zddc.IsAdminForChain(parentChain, userEmail, false) + for _, entry := range entries { name := entry.Name() @@ -162,6 +172,20 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, DisplayName: displayName, Declared: declared, } + // Writable surfaces whether THIS principal could PUT this file + // — same decision as the file API's authorizeAction would + // reach. Uses the parent-dir chain (computed once above); + // active-admin status short-circuits the per-file decider + // query when the principal already holds admin authority. + fileURL := baseURL + name + if parentActiveAdmin { + fi.Writable = true + } else { + allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, policy.ActionWrite) + if allowed { + fi.Writable = true + } + } result = append(result, fi) } diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go index 31e74e8..e3610be 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/", false) + got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, 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/", false) + got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, 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/", false) + got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false, 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/", false) + got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false, 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/", false) + "/Proj/working/alice@example.com/", false, 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/", false) + got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false, 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+"/", false) + "Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, 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/", false) + "/Proj/random-folder-that-doesnt-exist/", false, 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 e399d22..b915802 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -117,7 +117,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit // 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) + entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden, ElevatedFromContext(r)) if err != nil { if os.IsNotExist(err) { http.Error(w, "Not Found", http.StatusNotFound) diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 93e8747..013326f 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -46,4 +46,19 @@ type FileInfo struct { // page project names without a separate API. Empty when the // entry has no `.zddc` or its `.zddc` doesn't set `title:`. Title string `json:"title,omitempty"` + + // Writable surfaces whether the calling principal has write + // authority on this entry — answered by running the policy decider + // with ActionWrite on the entry's path, with the same admin-bypass + // branch that the file API uses. Clients (notably the in-place + // markdown editor) read this to mount in read-only mode when a + // PUT would 403, instead of letting the user type then lose their + // changes on save. + // + // omitempty: the listing JSON omits the field when false so the + // wire payload doesn't bloat; clients should treat absence as + // false-or-unknown and gate writes accordingly. Read-only-by- + // default is the safer client-side fallback if the server forgets + // to populate it. + Writable bool `json:"writable,omitempty"` }