fix(extension): store imports in shared storage

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-06 15:00:54 +02:00
1 parent c52554fe7e
commit 84b4f5bd14
3 files changed
+113 -24

No files matched your search

+14 -6
View File
@@ -1,6 +1,6 @@
# Thingport Grab privacy policy
_Effective 30 September 2026._
_Effective 6 October 2026._
Thingport Grab is a browser extension that imports 3D-printing models from MakerWorld, Printables
and Thingiverse into **your own self-hosted Thingport instance**, and converts MakerWorld files for
@@ -9,16 +9,22 @@ extension's developer never receives any of your data, and nothing is shared wit
## What the extension stores
Everything below is kept in your browser's local extension storage, on your device only. It isn't
synced between browsers or devices.
Everything below is kept in your browser's local extension storage, on your device only, except
the list of recent imports, which is synced between your browsers as described below.
- **Your Thingport connection:** the address of your Thingport instance, and the email and password
you sign in to it with. They're stored as you entered them, so the extension can sign in again
when its session expires.
- **A session token** your Thingport instance issues when the extension signs in.
- **Your settings:** whether the extension is switched on.
- **Recent imports:** the title, a small thumbnail and a link for the last five models you imported
through the extension, shown in its toolbar popup.
- **Recent imports:** the title and a link for the last five models you imported through the
extension, shown in its toolbar popup. This list is kept in your browser's sync storage, so it
appears in the extension on your other browsers and devices where you're signed in to the same
browser account and have sync on. It travels through your browser vendor's sync service (Google,
Mozilla or Microsoft) under that service's own privacy terms. It doesn't include your email or
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.
- **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.
@@ -79,7 +85,9 @@ model, and the model links listed on collection pages. It doesn't read any other
## Your control over your data
- Change your Thingport connection, or switch the extension off, from its toolbar popup.
- Uninstalling the extension deletes everything it stored.
- Uninstalling the extension deletes everything it stored on that device. The synced list of recent
imports can stay in your browser account's sync data while the extension is still installed on
another of your browsers; clearing your browser's synced data removes it.
- Anything the extension sends to your Thingport instance is stored on your own server, under your
control, like everything else in your Thingport library.
+98 -16
View File
@@ -1,5 +1,6 @@
// The popup's "Recent imports" strip, kept entirely in extension storage. Entries reflect the model
// as it was when imported.
// The popup's "Recent imports" strip. The list is in the browser's sync storage, so it follows the
// user to their other browsers and devices; thumbnails are too big for its quota, so each browser
// caches its own in local storage. Entries reflect the model as it was when imported.
import type { Print } from "../shared/api";
import type { RecentImport } from "../shared/messages";
@@ -8,33 +9,94 @@ import { apiFetchBlob } from "./api";
import { getStoredConfig, isConfigured, type ConfiguredConfig } from "./config";
const RECENT_IMPORTS_STORAGE_KEY = "recentImports";
const THUMBS_STORAGE_KEY = "recentImportThumbs";
const RECENT_IMPORTS_KEPT = 5;
const RECENT_IMPORT_THUMB_PX = 96;
async function readStored(): Promise<RecentImport[]> {
/** No email: the account is a hash of instance + email, so sync never carries the address. */
type SyncedRecentImport = {
printId: string;
title: string | null;
url: string;
thumbPath: string | null;
account: string;
};
/** Up to 1.3.0 the list, thumbnails included, lived in local storage only. */
type LegacyRecentImport = Omit<SyncedRecentImport, "account" | "thumbPath"> & {
thumbDataUrl: string | null;
instanceUrl: string;
email: string;
};
async function accountKey(config: ConfiguredConfig): Promise<string> {
const text = `${normalizeInstanceUrl(config.instanceUrl)}\n${config.email.trim().toLowerCase()}`;
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(digest).slice(0, 12)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function thumbKey(entry: Pick<SyncedRecentImport, "account" | "printId">): string {
return `${entry.account}:${entry.printId}`;
}
async function readThumbs(): Promise<Record<string, string>> {
const stored = await chrome.storage.local.get(THUMBS_STORAGE_KEY);
return (stored[THUMBS_STORAGE_KEY] as Record<string, string> | undefined) ?? {};
}
/** Drops thumbnails for models that fell off the list. */
async function writeThumbs(thumbs: Record<string, string>, list: SyncedRecentImport[]): Promise<void> {
const kept = Object.fromEntries(list.map(thumbKey).flatMap((key) => (thumbs[key] ? [[key, thumbs[key]]] : [])));
await chrome.storage.local.set({ [THUMBS_STORAGE_KEY]: kept });
}
/** Moves a pre-sync list into sync storage, once. Only the signed-in account's entries can be
* re-keyed, since the hash needs the email; the rest are dropped. */
async function migrateLegacy(config: ConfiguredConfig): Promise<SyncedRecentImport[] | null> {
const stored = await chrome.storage.local.get(RECENT_IMPORTS_STORAGE_KEY);
return (stored[RECENT_IMPORTS_STORAGE_KEY] as RecentImport[] | undefined) ?? [];
const legacy = stored[RECENT_IMPORTS_STORAGE_KEY] as LegacyRecentImport[] | undefined;
if (!legacy) return null;
await chrome.storage.local.remove(RECENT_IMPORTS_STORAGE_KEY);
const account = await accountKey(config);
const instanceUrl = normalizeInstanceUrl(config.instanceUrl);
const mine = legacy.filter((e) => e.instanceUrl === instanceUrl && e.email === config.email);
const list = mine.map(({ printId, title, url }) => ({ printId, title, url, thumbPath: null, account }));
const thumbs = await readThumbs();
for (const e of mine) if (e.thumbDataUrl) thumbs[thumbKey({ account, printId: e.printId })] = e.thumbDataUrl;
const synced = await readSynced();
const merged = [...synced, ...list.filter((e) => !synced.some((s) => thumbKey(s) === thumbKey(e)))];
await chrome.storage.sync.set({ [RECENT_IMPORTS_STORAGE_KEY]: merged.slice(0, RECENT_IMPORTS_KEPT) });
await writeThumbs(thumbs, merged);
return merged;
}
async function readSynced(): Promise<SyncedRecentImport[]> {
const stored = await chrome.storage.sync.get(RECENT_IMPORTS_STORAGE_KEY);
return (stored[RECENT_IMPORTS_STORAGE_KEY] as SyncedRecentImport[] | undefined) ?? [];
}
/** `titleHint` covers zip imports, whose job reports only the id. Scoped to instance + account. */
export async function recordRecentImport(print: Print, titleHint: string | null): Promise<void> {
const config = await getStoredConfig();
if (!isConfigured(config)) return;
const instanceUrl = normalizeInstanceUrl(config.instanceUrl);
await migrateLegacy(config);
const thumbPath = print.thumb_url || print.preview_images?.[0]?.url || null;
const entry: RecentImport = {
const entry: SyncedRecentImport = {
printId: print.id,
title: print.title || print.name || titleHint || null,
url: `${instanceUrl}/models/${print.id}`,
thumbDataUrl: thumbPath ? await fetchThumbDataUrl(config, thumbPath).catch(() => null) : null,
instanceUrl,
email: config.email,
url: `${normalizeInstanceUrl(config.instanceUrl)}/models/${print.id}`,
thumbPath,
account: await accountKey(config),
};
// Another profile of a listed model moves it to the front instead of adding a slot.
const rest = (await readStored()).filter(
(e) => !(e.printId === entry.printId && e.instanceUrl === instanceUrl && e.email === entry.email),
);
await chrome.storage.local.set({ [RECENT_IMPORTS_STORAGE_KEY]: [entry, ...rest].slice(0, RECENT_IMPORTS_KEPT) });
const rest = (await readSynced()).filter((e) => thumbKey(e) !== thumbKey(entry));
// Lists from other accounts share the slots; only the newest few overall are kept.
const list = [entry, ...rest].slice(0, RECENT_IMPORTS_KEPT * 2);
await chrome.storage.sync.set({ [RECENT_IMPORTS_STORAGE_KEY]: list });
const thumbs = await readThumbs();
const thumb = thumbPath ? await fetchThumbDataUrl(config, thumbPath).catch(() => null) : null;
if (thumb) thumbs[thumbKey(entry)] = thumb;
await writeThumbs(thumbs, list);
}
/** A small center-cropped square JPEG data URL. */
@@ -64,9 +126,29 @@ async function fetchThumbDataUrl(config: ConfiguredConfig, thumbPath: string): P
return `data:image/jpeg;base64,${btoa(binary)}`;
}
/** Entries synced from another browser have no thumbnail here yet; they're fetched and cached on
* first view. */
export async function getRecentImports(): Promise<RecentImport[]> {
const config = await getStoredConfig();
if (!isConfigured(config)) return [];
const instanceUrl = normalizeInstanceUrl(config.instanceUrl);
return (await readStored()).filter((e) => e.instanceUrl === instanceUrl && e.email === config.email);
const all = (await migrateLegacy(config)) ?? (await readSynced());
const account = await accountKey(config);
const mine = all.filter((e) => e.account === account).slice(0, RECENT_IMPORTS_KEPT);
const thumbs = await readThumbs();
const missing = mine.filter((e) => !thumbs[thumbKey(e)] && e.thumbPath);
if (missing.length) {
await Promise.all(
missing.map(async (e) => {
const thumb = await fetchThumbDataUrl(config, e.thumbPath!).catch(() => null);
if (thumb) thumbs[thumbKey(e)] = thumb;
}),
);
await writeThumbs(thumbs, all);
}
return mine.map((e) => ({
printId: e.printId,
title: e.title,
url: e.url,
thumbDataUrl: thumbs[thumbKey(e)] ?? null,
}));
}
+1 -2
View File
@@ -12,13 +12,12 @@ export type ExtensionState = {
email: string;
};
/** The signed-in account's recent imports, as the popup shows them. */
export type RecentImport = {
printId: string;
title: string | null;
url: string;
thumbDataUrl: string | null;
instanceUrl: string;
email: string;
};
/** See content/makerworld/downloadResolver.ts. */