From c9efacc44c76f1cb3cf3200cfc3f94b78e25e553 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Thu, 12 Nov 2026 00:22:06 +0100 Subject: [PATCH] fix(studio): export only a deck's own data, read as its instance allows The Studio's deck export read a deck's cards and reviews documents with the signed-in user's credentials whenever they were in the user's own storage, even for a deck of an instance attached from someone else's pod, and then wrote every triple of those documents into the file. Such an instance's catalog may name a document of the user's own as a deck's, so the file could hold a whole document the user never thought of as part of the deck. Moving or copying cards and copying a deck into a draft already read such a document as no one; export did not. exportDeckFile now takes the instance and the WebID, and reads each of the deck's two documents as the user only when it is in the instance's pod (isInInstancePod), any other as no one, as transferCards does. The archive also exports only the deck's own subjects of those documents: its cards with the distractors they name, and the review states of those cards, with the blank nodes they lead to. An import already read nothing else, so a file still round-trips. Every caller of the archive now gives it the fetch that reads as no one and says which documents to read with it, so no document is read as the user by default. Refs: GHSA-xcjw-3f3x-g9fc --- apps/studio/src/ui/StudioWorkspace.tsx | 1 + apps/studio/src/ui/TransferContainer.test.tsx | 27 ++- apps/studio/src/ui/TransferContainer.tsx | 8 +- docs/data-model.md | 14 +- docs/studio.md | 4 +- e2e/pod/src/deckFiles.integration.test.ts | 12 +- packages/application/src/cards.test.ts | 46 ++++- packages/application/src/cards.ts | 28 ++- packages/application/src/ports.ts | 21 +- packages/composition/src/appUseCases.ts | 6 +- packages/solid/src/deckPlace.test.ts | 8 +- packages/solid/src/malformedDue.test.ts | 9 +- .../solid/src/mappers/reviewStateMapper.ts | 9 + packages/solid/src/solidDeckArchive.test.ts | 190 +++++++++++++++--- packages/solid/src/solidDeckArchive.ts | 94 +++++++-- 15 files changed, 392 insertions(+), 85 deletions(-) diff --git a/apps/studio/src/ui/StudioWorkspace.tsx b/apps/studio/src/ui/StudioWorkspace.tsx index a117c4ba9..3179c56bf 100644 --- a/apps/studio/src/ui/StudioWorkspace.tsx +++ b/apps/studio/src/ui/StudioWorkspace.tsx @@ -665,6 +665,7 @@ export function StudioWorkspace({ useCases, session, banner, children }: Workspa key={route.instanceUrl} useCases={useCases} instance={activeInstance!} + webId={session.webId} chosen={route.deckUrls ?? []} // Like Home's view, which decks are checked is no Back stop. onChoose={(urls) => diff --git a/apps/studio/src/ui/TransferContainer.test.tsx b/apps/studio/src/ui/TransferContainer.test.tsx index dd5ef1a0f..a35bfbcba 100644 --- a/apps/studio/src/ui/TransferContainer.test.tsx +++ b/apps/studio/src/ui/TransferContainer.test.tsx @@ -21,6 +21,8 @@ const file: DeckFile = { content: { deck: capitals, cards: [makeCard(capitals, "a")], upgraded: [], dropped: [] }, }; +const WEB_ID = "https://alice.example/profile/card#me"; + function renderContainer(useCases: UseCases, chosen: readonly string[] = []) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const invalidate = vi.spyOn(queryClient, "invalidateQueries"); @@ -30,6 +32,7 @@ function renderContainer(useCases: UseCases, chosen: readonly string[] = []) { `#/cards?deck=${deck.id}`} @@ -55,14 +58,22 @@ describe("TransferContainer", () => { expect(await screen.findByText("Exporting Kanji N5 (1 of 2)…")).toBeInTheDocument(); finish(); expect(await screen.findByText(/^Handed 2 decks to your browser/)).toBeInTheDocument(); - expect(useCases.exportDeckFile).toHaveBeenNthCalledWith(1, kanji, { - format: "turtle", - withProgress: false, - }); - expect(useCases.exportDeckFile).toHaveBeenNthCalledWith(2, verbs, { - format: "turtle", - withProgress: false, - }); + // Each read as the user only in the instance's pod: the use case is told which instance, and who. + const options = { format: "turtle", withProgress: false }; + expect(useCases.exportDeckFile).toHaveBeenNthCalledWith( + 1, + instanceA.url, + kanji, + options, + WEB_ID, + ); + expect(useCases.exportDeckFile).toHaveBeenNthCalledWith( + 2, + instanceA.url, + verbs, + options, + WEB_ID, + ); }); it("says why an export failed", async () => { diff --git a/apps/studio/src/ui/TransferContainer.tsx b/apps/studio/src/ui/TransferContainer.tsx index 63eb057cc..c9d90d67b 100644 --- a/apps/studio/src/ui/TransferContainer.tsx +++ b/apps/studio/src/ui/TransferContainer.tsx @@ -20,7 +20,8 @@ import { queryKeys } from "./queryKeys"; /** * The import and export screen's data: the instance's decks as the user * arranged them (Home's query). An export saves the decks one after - * another (UseCases.exportDeckFile), saying which it is at. Opening a + * another (UseCases.exportDeckFile, which reads a document outside the + * instance's pod as no one), saying which it is at. Opening a * file reads it (openDeckFile); importing it (importDeckFile) writes the * catalog, so it waits its turn with the other writes of the catalog * (catalogScope), and every query of the instance's decks is read afresh @@ -30,6 +31,7 @@ import { queryKeys } from "./queryKeys"; export function TransferContainer({ useCases, instance, + webId, chosen, onChoose, cardsHref, @@ -37,6 +39,8 @@ export function TransferContainer({ }: { useCases: UseCases; instance: Instance; + /** The signed-in user: a deck's documents are exported as theirs only in the instance's pod. */ + webId: string; /** The URLs of the decks checked to export (the route's). */ chosen: readonly string[]; onChoose: (urls: readonly string[]) => void; @@ -65,7 +69,7 @@ export function TransferContainer({ try { for (const [index, deck] of decks.entries()) { setExporting({ deck, index, total: decks.length }); - await useCases.exportDeckFile(deck, options); + await useCases.exportDeckFile(instance.url, deck, options, webId); } } finally { setExporting(null); diff --git a/docs/data-model.md b/docs/data-model.md index 3c0f9bdd3..85b300692 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1633,9 +1633,17 @@ An export holds triples as the pod has them, at the pod's IRIs: names as its creators, its card language (`sm:deckCardLanguage`) and picture hosts (`sm:deckPictureHost`) among them, but not its `sm:position`, nor its `sm:completedChapter`s unless the progress goes too; -- every subject of its cards document: its cards and their wrong - options, retired ones too, and whatever another app put there; -- with the progress, every subject of its reviews document. +- its cards (each `sm:Card` of its cards document) and the wrong + options they name, retired ones too, with whatever another app said + of them; +- with the progress, the review states of those cards in its reviews + document. + +Nothing else of either document goes: a deck may name a document that +holds more than the deck. Each of the two documents is read as the user +only when it's the instance's (`isInInstancePod`), as a move or copy of +cards reads its source. An instance attached from someone else's pod +may name a document of the user's own. Any other is read as no one. Nothing is rewritten: a deck still at an older format is exported at it. A JSON-LD file is flattened: every subject is a node of its `@graph`, diff --git a/docs/studio.md b/docs/studio.md index f4d3f67db..eaae9b8fa 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -1459,7 +1459,9 @@ goes too: the cards' review states and a course's completed chapters. (`capitals.ttl`), one after another, the screen naming the deck it's at. Export writes nothing to the pod. The use case `exportDeckFile` reads the deck's documents (`DeckArchive.exportDeck`) and hands the -text to the browser to save (`FileExchange.save`). +text to the browser to save (`FileExchange.save`). It reads a document +as the user only when it's in the instance's pod, any other as no one, +and the file holds only the deck's own cards and review states. Browsers ask before a page's second download, and save none of the rest on a no. The app can't tell, so after several decks the screen diff --git a/e2e/pod/src/deckFiles.integration.test.ts b/e2e/pod/src/deckFiles.integration.test.ts index 6b17a0725..86ad4f852 100644 --- a/e2e/pod/src/deckFiles.integration.test.ts +++ b/e2e/pod/src/deckFiles.integration.test.ts @@ -84,7 +84,7 @@ function page(fileExchange: FileExchange = files()) { digestRepository: createSolidDigestRepository({ fetch: podFetch, checkWrite }), answerLog: createSolidAnswerLog({ fetch: podFetch, checkWrite }), ruleset: "e2e-rules", - deckArchive: createSolidDeckArchive({ fetch: podFetch }), + deckArchive: createSolidDeckArchive({ fetch: podFetch, publicFetch: podFetch }), fileExchange, }); } @@ -152,7 +152,7 @@ async function seed(server: string) { new Date(), ); } - return { main, other, deck, useCases }; + return { main, other, deck, useCases, webId }; } /** A document as N-Triples: every IRI written out in full, whatever the server's Turtle abbreviates. */ @@ -174,10 +174,10 @@ const statesOf = async (deck: Deck) => describe.each(SERVERS)("decks as files on $name", ({ url: server }) => { for (const format of DECK_FILE_FORMATS) { it(`takes a deck through ${format} into another instance, its progress with it`, async () => { - const { other, deck } = await seed(server); + const { main, other, deck, webId } = await seed(server); const exchange = files(); const useCases = page(exchange); - await useCases.exportDeckFile(deck, { format, withProgress: true }); + await useCases.exportDeckFile(main.url, deck, { format, withProgress: true }, webId); expect(exchange.saved).toHaveLength(1); const file = (await useCases.openDeckFile())!; expect(file).toMatchObject({ @@ -214,10 +214,10 @@ describe.each(SERVERS)("decks as files on $name", ({ url: server }) => { } it("imports a deck without its progress, and one exported and removed back at its URLs", async () => { - const { main, other, deck } = await seed(server); + const { main, other, deck, webId } = await seed(server); const exchange = files(); const useCases = page(exchange); - await useCases.exportDeckFile(deck, { format: "turtle", withProgress: false }); + await useCases.exportDeckFile(main.url, deck, { format: "turtle", withProgress: false }, webId); const file = (await useCases.openDeckFile())!; expect(file.content.reviews).toBeUndefined(); const fresh = await useCases.importDeckFile(other.url, file, { withProgress: false }); diff --git a/packages/application/src/cards.test.ts b/packages/application/src/cards.test.ts index 12966be6c..7b935e145 100644 --- a/packages/application/src/cards.test.ts +++ b/packages/application/src/cards.test.ts @@ -1115,11 +1115,18 @@ describe("cards", () => { it("saves a deck as a file named after it", async () => { const { deckArchive, fileExchange, useCases } = withFiles(); - await useCases.exportDeckFile(deck, { format: "jsonld", withProgress: true }); - expect(deckArchive.exportDeck).toHaveBeenCalledWith(deck, { - format: "jsonld", - withProgress: true, - }); + await useCases.exportDeckFile( + instance.url, + deck, + { format: "jsonld", withProgress: true }, + session.webId, + ); + // The instance's own documents: read as the user. + expect(deckArchive.exportDeck).toHaveBeenCalledWith( + deck, + { format: "jsonld", withProgress: true }, + { asNoOne: { cards: false, reviews: false } }, + ); expect(fileExchange.save).toHaveBeenCalledWith( "kanji-n5.jsonld", "application/ld+json", @@ -1127,6 +1134,35 @@ describe("cards", () => { ); }); + it("exports a deck's documents as the user only in the instance's pod, any other as no one (GHSA-xcjw-3f3x-g9fc)", async () => { + const { deps, deckArchive, useCases } = withFiles(); + vi.mocked(deps.storageGateway.discoverStorages).mockResolvedValue([ + { url: "https://alice.example/", source: "profile" }, + { url: "https://other.example/alice/", source: "profile" }, + ]); + const asNoOne = async (documents: Partial) => { + deckArchive.exportDeck.mockClear(); + await useCases.exportDeckFile( + instance.url, + { ...deck, ...documents }, + { format: "turtle", withProgress: true }, + session.webId, + ); + return (deckArchive.exportDeck.mock.calls[0] as unknown[])[2]; + }; + await expect( + asNoOne({ cardsDocumentUrl: "https://alice.example/elsewhere/deck-1.ttl" }), + ).resolves.toEqual({ asNoOne: { cards: false, reviews: false } }); + expect(deps.storageGateway.discoverStorages).toHaveBeenCalledWith(session.webId); + // The user's own, but in a storage the instance is not in; anyone else's. + await expect( + asNoOne({ cardsDocumentUrl: "https://other.example/alice/private/notes.ttl" }), + ).resolves.toEqual({ asNoOne: { cards: true, reviews: false } }); + await expect( + asNoOne({ reviewsDocumentUrl: "https://elsewhere.example/reviews.ttl" }), + ).resolves.toEqual({ asNoOne: { cards: false, reviews: true } }); + }); + it("reads the file the user picks, in the format its name says, at a base of its own", async () => { const { deckArchive, fileExchange, useCases } = withFiles({ name: "kanji.jsonld", diff --git a/packages/application/src/cards.ts b/packages/application/src/cards.ts index 1308673f0..aed0afc9d 100644 --- a/packages/application/src/cards.ts +++ b/packages/application/src/cards.ts @@ -173,11 +173,20 @@ export interface CardUseCases { webId: string, ): Promise; /** - * Save the deck as a file (domain/deckFile.ts, DeckArchive.exportDeck), - * in `options.format`, named after its title (deckFileName), with its - * progress when `options.withProgress`. Writes nothing to the pod. + * Save the deck of the instance as a file (domain/deckFile.ts, + * DeckArchive.exportDeck), in `options.format`, named after its title + * (deckFileName), with its progress when `options.withProgress`. Each + * of the deck's two documents is read as `webId` only when it is the + * instance's (isInInstancePod), any other as no one, as transferCards + * reads a source: the file holds what it reads. Writes nothing to the + * pod. */ - exportDeckFile(deck: Deck, options: DeckFileOptions): Promise; + exportDeckFile( + instanceUrl: string, + deck: Deck, + options: DeckFileOptions, + webId: string, + ): Promise; /** * The deck file the user picks (Turtle or JSON-LD, by its name, else * its text: deckFileFormatOf), read as DeckArchive.readDeckFile reads @@ -504,8 +513,15 @@ export function createCardUseCases({ } } }, - async exportDeckFile(deck, options) { - const text = await deckArchive.exportDeck(deck, options); + async exportDeckFile(instanceUrl, deck, options, webId) { + // Saved to a file the user may hand on: each document read as the user only when it is the instance's. + const [cardsHere, reviewsHere] = await Promise.all([ + isInInstancePod(storageGateway, instanceUrl, deck.cardsDocumentUrl, webId), + isInInstancePod(storageGateway, instanceUrl, deck.reviewsDocumentUrl, webId), + ]); + const text = await deckArchive.exportDeck(deck, options, { + asNoOne: { cards: !cardsHere, reviews: !reviewsHere }, + }); fileExchange.save( deckFileName(deck, options.format), DECK_FILE_TYPES[options.format].mediaType, diff --git a/packages/application/src/ports.ts b/packages/application/src/ports.ts index cb36bc256..c5f8773e6 100644 --- a/packages/application/src/ports.ts +++ b/packages/application/src/ports.ts @@ -558,13 +558,20 @@ export type LinkedReleaseReads = Pick< */ export interface DeckArchive { /** - * The deck's entry, with its agents and distribution, and every subject - * of its cards document, as the pod has them, at their IRIs; with - * `withProgress`, every subject of its reviews document and a course's - * completed chapters too. Throws deckGone when the catalog has no entry - * of it. - */ - exportDeck(deck: Deck, options: DeckFileOptions): Promise; + * The deck's entry, with its agents and distribution, and its cards + * with the distractors they name, as the pod has them, at their IRIs; + * with `withProgress`, the review states of those cards and a course's + * completed chapters too. Nothing else of its cards and reviews + * documents. Each of the two documents `asNoOne` names is read as no + * one (as listCards reads one), any other as the user: the caller + * says which, so none is read as the user by default. Throws deckGone + * when the catalog has no entry of it. + */ + exportDeck( + deck: Deck, + options: DeckFileOptions, + reads: { asNoOne: { cards: boolean; reviews: boolean } }, + ): Promise; /** * What a file holds: its one deck, read as the pod's are (an older * format brought up to date, in memory), the cards of its cards diff --git a/packages/composition/src/appUseCases.ts b/packages/composition/src/appUseCases.ts index b9e40a840..ff290c3c1 100644 --- a/packages/composition/src/appUseCases.ts +++ b/packages/composition/src/appUseCases.ts @@ -379,7 +379,11 @@ export function createAppUseCases({ ruleset, // Its profile is written through the fences too, as every pod write is. guestPod: createLocalGuestPod({ fetch: podFetch, store: guestStore }), - deckArchive: createSolidDeckArchive({ fetch: dataFetch }), + deckArchive: createSolidDeckArchive({ + fetch: dataFetch, + // A deck's documents outside its instance's pod are exported as no one reads them. + publicFetch: createPodFence(foreignReads(noOneFetch)), + }), fileExchange: createBrowserFileExchange(), releaseDraftRepository: createSolidReleaseDraftRepository({ fetch: dataFetch, diff --git a/packages/solid/src/deckPlace.test.ts b/packages/solid/src/deckPlace.test.ts index 7bea5d952..4521b08c5 100644 --- a/packages/solid/src/deckPlace.test.ts +++ b/packages/solid/src/deckPlace.test.ts @@ -1163,8 +1163,12 @@ describe("an instance whose address is spelled otherwise than a request sends it const states = await p.reviews.listReviewStates(listed!); expect(states.sm2.map((one) => one.repetitions)).toEqual([2]); - const archive = createSolidDeckArchive({ fetch: p.fetch }); - const file = await archive.exportDeck(listed!, { format: "turtle", withProgress: true }); + const archive = createSolidDeckArchive({ fetch: p.fetch, publicFetch: p.fetch }); + const file = await archive.exportDeck( + listed!, + { format: "turtle", withProgress: true }, + { asNoOne: { cards: false, reviews: false } }, + ); const read = await archive.readDeckFile(file, "turtle", "https://example.org/deck"); expect(read.cards).toHaveLength(2); expect(read.reviews).toHaveLength(1); diff --git a/packages/solid/src/malformedDue.test.ts b/packages/solid/src/malformedDue.test.ts index 3f59cd8f0..a58093eff 100644 --- a/packages/solid/src/malformedDue.test.ts +++ b/packages/solid/src/malformedDue.test.ts @@ -127,11 +127,10 @@ describe("a due day that is no date, written back", () => { sm:cardsDocument ; sm:reviewsDocument . a sm:Card ; sm:front "F" ; sm:back "B" ; sm:formatVersion 1 . ${stateOf("c1", "zzz", 2).replace("<#c1>", "").replace("../decks", "decks")}`; - const content = await createSolidDeckArchive({ fetch: globalThis.fetch }).readDeckFile( - file, - "turtle", - deckFileBaseOf("d.ttl"), - ); + const content = await createSolidDeckArchive({ + fetch: globalThis.fetch, + publicFetch: globalThis.fetch, + }).readDeckFile(file, "turtle", deckFileBaseOf("d.ttl")); const imported = importedDeck(INSTANCE, content, { withProgress: true, decks: [], diff --git a/packages/solid/src/mappers/reviewStateMapper.ts b/packages/solid/src/mappers/reviewStateMapper.ts index 4b964ce23..a36230bf1 100644 --- a/packages/solid/src/mappers/reviewStateMapper.ts +++ b/packages/solid/src/mappers/reviewStateMapper.ts @@ -111,6 +111,15 @@ function fsrsKeyOfSubject(thing: Subject, deck: ReviewedDeck): ReviewKey | null return fsrsKeyOfLinks(deck, linksOf(thing)); } +/** + * The id of the card a subject typed sm:ReviewState is of, SM-2 or + * FSRS-6, by its links alone (keyOfSubject, fsrsKeyOfSubject); null for + * any other subject, and for a state of a card elsewhere. + */ +export function reviewedCardIdOf(thing: Subject, deck: ReviewedDeck): string | null { + return (keyOfSubject(thing, deck) ?? fsrsKeyOfSubject(thing, deck))?.cardId ?? null; +} + const sameKey = (a: ReviewKey, b: ReviewKey) => a.cardId === b.cardId && a.direction === b.direction; diff --git a/packages/solid/src/solidDeckArchive.test.ts b/packages/solid/src/solidDeckArchive.test.ts index 3f1e5cbda..a08e91480 100644 --- a/packages/solid/src/solidDeckArchive.test.ts +++ b/packages/solid/src/solidDeckArchive.test.ts @@ -143,8 +143,12 @@ describe("createSolidDeckArchive", () => { for (const format of ["turtle", "jsonld"] as const satisfies readonly DeckFileFormat[]) { it(`reads back in ${format} what it exports, progress and all`, async () => { const { pod, deck, cards, states } = await podWithDeck(); - const archive = createSolidDeckArchive({ fetch: pod.fetch }); - const text = await archive.exportDeck(deck, { format, withProgress: true }); + const archive = createSolidDeckArchive({ fetch: pod.fetch, publicFetch: pod.fetch }); + const text = await archive.exportDeck( + deck, + { format, withProgress: true }, + { asNoOne: { cards: false, reviews: false } }, + ); expect(text).toContain(FOREIGN); const read = await archive.readDeckFile(text, format, `${DECK_FILE_BASE}deck`); expect(read).toEqual({ @@ -165,8 +169,12 @@ describe("createSolidDeckArchive", () => { it("leaves the progress and the deck's place behind unless asked", async () => { const { pod, deck, cards } = await podWithDeck(); - const archive = createSolidDeckArchive({ fetch: pod.fetch }); - const text = await archive.exportDeck(deck, { format: "turtle", withProgress: false }); + const archive = createSolidDeckArchive({ fetch: pod.fetch, publicFetch: pod.fetch }); + const text = await archive.exportDeck( + deck, + { format: "turtle", withProgress: false }, + { asNoOne: { cards: false, reviews: false } }, + ); expect(text).not.toContain("position"); expect(text).not.toContain("completedChapter"); // The card language is the learner's choice of how the deck reads, not progress: it goes along. @@ -187,8 +195,12 @@ describe("createSolidDeckArchive", () => { ]) { it(`refuses a deck whose ${predicate} is an IRI no URL parser takes, before anything is written (GHSA-5hqv-c3r3-7mx9)`, async () => { const { pod, deck } = await podWithDeck(); - const archive = createSolidDeckArchive({ fetch: pod.fetch }); - const text = await archive.exportDeck(deck, { format: "turtle", withProgress: true }); + const archive = createSolidDeckArchive({ fetch: pod.fetch, publicFetch: pod.fetch }); + const text = await archive.exportDeck( + deck, + { format: "turtle", withProgress: true }, + { asNoOne: { cards: false, reviews: false } }, + ); const base = `${DECK_FILE_BASE}deck`; await expect( archive.readDeckFile( @@ -210,10 +222,14 @@ describe("createSolidDeckArchive", () => { it("writes JSON-LD with its context in full, as compact IRIs and value objects", async () => { const { pod, deck } = await podWithDeck(); const json = JSON.parse( - await createSolidDeckArchive({ fetch: pod.fetch }).exportDeck(deck, { - format: "jsonld", - withProgress: true, - }), + await createSolidDeckArchive({ fetch: pod.fetch, publicFetch: pod.fetch }).exportDeck( + deck, + { + format: "jsonld", + withProgress: true, + }, + { asNoOne: { cards: false, reviews: false } }, + ), ); expect(json["@context"].sm).toBe(SM_NS); const entry = json["@graph"].find((node: { "@id": string }) => node["@id"] === deck.url); @@ -228,14 +244,135 @@ describe("createSolidDeckArchive", () => { }); }); + it("exports only the deck's own subjects of its documents, and reads them back (GHSA-xcjw-3f3x-g9fc)", async () => { + const { pod, deck, cards, states } = await podWithDeck(); + const card = cards[0]!.url; + // What else the documents hold: another subject, a state of a card of another deck, and what another app said of a card through a blank node. + await pod.put( + deck.cardsDocumentUrl, + [ + ...pod.triples(deck.cardsDocumentUrl)!, + `<${deck.cardsDocumentUrl}#note> "not of the deck" .`, + `<${card}> _:rating .`, + `_:rating "4" ; _:rating ; _:rater .`, + ].join("\n"), + ); + await pod.put( + deck.reviewsDocumentUrl, + [ + ...pod.triples(deck.reviewsDocumentUrl)!, + `<${deck.reviewsDocumentUrl}#other> a <${SM.ReviewState}> ; <${SM_NS}reviewOf> <${INSTANCE}decks/other.ttl#x> .`, + `<${deck.reviewsDocumentUrl}#diary> "not of the deck" .`, + ].join("\n"), + ); + const archive = createSolidDeckArchive({ fetch: pod.fetch, publicFetch: pod.fetch }); + const text = await archive.exportDeck( + deck, + { format: "turtle", withProgress: true }, + { asNoOne: { cards: false, reviews: false } }, + ); + expect(text).not.toContain("not of the deck"); + expect(text).not.toContain("#other"); + expect(text).toContain("https://example.org/stars"); + const read = await archive.readDeckFile(text, "turtle", `${DECK_FILE_BASE}deck`); + expect(read).toMatchObject({ + cards, + reviews: states.sm2, + fsrsReviews: states.fsrs, + dropped: [], + }); + }); + + it("exports the deck's entry alone when its cards document is gone", async () => { + const { pod, deck } = await podWithDeck(); + const gone = { ...deck, cardsDocumentUrl: `${INSTANCE}decks/gone.ttl` }; + const text = await createSolidDeckArchive({ + fetch: pod.fetch, + publicFetch: pod.fetch, + }).exportDeck( + gone, + { + format: "turtle", + withProgress: true, + }, + { asNoOne: { cards: false, reviews: false } }, + ); + expect(text).toContain(deck.url); + expect(text).not.toContain(`${deck.cardsDocumentUrl}#`); + expect(text).not.toContain(`${deck.reviewsDocumentUrl}#`); + }); + + it("reads a document it is asked to as no one, and copies none of what is not the deck's (GHSA-xcjw-3f3x-g9fc)", async () => { + const ATTACKER = "https://attacker.example/memo/"; + const STORAGE = "https://victim.example/"; + const PRIVATE = `${STORAGE}private/notes.ttl`; + const REVIEWS = `${STORAGE}private/reviews.ttl`; + // What each server holds; the private documents answer only the user. + const server: Record = { + [`${ATTACKER}catalog.ttl`]: { + body: `<#deck> a <${SM.Deck}>; <${SM_NS}cardsDocument> <${PRIVATE}>; <${SM_NS}reviewsDocument> <${REVIEWS}>.`, + }, + [PRIVATE]: { + ownerOnly: true, + body: `<${PRIVATE}#note> "private note".`, + }, + [REVIEWS]: { + ownerOnly: true, + body: `<${REVIEWS}#log> "private log".`, + }, + }; + const reads: { url: string; asUser: boolean }[] = []; + const serve = (asUser: boolean) => + (async (input: RequestInfo | URL) => { + const url = String(input); + reads.push({ url, asUser }); + const doc = server[url]; + if (doc === undefined) return new Response(null, { status: 404 }); + if (doc.ownerOnly === true && !asUser) return new Response(null, { status: 401 }); + const response = new Response(doc.body, { + status: 200, + headers: { "Content-Type": "text/turtle" }, + }); + Object.defineProperty(response, "url", { value: url }); + return response; + }) as typeof globalThis.fetch; + const deck = { + url: `${ATTACKER}catalog.ttl#deck`, + title: "Course deck", + cardsDocumentUrl: PRIVATE, + reviewsDocumentUrl: REVIEWS, + } as unknown as Deck; + const export_ = (asNoOne: { cards: boolean; reviews: boolean }) => + createSolidDeckArchive({ fetch: serve(true), publicFetch: serve(false) }).exportDeck( + deck, + { format: "turtle", withProgress: true }, + { asNoOne }, + ); + // Read as no one, as the use case asks for a document outside the instance's pod: one kept from everyone is no file at all. + await expect(export_({ cards: true, reviews: true })).rejects.toThrow(); + expect(reads.filter(({ url }) => url !== `${ATTACKER}catalog.ttl`)).toEqual([ + { url: PRIVATE, asUser: false }, + { url: REVIEWS, asUser: false }, + ]); + // Read as the user, nothing of either document is the deck's: none of it goes. + const text = await export_({ cards: false, reviews: false }); + expect(text).toContain(deck.url); + expect(text).not.toContain("private note"); + expect(text).not.toContain("private log"); + }); + it("refuses to export a deck its catalog no longer lists", async () => { const { pod, deck } = await podWithDeck(); - const archive = createSolidDeckArchive({ fetch: pod.fetch }); + const archive = createSolidDeckArchive({ fetch: pod.fetch, publicFetch: pod.fetch }); await expect( - archive.exportDeck({ ...deck, url: `${deck.url}-gone` } as Deck, { - format: "turtle", - withProgress: false, - }), + archive.exportDeck( + { ...deck, url: `${deck.url}-gone` } as Deck, + { + format: "turtle", + withProgress: false, + }, + { asNoOne: { cards: false, reviews: false } }, + ), ).rejects.toMatchObject({ code: "deckGone", }); @@ -243,6 +380,7 @@ describe("createSolidDeckArchive", () => { archive.exportDeck( { ...deck, url: "https://pod.example/elsewhere/catalog.ttl#d" }, { format: "turtle", withProgress: false }, + { asNoOne: { cards: false, reviews: false } }, ), ).rejects.toMatchObject({ code: "deckGone" }); }); @@ -293,11 +431,10 @@ describe("a deck file's wrong options, imported", () => { sm:distractor ${urls.map((url) => `<${url}>`).join(", ")} . ${options}`; const pod = fakePod(); - const read = await createSolidDeckArchive({ fetch: pod.fetch }).readDeckFile( - file, - "turtle", - `${DECK_FILE_BASE}deck.ttl`, - ); + const read = await createSolidDeckArchive({ + fetch: pod.fetch, + publicFetch: pod.fetch, + }).readDeckFile(file, "turtle", `${DECK_FILE_BASE}deck.ttl`); const { deck, cards } = importedDeck(INSTANCE, read, { withProgress: false, decks: [], @@ -335,9 +472,8 @@ describe("a deck file's wrong options, imported", () => { }); describe("readDeckFile", () => { - const archive = createSolidDeckArchive({ - fetch: (() => Promise.reject(new Error("no reads"))) as unknown as typeof fetch, - }); + const noReads = (() => Promise.reject(new Error("no reads"))) as unknown as typeof fetch; + const archive = createSolidDeckArchive({ fetch: noReads, publicFetch: noReads }); const base = `${DECK_FILE_BASE}old.ttl`; it("brings older formats up to date, resolves relative IRIs, and says what it left out", async () => { @@ -623,9 +759,13 @@ describe("readDeckFile", () => { it("leaves out text in a language the pod's parser cannot read, so the imported deck reads back", async () => { const { pod, deck } = await podWithDeck(); - const archive = createSolidDeckArchive({ fetch: pod.fetch }); + const archive = createSolidDeckArchive({ fetch: pod.fetch, publicFetch: pod.fetch }); const json = JSON.parse( - await archive.exportDeck(deck, { format: "jsonld", withProgress: false }), + await archive.exportDeck( + deck, + { format: "jsonld", withProgress: false }, + { asNoOne: { cards: false, reviews: false } }, + ), ); const entry = json["@graph"].find((node: { "@id": string }) => node["@id"] === deck.url); for (const key of ["dcterms:title", "dcterms:description", "dcat:keyword"]) { diff --git a/packages/solid/src/solidDeckArchive.ts b/packages/solid/src/solidDeckArchive.ts index 66af2f72b..7c4584d3c 100644 --- a/packages/solid/src/solidDeckArchive.ts +++ b/packages/solid/src/solidDeckArchive.ts @@ -31,13 +31,13 @@ import { type FsrsState, type ReviewState, } from "@solid-memo/domain/review"; -import { documentUrlOf } from "@solid-memo/domain/subjectUrl"; +import { documentUrlOf, fragmentIdOf } from "@solid-memo/domain/subjectUrl"; import { LATEST_VERSION, type ShapeName } from "@solid-memo/vocab/types.generated"; import { getSolidDatasetOrNull } from "./datasets"; import { jsonLdOf, refuseRemoteContexts } from "./jsonLd"; import { refuseUnwritable, unwritableTermOf } from "./writableTerms"; import { agentNamesOf, toCard, toDeck } from "./mappers/deckMapper"; -import { toFsrsState, toReviewState } from "./mappers/reviewStateMapper"; +import { reviewedCardIdOf, toFsrsState, toReviewState } from "./mappers/reviewStateMapper"; import { storedVersionOf } from "./records"; import { DCTERMS, RDF, SM, SM_NS } from "./vocab"; @@ -69,20 +69,30 @@ const KINDS: readonly { type: string; scheduler?: string; shape: ShapeName }[] = /** * The DeckArchive over the pod (domain/deckFile.ts). An export copies - * triples as they are, so what another app said of the deck, its cards - * or their states goes with them; only the deck's place among its - * groups (sm:position) is left behind, and a course's progress unless - * asked for. Turtle is written and read as pod documents - * are (solidDatasetAsTurtle, parseQuads), JSON-LD read so too and - * written here (jsonLd.ts). + * the deck's own subjects as they are (ownSubjectsOf), so what another + * app said of the deck, its cards or their states goes with them; only + * the deck's place among its groups (sm:position) is left behind, and a + * course's progress unless asked for. Nothing else of its documents goes: + * a deck may name a document it shares with other data. Turtle is + * written and read as pod documents are (solidDatasetAsTurtle, + * parseQuads), JSON-LD read so too and written here (jsonLd.ts). A + * document exportDeck is asked to read as no one is read with + * `publicFetch`. */ -export function createSolidDeckArchive({ fetch }: { fetch: typeof globalThis.fetch }): DeckArchive { +export function createSolidDeckArchive({ + fetch, + publicFetch, +}: { + fetch: typeof globalThis.fetch; + publicFetch: typeof globalThis.fetch; +}): DeckArchive { + const as = (noOne: boolean) => (noOne ? publicFetch : fetch); return { - async exportDeck(deck, { format, withProgress }) { + async exportDeck(deck, { format, withProgress }, { asNoOne }) { const [catalog, cards, reviews] = await Promise.all([ getSolidDatasetOrNull(documentUrlOf(deck.url), fetch), - getSolidDatasetOrNull(deck.cardsDocumentUrl, fetch), - withProgress ? getSolidDatasetOrNull(deck.reviewsDocumentUrl, fetch) : null, + getSolidDatasetOrNull(deck.cardsDocumentUrl, as(asNoOne.cards)), + withProgress ? getSolidDatasetOrNull(deck.reviewsDocumentUrl, as(asNoOne.reviews)) : null, ]); const entry = catalog === null ? null : getThing(catalog, deck.url); if (entry === null) throw new AppError("deckGone", { deck: deck.title }); @@ -93,8 +103,7 @@ export function createSolidDeckArchive({ fetch }: { fetch: typeof globalThis.fet ({ subject, predicate }) => (subject.value === deck.url && !left.has(predicate.value)) || beside.has(subject.value), ), - ...quadsOf(cards), - ...quadsOf(reviews), + ...ownSubjectsOf(deck, cards, reviews), ]; refuseUnwritable(quads); if (format === "jsonld") return jsonLdOf(quads, DECK_FILE_PREFIXES); @@ -212,6 +221,63 @@ function hasNonUrlValue(quads: readonly Quad[], deckUrl: string): boolean { ); } +/** + * The triples of the deck's own subjects in its two documents, read as + * readDeckFile reads them back: each of its cards (an sm:Card of its + * cards document) with the distractors it names there, and each review + * state of one of those cards in its reviews document; with the blank + * nodes they lead to (quadsAbout). Anything else either document holds + * is not the deck's and is left out. + */ +function ownSubjectsOf(deck: Deck, cards: Dataset | null, reviews: Dataset | null): Quad[] { + const cardThings = subjectsIn(cards, deck.cardsDocumentUrl).filter((thing) => + getUrlAll(thing, RDF.type).includes(SM.Card), + ); + const ids = new Set(cardThings.map((thing) => fragmentIdOf(asUrl(thing)))); + const cardSubjects = new Set( + cardThings.flatMap((thing) => [asUrl(thing), ...getUrlAll(thing, SM.distractor)]), + ); + const stateSubjects = new Set( + subjectsIn(reviews, deck.reviewsDocumentUrl) + .filter((thing) => ids.has(reviewedCardIdOf(thing, deck) ?? "")) + .map(asUrl), + ); + return [ + ...quadsAbout(quadsOf(cards), cardSubjects), + ...quadsAbout(quadsOf(reviews), stateSubjects), + ]; +} + +/** The subjects the document read has at its own IRIs; none of one there is none of. */ +function subjectsIn(dataset: Dataset | null, documentUrl: string): Subject[] { + if (dataset === null) return []; + return getThingAll(dataset).filter((thing) => documentUrlOf(asUrl(thing)) === documentUrl); +} + +/** The triples of `subjects`, and of every blank node they lead to, in the document's order. */ +function quadsAbout(quads: readonly Quad[], subjects: ReadonlySet): Quad[] { + const keyOf = (term: Quad["subject"] | Quad["object"]) => + term.termType === "BlankNode" ? `_:${term.value}` : term.value; + const bySubject = new Map(); + for (const quad of quads) { + const key = keyOf(quad.subject); + const about = bySubject.get(key); + if (about === undefined) bySubject.set(key, [quad]); + else about.push(quad); + } + const kept = new Set(subjects); + const next = [...subjects]; + for (let key = next.pop(); key !== undefined; key = next.pop()) { + for (const { object } of bySubject.get(key) ?? []) { + const blank = keyOf(object); + if (object.termType !== "BlankNode" || kept.has(blank)) continue; + kept.add(blank); + next.push(blank); + } + } + return quads.filter((quad) => kept.has(keyOf(quad.subject))); +} + /** The triples of a document read; none of one there is none of. */ function quadsOf(dataset: Dataset | null): Quad[] { return dataset === null ? [] : ([...toRdfJsDataset(dataset)] as Quad[]);