feat(extension): tell Thingport who you're signed in as on Printables and Thingiverse

So the web app's Browse tab can list the user's collections there
without asking for a username. Thingiverse's comes from the page
header's own account link; Printables' from its API's `me`, asked with
the session token the way the site does. Sent only when it changes.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-12 05:31:19 +02:00
1 parent 1131700ec5
commit d3ed67342a
7 files changed
+114 -2

No files matched your search

+10 -2
View File
@@ -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. |
+3
View File
@@ -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<BackgroundMessages>({
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),
@@ -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<string | null> {
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<null> {
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;
}
+3
View File
@@ -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<void> {
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);
+55
View File
@@ -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: /<username>/designs. */
async function readThingiverseUsername(): Promise<string | null> {
for (let waited = 0; waited < HEADER_WAIT_MS; waited += HEADER_POLL_MS) {
const link = document.querySelector<HTMLAnchorElement>('[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<string | null> {
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 });
});
}
+8
View File
@@ -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 };
+4
View File
@@ -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<string, string> };
};
export const CONFIG_CHANGE_KEYS: readonly string[] = [