mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
fix(extension): store imports in shared storage
This commit is contained in:
1 parent
c52554fe7e
commit
84b4f5bd14
3 files changed
+113
-24
No files matched your search
+14
-6
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user