Skip to content
Draft
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
94 changes: 71 additions & 23 deletions apps/web/src/components/leaderboard/ProductOffersExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,55 @@ export function computeMerchantTrustScore(riskScore: unknown): number {
return 100 - clampedRisk;
}

export function sanitizeOfferRiskScore100(value: unknown): number {
const num = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(num) || num <= 0) return 0;
return Math.min(100, Math.round(num));
}

export function isSafeExternalOfferUrl(url: unknown): boolean {
if (typeof url !== 'string' || !url.trim()) return false;
if (typeof url !== 'string') return false;
if (/\p{Cc}/u.test(url)) return false;
const trimmed = url.trim();
if (!trimmed || trimmed.length > 2048 || !/^https?:\/\//i.test(trimmed)) {
return false;
}
try {
const parsed = new URL(url.trim());
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
if (parsed.username || parsed.password) return false;
return true;
} catch {
return false;
}
}

export function sanitizeOffersMarkdownCell(value: unknown): string {
if (typeof value !== 'string') return '-';
const cleaned = value
.replace(/\p{Cc}+/gu, ' ')
.replace(/\|/g, '-')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);
return cleaned || '-';
}

export function sanitizeOffersFileToken(value: unknown): string {
if (typeof value !== 'string') return 'audit';
const cleaned = value
.replace(/\p{Cc}+/gu, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120);
return cleaned || 'audit';
}

