From 62edb9d4ee5fd35b9998515ba29e87920ad0a2f0 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sun, 8 Nov 2026 05:41:32 +0100 Subject: [PATCH] fix(guest): refuse outside data that links to the guest's pod A release read from the library or a link, a release made a draft and a deck file could carry an address at the guest's pod, such as a card's picture or what it is about. Added to a guest's study, that address is not one of the study's own, so the move leaves it as it is: keeping the study as a new instance then failed on every try, and adding it to an instance refused that deck. Such a release is now refused where it is read, for every user (releaseLinksGuestPod), and an import checks what it keeps before it writes anything. Text the reader reads, such as a card's or what changed in the release, may still name any address. A deck file is refused the same way (deckFileLinksGuestPod), though its deck's and cards' own addresses may be at the guest's pod, as a guest's export has them. When a copied document still names the guest's pod, the move's error now names the guest's deck it belongs to, so the user can tell which deck to leave out. Refs: GHSA-hc3p-fpwj-fvjp --- docs/deck-library.md | 11 ++ docs/guest-mode.md | 33 +++++- docs/studio.md | 12 +- packages/application/src/ports.ts | 10 +- .../application/src/releaseDrafts.test.ts | 44 ++++++++ packages/application/src/releaseDrafts.ts | 7 +- packages/application/src/session.test.ts | 63 +++++++++++ packages/application/src/session.ts | 29 ++++- packages/domain/src/appError.ts | 6 + packages/domain/src/guest.test.ts | 62 ++++++++++ packages/domain/src/guest.ts | 35 +++++- packages/solid/src/guestStudy.test.ts | 106 +++++++++++++++++- .../solid/src/mappers/libraryMapper.test.ts | 49 ++++++++ packages/solid/src/mappers/libraryMapper.ts | 11 +- packages/solid/src/solidDeckArchive.test.ts | 18 +++ packages/solid/src/solidDeckArchive.ts | 4 + .../src/solidDeckRepository.import.test.ts | 30 ++++- packages/solid/src/solidDeckRepository.ts | 4 + packages/ui/src/i18n/de.json | 3 + packages/ui/src/i18n/en.json | 3 + packages/ui/src/i18n/es.json | 3 + packages/ui/src/i18n/fr.json | 3 + packages/ui/src/i18n/ko.json | 3 + packages/ui/src/i18n/sv.json | 3 + 24 files changed, 532 insertions(+), 20 deletions(-) diff --git a/docs/deck-library.md b/docs/deck-library.md index f60228774..f7a579f0a 100644 --- a/docs/deck-library.md +++ b/docs/deck-library.md @@ -780,6 +780,17 @@ The library conforms to [shapes.md](shapes.md)'s `LibraryDeckV6`, each release to its own format's shape. The app migrates a format-5 release to format 6 in memory. +The app refuses a release read at the guest's pod, or that states it's +there (`@base`). Nor does it read one that links there by an address +in it: a card's picture or what it's about, its license, a topic, its +series or its contact. Either is `releaseLinksGuestPod`, for an +import, an upgrade and a release from a link alike: a guest's copy of +it couldn't be kept +([guest-mode.md](guest-mode.md#what-comes-from-outside)). Text the +reader reads, such as a card's text or what changed in the release, +may name any address. Before it writes anything, an import checks a +release's license, topics and cards for such a link. + A release's cards and distractors share one set of ids: a copy keeps them all in one cards document, each at its id. The app refuses a release in which a card and a distractor have the same id, or two diff --git a/docs/guest-mode.md b/docs/guest-mode.md index 8b7ef566d..620d74627 100644 --- a/docs/guest-mode.md +++ b/docs/guest-mode.md @@ -68,6 +68,27 @@ session), and the deck opens in the guest's instance. A logged-in user's opens it in their own. A guest's study that can't start leaves the landing page, with the reason. +## What comes from outside + +When the study is kept, every address that names the guest's pod must +be one of the study's own, which the move gives a new place +([Keeping the study](#keeping-the-study)). So data from outside never +brings one in, for a guest and a logged-in user alike: + +- a [release](deck-library.md#reading-it), from the library or a link, + is refused when it's at the guest's pod or links to it by an + address in it, such as a card's picture or what it's about + (`releaseLinksGuestPod`); +- so is a release made a [draft](studio.md#drafts), from a file or as + a next version; +- so is a [deck file](studio.md#import-and-export) with such an + address (`deckFileLinksGuestPod`). The deck's, its cards' and their + documents' own addresses may name it: the import gives them new + ones. + +Text the reader reads, such as card text and notes or what changed in +a release, may name any address. + ## Keeping the study ```mermaid @@ -258,10 +279,14 @@ instance goes). It runs these steps: user's (`ContainerMove.renames`). 3. **adopt:** name the catalog's publisher as the user's profile names them. No Turtle document of the copy may still name an IRI at the - origin of `GUEST_ORIGIN`, in any spelling (`guestUrlsLeft`, which - names the document). Literals aren't looked at, their text or their - datatypes: the copy moves only the IRIs of subjects, predicates, - objects and graphs, and text such as a card's may name any address. + origin of `GUEST_ORIGIN`, in any spelling. The failure names the + guest's deck the document is of (`guestDeckCopyUrlsLeft`), so it + can be removed from the guest's study: the deck's cards or reviews + document, or the catalog whose entry of it links there. Else it + names the document (`guestUrlsLeft`). Literals aren't looked at, + their text or their datatypes: the copy moves only the IRIs of + subjects, predicates, objects and graphs, and text such as a card's + may name any address. 4. **validate:** the copy must fully conform to the shapes (`movedCopyInvalid`). 5. **verify:** the guest's instance must be unchanged since it was diff --git a/docs/studio.md b/docs/studio.md index 2b3061ff0..fcde4fcbc 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -1461,7 +1461,12 @@ file in a newer format than the app reads is refused (as `deckFileTooNew`, not `writtenByNewerApp`, since nothing is saved over), as is one without exactly one deck, or one that doesn't parse. So is one whose deck has an address that isn't a URL, which its entry -in the catalog can't hold. +in the catalog can't hold. So is one with an address the import keeps, +such as a card's picture or what it's about, that links to the guest's +pod (`deckFileLinksGuestPod`, +[guest-mode.md](guest-mode.md#what-comes-from-outside)). The deck's, +its cards' and their documents' own addresses may: the import gives +them new ones. **Import into** the instance (`importDeckFile`) makes a new deck of it, with its progress when the file has some and the user leaves it @@ -1599,7 +1604,10 @@ chapter, step and wrong option must be a fragment of the release (`#id`), as the draft holds only those as its parts. One anywhere else is shown by no editor and checked by no rule, but would be published with the draft, so such a release is refused too -(`releaseForeignPart`). +(`releaseForeignPart`). So is one that links to the guest's pod by an +IRI (`releaseLinksGuestPod`): a guest's draft of it couldn't be kept +([guest-mode.md](guest-mode.md#what-comes-from-outside)). Its text may +name any address. Of a release's other statements, the draft keeps those of the release, of its own subjects, and of what they name, at any depth: a source, a diff --git a/packages/application/src/ports.ts b/packages/application/src/ports.ts index e26825fcf..7b60265ab 100644 --- a/packages/application/src/ports.ts +++ b/packages/application/src/ports.ts @@ -282,7 +282,9 @@ export interface DeckRepository { * entry is checked before anything is written, and a failure after * that deletes the cards document, unless the entry was written after * all; one that cannot be deleted is reported, as for any import - * (finishCutOffImport in writes.ts). + * (finishCutOffImport in writes.ts). A release whose license, topics + * or cards link to the guest's pod is refused before any write + * (releaseLinksGuestPod, domain/guest.ts linksGuestPod). */ importDeck(instanceUrl: string, content: LibraryDeckContent): Promise; /** @@ -550,8 +552,10 @@ export interface DeckArchive { * the text does not parse (or a JSON-LD context would be fetched from * elsewhere), notADeckFile when it holds no deck this app can read, or * more than one, deckFileTooNew when a deck, card or review state - * is in a newer format than this app reads, and deckFileTooLarge when - * the file is larger than a deck file may be. + * is in a newer format than this app reads, deckFileTooLarge when + * the file is larger than a deck file may be, and + * deckFileLinksGuestPod when an address it would keep, but its own + * places, links to the guest's pod (domain/guest.ts linksGuestPod). */ readDeckFile(text: string, format: DeckFileFormat, baseUrl: string): Promise; } diff --git a/packages/application/src/releaseDrafts.test.ts b/packages/application/src/releaseDrafts.test.ts index c6bfdc36f..21a7ee752 100644 --- a/packages/application/src/releaseDrafts.test.ts +++ b/packages/application/src/releaseDrafts.test.ts @@ -416,6 +416,50 @@ describe("createReleaseDraft", () => { expect(repository.create).not.toHaveBeenCalled(); }); + it("refuses a release that links to the guest's pod, writing nothing, but not one whose text names it (GHSA-hc3p-fpwj-fvjp)", async () => { + const { useCases, repository } = setUp(); + const linking = { + ...v1, + triples: [ + ...v1.triples, + { + subject: V1, + predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso", + object: { kind: "iri" as const, value: "HTTPS://GUEST.solid-memo.invalid:443/x" }, + }, + ], + }; + const refused = new AppError("releaseLinksGuestPod", { url: V1 }); + repository.readRelease.mockResolvedValueOnce(linking); + await expect( + useCases.createReleaseDraft(INSTANCE, { kind: "nextVersionOf", url: V1 }, WEB_ID), + ).rejects.toEqual(refused); + repository.parseRelease.mockResolvedValueOnce(linking); + await expect( + useCases.createReleaseDraft(INSTANCE, { kind: "fromFile" }, WEB_ID), + ).rejects.toEqual(refused); + expect(repository.create).not.toHaveBeenCalled(); + const naming = { + ...v1, + triples: [ + ...v1.triples, + { + subject: V1, + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + kind: "literal" as const, + value: `See ${GUEST_INSTANCE_URL}`, + language: "", + datatype: "http://www.w3.org/2001/XMLSchema#string", + }, + }, + ], + }; + repository.parseRelease.mockResolvedValueOnce(naming); + await useCases.createReleaseDraft(INSTANCE, { kind: "fromFile" }, WEB_ID); + expect(repository.create).toHaveBeenCalledOnce(); + }); + it("refuses a release with a card outside it, writing nothing, but not one of its own its records do not hold", async () => { const { useCases, repository } = setUp(); const typed = (subject: string, type: string): DraftTriple => ({ diff --git a/packages/application/src/releaseDrafts.ts b/packages/application/src/releaseDrafts.ts index 5d890961c..67f928683 100644 --- a/packages/application/src/releaseDrafts.ts +++ b/packages/application/src/releaseDrafts.ts @@ -251,10 +251,13 @@ function placeTaken(error: unknown, url: string): boolean { * The release, unless it names a subject by an id a draft cannot keep * (releaseIdUnsupported), or has a card, chapter, step or distractor * outside it (releaseForeignPart): no editor or check of a draft would - * see that, but its release would publish it (GHSA-67w8-qjpm-jhwp). Of - * its other statements, it keeps only its own (ownStatements). + * see that, but its release would publish it (GHSA-67w8-qjpm-jhwp). Nor + * one that links to the guest's pod by an IRI (releaseLinksGuestPod): a + * guest's draft of it could not be kept (GHSA-hc3p-fpwj-fvjp). Of its + * other statements, it keeps only its own (ownStatements). */ function draftable(read: ReleaseDraft): ReleaseDraft { + if (draftAddressesGuest(read)) throw new AppError("releaseLinksGuestPod", { url: read.url }); const id = unsupportedIdOf(read); if (id !== null) throw new AppError("releaseIdUnsupported", { id }); const release = { diff --git a/packages/application/src/session.test.ts b/packages/application/src/session.test.ts index 2f27b8c78..febd614e0 100644 --- a/packages/application/src/session.test.ts +++ b/packages/application/src/session.test.ts @@ -912,6 +912,69 @@ describe("guests", () => { expect(deps.instanceCopier.deleteRecursively).not.toHaveBeenCalled(); }); + it("transferGuestStudy names the guest's deck whose copied document still names the guest's pod, so it can be left out", async () => { + const { deps } = guestDeps(); + const deckOf = (id: string, extra: Partial = {}): Deck => ({ + ...deck, + id, + url: `${GUEST_INSTANCE_URL}catalog.ttl#${id}`, + title: { en: id }, + cardsDocumentUrl: `${GUEST_INSTANCE_URL}decks/${id}.ttl`, + reviewsDocumentUrl: `${GUEST_INSTANCE_URL}reviews/${id}.ttl`, + ...extra, + }); + const linking = deckOf("deck-l", { license: `${GUEST_ORIGIN}license` }); + vi.mocked(deps.deckRepository.listDecks).mockImplementation(async (url) => + url === GUEST_INSTANCE_URL ? [deckOf("deck-a"), deckOf("deck-b"), linking] : [], + ); + vi.mocked(deps.instanceCopier.listResources).mockResolvedValue([ + `${GUEST_INSTANCE_URL}catalog.ttl`, + `${GUEST_INSTANCE_URL}decks/deck-a.ttl`, + `${GUEST_INSTANCE_URL}reviews/deck-b.ttl`, + `${GUEST_INSTANCE_URL}history/2026-10.ttl`, + ]); + const moveNaming = async (named: string) => { + vi.mocked(deps.instanceCopier.namesIriAt).mockImplementation(async (url) => url === named); + const outcome = await createUseCases(deps).transferGuestStudy(session, guestInstance, { + containerUrl: TARGET, + registrationTarget: "private", + }); + return outcome.ok ? null : outcome.error; + }; + expect(await moveNaming(`${TARGET}decks/deck-a.ttl`)).toEqual( + new AppError("guestDeckCopyUrlsLeft", { + deck: { en: "deck-a" }, + url: `${TARGET}decks/deck-a.ttl`, + }), + ); + expect(await moveNaming(`${TARGET}reviews/deck-b.ttl`)).toEqual( + new AppError("guestDeckCopyUrlsLeft", { + deck: { en: "deck-b" }, + url: `${TARGET}reviews/deck-b.ttl`, + }), + ); + // In the catalog, the deck whose entry links to the guest's pod. + expect(await moveNaming(`${TARGET}catalog.ttl`)).toEqual( + new AppError("guestDeckCopyUrlsLeft", { + deck: { en: "deck-l" }, + url: `${TARGET}catalog.ttl`, + }), + ); + // A document of no deck, or a catalog whose entries link nowhere there, is named itself. + expect(await moveNaming(`${TARGET}history/2026-10.ttl`)).toEqual( + new AppError("guestUrlsLeft", { url: `${TARGET}history/2026-10.ttl` }), + ); + vi.mocked(deps.deckRepository.listDecks).mockResolvedValue([deckOf("deck-a")]); + expect(await moveNaming(`${TARGET}catalog.ttl`)).toEqual( + new AppError("guestUrlsLeft", { url: `${TARGET}catalog.ttl` }), + ); + // So is any, when the guest's decks cannot be listed. + vi.mocked(deps.deckRepository.listDecks).mockRejectedValue(new Error("unreadable")); + expect(await moveNaming(`${TARGET}decks/deck-a.ttl`)).toEqual( + new AppError("guestUrlsLeft", { url: `${TARGET}decks/deck-a.ttl` }), + ); + }); + it("transferGuestStudy refuses a copy that still names the guest's pod, or does not conform", async () => { const { deps } = guestDeps(); vi.mocked(deps.instanceCopier.namesIriAt).mockImplementation(async (url) => diff --git a/packages/application/src/session.ts b/packages/application/src/session.ts index 0153c25bc..4a363a089 100644 --- a/packages/application/src/session.ts +++ b/packages/application/src/session.ts @@ -4,6 +4,7 @@ import { profileNameOf, type SolidAccount } from "@solid-memo/domain/account"; import { rebaseIri } from "@solid-memo/domain/instanceUpdate"; +import { documentUrlOf } from "@solid-memo/domain/subjectUrl"; import { DECK_FORMAT_VERSION, type Deck } from "@solid-memo/domain/deck"; import type { Instance, RegistrationTarget } from "@solid-memo/domain/instance"; import { @@ -29,6 +30,7 @@ import { guestMergeKey, guestMergePlan, isGuestUrl, + linksGuestPod, mergedAnswer, mergedDeck, type GuestMergeOutcome, @@ -331,6 +333,31 @@ export function createSessionUseCases({ if ((await instanceRepository.listInstances(GUEST_WEBID)).length === 0) await guestPod.discard(); } + /** + * The error of a move whose copied document at `url` still names the + * guest's pod: when the document is a guest's deck's, its cards or + * reviews document, or the catalog whose entry of it links to the + * guest's pod, the error names that deck (guestDeckCopyUrlsLeft), so + * the user can tell which to leave out; else the document + * (guestUrlsLeft), as when the guest's decks cannot be listed. + */ + async function guestUrlsLeftIn( + url: string, + guestInstanceUrl: string, + { from, to }: ContainerMove, + ): Promise { + const moved = (iri: string) => rebaseIri(iri, from, to); + const decks = await deckRepository.listDecks(guestInstanceUrl).catch((): Deck[] => []); + const deck = decks.find( + (deck) => + moved(deck.cardsDocumentUrl) === url || + moved(deck.reviewsDocumentUrl) === url || + (moved(documentUrlOf(deck.url)) === url && linksGuestPod(deck)), + ); + return deck === undefined + ? new AppError("guestUrlsLeft", { url }) + : new AppError("guestDeckCopyUrlsLeft", { deck: deck.title, url }); + } /** * Add a guest's deck to the instance as a new deck, whole: its cards * document, then its reviews document (each created only where nothing @@ -594,7 +621,7 @@ export function createSessionUseCases({ progress.stepped(); for (const url of documents) { if (await instanceCopier.namesIriAt(url, GUEST_ORIGIN)) - throw new AppError("guestUrlsLeft", { url }); + throw await guestUrlsLeftIn(url, guestInstance.url, move); progress.stepped(); } progress.finished("validate"); diff --git a/packages/domain/src/appError.ts b/packages/domain/src/appError.ts index 13491e2e8..d85e658c9 100644 --- a/packages/domain/src/appError.ts +++ b/packages/domain/src/appError.ts @@ -112,6 +112,8 @@ export const ERROR_TEMPLATES = { }, guestUrlsLeft: "The copy {url} in your Pod still points to the guest's study, so your study is left as it was. Try again.", + guestDeckCopyUrlsLeft: + "The copy of the deck “{deck}” in your Pod still points to the guest's study, so your study is left as it was. Remove that deck from the guest's study, then try again.", guestDeckUrlsLeft: "The deck “{deck}” still points to the guest's study, so it was not added to your Pod. Leave it out, then try again.", guestStudyInvalid: { @@ -175,6 +177,8 @@ export const ERROR_TEMPLATES = { notADeck: "That is not a Solid Memo deck, so it cannot be added. Choose another deck.", libraryIdsRepeat: "This deck gives two of its cards or wrong options the same id, so Solid Memo cannot read it. Ask its creator to check it.", + releaseLinksGuestPod: + "This release links to the guest's study in this browser, which no release may, so Solid Memo does not read it. Ask its creator to check it.", libraryDeckTooNew: "This deck is in a newer format than this version of Solid Memo can read. Reload the page to get the latest version.", libraryCardTooNew: @@ -183,6 +187,8 @@ export const ERROR_TEMPLATES = { "Solid Memo cannot read that file. Choose a Turtle or JSON-LD file exported from Solid Memo.", notADeckFile: "That file does not hold one Solid Memo deck. Choose a file exported from Solid Memo.", + deckFileLinksGuestPod: + "That file links to the guest's study in this browser, which no deck may, so it was not imported. Choose another file.", deckFileTooNew: "That file was written by a newer version of Solid Memo, so this one cannot import it. Reload the page to get the latest version.", deckFileTooLarge: diff --git a/packages/domain/src/guest.test.ts b/packages/domain/src/guest.test.ts index 9646b9e02..729fbf6d4 100644 --- a/packages/domain/src/guest.test.ts +++ b/packages/domain/src/guest.test.ts @@ -6,11 +6,13 @@ import { describe, expect, it } from "vitest"; import type { Answer } from "./answer"; import type { Card, Deck, Distractor } from "./deck"; import type { DeckTree } from "./deckTree"; +import type { LibraryDeckContent } from "./library"; import type { FsrsSnapshot, FsrsState, ReviewSnapshot, ReviewState, ReviewStates } from "./review"; import { rebaseDraft } from "./release/releaseToDraft"; import { card as draftCard, courseDraft } from "./testing/releaseDraft"; import { addressesGuest, + linksGuestPod, decodeGuestMergeNote, draftAddressesGuest, encodeGuestMergeNote, @@ -368,6 +370,66 @@ describe("adding a guest's study to an instance", () => { expect(addressesGuest({ [field]: `${GUEST_ORIGIN}x` }), field).toBe(!readerText); }); + it("finds outside data that links to the guest's pod, but not by its own places, which an import gives anew (GHSA-hc3p-fpwj-fvjp)", () => { + const own = { + id: "deck-1", + url: `${GUEST_INSTANCE_URL}catalog.ttl#deck-1`, + cardsDocumentUrl: `${GUEST_INSTANCE_URL}decks/deck-1.ttl`, + reviewsDocumentUrl: `${GUEST_INSTANCE_URL}reviews/deck-1.ttl`, + title: { en: `About ${GUEST_ORIGIN}` }, + }; + const card = { id: "c1", url: `${GUEST_INSTANCE_URL}decks/deck-1.ttl#c1`, front: { en: "Q" } }; + expect(linksGuestPod([own, [card], [{ cardId: "c1" }]])).toBe(false); + expect(addressesGuest([own])).toBe(true); + for (const link of [ + { frontImageUrl: `${GUEST_ORIGIN}p.png` }, + { backImageUrl: "HTTPS://GUEST.solid-memo.invalid:443/p.png" }, + { subjects: [`${GUEST_ORIGIN}a`] }, + ]) + expect(linksGuestPod([own, [{ ...card, ...link }]]), JSON.stringify(link)).toBe(true); + for (const link of [ + { license: `${GUEST_ORIGIN}l` }, + { themes: [`${GUEST_ORIGIN}t`] }, + { sourceUrl: `${GUEST_ORIGIN}r.ttl` }, + { seriesUrl: `${GUEST_ORIGIN}r.ttl#series` }, + ]) + expect(linksGuestPod({ ...own, ...link }), JSON.stringify(link)).toBe(true); + }); + + it("skips in a release only its own address and the text the reader reads, which may name the guest's pod (GHSA-hc3p-fpwj-fvjp)", () => { + // Every field, true when it is skipped: a field added to a release fails to typecheck until it is placed here. + const releaseFields: Record = { + url: true, + title: true, + formatVersion: false, + authors: true, + license: false, + description: true, + direction: false, + version: false, + seriesUrl: false, + versionNotes: true, + modifiedAt: false, + themes: false, + keywords: true, + cards: false, + isCourse: false, + contact: false, + }; + for (const [field, skipped] of Object.entries(releaseFields)) + expect(linksGuestPod({ [field]: `${GUEST_ORIGIN}x` }), field).toBe(!skipped); + const notes = `Pictures no longer at ${GUEST_ORIGIN}p.png, see HTTPS://GUEST.solid-memo.invalid:443`; + expect(linksGuestPod({ versionNotes: notes, cards: [{ id: "c1", front: { en: "Q" } }] })).toBe( + false, + ); + for (const link of [ + { license: `${GUEST_ORIGIN}l` }, + { cards: [{ id: "c1", frontImageUrl: `${GUEST_ORIGIN}p.png` }] }, + { cards: [{ id: "c1", subjects: [`${GUEST_ORIGIN}a`] }] }, + ]) + expect(linksGuestPod({ versionNotes: notes, ...link }), JSON.stringify(link)).toBe(true); + }); + it("finds a draft that links to the guest's pod, but not one whose text names it", () => { const text = `See ${GUEST_ORIGIN} and HTTPS://GUEST.solid-memo.invalid:443`; const draft = courseDraft(); diff --git a/packages/domain/src/guest.ts b/packages/domain/src/guest.ts index 95914c709..4113252fb 100644 --- a/packages/domain/src/guest.ts +++ b/packages/domain/src/guest.ts @@ -313,10 +313,43 @@ const READER_TEXT_FIELDS: ReadonlySet = new Set([ * does: a URL parser reads spellings without `//` as the guest's pod too. */ export function addressesGuest(value: unknown): boolean { + return addressesGuestBut(value, READER_TEXT_FIELDS); +} + +/** + * The fields of a deck, a card and a wrong option that hold its own + * place (its id, its address and its documents'): an import gives it a + * new one, and a release or a deck file read at the guest's pod has + * them there. With them, the text a release adds for the reader (what + * changed in it), which may name any address: guest.test.ts lists every + * field of LibraryDeckContent, so a new one fails it until it is placed. + */ +const OWN_PLACE_FIELDS: ReadonlySet = new Set([ + ...READER_TEXT_FIELDS, + "versionNotes", + "id", + "url", + "cardsDocumentUrl", + "reviewsDocumentUrl", +]); + +/** + * Whether data from outside the instance, a release or a deck file as + * it is read, links to the guest's pod by an address in a field that + * is neither its own place nor text for the reader (OWN_PLACE_FIELDS), + * as addressesGuest finds one: added to a guest's study, such an + * address would stay as it is when the study is kept, so it would not + * be kept (GHSA-hc3p-fpwj-fvjp). + */ +export function linksGuestPod(value: unknown): boolean { + return addressesGuestBut(value, OWN_PLACE_FIELDS); +} + +function addressesGuestBut(value: unknown, skipped: ReadonlySet): boolean { if (typeof value === "string") return isGuestPodIri(value) || namesGuestPod(value); if (typeof value !== "object" || value === null) return false; return Object.entries(value).some( - ([field, held]) => !READER_TEXT_FIELDS.has(field) && addressesGuest(held), + ([field, held]) => !skipped.has(field) && addressesGuestBut(held, skipped), ); } diff --git a/packages/solid/src/guestStudy.test.ts b/packages/solid/src/guestStudy.test.ts index 2e1d47d7c..ce60cf259 100644 --- a/packages/solid/src/guestStudy.test.ts +++ b/packages/solid/src/guestStudy.test.ts @@ -8,18 +8,23 @@ * the pod kept on their device, then logs in and keeps it. Their pod is a * second local pod here; e2e/pod moves a guest's study into real servers. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { VOCAB_ROOT } from "@solid-memo/vocab/tooling/root"; import { SHAPE_SOURCES, shapesFetch } from "@solid-memo/vocab/tooling/testing/shapeSources"; import { createUseCases } from "@solid-memo/application/useCases"; import { NO_PORTS } from "@solid-memo/application/testing/noPorts"; -import type { ResourceStore } from "@solid-memo/application/ports"; +import type { DeckLibrary, ResourceStore } from "@solid-memo/application/ports"; import { GUEST_ORIGIN, GUEST_SESSION } from "@solid-memo/domain/guest"; +import type { LibraryDeck } from "@solid-memo/domain/library"; import { createLocalGuestPod } from "./localGuestPod"; import { createLocalPod } from "./localPod"; import { createMemoryResourceStore } from "./memoryResourceStore"; import { routedFetch } from "./routedFetch"; import { createShaclShapeValidator } from "./shaclShapeValidator"; import { createSolidAnswerLog } from "./solidAnswerLog"; +import { createSolidDeckLibrary } from "./solidDeckLibrary"; import { createSolidDeckRepository } from "./solidDeckRepository"; import { createSolidDigestRepository } from "./solidDigestRepository"; import { createSolidInstanceCopier } from "./solidInstanceCopier"; @@ -86,7 +91,7 @@ async function addTriples(store: ResourceStore, document: string, lines: string[ }); } -async function app() { +async function app(deckLibrary?: DeckLibrary) { const newEtag = () => `"${crypto.randomUUID()}"`; const guestStore = createMemoryResourceStore(); const guestFetch = createLocalPod({ root: GUEST_ORIGIN, store: guestStore, newEtag }); @@ -119,7 +124,7 @@ async function app() { restore: async () => null, discoverOidcIssuer: async () => Promise.reject(new Error("none")), } as never, - deckLibrary: undefined as never, + deckLibrary: deckLibrary ?? (undefined as never), webIdDocumentRepository: createSolidWebIdDocumentRepository({ fetch: podFetch }), storageGateway: createSolidStorageGateway({ fetch: podFetch, @@ -225,10 +230,14 @@ describe("a guest's study", () => { const link = `<${card.url}> .`; await addTriples(guestStore, deck.cardsDocumentUrl, [link]); const refused = await useCases.transferGuestStudy(ALICE, guestInstance!, into); + // The document is the deck's: the error names the deck, so the user can tell which to leave out. expect(refused).toMatchObject({ ok: false, step: "adopt", - error: expect.objectContaining({ code: "guestUrlsLeft" }), + error: expect.objectContaining({ + code: "guestDeckCopyUrlsLeft", + vars: { deck: { en: "Guests" }, url: `${TARGET}decks/${deck.id}.ttl` }, + }), cleanedUp: true, }); const resource = await guestStore.get(deck.cardsDocumentUrl); @@ -250,6 +259,95 @@ describe("a guest's study", () => { ); }); + // A release that links to the guest's pod, by a card's picture and what it is about, as a release from a link may. + const PLANTED = "https://mallory.example/releases/capitals/v2.ttl"; + const plantedLibrary = () => { + const release = readFileSync( + join(VOCAB_ROOT, "fixtures/library-deck/v6/valid/library-standalone-release.ttl"), + "utf8", + ) + .replaceAll( + "https://alice.example/solid-memo/main/releases/", + "https://mallory.example/releases/", + ) + .replace( + 'solid-memo:formatVersion 5 ;\n solid-memo:front "Norway"@en ;', + `solid-memo:formatVersion 7 ;\n solid-memo:frontImage <${GUEST_ORIGIN}p.png> ;\n <${GUEST_ORIGIN}a> ;\n solid-memo:front "Norway"@en ;`, + ); + expect(release).toContain(`<${GUEST_ORIGIN}a>`); + const documents: Record = { + [PLANTED]: release, + "https://solid-memo.com/decks/index.ttl": "", + }; + const fetch: typeof globalThis.fetch = async (input) => { + const url = String(input instanceof Request ? input.url : input); + const body = documents[url]; + const response = + body === undefined + ? new Response("", { status: 404 }) + : new Response(body, { headers: { "content-type": "text/turtle" } }); + Object.defineProperty(response, "url", { value: url }); + return response; + }; + return createSolidDeckLibrary({ + fetch, + indexUrl: "https://solid-memo.com/decks/index.ttl", + validate: async (url) => ({ url, status: "checked", subjects: [] }), + }); + }; + + /** A guest who studied a deck of their own, then was refused a release that links to the guest's pod. */ + async function refusedPlanted() { + const study = await app(plantedLibrary()); + const { useCases } = study; + await useCases.startGuest("My study"); + const [guestInstance] = await useCases.listInstances(GUEST_SESSION); + const deck = await useCases.createDeck(guestInstance!.url, { en: "Capitals" }); + await useCases.addCard(deck, { front: { en: "Sweden" }, back: { en: "Stockholm" } }); + const refused = expect.objectContaining({ code: "releaseLinksGuestPod" }); + await expect(useCases.readReleaseFromLink(PLANTED)).rejects.toEqual(refused); + await expect( + useCases.importReleaseFromUrl(guestInstance!.url, { url: PLANTED } as LibraryDeck), + ).rejects.toEqual(refused); + expect((await useCases.listDecks(guestInstance!.url)).map(({ title }) => title)).toEqual([ + { en: "Capitals" }, + ]); + return { ...study, guestInstance: guestInstance! }; + } + + it("adds no release that links to the guest's pod, so the study moves into a new instance (GHSA-hc3p-fpwj-fvjp)", async () => { + const { useCases, aliceStore, guestInstance } = await refusedPlanted(); + + const outcome = await useCases.transferGuestStudy(ALICE, guestInstance, { + containerUrl: TARGET, + registrationTarget: "private", + }); + + expect(outcome).toMatchObject({ ok: true }); + expect((await useCases.listDecks(TARGET)).map(({ title }) => title)).toEqual([ + { en: "Capitals" }, + ]); + expect((await everyTriple(aliceStore)).filter((line) => line.includes(GUEST_ORIGIN))).toEqual( + [], + ); + }); + + it("adds no release that links to the guest's pod, so the study is added to an instance (GHSA-hc3p-fpwj-fvjp)", async () => { + const { useCases, guestInstance } = await refusedPlanted(); + const target = await useCases.createInstance(ALICE, { + containerUrl: TARGET, + name: "Main", + registrationTarget: "private", + }); + + const outcome = await useCases.mergeGuestStudy(ALICE, guestInstance, target); + + expect(outcome).toMatchObject({ ok: true }); + expect((await useCases.listDecks(TARGET)).map(({ title }) => title)).toEqual([ + { en: "Capitals" }, + ]); + }); + it("adds a deck whose card text names the guest's pod to an instance, the text as it was", async () => { const { useCases } = await app(); const target = await useCases.createInstance(ALICE, { diff --git a/packages/solid/src/mappers/libraryMapper.test.ts b/packages/solid/src/mappers/libraryMapper.test.ts index d20555ca7..a46e7507e 100644 --- a/packages/solid/src/mappers/libraryMapper.test.ts +++ b/packages/solid/src/mappers/libraryMapper.test.ts @@ -481,6 +481,55 @@ ${base === DOC ? "" : ' a sm:Deck ; dcterms ).not.toHaveProperty("contact"); }); + it("refuses a release at the guest's pod, or that links to it by an address in it (GHSA-hc3p-fpwj-fvjp)", async () => { + const url = "https://alice.example/solid-memo/main/releases/capitals/v2.ttl"; + const turtle = await readFile( + `${VOCAB_ROOT}fixtures/library-deck/v6/valid/library-standalone-release.ttl`, + "utf8", + ); + const guest = "https://guest.solid-memo.invalid/"; + const card = `\n<#gx> a solid-memo:Card ; solid-memo:formatVersion 7 ; solid-memo:front "Q"@en ; solid-memo:back "A"@en ;`; + // Text the reader reads may name any address. + expect( + toLibraryDeckContent( + url, + await datasetOf(`${turtle}${card} solid-memo:frontNote "See ${guest}"@en .\n`, url), + ).cards, + ).toHaveLength(3); + const notes = `adms:versionNotes "Pictures moved from ${guest}p.png." ;`; + const noted = turtle.replace('adms:versionNotes "Added Norway." ;', notes); + expect(noted).not.toBe(turtle); + expect(toLibraryDeckContent(url, await datasetOf(noted, url)).versionNotes).toBe( + `Pictures moved from ${guest}p.png.`, + ); + const refused = `This release links to the guest's study in this browser, which no release may, so Solid Memo does not read it. Ask its creator to check it.\nurl: ${url}`; + const license = "dcterms:license "; + for (const linking of [ + `${turtle}${card} solid-memo:frontImage <${guest}p.png> .`, + `${turtle}${card} solid-memo:backImage .`, + `${turtle}${card} dcterms:subject <${guest}a> .`, + turtle.replace(license, `dcterms:license <${guest}license>`), + `${turtle}\n<> dcat:theme <${guest}theme> .`, + `${noted}${card} solid-memo:frontImage <${guest}p.png> .`, + ]) { + expect(linking).toMatch(/guest\.solid-memo\.invalid/i); + const dataset = await datasetOf(`${linking}\n`, url); + expect(() => toLibraryDeckContent(url, dataset), linking.slice(-80)).toThrow(refused); + } + // Read at the guest's pod, or stating it is there (@base). + const atGuest = turtle.replaceAll("https://alice.example/solid-memo/main/", guest); + const there = `${guest}releases/capitals/v2.ttl`; + expect(() => toLibraryDeckContent(there, datasetFrom(there) as Dataset)).toThrow( + "That is not a Solid Memo deck", + ); + for (const read of [there, url]) { + const dataset = await datasetOf(atGuest, read); + expect(() => toLibraryDeckContent(read, dataset), read).toThrow("which no release may"); + } + const elsewhere = await datasetOf(turtle, there); + expect(() => toLibraryDeckContent(there, elsewhere)).toThrow("which no release may"); + }); + it("refuses a deck in a newer format than it writes", () => { const dataset = deckDocument( thing(DOC, (t) => diff --git a/packages/solid/src/mappers/libraryMapper.ts b/packages/solid/src/mappers/libraryMapper.ts index f4ea3ef18..2ca5fd6e2 100644 --- a/packages/solid/src/mappers/libraryMapper.ts +++ b/packages/solid/src/mappers/libraryMapper.ts @@ -35,6 +35,7 @@ import { } from "@solid-memo/domain/course"; import { AppError } from "@solid-memo/domain/appError"; import { documentUrlOf } from "@solid-memo/domain/subjectUrl"; +import { isGuestPodIri, linksGuestPod } from "@solid-memo/domain/guest"; import { isPublishedReleaseUrl } from "@solid-memo/domain/release/releasePlace"; import type { LibraryIndexView } from "@solid-memo/domain/release/releaseCheck"; @@ -178,7 +179,10 @@ function toLibrarySource(url: string, thing: Subject | null): LibrarySource { * are brought up to the current one in memory. A release that gives two * of its cards and distractors one id is refused (libraryIdsRepeat): a * copy keeps them all as subjects of one cards document, by their ids - * (refuseRepeatedIds). + * (refuseRepeatedIds). So is one at the guest's pod, or that links to it + * by an address a copy would keep, such as a card's picture or subject + * (releaseLinksGuestPod): a guest could not keep a copy of it + * (GHSA-hc3p-fpwj-fvjp). */ export function toLibraryDeckContent(url: string, dataset: Dataset): LibraryDeckContentOf { const deck = releaseDeckOf(url, dataset); @@ -197,7 +201,7 @@ export function toLibraryDeckContent(url: string, dataset: Dataset): LibraryDeck const cards = refuseRepeatedIds(url, toLibraryCards(url, dataset)); const subject = asUrl(deck!); const own = read.record.version < 3 ? read.record.data.creator : []; - return { + const content = { ...libraryDeckFromRecord( url, read.storedVersion, @@ -208,6 +212,9 @@ export function toLibraryDeckContent(url: string, dataset: Dataset): LibraryDeck ...courseOf(deck!), ...contactOf(deck!, dataset), }; + if (isGuestPodIri(url) || isGuestPodIri(subject) || linksGuestPod(content)) + throw new AppError("releaseLinksGuestPod", { url }); + return content; } /** diff --git a/packages/solid/src/solidDeckArchive.test.ts b/packages/solid/src/solidDeckArchive.test.ts index c1c8dba32..3f1e5cbda 100644 --- a/packages/solid/src/solidDeckArchive.test.ts +++ b/packages/solid/src/solidDeckArchive.test.ts @@ -385,6 +385,24 @@ describe("readDeckFile", () => { }); }); + it("refuses a file that links to the guest's pod by an address an import keeps, but not by its own places (GHSA-hc3p-fpwj-fvjp)", async () => { + const guest = "https://guest.solid-memo.invalid/"; + // A guest's own export: its deck, cards and review states at the guest's pod. + const own = await archive.readDeckFile(OLD_DECK, "turtle", `${guest}solid-memo/old.ttl`); + expect(own.deck.url).toBe(`${guest}solid-memo/old.ttl#deck`); + expect(own.cards.map((card) => card.id)).toEqual(["c1"]); + const card = ` a sm:Card ; sm:front "Q"@en ; sm:back "A"@en ; sm:formatVersion 7`; + for (const linking of [ + `${card} ; sm:frontImage <${guest}p.png> .`, + `${card} ; dcterms:subject .`, + ]) { + await expect( + archive.readDeckFile(`${OLD_DECK}${linking}`, "turtle", base), + linking, + ).rejects.toMatchObject({ code: "deckFileLinksGuestPod" }); + } + }); + it("refuses a file that holds no deck it can read, or more than one", async () => { await expect(archive.readDeckFile(PREFIXES, "turtle", base)).rejects.toMatchObject({ code: "notADeckFile", diff --git a/packages/solid/src/solidDeckArchive.ts b/packages/solid/src/solidDeckArchive.ts index f802c17a0..edcb5dc11 100644 --- a/packages/solid/src/solidDeckArchive.ts +++ b/packages/solid/src/solidDeckArchive.ts @@ -18,6 +18,7 @@ import type { Quad } from "@rdfjs/types"; import type { DeckArchive } from "@solid-memo/application/ports"; import { AppError } from "@solid-memo/domain/appError"; import { distributionUrlOf } from "@solid-memo/domain/dcat"; +import { linksGuestPod } from "@solid-memo/domain/guest"; import { CARD_FORMAT_VERSION, DECK_FORMAT_VERSION, @@ -123,6 +124,9 @@ export function createSolidDeckArchive({ fetch }: { fetch: typeof globalThis.fet const cards = readCards(typed(SM.Card), dataset, deck, report); const ids = new Set(cards.map((card) => card.id)); const { reviews, fsrsReviews } = readReviewStates(typed(SM.ReviewState), deck, ids, report); + // An address the import keeps as it is, such as a card's picture or subject, never links to a guest's pod: a guest's copy would stay there (GHSA-hc3p-fpwj-fvjp). + if (linksGuestPod([deck, cards, reviews, fsrsReviews])) + throw new AppError("deckFileLinksGuestPod"); return { deck, cards, diff --git a/packages/solid/src/solidDeckRepository.import.test.ts b/packages/solid/src/solidDeckRepository.import.test.ts index 3ae9462f1..620375c66 100644 --- a/packages/solid/src/solidDeckRepository.import.test.ts +++ b/packages/solid/src/solidDeckRepository.import.test.ts @@ -13,6 +13,7 @@ import { AppError } from "@solid-memo/domain/appError"; import { createLocalPod } from "./localPod"; import { createMemoryResourceStore } from "./memoryResourceStore"; import { createSolidDeckRepository } from "./solidDeckRepository"; +import type { LibraryCard } from "@solid-memo/domain/library"; import { release } from "./testing/deckWrites"; const INSTANCE = "https://pod.example/memo/"; @@ -45,7 +46,7 @@ function importInto(answer: Answer, root = "https://pod.example/", instance = IN now: () => new Date("2026-11-06T00:00:00Z"), randomId: () => "new", }); - return { store, run: () => repository.importDeck(instance, release(2)) }; + return { store, repository, run: () => repository.importDeck(instance, release(2)) }; } const refused = (status: number) => new Response("", { status }); @@ -197,3 +198,30 @@ describe("an import cut off", () => { expect(await store.urls()).toEqual(expect.arrayContaining([CARDS, CATALOG])); }); }); + +describe("an import of a release that links to the guest's pod", () => { + it("is refused before anything is written, whatever links there: a guest could not keep it (GHSA-hc3p-fpwj-fvjp)", async () => { + const content = release(2); + const [first, second] = content.cards as [LibraryCard, LibraryCard]; + for (const linking of [ + { ...content, license: `${GUEST_ORIGIN}license` }, + { ...content, themes: [`${GUEST_ORIGIN}theme`] }, + { ...content, cards: [first, { ...second, frontImageUrl: `${GUEST_ORIGIN}p.png` }] }, + { ...content, cards: [{ ...first, subjects: [`${GUEST_ORIGIN}a`] }, second] }, + ]) { + const { store, repository } = importInto(() => undefined); + await expect(repository.importDeck(INSTANCE, linking)).rejects.toEqual( + new AppError("releaseLinksGuestPod", { url: content.url }), + ); + expect(await store.urls()).toEqual([]); + } + }); + + it("is not refused for the release's own address, which the copy names as its source", async () => { + const { repository } = importInto(() => undefined); + const url = `${GUEST_ORIGIN}memo/drafts/capitals/v1/release.ttl`; + expect(await repository.importDeck(INSTANCE, { ...release(2), url })).toMatchObject({ + sourceUrl: url, + }); + }); +}); diff --git a/packages/solid/src/solidDeckRepository.ts b/packages/solid/src/solidDeckRepository.ts index 7e31aacb3..56d8b49e8 100644 --- a/packages/solid/src/solidDeckRepository.ts +++ b/packages/solid/src/solidDeckRepository.ts @@ -81,6 +81,7 @@ import { withoutReviewStates } from "./mappers/reviewStateMapper"; import { readDatetime, recordThing, unlessNewer, unlessTyped } from "./records"; import { mapSince, readSince } from "./readSince"; import { AppError } from "@solid-memo/domain/appError"; +import { linksGuestPod } from "@solid-memo/domain/guest"; import { finishCutOffImport, unsent } from "@solid-memo/application/writes"; import { sameDeckState, withDeckChanges } from "@solid-memo/domain/deckUpgrade"; import { withinCatalogLimit } from "./catalogLinks"; @@ -205,6 +206,9 @@ export function createSolidDeckRepository({ registerDeck, async importDeck(instanceUrl, content): Promise { + // What a copy keeps of the release, by address, never links to a guest's pod: a guest's copy would stay there (GHSA-hc3p-fpwj-fvjp). + if (linksGuestPod([content.license, content.themes, content.cards])) + throw new AppError("releaseLinksGuestPod", { url: content.url }); const deck = newDeck(instanceUrl, content.title, content); // Each card names the release it is taken from: the series', which an upgrade may change. const written = withCards(createSolidDataset(), deck, content.cards, (_, card) => ({ diff --git a/packages/ui/src/i18n/de.json b/packages/ui/src/i18n/de.json index 69263a3c5..afeaed6bc 100644 --- a/packages/ui/src/i18n/de.json +++ b/packages/ui/src/i18n/de.json @@ -1696,6 +1696,7 @@ "other": "Die Kopie in deinem Pod hat nicht das Format, das Solid Memo erwartet ({count} Probleme), daher bleibt dein Lernstand unverändert. Versuche es später erneut." }, "guestUrlsLeft": "Die Kopie {url} in deinem Pod verweist noch auf den Lernstand als Gast, daher bleibt dein Lernstand unverändert. Versuche es erneut.", + "guestDeckCopyUrlsLeft": "Die Kopie des Stapels „{deck}“ in deinem Pod verweist noch auf den Lernstand als Gast, daher bleibt dein Lernstand unverändert. Entferne diesen Stapel aus dem Lernstand als Gast und versuche es erneut.", "guestDeckUrlsLeft": "Der Stapel „{deck}“ verweist noch auf den Lernstand als Gast, daher wurde er nicht zu deinem Pod hinzugefügt. Lass ihn weg und versuche es erneut.", "guestStudyInvalid": { "one": "Ein Teil deines Lernstands in diesem Browser hat nicht das Format, das Solid Memo erwartet ({count} Problem), daher wurde nichts davon in deinen Pod übertragen. Dein Lernstand ist weiterhin hier.", @@ -1732,10 +1733,12 @@ "cardGone": "Diese Karte gibt es nicht mehr. Vielleicht wurde sie in einem anderen Tab oder einer anderen App entfernt. Lade die Seite neu.", "notADeck": "Das ist kein Stapel von Solid Memo, daher kann er nicht hinzugefügt werden. Wähle einen anderen Stapel.", "libraryIdsRepeat": "Dieser Stapel gibt zwei seiner Karten oder falschen Antwortmöglichkeiten dieselbe ID, daher kann Solid Memo ihn nicht lesen. Bitte die Person, die ihn erstellt hat, ihn zu prüfen.", + "releaseLinksGuestPod": "Diese Ausgabe verlinkt auf den Lernstand als Gast in diesem Browser, was keine Ausgabe darf, daher liest Solid Memo sie nicht. Bitte die Person, die sie erstellt hat, sie zu prüfen.", "libraryDeckTooNew": "Dieser Stapel hat ein neueres Format, als diese Version von Solid Memo lesen kann. Lade die Seite neu, um die neueste Version zu erhalten.", "libraryCardTooNew": "Eine Karte in diesem Stapel hat ein neueres Format, als diese Version von Solid Memo lesen kann. Lade die Seite neu, um die neueste Version zu erhalten.", "deckFileUnreadable": "Solid Memo kann diese Datei nicht lesen. Wähle eine Turtle- oder JSON-LD-Datei, die aus Solid Memo exportiert wurde.", "notADeckFile": "Diese Datei enthält nicht genau einen Stapel von Solid Memo. Wähle eine Datei, die aus Solid Memo exportiert wurde.", + "deckFileLinksGuestPod": "Diese Datei verlinkt auf den Lernstand als Gast in diesem Browser, was kein Stapel darf, daher wurde sie nicht importiert. Wähle eine andere Datei.", "deckFileTooNew": "Diese Datei wurde von einer neueren Version von Solid Memo geschrieben, deshalb kann diese Version sie nicht importieren. Lade die Seite neu, um die neueste Version zu erhalten.", "deckFileTooLarge": "Diese Datei ist größer als jeder Stapel, den Solid Memo liest, deshalb wurde sie nicht gelesen. Wähle eine kleinere Datei.", "noGuestPod": "Auf diesem Gerät gibt es keinen Lernstand als Gast.", diff --git a/packages/ui/src/i18n/en.json b/packages/ui/src/i18n/en.json index 617448e65..c1d05d155 100644 --- a/packages/ui/src/i18n/en.json +++ b/packages/ui/src/i18n/en.json @@ -1696,6 +1696,7 @@ "other": "The copy in your Pod is not in the format Solid Memo expects ({count} problems), so your study is left as it was. Try again later." }, "guestUrlsLeft": "The copy {url} in your Pod still points to the guest's study, so your study is left as it was. Try again.", + "guestDeckCopyUrlsLeft": "The copy of the deck “{deck}” in your Pod still points to the guest's study, so your study is left as it was. Remove that deck from the guest's study, then try again.", "guestDeckUrlsLeft": "The deck “{deck}” still points to the guest's study, so it was not added to your Pod. Leave it out, then try again.", "guestStudyInvalid": { "one": "Part of your study in this browser is not in the format Solid Memo expects ({count} problem), so none of it was added to your Pod. It is still here.", @@ -1732,10 +1733,12 @@ "cardGone": "That card no longer exists. Perhaps it was removed in another tab or app. Reload the page.", "notADeck": "That is not a Solid Memo deck, so it cannot be added. Choose another deck.", "libraryIdsRepeat": "This deck gives two of its cards or wrong options the same id, so Solid Memo cannot read it. Ask its creator to check it.", + "releaseLinksGuestPod": "This release links to the guest's study in this browser, which no release may, so Solid Memo does not read it. Ask its creator to check it.", "libraryDeckTooNew": "This deck is in a newer format than this version of Solid Memo can read. Reload the page to get the latest version.", "libraryCardTooNew": "A card in this deck is in a newer format than this version of Solid Memo can read. Reload the page to get the latest version.", "deckFileUnreadable": "Solid Memo cannot read that file. Choose a Turtle or JSON-LD file exported from Solid Memo.", "notADeckFile": "That file does not hold one Solid Memo deck. Choose a file exported from Solid Memo.", + "deckFileLinksGuestPod": "That file links to the guest's study in this browser, which no deck may, so it was not imported. Choose another file.", "deckFileTooNew": "That file was written by a newer version of Solid Memo, so this one cannot import it. Reload the page to get the latest version.", "deckFileTooLarge": "That file is larger than any deck Solid Memo reads, so it was not read. Choose a smaller file.", "noGuestPod": "There is no guest study on this device.", diff --git a/packages/ui/src/i18n/es.json b/packages/ui/src/i18n/es.json index d85be4b39..7f9205db2 100644 --- a/packages/ui/src/i18n/es.json +++ b/packages/ui/src/i18n/es.json @@ -1772,6 +1772,7 @@ "other": "La copia de tu Pod no está en el formato que espera Solid Memo ({count} problemas), así que tus datos de estudio se quedan como estaban. Inténtalo de nuevo más tarde." }, "guestUrlsLeft": "La copia {url} de tu Pod todavía apunta a los datos de estudio en modo invitado, así que tus datos de estudio se quedan como estaban. Inténtalo de nuevo.", + "guestDeckCopyUrlsLeft": "La copia del mazo «{deck}» de tu Pod todavía apunta a los datos de estudio en modo invitado, así que tus datos de estudio se quedan como estaban. Quita ese mazo de los datos de estudio en modo invitado e inténtalo de nuevo.", "guestDeckUrlsLeft": "El mazo «{deck}» todavía apunta a los datos de estudio en modo invitado, así que no se añadió a tu Pod. Déjalo fuera e inténtalo de nuevo.", "guestStudyInvalid": { "one": "Parte de tus datos de estudio en este navegador no está en el formato que espera Solid Memo ({count} problema), así que no se añadió nada a tu Pod. Siguen aquí.", @@ -1809,10 +1810,12 @@ "cardGone": "Esa tarjeta ya no existe. Quizá se eliminó en otra pestaña o aplicación. Vuelve a cargar la página.", "notADeck": "Eso no es un mazo de Solid Memo, así que no se puede añadir. Elige otro mazo.", "libraryIdsRepeat": "Este mazo da el mismo id a dos de sus tarjetas u opciones incorrectas, así que Solid Memo no puede leerlo. Pide a quien lo creó que lo revise.", + "releaseLinksGuestPod": "Esta publicación enlaza con los datos de estudio en modo invitado de este navegador, algo que ninguna publicación puede hacer, así que Solid Memo no la lee. Pide a quien la creó que la revise.", "libraryDeckTooNew": "Este mazo está en un formato más reciente del que puede leer esta versión de Solid Memo. Vuelve a cargar la página para obtener la última versión.", "libraryCardTooNew": "Una tarjeta de este mazo está en un formato más reciente del que puede leer esta versión de Solid Memo. Vuelve a cargar la página para obtener la última versión.", "deckFileUnreadable": "Solid Memo no puede leer ese archivo. Elige un archivo Turtle o JSON-LD exportado desde Solid Memo.", "notADeckFile": "Ese archivo no contiene un único mazo de Solid Memo. Elige un archivo exportado desde Solid Memo.", + "deckFileLinksGuestPod": "Ese archivo enlaza con los datos de estudio en modo invitado de este navegador, algo que ningún mazo puede hacer, así que no se importó. Elige otro archivo.", "deckFileTooNew": "Ese archivo lo escribió una versión más reciente de Solid Memo, así que esta no puede importarlo. Vuelve a cargar la página para obtener la última versión.", "deckFileTooLarge": "Ese archivo es más grande que cualquier mazo que Solid Memo lee, así que no se ha leído. Elige un archivo más pequeño.", "noGuestPod": "No hay datos de estudio en modo invitado en este dispositivo.", diff --git a/packages/ui/src/i18n/fr.json b/packages/ui/src/i18n/fr.json index 6866f3460..b2897dfd5 100644 --- a/packages/ui/src/i18n/fr.json +++ b/packages/ui/src/i18n/fr.json @@ -1772,6 +1772,7 @@ "other": "La copie dans votre Pod n’est pas au format attendu par Solid Memo ({count} problèmes), votre travail reste donc tel quel. Réessayez plus tard." }, "guestUrlsLeft": "La copie {url} dans votre Pod renvoie encore au travail du mode invité, votre travail reste donc tel quel. Réessayez.", + "guestDeckCopyUrlsLeft": "La copie du paquet « {deck} » dans votre Pod renvoie encore au travail du mode invité, votre travail reste donc tel quel. Retirez ce paquet du travail du mode invité, puis réessayez.", "guestDeckUrlsLeft": "Le paquet « {deck} » renvoie encore au travail du mode invité, il n’a donc pas été ajouté à votre Pod. Laissez-le de côté, puis réessayez.", "guestStudyInvalid": { "one": "Une partie de votre travail dans ce navigateur n’est pas au format attendu par Solid Memo ({count} problème), rien n’a donc été ajouté à votre Pod. Tout est toujours ici.", @@ -1809,10 +1810,12 @@ "cardGone": "Cette carte n’existe plus. Elle a peut-être été supprimée dans un autre onglet ou une autre application. Rechargez la page.", "notADeck": "Ce n’est pas un paquet Solid Memo, il ne peut donc pas être ajouté. Choisissez un autre paquet.", "libraryIdsRepeat": "Ce paquet donne le même identifiant à deux de ses cartes ou mauvaises réponses, Solid Memo ne peut donc pas le lire. Demandez à son créateur de le vérifier.", + "releaseLinksGuestPod": "Cette publication renvoie au travail du mode invité de ce navigateur, ce qu’aucune publication ne peut faire, Solid Memo ne la lit donc pas. Demandez à son créateur de la vérifier.", "libraryDeckTooNew": "Ce paquet est dans un format plus récent que celui que cette version de Solid Memo sait lire. Rechargez la page pour obtenir la dernière version.", "libraryCardTooNew": "Une carte de ce paquet est dans un format plus récent que celui que cette version de Solid Memo sait lire. Rechargez la page pour obtenir la dernière version.", "deckFileUnreadable": "Solid Memo ne peut pas lire ce fichier. Choisissez un fichier Turtle ou JSON-LD exporté depuis Solid Memo.", "notADeckFile": "Ce fichier ne contient pas exactement un paquet Solid Memo. Choisissez un fichier exporté depuis Solid Memo.", + "deckFileLinksGuestPod": "Ce fichier renvoie au travail du mode invité de ce navigateur, ce qu’aucun paquet ne peut faire, il n’a donc pas été importé. Choisissez un autre fichier.", "deckFileTooNew": "Ce fichier a été écrit par une version plus récente de Solid Memo : celle-ci ne peut donc pas l’importer. Rechargez la page pour obtenir la dernière version.", "deckFileTooLarge": "Ce fichier est plus volumineux que tout paquet que Solid Memo lit : il n’a donc pas été lu. Choisissez un fichier plus petit.", "noGuestPod": "Il n’y a pas de travail en mode invité sur cet appareil.", diff --git a/packages/ui/src/i18n/ko.json b/packages/ui/src/i18n/ko.json index 401ba9eed..c7e544495 100644 --- a/packages/ui/src/i18n/ko.json +++ b/packages/ui/src/i18n/ko.json @@ -1620,6 +1620,7 @@ "other": "Pod에 있는 복사본이 Solid Memo가 기대하는 형식에 맞지 않아서(문제 {count}건) 학습 기록을 그대로 두었어요. 나중에 다시 시도해 주세요." }, "guestUrlsLeft": "Pod에 있는 복사본({url})이 아직 게스트 학습을 가리키고 있어서 학습 기록을 그대로 두었어요. 다시 시도해 주세요.", + "guestDeckCopyUrlsLeft": "Pod에 있는 ‘{deck}’ 덱의 복사본이 아직 게스트 학습을 가리키고 있어서 학습 기록을 그대로 두었어요. 게스트 학습에서 이 덱을 지운 다음 다시 시도해 주세요.", "guestDeckUrlsLeft": "‘{deck}’ 덱이 아직 게스트 학습을 가리키고 있어서 Pod에 추가하지 않았어요. 이 덱을 빼고 다시 시도해 주세요.", "guestStudyInvalid": { "other": "이 브라우저에 있는 학습 기록 일부가 Solid Memo가 기대하는 형식에 맞지 않아서(문제 {count}건) 아무것도 Pod에 추가하지 않았어요. 학습 기록은 여기에 그대로 있어요." @@ -1655,10 +1656,12 @@ "cardGone": "그 카드가 더 이상 없어요. 다른 탭이나 앱에서 삭제됐을 수 있어요. 페이지를 새로고침해 주세요.", "notADeck": "Solid Memo 덱이 아니라서 추가할 수 없어요. 다른 덱을 선택해 주세요.", "libraryIdsRepeat": "이 덱은 카드나 오답 선택지 두 개에 같은 id를 붙여서, Solid Memo가 읽을 수 없어요. 만든 사람에게 확인해 달라고 해 주세요.", + "releaseLinksGuestPod": "이 릴리스는 이 브라우저의 게스트 학습에 연결되어 있어요. 어떤 릴리스도 그럴 수 없어서 Solid Memo가 읽지 않아요. 만든 사람에게 확인해 달라고 해 주세요.", "libraryDeckTooNew": "이 덱은 이 버전의 Solid Memo가 읽을 수 없는 더 새로운 형식이에요. 최신 버전을 받으려면 페이지를 새로고침해 주세요.", "libraryCardTooNew": "이 덱의 카드 중 하나가 이 버전의 Solid Memo가 읽을 수 없는 더 새로운 형식이에요. 최신 버전을 받으려면 페이지를 새로고침해 주세요.", "deckFileUnreadable": "Solid Memo가 이 파일을 읽을 수 없어요. Solid Memo에서 내보낸 Turtle 또는 JSON-LD 파일을 선택해 주세요.", "notADeckFile": "이 파일에는 Solid Memo 덱 하나가 들어 있지 않아요. Solid Memo에서 내보낸 파일을 선택해 주세요.", + "deckFileLinksGuestPod": "이 파일은 이 브라우저의 게스트 학습에 연결되어 있어요. 어떤 덱도 그럴 수 없어서 가져오지 않았어요. 다른 파일을 선택해 주세요.", "deckFileTooNew": "이 파일은 더 새로운 버전의 Solid Memo에서 만들어져서 지금 버전으로는 가져올 수 없어요. 페이지를 새로고침해 최신 버전을 받아 주세요.", "deckFileTooLarge": "이 파일은 Solid Memo가 읽을 수 있는 덱보다 커서 읽지 않았어요. 더 작은 파일을 골라 주세요.", "noGuestPod": "이 기기에는 게스트 학습이 없어요.", diff --git a/packages/ui/src/i18n/sv.json b/packages/ui/src/i18n/sv.json index d1b4a7c52..ba208b1f8 100644 --- a/packages/ui/src/i18n/sv.json +++ b/packages/ui/src/i18n/sv.json @@ -1696,6 +1696,7 @@ "other": "Kopian i din Pod har inte det format Solid Memo förväntar sig ({count} problem), så dina studier lämnas som de var. Försök igen senare." }, "guestUrlsLeft": "Kopian {url} i din Pod pekar fortfarande på gästens studier, så dina studier lämnas som de var. Försök igen.", + "guestDeckCopyUrlsLeft": "Kopian av kortleken ”{deck}” i din Pod pekar fortfarande på gästens studier, så dina studier lämnas som de var. Ta bort den kortleken från gästens studier och försök igen.", "guestDeckUrlsLeft": "Kortleken ”{deck}” pekar fortfarande på gästens studier, så den lades inte till i din Pod. Lämna den utanför och försök igen.", "guestStudyInvalid": { "one": "En del av dina studier i den här webbläsaren har inte det format Solid Memo förväntar sig ({count} problem), så inget av dem lades till i din Pod. De finns kvar här.", @@ -1732,10 +1733,12 @@ "cardGone": "Kortet finns inte längre. Kanske togs det bort i en annan flik eller app. Läs in sidan igen.", "notADeck": "Det är ingen kortlek från Solid Memo, så den kan inte läggas till. Välj en annan kortlek.", "libraryIdsRepeat": "Den här kortleken ger två av sina kort eller felaktiga alternativ samma id, så Solid Memo kan inte läsa den. Be den som skapade den att kontrollera den.", + "releaseLinksGuestPod": "Den här utgåvan länkar till gästens studier i den här webbläsaren, vilket ingen utgåva får göra, så Solid Memo läser den inte. Be den som skapade den att kontrollera den.", "libraryDeckTooNew": "Kortleken har ett nyare format än den här versionen av Solid Memo kan läsa. Läs in sidan igen för att få den senaste versionen.", "libraryCardTooNew": "Ett kort i kortleken har ett nyare format än den här versionen av Solid Memo kan läsa. Läs in sidan igen för att få den senaste versionen.", "deckFileUnreadable": "Solid Memo kan inte läsa den filen. Välj en Turtle- eller JSON-LD-fil som exporterats från Solid Memo.", "notADeckFile": "Den filen innehåller inte en kortlek från Solid Memo. Välj en fil som exporterats från Solid Memo.", + "deckFileLinksGuestPod": "Den filen länkar till gästens studier i den här webbläsaren, vilket ingen kortlek får göra, så den importerades inte. Välj en annan fil.", "deckFileTooNew": "Den filen skrevs av en nyare version av Solid Memo, så den här versionen kan inte importera den. Läs in sidan igen för att få den senaste versionen.", "deckFileTooLarge": "Den filen är större än någon kortlek Solid Memo läser, så den lästes inte. Välj en mindre fil.", "noGuestPod": "Det finns inga gäststudier på den här enheten.",