Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/extension/CHROMEWEBSTORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ This table lists exactly what `manifest.json` requests; `test-extension-pipeline

1. **Where it runs:** the content script loads on the 10 supported stores listed above (`shopee.vn`, `lazada.vn`, `tiki.vn`, `www.tiktok.com`, `shop.tiktok.com`, `cellphones.com.vn`, `thegioididong.com`, `fptshop.com.vn`, `hoanghamobile.com`, `didongviet.vn`, `phongvu.vn`). It acts only on pages it recognises as a product page; it never opens or fetches pages the user did not open.
2. **Lookup (on by default, no opt-in):** on a recognised product page the extension sends `GET /api/v1/rate/extension/lookup` with the store, the product URL with query parameters and tracking tokens (`utm_*`, `sp_atk`, `xptdk`, `fbclid`, `gclid`) stripped locally, the product title, the store's item id and shop id, the selected variant, the listed price and the original price. The request is read-only and carries no account or personal data.
3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (Opt-In)" in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue and never change public prices directly.
3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)" in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue and never change public prices directly.
4. **Stored in the browser only:** the server address and the opt-in choice (`chrome.storage.local`), and a session-only cache of the products viewed in each tab, the latest side panel search and their lookup results (`chrome.storage.session`).
5. **Side panel requests:** text the user types into the side panel search is sent to the same lookup endpoint; the side panel also reads public deals and merchant lists from the Rate.vn API.
6. **Privacy policy:** https://rate.vn/privacy-policy has a section on the extension.
Expand Down
176 changes: 112 additions & 64 deletions apps/extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
sanitizeExtensionProductSignal,
selectFreshestSessionSignal,
} from '../security-guards.js';
import { resolveActionBadge } from '../verdict-display.js';

const DEFAULT_SETTINGS: ExtensionSettings = {
apiBaseUrl: DEFAULT_API_BASE_URL,
Expand All @@ -40,10 +41,46 @@ async function getSettings(): Promise<ExtensionSettings> {
}
}

/**
* A lookup either answers (data may be null: nothing matched) or fails with
* the HTTP status, `null` for a network error or timeout, so the panel can
* say which (DOS/Rate#1300).
*/
type LookupOutcome =
| { ok: true; data: ExtensionLookupData | null }
| { ok: false; status: number | null };

async function requestLookup(endpoint: string): Promise<LookupOutcome> {
let response: Response;
try {
response = await fetch(endpoint, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Rate-Extension-Version': '2.0.0',
},
signal: AbortSignal.timeout(10_000),
});
} catch {
return { ok: false, status: null };
}
if (!response.ok) return { ok: false, status: response.status };
try {
const json = (await response.json()) as {
ok?: boolean;
data?: ExtensionLookupData;
};
if (json.ok === false) return { ok: false, status: response.status };
return { ok: true, data: json.data ?? null };
} catch {
return { ok: false, status: response.status };
}
}

async function fetchExtensionLookup(
rawSignal: ExtractedPageProductSignal,
mode: 'pill' | 'full'
): Promise<ExtensionLookupData | null> {
): Promise<LookupOutcome> {
const signal = sanitizeExtensionProductSignal(rawSignal);
const settings = await getSettings();
const params = new URLSearchParams();
Expand All @@ -63,22 +100,9 @@ async function fetchExtensionLookup(
params.set('originalPrice', String(signal.originalPrice));
}

const endpoint = `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Rate-Extension-Version': '2.0.0',
},
signal: AbortSignal.timeout(10_000),
});

if (!response.ok) return null;
const json = (await response.json()) as {
ok?: boolean;
data?: ExtensionLookupData;
};
return json.ok && json.data ? json.data : null;
return requestLookup(
`${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}`
);
}

