🐛 Trim linked-library data in view-only bundle for share links

The anonymous get-view-only-bundle RPC merged each linked library whole, exposing library pages the share link never granted. For share-link permissions, each library is now reduced to the narrow data keys with its own pages dropped and only the components referenced by the allowed pages kept (nested references followed); membership bundles are unchanged. Closes #11617

AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
Andrey Antukh committed 2026-09-10 13:49:58 +00:00
1 parent 9b24907992
commit fc11fbb8cf
2 files changed
+271

No files matched your search

+55
View File
@@ -12,6 +12,7 @@
[app.common.schema :as sm]
[app.config :as cf]
[app.db :as db]
[app.features.fdata :as fdata]
[app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond]
@@ -28,6 +29,47 @@
(update :pages (fn [pages] (filterv #(contains? allowed %) pages)))
(update :pages-index select-keys allowed)))
(defn- collect-used-library-components
"Walk the objects of the (already page-scoped) primary file data and of
the referenced library components themselves, and return a map of
`{lib-id #{component-id}}` with every library component the allowed
pages need. Follows nested references to a fixpoint so trimming never
drops a component that is only reached through another component."
[primary-data libs-by-id]
(loop [seen {}]
(let [objects (->> (concat (map :objects (vals (:pages-index primary-data)))
(map :objects (vals (:components primary-data)))
(for [[lib-id comp-ids] seen
comp-id comp-ids
:let [component (get-in libs-by-id [lib-id :data :components comp-id])]
:when (some? component)]
(:objects component)))
(mapcat vals))
found (reduce (fn [acc shape]
(if (and (map? shape)
(some? (:component-file shape))
(some? (:component-id shape)))
(update acc (:component-file shape) (fnil conj #{}) (:component-id shape))
acc))
{}
objects)
merged (merge-with into seen found)]
(if (= merged seen)
seen
(recur merged)))))
(defn- trim-library-data
"Reduce a linked library to the minimum a share-link viewer needs: keep
the narrow `:data` keys, drop the library's own pages (a share link
never grants pages of a library), and keep only the components
referenced by the allowed pages of the primary file."
[used-refs {:keys [id] :as lib}]
(-> lib
(update :data select-keys [:id :options :pages :pages-index :components])
(assoc-in [:data :pages] [])
(assoc-in [:data :pages-index] {})
(update-in [:data :components] select-keys (get used-refs id #{}))))
(defn obfuscate-email
"Obfuscate the `email` for share-link members so the viewer only sees a
partially redacted address. Accepts any string shape (including nil,
@@ -89,6 +131,19 @@
(mapv (fn [{:keys [id] :as lib}]
(merge lib (bfc/get-file cfg id)))))
;; A share link never grants pages of a linked library, so trim
;; each library to the components the allowed pages reference.
;; File data may hold lazy pointer-mapped pages/components, so
;; realize each file with its own loader before walking it.
;; Membership bundles keep the full libraries.
libs (if (= :share-link (:type perms))
(let [file* (fdata/realize-pointers cfg file)
libs* (mapv #(fdata/realize-pointers cfg %) libs)
libs-by-id (into {} (map (juxt :id identity)) libs*)
used (collect-used-library-components (:data file*) libs-by-id)]
(mapv #(trim-library-data used %) libs*))
libs)
links (cond->> (->> (db/query conn :share-link {:file-id file-id})
(mapv (fn [row]
(-> row
@@ -205,3 +205,219 @@
(t/is (= 2 (count share-links)))
(t/is (some #(= link-a-id (:id %)) share-links))
(t/is (some #(= link-b-id (:id %)) share-links))))))
(t/deftest trim-library-data-unit
(let [lib-id (uuid/random)
comp-a (uuid/random)
comp-b (uuid/random)
comp-c (uuid/random)
page-id (uuid/random)
mk-shape (fn [id comp] {:id id :type :rect :component-id comp :component-file lib-id})
shape-a (mk-shape (uuid/random) comp-a)
primary {:pages [page-id]
:pages-index {page-id {:id page-id :objects {(:id shape-a) shape-a}}}
:components {}}
lib {:id lib-id
:synced-at "now"
:data {:id lib-id
:options {}
:pages [page-id]
:pages-index {page-id {:id page-id}}
:components {comp-a {:id comp-a :name "a" :objects {}}
comp-b {:id comp-b :name "b" :objects {}}}}}]
(t/testing "keeps referenced components and drops library pages"
(let [used (#'viewer/collect-used-library-components primary {lib-id lib})
trimmed (#'viewer/trim-library-data used lib)]
(t/is (= #{comp-a} (get used lib-id)))
(t/is (= #{comp-a} (set (keys (get-in trimmed [:data :components])))))
(t/is (= [] (get-in trimmed [:data :pages])))
(t/is (= {} (get-in trimmed [:data :pages-index])))
(t/is (= lib-id (:id trimmed)))
(t/is (= "now" (:synced-at trimmed)))))
(t/testing "no references keeps no components"
(let [trimmed (#'viewer/trim-library-data {} lib)]
(t/is (= {} (get-in trimmed [:data :components])))
(t/is (= [] (get-in trimmed [:data :pages])))))
(t/testing "follows nested component references"
(let [inner (mk-shape (uuid/random) comp-c)
lib2 (-> lib
(assoc-in [:data :components comp-c] {:id comp-c :name "c" :objects {}})
(assoc-in [:data :components comp-a :objects] {(:id inner) inner}))
used (#'viewer/collect-used-library-components primary {lib-id lib2})
trimmed (#'viewer/trim-library-data used lib2)]
(t/is (= #{comp-a comp-c} (get used lib-id)))
(t/is (= #{comp-a comp-c} (set (keys (get-in trimmed [:data :components])))))
(t/is (not (contains? (get-in trimmed [:data :components]) comp-b)))))))
(t/deftest share-link-bundle-trims-linked-libraries
(let [owner (th/create-profile* 1 {:is-active true})
stranger (th/create-profile* 2 {:is-active true})
proj-id (:default-project-id owner)
lib (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared true})
;; A second page that only exists inside the library; the main
;; file never references it, so no share link on the main file
;; should ever expose it.
canary (uuid/random)
_ (th/command! {::th/type :update-file
::rpc/profile-id (:id owner)
:id (:id lib)
:session-id (uuid/random)
:revn 0
:vern 0
:changes [{:type :add-page
:id canary
:page {:id canary
:name "Private canary"
:options {}
:objects {}}}]})
file (th/create-file* 2 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
_ (th/link-file-to-library* {:file-id (:id file)
:library-id (:id lib)})
slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{(get-in file [:data :pages 0])}
:who-comment "team"
:who-inspect "all"})
slink-id (get-in slink [:result :id])]
(t/testing "control: non-member cannot read the library directly"
(let [out (th/command! {::th/type :get-file
::rpc/profile-id (:id stranger)
:id (:id lib)
:components-v2 true})
error-data (ex-data (:error out))]
(t/is (th/ex-info? (:error out)))
(t/is (= :not-found (:type error-data)))))
(t/testing "anonymous bundle omits unreferenced library pages"
(let [out (th/command! {::th/type :get-view-only-bundle
:share-id slink-id
:file-id (:id file)})
result (:result out)
lib-pages (into #{} (mapcat #(get-in % [:data :pages] []))
(:libraries result))]
(t/is (nil? (:error out)))
(t/is (not (contains? lib-pages canary)))
(t/is (every? #(every? #{:id :options :pages :pages-index :components} (keys (:data %)))
(:libraries result)))))))
(t/deftest share-link-bundle-library-edge-cases
(let [owner (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id owner)
add-canary (fn [file n]
(let [canary (uuid/random)]
(th/command! {::th/type :update-file
::rpc/profile-id (:id owner)
:id (:id file)
:session-id (uuid/random)
:revn 0
:vern 0
:changes [{:type :add-page
:id canary
:page {:id canary
:name n
:options {}
:objects {}}}]})
canary))
lib2 (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared true})
canary2 (add-canary lib2 "Private canary 2")
lib1 (th/create-file* 2 {:profile-id (:id owner)
:project-id proj-id
:is-shared true})
canary1 (add-canary lib1 "Private canary 1")
_ (th/link-file-to-library* {:file-id (:id lib1)
:library-id (:id lib2)})
file (th/create-file* 3 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
_ (th/link-file-to-library* {:file-id (:id file)
:library-id (:id lib1)})
full (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{(get-in file [:data :pages 0])}
:who-comment "team"
:who-inspect "all"})
full-id (get-in full [:result :id])
empty (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
empty-id (get-in empty [:result :id])
bundle (fn [& {:as params}]
(th/command! (merge {::th/type :get-view-only-bundle
:file-id (:id file)}
params)))
lib-pages (fn [result]
(into #{} (mapcat #(get-in % [:data :pages] []))
(:libraries result)))]
(t/testing "indirect libraries are trimmed too"
(let [out (bundle :share-id full-id)
result (:result out)
pages (lib-pages result)]
(t/is (nil? (:error out)))
(t/is (= 2 (count (:libraries result))))
(t/is (not (contains? pages canary1)))
(t/is (not (contains? pages canary2)))))
(t/testing "empty-pages link exposes no library content"
(let [out (bundle :share-id empty-id)
result (:result out)]
(t/is (nil? (:error out)))
(t/is (every? #(= {} (get-in % [:data :components])) (:libraries result)))
(t/is (every? #(= [] (get-in % [:data :pages])) (:libraries result)))))
(t/testing "member bundle keeps full libraries"
(let [out (bundle ::rpc/profile-id (:id owner))
result (:result out)
pages (lib-pages result)]
(t/is (nil? (:error out)))
(t/is (contains? pages canary1))
(t/is (contains? pages canary2))))
(t/testing "file without libraries returns no libraries"
(let [plain (th/create-file* 4 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
link (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id plain)
:pages #{(get-in plain [:data :pages 0])}
:who-comment "team"
:who-inspect "all"})
out (th/command! {::th/type :get-view-only-bundle
:share-id (get-in link [:result :id])
:file-id (:id plain)})]
(t/is (nil? (:error out)))
(t/is (= [] (:libraries (:result out))))))))