diff --git a/extension/PRIVACY.md b/extension/PRIVACY.md index c625799..b8a46b8 100644 --- a/extension/PRIVACY.md +++ b/extension/PRIVACY.md @@ -25,6 +25,8 @@ the list of recent imports, which is synced between your browsers as described b password: entries are tied to your Thingport account by a one-way hash of your instance address and email. Each model's thumbnail is fetched from your Thingport instance and kept in local storage on each device; it isn't synced. +- **Your Printables and Thingiverse usernames,** as last sent to your Thingport instance, so they're + only sent again when they change. - **Collection import progress:** while a MakerWorld collection import is running, the list of model pages still to visit, so it can carry on across page loads. It's deleted when the import ends. @@ -50,6 +52,9 @@ the **provider site you're on**. - If you're logged into MakerWorld in this browser: your MakerWorld session cookie, when you import from MakerWorld. MakerWorld requires it to download files. The extension also saves it to your Thingport account's MakerWorld setting, so imports made from Thingport's own web app work too. +- If you're logged into Printables or Thingiverse in this browser: your username there, when it + first sees it or it changes, so Thingport's web app can list your collections on that site when + you pick one to sync with. **To MakerWorld, Printables and Thingiverse:** @@ -63,9 +68,12 @@ the **provider site you're on**. browser and saves the result to your downloads. The file isn't sent anywhere else. - On a MakerWorld collection import, the extension moves your tab from one model page to the next, just as if you were clicking through them. +- On Printables, once per open tab, the extension asks Printables' own API which account you're + logged in as, the same way the page does, using your existing Printables session. **The extension reads, on supported pages only:** the model's title, MakerWorld's page data for the -model, and the model links listed on collection pages. It doesn't read any other websites. +model, the model links listed on collection pages, and, on Thingiverse, your username from the +page's header. It doesn't read any other websites. ## What the extension doesn't do @@ -80,7 +88,7 @@ model, and the model links listed on collection pages. It doesn't read any other | Permission | Why | | ------------------------------------------------ | ---------------------------------------------------------------------------------------------- | | Storage | Keeps the settings and data listed under "What the extension stores". | -| Cookies | Reads MakerWorld's session cookie, only on makerworld.com, for MakerWorld imports. | +| Cookies | Reads MakerWorld's and Printables' session cookies, only on their sites (see above). | | Downloads | Reads the link from the MakerWorld download it starts, then cancels and removes that download. | | Access to MakerWorld, Printables and Thingiverse | Shows the import button, and MakerWorld's Download normalized button, on their pages. | | Access to your Thingport instance | Requested for that one address when you set the extension up, so it can talk to your instance. | diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index b75fbec..2422dcb 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -15,6 +15,7 @@ import { resumeJob, startJob, } from "./makerworldJob"; +import { getPrintablesToken, reportProviderAccount } from "./providerAccounts"; import { getRecentImports } from "./recentImports"; import { openSetup } from "./setup"; import { setTabIconState } from "./tabIcon"; @@ -30,6 +31,8 @@ listen({ SET_DISABLED: ({ disabled }) => setDisabled(disabled), GET_RECENT_IMPORTS: () => getRecentImports(), CHECK_CONNECTION: () => checkConnection(), + GET_PRINTABLES_TOKEN: () => getPrintablesToken(), + REPORT_PROVIDER_ACCOUNT: ({ provider, value }) => reportProviderAccount(provider, value), OPEN_SETUP: (_payload, sender) => openSetup(sender.tab?.id), SET_TAB_ICON_STATE: ({ active }, sender) => setTabIconState(senderTabId(sender), active), API_CALL: ({ method, path, body }) => apiCall(method, path, body), diff --git a/extension/src/background/providerAccounts.ts b/extension/src/background/providerAccounts.ts new file mode 100644 index 0000000..e16b303 --- /dev/null +++ b/extension/src/background/providerAccounts.ts @@ -0,0 +1,31 @@ +// Who the user is signed in as on Printables and Thingiverse, read on their pages, so the web app's +// Browse tab lists their collections without asking. Sent only when it changes; the signed-in +// account replaces a username typed into the app. + +import type { AccountProvider } from "../shared/messages"; +import { apiCall, requireConfig } from "./api"; + +/** HttpOnly, so the page can't read it but chrome.cookies can. Null (never throws) when signed out. */ +export async function getPrintablesToken(): Promise { + try { + const cookie = await chrome.cookies.get({ url: "https://www.printables.com", name: "auth.access_token" }); + return cookie?.value || null; + } catch { + return null; + } +} + +/** Best-effort: an instance without the endpoint, or without Thingiverse connected, just says no. */ +export async function reportProviderAccount(provider: AccountProvider, value: string): Promise { + const config = await requireConfig().catch(() => null); + if (!config) return null; + // Per Thingport account, so switching the extension to another one sends it there too. + const account = `${config.instanceUrl} ${config.email}`; + const sent = config.lastSentProviderAccounts?.account === account ? config.lastSentProviderAccounts.values : {}; + if (sent[provider] === value) return null; + try { + await apiCall("PUT", `/provider-collections/accounts/${provider}`, { value }); + await chrome.storage.local.set({ lastSentProviderAccounts: { account, values: { ...sent, [provider]: value } } }); + } catch {} + return null; +} diff --git a/extension/src/content/index.ts b/extension/src/content/index.ts index f54145a..79ad9cb 100644 --- a/extension/src/content/index.ts +++ b/extension/src/content/index.ts @@ -10,6 +10,7 @@ import { isMakerworldBlockingError, resolveMakerworldDownloadUrl } from "./maker import { offerNormalizedDownload, unmountNormalizedDownload } from "./makerworld/normalizedDownload"; import { mountJobOverlay, mountPausedJobOverlay } from "./overlays"; import { loadPanel } from "./panels"; +import { captureProviderAccount } from "./providerAccount"; import { errorHtml } from "./panels/results"; import { api } from "./runtime"; import { closeSetupModal, openSetupModal } from "./setupModal"; @@ -56,6 +57,8 @@ async function init(): Promise { reportTabIconState(false); return; } + // On every page, importable or not: the header shows who's signed in everywhere. + if (stateRes.data.configured) captureProviderAccount(location.hostname); const url = location.href; const classification = classifyUrl(url); diff --git a/extension/src/content/providerAccount.ts b/extension/src/content/providerAccount.ts new file mode 100644 index 0000000..1730442 --- /dev/null +++ b/extension/src/content/providerAccount.ts @@ -0,0 +1,55 @@ +// Reads who the user is signed in as on Printables and Thingiverse, once per tab, and tells the +// background (see background/providerAccounts.ts). Only the signed-in account: these pages link to +// plenty of other people's profiles too. + +import { request, send } from "../shared/messages"; +import { sleep } from "./runtime"; + +// The header is drawn after load; give up quietly if it never shows (e.g. signed out). +const HEADER_WAIT_MS = 15_000; +const HEADER_POLL_MS = 500; + +let captured = false; + +/** Thingiverse's header avatar links to the signed-in user's designs: //designs. */ +async function readThingiverseUsername(): Promise { + for (let waited = 0; waited < HEADER_WAIT_MS; waited += HEADER_POLL_MS) { + const link = document.querySelector('[class*="PageHeader__pageHeader"] a[href$="/designs"]'); + const username = link?.getAttribute("href")?.split("/").find(Boolean); + if (username) return decodeURIComponent(username); + await sleep(HEADER_POLL_MS); + } + return null; +} + +/** Asked of Printables' API as the site itself does; from this page, so its CORS rules allow it. */ +async function readPrintablesHandle(): Promise { + const token = await request("GET_PRINTABLES_TOKEN").catch(() => null); + if (!token) return null; + try { + const res = await fetch("https://api.printables.com/graphql/", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query: "query { me { handle } }" }), + }); + const body = (await res.json()) as { data?: { me?: { handle?: unknown } | null } }; + const handle = body.data?.me?.handle; + return typeof handle === "string" && handle ? handle : null; + } catch { + return null; + } +} + +export function captureProviderAccount(hostname: string): void { + if (captured) return; + const provider = hostname.endsWith("printables.com") + ? "printables" + : hostname.endsWith("thingiverse.com") + ? "thingiverse" + : null; + if (!provider) return; + captured = true; + void (provider === "printables" ? readPrintablesHandle() : readThingiverseUsername()).then((value) => { + if (value) void send("REPORT_PROVIDER_ACCOUNT", { provider, value }); + }); +} diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 3fe8db3..dd03796 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -16,6 +16,10 @@ export type ExtensionState = { export type Connection = { connected: true } | { connected: false; reason: "credentials" | "unreachable"; detail: string }; +/** Providers whose signed-in user the extension tells the instance, for its Browse tab. MakerWorld + * needs none: the instance has its session cookie. */ +export type AccountProvider = "printables" | "thingiverse"; + /** The signed-in account's recent imports, as the popup shows them. */ export type RecentImport = { printId: string; @@ -76,6 +80,10 @@ export type BackgroundMessages = { SET_DISABLED: { payload: { disabled: boolean }; result: null }; GET_RECENT_IMPORTS: { payload: void; result: RecentImport[] }; CHECK_CONNECTION: { payload: void; result: Connection }; + /** Printables' session token, which the page itself can't read. Null when signed out. */ + GET_PRINTABLES_TOKEN: { payload: void; result: string | null }; + /** Who the user is signed in as on a provider, read from its pages. */ + REPORT_PROVIDER_ACCOUNT: { payload: { provider: AccountProvider; value: string }; result: null }; OPEN_SETUP: { payload: void; result: "popup" | "tab" }; SET_TAB_ICON_STATE: { payload: { active: boolean }; result: null }; API_CALL: { payload: ApiCallPayload; result: unknown }; diff --git a/extension/src/shared/storage.ts b/extension/src/shared/storage.ts index 50b4f08..02ee1bd 100644 --- a/extension/src/shared/storage.ts +++ b/extension/src/shared/storage.ts @@ -13,6 +13,8 @@ export const STORAGE_KEYS = { loginVerifiedAt: "loginVerifiedAt", // Lets the cookie sync skip a redundant PATCH. lastSyncedMakerworldCookie: "lastSyncedMakerworldCookie", + // The Printables and Thingiverse usernames last sent to the instance, so unchanged ones aren't. + lastSentProviderAccounts: "lastSentProviderAccounts", } as const; export type StoredConfig = { @@ -25,6 +27,8 @@ export type StoredConfig = { tokenExpiresAt?: number; loginVerifiedAt?: number; lastSyncedMakerworldCookie?: string; + /** `account` is the Thingport instance and email they were sent to. */ + lastSentProviderAccounts?: { account: string; values: Record }; }; export const CONFIG_CHANGE_KEYS: readonly string[] = [