const SUPPORTED_CROWD_PLATFORMS = new Set([
Expand All @@ -94,6 +118,9 @@ const SUPPORTED_CROWD_PLATFORMS = new Set([
'phongvu',
]);

// The latest product lookup per tab (DOS/Rate#1300).
let detectRequestCounter = 0;
const latestDetectByTab = new Map<number, number>();
async function maybeSendOptInCrowdSignal(
rawSignal: ExtractedPageProductSignal
): Promise<void> {
Expand Down Expand Up @@ -129,23 +156,13 @@ async function updateActionBadge(
): Promise<void> {
if (!tabId) return;
try {
if (!lookup || lookup.matchStatus === 'not_found') {
await chrome.action.setBadgeText({ text: '', tabId });
return;
}
const verdict = lookup.priceAssessment.verdict;
if (verdict === 'inflated_fake_sale') {
await chrome.action.setBadgeText({ text: 'ẢO!', tabId });
await chrome.action.setBadgeBackgroundColor({ color: '#d83030', tabId });
} else if (verdict === 'genuine_deal') {
await chrome.action.setBadgeText({ text: 'TỐT', tabId });
await chrome.action.setBadgeBackgroundColor({ color: '#2dba69', tabId });
} else if (verdict === 'above_market') {
await chrome.action.setBadgeText({ text: 'CAO', tabId });
await chrome.action.setBadgeBackgroundColor({ color: '#f59e0b', tabId });
} else {
await chrome.action.setBadgeText({ text: 'OK', tabId });
await chrome.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId });
const badge = resolveActionBadge(lookup);
await chrome.action.setBadgeText({ text: badge.text, tabId });
if (badge.color) {
await chrome.action.setBadgeBackgroundColor({
color: badge.color,
tabId,
});
}
} catch {
// Ignore badge errors if tab closed
Expand Down Expand Up @@ -191,6 +208,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}

if (msgType === 'RATE_PRODUCT_DETECTED') {
const requestId = ++detectRequestCounter;
if (sender.tab?.id) latestDetectByTab.set(sender.tab.id, requestId);
// Only the tab's latest lookup may set its badge, stored lookup or
// panel: an earlier one can answer after a later one (DOS/Rate#1300).
const isLatest = () =>
!sender.tab?.id || latestDetectByTab.get(sender.tab.id) === requestId;
(async () => {
try {
const signal = sanitizeExtensionProductSignal(
Expand Down Expand Up @@ -218,8 +241,33 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
await chrome.storage.session.set(signalPayload);

const lookup = await fetchExtensionLookup(signal, 'pill');
const outcome = await fetchExtensionLookup(signal, 'pill');
// Fire-and-forget Opt-In Quarantine Crowd Signal if user enabled it.
// Sent whenever the server answered, as before (#1175): a network
// error threw before it then, so it still sends nothing (#1300).
if (outcome.ok || outcome.status !== null) {
void maybeSendOptInCrowdSignal(signal);
}
// A failed lookup clears the previous product's badge (#1300).
const lookup = outcome.ok ? outcome.data : null;
if (!isLatest()) {
sendResponse({ ok: outcome.ok, data: lookup, stale: true });
return;
}
await updateActionBadge(tabId, lookup);
if (!outcome.ok) {
// An open side panel reloads for this page instead of keeping the
// previous product (it listens to this key).
if (!isBackgroundTab) {
await chrome.storage.session.set({ lookup_tab_latest: null });
}
sendResponse({
ok: false,
status: outcome.status,
error: 'Lookup failed',
});
return;
}

if (lookup) {
const lookupPayload: Record<string, unknown> = {
Expand All @@ -231,13 +279,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
await chrome.storage.session.set(lookupPayload);
}

// Fire-and-forget Opt-In Quarantine Crowd Signal if user enabled it
void maybeSendOptInCrowdSignal(signal);

sendResponse({ ok: true, data: lookup });
} catch (err) {
if (isLatest()) await updateActionBadge(sender.tab?.id, null);
sendResponse({
ok: false,
status: null,
error: err instanceof Error ? err.message : 'Lookup failed',
});
}
Expand Down Expand Up @@ -335,16 +382,26 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
}

const fullLookup = await fetchExtensionLookup(effectiveSignal, 'full');
sendResponse({
ok: true,
settings,
signal: effectiveSignal,
data: fullLookup,
});
const outcome = await fetchExtensionLookup(effectiveSignal, 'full');
sendResponse(
outcome.ok
? {
ok: true,
settings,
signal: effectiveSignal,
data: outcome.data,
}
: {
ok: false,
status: outcome.status,
settings,
signal: effectiveSignal,
}
);
} catch (err) {
sendResponse({
ok: false,
status: null,
error: err instanceof Error ? err.message : 'Full lookup failed',
});
}
Expand Down Expand Up @@ -417,31 +474,21 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
params.set('title', rawQuery);

const endpoint = `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Rate-Extension-Version': '2.0.0',
},
signal: AbortSignal.timeout(10_000),
});

if (!response.ok) {
const outcome = await requestLookup(
`${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}`
);
if (!outcome.ok) {
// The panel tells a rate limit, a server error and a network
// error apart (DOS/Rate#1300).
sendResponse({
ok: true,
ok: false,
status: outcome.status,
settings,
signal: syntheticSignal,
data: null,
});
return;
}

const json = (await response.json()) as {
ok?: boolean;
data?: ExtensionLookupData;
};
const fullLookup = json.ok && json.data ? json.data : null;
const fullLookup = outcome.data;

if (fullLookup?.platform) {
syntheticSignal.platform = fullLookup.platform;
Expand All @@ -465,6 +512,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
} catch (err) {
sendResponse({
ok: false,
status: null,
error:
err instanceof Error ? err.message : 'Quick query lookup failed',
});
Expand Down
Loading
Loading