export function computeOffersPriceSpreadAudit(
offers: readonly MerchantOffer[],
implausibleOfferIds: ReadonlySet<string> = new Set()
Expand Down Expand Up @@ -187,11 +226,7 @@ export default function ProductOffersExplorer({
implausibleOfferIds,
]);

const safeSlugFileToken =
(typeof productSlug === 'string' ? productSlug : '')
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'audit';
const safeSlugFileToken = sanitizeOffersFileToken(productSlug);

const handleCopyOffersMarkdown = async () => {
if (typeof navigator === 'undefined' || filteredOffers.length === 0) return;
Expand All @@ -213,18 +248,19 @@ export default function ProductOffersExplorer({
Number.isFinite(Number(o.price_original)) &&
Number(o.price_original) > o.price_current &&
Number(o.price_original) <= MAX_VALID_VND_PRICE;
const verdictCell = implausibleOfferIds.has(o.id)
? t('offersImplausiblyLowBadge')
: analysis
? discountVerdictCopy(tVerdict, analysis).label
: tVerdict('labelNormal');
return [
o.platform.toUpperCase(),
o.merchant.name.replace(/\|/g, '-'),
sanitizeOffersMarkdownCell(o.platform.toUpperCase()),
sanitizeOffersMarkdownCell(o.merchant.name),
o.merchant.is_official ? 'Yes' : 'No',
`${trust}%`,
`${o.price_current.toLocaleString('vi-VN')} ₫`,
hasValidOrig ? `${o.price_original!.toLocaleString('vi-VN')} ₫` : '-',
implausibleOfferIds.has(o.id)
? t('offersImplausiblyLowBadge')
: analysis
? discountVerdictCopy(tVerdict, analysis).label
: tVerdict('labelNormal'),
sanitizeOffersMarkdownCell(verdictCell),
];
});

Expand All @@ -236,14 +272,20 @@ export default function ProductOffersExplorer({
count: priceSpreadAudit.platformCount,
spreadVnd: priceSpreadAudit.spreadVnd.toLocaleString('vi-VN'),
spreadPct: priceSpreadAudit.spreadPct,
cheapestPlatform: priceSpreadAudit.cheapest.platform.toUpperCase(),
cheapestMerchant: priceSpreadAudit.cheapest.merchant.name,
priciestPlatform: priceSpreadAudit.priciest.platform.toUpperCase(),
cheapestPlatform: sanitizeOffersMarkdownCell(
priceSpreadAudit.cheapest.platform.toUpperCase()
),
cheapestMerchant: sanitizeOffersMarkdownCell(
priceSpreadAudit.cheapest.merchant.name
),
priciestPlatform: sanitizeOffersMarkdownCell(
priceSpreadAudit.priciest.platform.toUpperCase()
),
})
: '';

const md = [
`### 🛡️ Rate.vn cross-store price check: ${productName}`,
`### 🛡️ Rate.vn cross-store price check: ${sanitizeOffersMarkdownCell(productName)}`,
radarLine + spreadLine,
`| ${headers.join(' | ')} |`,
`| ${sep.join(' | ')} |`,
Expand Down Expand Up @@ -290,8 +332,8 @@ export default function ProductOffersExplorer({
o.merchant.name,
o.merchant.is_official ? 'true' : 'false',
String(trust),
String(o.merchant.dosafe_risk_score || 0),
String(o.merchant.review_risk_score || 0),
String(sanitizeOfferRiskScore100(o.merchant.dosafe_risk_score)),
String(sanitizeOfferRiskScore100(o.merchant.review_risk_score)),
String(o.price_current),
hasValidOrig ? String(o.price_original) : '',
implausibleOfferIds.has(o.id)
Expand Down Expand Up @@ -682,8 +724,14 @@ export default function ProductOffersExplorer({
className="text-muted"
style={{ fontSize: '0.8125rem' }}
>
DOSafe: {offer.merchant.dosafe_risk_score}/100 |
Review Risk: {offer.merchant.review_risk_score}
DOSafe:{' '}
{sanitizeOfferRiskScore100(
offer.merchant.dosafe_risk_score
)}
/100 | Review Risk:{' '}
{sanitizeOfferRiskScore100(
offer.merchant.review_risk_score
)}
/100
</div>
</div>
Expand Down
58 changes: 43 additions & 15 deletions apps/web/src/components/leaderboard/ProductShareAuditButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ interface ProductShareAuditButtonProps {
readonly radarSignal?: RadarSignal;
}

export function sanitizeShareAuditText(
value: unknown,
maxLength = 200
): string {
if (typeof value !== 'string') return '';
return value
.replace(/\p{Cc}+/gu, ' ')
.replace(/\|/g, '-')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength);
}

export function normalizeShareAuditFields(input: {
readonly modelKey: string;
readonly userScore5: number;
Expand All @@ -45,20 +58,28 @@ export function normalizeShareAuditFields(input: {
: null;
const rawReviews = Number(input.authenticReviews);
const safeAuthenticReviews =
Number.isFinite(rawReviews) && rawReviews > 0 ? Math.floor(rawReviews) : 0;
Number.isFinite(rawReviews) && rawReviews > 0
? Math.min(10_000_000, Math.floor(rawReviews))
: 0;
const rawPrice = Number(input.minPriceVnd);
const safeMinPriceVnd =
Number.isFinite(rawPrice) && rawPrice > 0 && rawPrice <= MAX_VALID_VND_PRICE
? Math.round(rawPrice)
: 0;
const rawOffers = Number(input.offerCount);
const safeOfferCount =
Number.isFinite(rawOffers) && rawOffers > 0 ? Math.floor(rawOffers) : 0;
Number.isFinite(rawOffers) && rawOffers > 0
? Math.min(10_000_000, Math.floor(rawOffers))
: 0;
const safeModelKeyToken =
(typeof input.modelKey === 'string' ? input.modelKey : '')
.replace(/\p{Cc}+/gu, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'audit';
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'audit';

return {
safeUserScore5,
Expand Down Expand Up @@ -102,6 +123,13 @@ export default function ProductShareAuditButton({
offerCount,
});

const safeName = sanitizeShareAuditText(name, 160);
const safeBrand = sanitizeShareAuditText(brand, 80);
const safeModelKey = sanitizeShareAuditText(modelKey, 120);
const safeKeySpecLine = keySpecLine
? sanitizeShareAuditText(keySpecLine, 240)
: '';

const handleCopyMarkdown = async () => {
if (typeof window === 'undefined') return;
try {
Expand All @@ -124,17 +152,17 @@ export default function ProductShareAuditButton({
? `${safeExpertScore10.toFixed(1)}/10`
: t('shareAuditMdNoExpert');
const lines = [
t('shareAuditMdTitle', { name, brand }),
t('shareAuditMdTitle', { name: safeName, brand: safeBrand }),
t('shareAuditMdUserScore', { score: userScoreStr }),
t('shareAuditMdExpertScore', { score: expertScoreStr }),
...(keySpecLine
? [t('shareAuditMdKeySpecs', { specs: keySpecLine })]
...(safeKeySpecLine
? [t('shareAuditMdKeySpecs', { specs: safeKeySpecLine })]
: []),
t('shareAuditMdMinPrice', { price: priceStr }),
...(radarSignal
? [t('shareAuditMdRadar', { signal: radarLabel(radarSignal) })]
: []),
t('shareAuditMdModelKey', { modelKey }),
t('shareAuditMdModelKey', { modelKey: safeModelKey }),
t('shareAuditMdUrl', { url: window.location.href }),
];
await navigator.clipboard.writeText(lines.join('\n'));
Expand Down Expand Up @@ -171,15 +199,15 @@ export default function ProductShareAuditButton({
...(radarSignal ? RADAR_CSV_HEADERS : []),
];
const row = [
name,
brand,
modelKey,
safeName,
safeBrand,
safeModelKey,
safeUserScore5 > 0 ? safeUserScore5.toFixed(2) : '',
safeExpertScore10 != null ? safeExpertScore10.toFixed(1) : '',
safeAuthenticReviews,
safeMinPriceVnd > 0 ? safeMinPriceVnd : '',
safeOfferCount,
keySpecLine || '',
safeKeySpecLine,
...(radarSignal ? radarCsvCells(radarSignal) : []),
];
const csvContent = `\uFEFF${headers.map(escapeCsvCell).join(',')}\n${row.map(escapeCsvCell).join(',')}`;
Expand All @@ -198,15 +226,15 @@ export default function ProductShareAuditButton({
source: 'Rate.vn product summary',
exportedAt: new Date().toISOString(),
product: {
name,
brand,
modelKey,
name: safeName,
brand: safeBrand,
modelKey: safeModelKey,
userScore5: safeUserScore5 > 0 ? safeUserScore5 : null,
expertScore10: safeExpertScore10,
authenticReviews: safeAuthenticReviews,
minPriceVnd: safeMinPriceVnd,
offerCount: safeOfferCount,
keySpecLine: keySpecLine || null,
keySpecLine: safeKeySpecLine || null,
dealRadar: radarSignal ? radarExportFields(radarSignal) : null,
},
};
Expand Down
30 changes: 21 additions & 9 deletions apps/web/src/components/leaderboard/ProductValueCalculator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ interface ProductValueCalculatorProps {

export type HorizonMonths = 12 | 24 | 36;

export function sanitizeValueCriterionScore5(value: unknown): number {
const num = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(num) || num <= 0) return 0;
return Number(Math.min(5, num).toFixed(1));
}

export function sanitizeValueCalcFileToken(value: unknown): string {
if (typeof value !== 'string') return 'devices';
const cleaned = value
.replace(/\p{Cc}+/gu, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return cleaned || 'devices';
}

export function estimateResaleRetentionRatio(
brand: string,
categorySlug: string,
Expand Down Expand Up @@ -172,16 +191,9 @@ export default function ProductValueCalculator({
return null;
}

const rawValueScore = Number(valueCriterionScore5);
const safeValueCriterionScore5 =
Number.isFinite(rawValueScore) && rawValueScore > 0
? Math.min(5, rawValueScore)
: 0;
const safeCategoryFileToken =
(typeof categorySlug === 'string' ? categorySlug : '')
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'devices';
sanitizeValueCriterionScore5(valueCriterionScore5);
const safeCategoryFileToken = sanitizeValueCalcFileToken(categorySlug);

const handleDownloadValueCsv = () => {
if (typeof window === 'undefined') return;
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12852,6 +12852,73 @@ async function runCoreArchitectureSuite() {
);
}

// -------------------------------------------------------------------------
// 126h. ProductOffersExplorer, ProductShareAuditButton & ProductValueCalculator Bounds (#1055)
// -------------------------------------------------------------------------
{
const {
isSafeExternalOfferUrl,
sanitizeOfferRiskScore100,
sanitizeOffersMarkdownCell,
sanitizeOffersFileToken,
} = await import('../../../components/leaderboard/ProductOffersExplorer');
const { normalizeShareAuditFields, sanitizeShareAuditText } =
await import('../../../components/leaderboard/ProductShareAuditButton');
const { sanitizeValueCalcFileToken, sanitizeValueCriterionScore5 } =
await import('../../../components/leaderboard/ProductValueCalculator');

assert.equal(isSafeExternalOfferUrl('https://shopee.vn/item-i.1.2'), true);
assert.equal(
isSafeExternalOfferUrl('https://user:pass@evil.example/item'),
false
);
assert.equal(isSafeExternalOfferUrl('https://shopee.vn/item\u0000'), false);
assert.equal(isSafeExternalOfferUrl('javascript:alert(1)'), false);
assert.equal(sanitizeOfferRiskScore100(145.6), 100);
assert.equal(sanitizeOfferRiskScore100(-12), 0);
assert.equal(
sanitizeOffersMarkdownCell(' Shop\r\nMall | Official\u0000 '),
'Shop Mall - Official'
);
assert.equal(
sanitizeOffersFileToken(' ../iPhone 16 Pro\u0000!! '),
'iphone-16-pro'
);
assert.equal(sanitizeOffersFileToken(' '), 'audit');

const auditFields = normalizeShareAuditFields({
modelKey: ' ../apple:iphone-16\u0000!! ',
userScore5: 7.5,
expertScore10: 14,
authenticReviews: 50_000_000,
minPriceVnd: 9_000_000_000,
offerCount: -5,
});
assert.equal(auditFields.safeModelKeyToken, 'apple-iphone-16');
assert.equal(auditFields.safeUserScore5, 5);
assert.equal(auditFields.safeExpertScore10, 10);
assert.equal(auditFields.safeAuthenticReviews, 10_000_000);
assert.equal(auditFields.safeMinPriceVnd, 0);
assert.equal(auditFields.safeOfferCount, 0);
assert.equal(
sanitizeShareAuditText(' iPhone\r\n16 | Pro\u0000 ', 160),
'iPhone 16 - Pro'
);

assert.equal(sanitizeValueCriterionScore5(4.84), 4.8);
assert.equal(sanitizeValueCriterionScore5(9.2), 5);
assert.equal(sanitizeValueCriterionScore5(-2), 0);
assert.equal(
sanitizeValueCalcFileToken(' ../Smartphones\u0000!! '),
'smartphones'
);
assert.equal(sanitizeValueCalcFileToken(' '), 'devices');

console.log(
' ✅ [126h/135] ProductOffersExplorer, ProductShareAuditButton & ProductValueCalculator Bounds (#1055) passed'
);
}

// -------------------------------------------------------------------------
// 127. Rate Server Index & Extension Entity, Slug, and Category Bounds (#1029)
// -------------------------------------------------------------------------
Expand Down
Loading