mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
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:
1 parent
1131700ec5
commit
d3ed67342a
7 files changed
+114
-2
No files matched your search
+10
-2
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in new issue
Block a user