diff --git a/apps/studio/src/ui/StudioWorkspace.tsx b/apps/studio/src/ui/StudioWorkspace.tsx index a117c4ba..3179c56b 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 dd5ef1a0..a35bfbcb 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 63eb057c..c9d90d67 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 3c0f9bdd..85b30069 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 f4d3f67d..eaae9b8f 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 6b17a072..86ad4f85 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 12966be6..7b935e14 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 1308673f..aed0afc9 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 cb36bc25..c5f8773e 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 b9e40a84..ff290c3c 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 7bea5d95..4521b08c 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 3f59cd8f..a58093ef 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 4b964ce2..a36230bf 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 3f1e5cbd..a08e9148 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 66af2f72..7c4584d3 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[]);