Add ShieldAI browser extension with phishing & spam detection (FRE-4576)
- Extension package: Manifest V3, background service worker, content scripts - Phishing detection engine with heuristic analysis (typosquatting, entropy, TLD, brand impersonation) - Local URL caching layer (Storage API) for <100ms cached lookups - Popup UI with protection status, stats, and phishing report button - Options page for settings management (blocked/allowed domains, feature toggles) - Server-side extension routes: URL check, phishing report, auth, stats, exposure check - Tier-aware feature gating (Basic/Plus/Premium) - 25 passing tests for phishing detection heuristics - Declarative net request rules for known phishing patterns - DarkWatch integration for credential exposure checks - Firefox compatibility layer via build modes Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
131
packages/extension/src/lib/api-client.ts
Normal file
131
packages/extension/src/lib/api-client.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { UrlCheckResult, UrlVerdict, ThreatInfo, PhishingReport, SubscriptionTier } from '../types';
|
||||
import { settingsManager } from './settings';
|
||||
import { API_TIMEOUT } from './cache';
|
||||
|
||||
export class ShieldApiClient {
|
||||
private baseUrl: string = '';
|
||||
|
||||
async checkUrl(url: string): Promise<UrlCheckResult | null> {
|
||||
const settings = await settingsManager.get();
|
||||
const token = settings.authToken;
|
||||
if (!token) return null;
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const response = await fetch(`${settings.apiBaseUrl}/extension/url-check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
signal: AbortSignal.timeout(API_TIMEOUT),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
url,
|
||||
domain: new URL(url).hostname,
|
||||
verdict: data.verdict || UrlVerdict.UNKNOWN,
|
||||
confidence: data.confidence || 0,
|
||||
threats: (data.threats || []) as ThreatInfo[],
|
||||
cached: false,
|
||||
latencyMs: latency,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async checkDomainExposure(domain: string): Promise<{ exposed: boolean; sources: string[] } | null> {
|
||||
const settings = await settingsManager.get();
|
||||
const token = settings.authToken;
|
||||
if (!token || !settings.darkWatchEnabled) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${settings.apiBaseUrl}/darkwatch/exposures/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ domain }),
|
||||
signal: AbortSignal.timeout(API_TIMEOUT),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async submitPhishingReport(report: PhishingReport): Promise<boolean> {
|
||||
const settings = await settingsManager.get();
|
||||
const token = settings.authToken;
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${settings.apiBaseUrl}/extension/phishing-report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(report),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(apiKey: string): Promise<{ userId: string; tier: SubscriptionTier } | null> {
|
||||
try {
|
||||
const response = await fetch(`${settingsManager.get().then(s => s.apiBaseUrl)}/extension/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getProtectionStats(): Promise<{
|
||||
threatsBlockedToday: number;
|
||||
urlsCheckedToday: number;
|
||||
} | null> {
|
||||
const settings = await settingsManager.get();
|
||||
const token = settings.authToken;
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${settings.apiBaseUrl}/extension/stats`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const shieldApiClient = new ShieldApiClient();
|
||||
80
packages/extension/src/lib/cache.ts
Normal file
80
packages/extension/src/lib/cache.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { UrlCheckResult } from '../types';
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const API_TIMEOUT_MS = 500;
|
||||
const MAX_CACHE_SIZE = 5000;
|
||||
|
||||
export class UrlCache {
|
||||
private cache: Map<string, { result: UrlCheckResult; expiresAt: number }> = new Map();
|
||||
|
||||
async get(url: string): Promise<UrlCheckResult | null> {
|
||||
const normalized = this.normalizeUrl(url);
|
||||
const entry = this.cache.get(normalized);
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(normalized);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...entry.result, cached: true };
|
||||
}
|
||||
|
||||
async set(url: string, result: UrlCheckResult): Promise<void> {
|
||||
const normalized = this.normalizeUrl(url);
|
||||
|
||||
if (this.cache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey) this.cache.delete(firstKey);
|
||||
}
|
||||
|
||||
this.cache.set(normalized, {
|
||||
result,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async persistToStorage(): Promise<void> {
|
||||
const entries: Record<string, { result: UrlCheckResult; expiresAt: number }> = {};
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
entries[key] = value;
|
||||
}
|
||||
await chrome.storage.local.set({ urlCache: entries });
|
||||
}
|
||||
|
||||
async loadFromStorage(): Promise<void> {
|
||||
const data = await chrome.storage.local.get('urlCache');
|
||||
if (data.urlCache) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of Object.entries(data.urlCache)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.cache.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): { size: number; max: number } {
|
||||
return { size: this.cache.size, max: MAX_CACHE_SIZE };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
private normalizeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.hash = '';
|
||||
u.search = '';
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const urlCache = new UrlCache();
|
||||
export const CACHE_TTL = CACHE_TTL_MS;
|
||||
export const API_TIMEOUT = API_TIMEOUT_MS;
|
||||
277
packages/extension/src/lib/phishing-detector.ts
Normal file
277
packages/extension/src/lib/phishing-detector.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { ThreatType, UrlVerdict, ThreatInfo } from '../types';
|
||||
|
||||
export class PhishingDetector {
|
||||
private knownSuspiciousTlds: Set<string> = new Set([
|
||||
'.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.click', '.link', '.work',
|
||||
]);
|
||||
|
||||
private commonBrands: Map<string, string[]> = new Map([
|
||||
['google', ['gmail', 'drive', 'docs', 'maps', 'play', 'chrome', 'youtube']],
|
||||
['apple', ['icloud', 'appstore', 'icloud_content', 'appleid']],
|
||||
['amazon', ['aws', 'amazonaws', 'amazon-adsystem', 'prime-video']],
|
||||
['microsoft', ['office', 'outlook', 'onedrive', 'teams', 'azure', 'windows']],
|
||||
['facebook', ['fb', 'fbcdn', 'instagram', 'whatsapp', 'messenger']],
|
||||
['paypal', ['paypalobjects', 'paypal-web', 'xoom']],
|
||||
['netflix', ['nflximg', 'nflxso', 'nflxvideo', 'nflxext']],
|
||||
['bank', ['chase', 'bofa', 'wellsfargo', 'citi', 'hsbc', 'barclays']],
|
||||
]);
|
||||
|
||||
analyzeUrl(url: string): { verdict: UrlVerdict; threats: ThreatInfo[]; score: number } {
|
||||
const threats: ThreatInfo[] = [];
|
||||
let score = 0;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const domainParts = hostname.split('.');
|
||||
const tld = domainParts[domainParts.length - 1];
|
||||
|
||||
score += this.checkTld(tld, domainParts, threats);
|
||||
score += this.checkEntropy(parsed.pathname + parsed.search, threats);
|
||||
score += this.checkTyposquatting(hostname, threats);
|
||||
score += this.checkIpAddress(hostname, threats);
|
||||
score += this.checkLongUrl(url, threats);
|
||||
score += this.checkSubdomainDepth(domainParts, threats);
|
||||
score += this.checkHttpsProtocol(parsed.protocol, threats);
|
||||
score += this.checkRedirectPatterns(parsed.search, threats);
|
||||
score += this.checkEncodedChars(url, threats);
|
||||
score += this.checkBrandImpersonation(hostname, threats);
|
||||
} catch {
|
||||
return {
|
||||
verdict: UrlVerdict.UNKNOWN,
|
||||
threats: [{ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: 'Malformed URL' }],
|
||||
score: 30,
|
||||
};
|
||||
}
|
||||
|
||||
const verdict = score >= 70 ? UrlVerdict.PHISHING
|
||||
: score >= 40 ? UrlVerdict.SUSPICIOUS
|
||||
: score >= 20 ? UrlVerdict.SPAM
|
||||
: UrlVerdict.SAFE;
|
||||
|
||||
return { verdict, threats, score };
|
||||
}
|
||||
|
||||
private checkTld(tld: string, parts: string[], threats: ThreatInfo[]): number {
|
||||
if (this.knownSuspiciousTlds.has(`.${tld}`)) {
|
||||
threats.push({
|
||||
type: ThreatType.DOMAIN_AGE,
|
||||
severity: 4,
|
||||
source: 'heuristic',
|
||||
description: `Suspicious TLD: .${tld}`,
|
||||
});
|
||||
return 25;
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
threats.push({
|
||||
type: ThreatType.DOMAIN_AGE,
|
||||
severity: 3,
|
||||
source: 'heuristic',
|
||||
description: 'Single-label domain (possible local or new domain)',
|
||||
});
|
||||
return 15;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkEntropy(pathname: string, threats: ThreatInfo[]): number {
|
||||
if (!pathname || pathname.length < 20) return 0;
|
||||
const entropy = this.calculateEntropy(pathname);
|
||||
if (entropy > 4.5) {
|
||||
threats.push({
|
||||
type: ThreatType.URL_ENTROPY,
|
||||
severity: 4,
|
||||
source: 'heuristic',
|
||||
description: `High URL path entropy (${entropy.toFixed(2)}) suggests obfuscation`,
|
||||
});
|
||||
return 20;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkTyposquatting(hostname: string, threats: ThreatInfo[]): number {
|
||||
for (const [brand, subdomains] of this.commonBrands) {
|
||||
const brandParts = hostname.split('.');
|
||||
const mainDomain = brandParts.slice(0, -1).join('.');
|
||||
const firstLabel = mainDomain.split('.')[0];
|
||||
|
||||
if (mainDomain.includes(brand) && mainDomain !== brand) {
|
||||
const editDist = this.levenshteinDistance(firstLabel, brand);
|
||||
if (editDist <= 2 && editDist > 0) {
|
||||
threats.push({
|
||||
type: ThreatType.TYPOSQUAT,
|
||||
severity: 5,
|
||||
source: 'heuristic',
|
||||
description: `Possible typosquat of "${brand}" (edit distance: ${editDist})`,
|
||||
});
|
||||
return 35;
|
||||
}
|
||||
}
|
||||
|
||||
const editDist = this.levenshteinDistance(firstLabel, brand);
|
||||
if (editDist <= 2 && editDist > 0 && firstLabel.length >= brand.length - 1) {
|
||||
threats.push({
|
||||
type: ThreatType.TYPOSQUAT,
|
||||
severity: 5,
|
||||
source: 'heuristic',
|
||||
description: `Possible typosquat of "${brand}" (edit distance: ${editDist})`,
|
||||
});
|
||||
return 35;
|
||||
}
|
||||
|
||||
for (const sub of subdomains) {
|
||||
if (hostname.includes(sub) && !hostname.startsWith(`${sub}.` + brandParts[brandParts.length - 1])) {
|
||||
threats.push({
|
||||
type: ThreatType.TYPOSQUAT,
|
||||
severity: 3,
|
||||
source: 'heuristic',
|
||||
description: `Domain contains "${sub}" but not an official ${brand} subdomain`,
|
||||
});
|
||||
return 15;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkIpAddress(hostname: string, threats: ThreatInfo[]): number {
|
||||
const ipPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
||||
if (ipPattern.test(hostname) && hostname !== '127.0.0.1' && hostname !== 'localhost') {
|
||||
threats.push({
|
||||
type: ThreatType.PHISHING_HEURISTIC,
|
||||
severity: 4,
|
||||
source: 'heuristic',
|
||||
description: `IP address used as hostname: ${hostname}`,
|
||||
});
|
||||
return 25;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkLongUrl(url: string, threats: ThreatInfo[]): number {
|
||||
if (url.length > 200) {
|
||||
threats.push({
|
||||
type: ThreatType.PHISHING_HEURISTIC,
|
||||
severity: 3,
|
||||
source: 'heuristic',
|
||||
description: `Unusually long URL (${url.length} chars)`,
|
||||
});
|
||||
return 15;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkSubdomainDepth(parts: string[], threats: ThreatInfo[]): number {
|
||||
if (parts.length > 5) {
|
||||
threats.push({
|
||||
type: ThreatType.PHISHING_HEURISTIC,
|
||||
severity: 3,
|
||||
source: 'heuristic',
|
||||
description: `Deep subdomain nesting (${parts.length} levels)`,
|
||||
});
|
||||
return 15;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkHttpsProtocol(protocol: string, threats: ThreatInfo[]): number {
|
||||
if (protocol === 'http:') {
|
||||
threats.push({
|
||||
type: ThreatType.MIXED_CONTENT,
|
||||
severity: 2,
|
||||
source: 'heuristic',
|
||||
description: 'Page loaded over HTTP (not HTTPS)',
|
||||
});
|
||||
return 10;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkRedirectPatterns(query: string, threats: ThreatInfo[]): number {
|
||||
const redirectParams = ['redirect', 'url', 'dest', 'return', 'next', 'target'];
|
||||
let count = 0;
|
||||
for (const param of redirectParams) {
|
||||
if (query.includes(`${param}=`)) count++;
|
||||
}
|
||||
if (count >= 2) {
|
||||
threats.push({
|
||||
type: ThreatType.REDIRECT_CHAIN,
|
||||
severity: 3,
|
||||
source: 'heuristic',
|
||||
description: `Multiple redirect parameters detected (${count})`,
|
||||
});
|
||||
return 15;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkEncodedChars(url: string, threats: ThreatInfo[]): number {
|
||||
const encodedPattern = /(%[0-9a-fA-F]{2}){3,}/g;
|
||||
const matches = url.match(encodedPattern);
|
||||
if (matches && matches.length > 0) {
|
||||
threats.push({
|
||||
type: ThreatType.URL_ENTROPY,
|
||||
severity: 3,
|
||||
source: 'heuristic',
|
||||
description: 'Excessive URL encoding detected',
|
||||
});
|
||||
return 15;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private checkBrandImpersonation(hostname: string, threats: ThreatInfo[]): number {
|
||||
const impersonationPatterns = [
|
||||
/login[-_]?(secure|portal|page|form)/i,
|
||||
/account[-_]?(verify|confirm|update)/i,
|
||||
/secure[-_]?(signin|auth|login)/i,
|
||||
/wallet[-_]?(connect|link|verify)/i,
|
||||
];
|
||||
for (const pattern of impersonationPatterns) {
|
||||
if (pattern.test(hostname)) {
|
||||
threats.push({
|
||||
type: ThreatType.PHISHING_HEURISTIC,
|
||||
severity: 4,
|
||||
source: 'heuristic',
|
||||
description: `Common phishing pattern detected: ${hostname}`,
|
||||
});
|
||||
return 20;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private calculateEntropy(str: string): number {
|
||||
const freq: Record<string, number> = {};
|
||||
for (const char of str) {
|
||||
freq[char] = (freq[char] || 0) + 1;
|
||||
}
|
||||
let entropy = 0;
|
||||
const len = str.length;
|
||||
for (const count of Object.values(freq)) {
|
||||
const p = count / len;
|
||||
entropy -= p * Math.log2(p);
|
||||
}
|
||||
return entropy;
|
||||
}
|
||||
|
||||
private levenshteinDistance(a: string, b: string): number {
|
||||
const matrix: number[][] = [];
|
||||
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
||||
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
matrix[i][j] = b[i - 1] === a[j - 1]
|
||||
? matrix[i - 1][j - 1]
|
||||
: Math.min(
|
||||
matrix[i - 1][j - 1] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j] + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
return matrix[b.length][a.length];
|
||||
}
|
||||
}
|
||||
|
||||
export const phishingDetector = new PhishingDetector();
|
||||
117
packages/extension/src/lib/settings.ts
Normal file
117
packages/extension/src/lib/settings.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ExtensionSettings, SubscriptionTier, TIER_FEATURES, MessageType } from '../types';
|
||||
|
||||
const DEFAULT_SETTINGS: ExtensionSettings = {
|
||||
apiKey: '',
|
||||
apiBaseUrl: 'https://api.shieldai.com',
|
||||
authToken: null,
|
||||
userId: null,
|
||||
tier: null,
|
||||
enabled: true,
|
||||
activeBlocking: false,
|
||||
darkWatchEnabled: false,
|
||||
spamProtectionEnabled: true,
|
||||
showNotifications: true,
|
||||
blockedDomains: [],
|
||||
allowedDomains: [],
|
||||
lastSyncAt: null,
|
||||
};
|
||||
|
||||
export class SettingsManager {
|
||||
private settings: ExtensionSettings = { ...DEFAULT_SETTINGS };
|
||||
private loaded = false;
|
||||
|
||||
async load(): Promise<ExtensionSettings> {
|
||||
if (this.loaded) return this.settings;
|
||||
|
||||
const stored = await chrome.storage.sync.get('shieldaiSettings');
|
||||
if (stored.shieldaiSettings) {
|
||||
this.settings = { ...DEFAULT_SETTINGS, ...stored.shieldaiSettings };
|
||||
}
|
||||
this.loaded = true;
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
async get(): Promise<ExtensionSettings> {
|
||||
if (!this.loaded) await this.load();
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
async update(partial: Partial<ExtensionSettings>): Promise<ExtensionSettings> {
|
||||
await this.load();
|
||||
this.settings = { ...this.settings, ...partial };
|
||||
await chrome.storage.sync.set({ shieldaiSettings: this.settings });
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
async getAuthToken(): Promise<string | null> {
|
||||
await this.load();
|
||||
return this.settings.authToken;
|
||||
}
|
||||
|
||||
async isLoggedIn(): Promise<boolean> {
|
||||
await this.load();
|
||||
return this.settings.authToken !== null && this.settings.userId !== null;
|
||||
}
|
||||
|
||||
async getTier(): Promise<SubscriptionTier | null> {
|
||||
await this.load();
|
||||
return this.settings.tier;
|
||||
}
|
||||
|
||||
async getFeatures(): Promise<typeof TIER_FEATURES[SubscriptionTier]> {
|
||||
const tier = await this.getTier();
|
||||
if (tier) return TIER_FEATURES[tier];
|
||||
return TIER_FEATURES[SubscriptionTier.BASIC];
|
||||
}
|
||||
|
||||
async isDomainBlocked(domain: string): Promise<boolean> {
|
||||
await this.load();
|
||||
return this.settings.blockedDomains.some(
|
||||
(d) => d.toLowerCase() === domain.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
async isDomainAllowed(domain: string): Promise<boolean> {
|
||||
await this.load();
|
||||
return this.settings.allowedDomains.some(
|
||||
(d) => d.toLowerCase() === domain.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
async isProtectionEnabled(): Promise<boolean> {
|
||||
await this.load();
|
||||
return this.settings.enabled;
|
||||
}
|
||||
|
||||
async toggleProtection(): Promise<boolean> {
|
||||
await this.load();
|
||||
this.settings.enabled = !this.settings.enabled;
|
||||
await chrome.storage.sync.set({ shieldaiSettings: this.settings });
|
||||
return this.settings.enabled;
|
||||
}
|
||||
|
||||
async addBlockedDomain(domain: string): Promise<void> {
|
||||
await this.load();
|
||||
const lower = domain.toLowerCase();
|
||||
if (!this.settings.blockedDomains.includes(lower)) {
|
||||
this.settings.blockedDomains.push(lower);
|
||||
await chrome.storage.sync.set({ shieldaiSettings: this.settings });
|
||||
}
|
||||
}
|
||||
|
||||
async removeBlockedDomain(domain: string): Promise<void> {
|
||||
await this.load();
|
||||
this.settings.blockedDomains = this.settings.blockedDomains.filter(
|
||||
(d) => d !== domain.toLowerCase()
|
||||
);
|
||||
await chrome.storage.sync.set({ shieldaiSettings: this.settings });
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
this.loaded = true;
|
||||
await chrome.storage.sync.set({ shieldaiSettings: this.settings });
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsManager = new SettingsManager();
|
||||
Reference in New Issue
Block a user