Add GamepadAPI, WebGL readPixels noise, auto-prune, import/export, badge
New fingerprint vectors: - Gamepad API: getGamepads() returns empty (prevents controller fingerprinting) - WebGL readPixels: seeded pixel noise on framebuffer reads New features: - Auto-prune: configurable automatic removal of inactive containers - Import/export: backup and restore all settings from options page - Toolbar badge: shows active container count - CHANGELOG.md: version history Bumped to v0.5.0 with 20 spoofed fingerprint vectors.
This commit is contained in:
46
CHANGELOG.md
Normal file
46
CHANGELOG.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Gamepad API spoofing (returns empty, prevents controller fingerprinting)
|
||||
- WebGL readPixels noise (seeded pixel noise on framebuffer reads)
|
||||
- Auto-prune: automatically remove inactive containers after configurable days
|
||||
- Import/export all settings (seeds, whitelist, vector config) from options page
|
||||
- Active container count badge on toolbar icon
|
||||
- Updated test page with new vector sections
|
||||
|
||||
## 0.4.1
|
||||
|
||||
- Skip all fingerprint overrides on Google auth domains to fix login rejection
|
||||
- Keep auth redirects in originating container for session isolation
|
||||
- Skip User-Agent spoofing on accounts.google.com and accounts.youtube.com
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Added 6 new fingerprint vectors: Font API (document.fonts), DOM element dimensions, HTTP header spoofing (User-Agent, Accept-Language, Client Hints), Speech Synthesis, Performance Timing, Storage Estimate
|
||||
- WebGL parameter normalization (MAX_TEXTURE_SIZE, MAX_VERTEX_ATTRIBS, etc.)
|
||||
- Font enumeration hardening via offsetWidth/Height noise
|
||||
- Total spoofed vectors: 18
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- HTTP header spoofing: User-Agent and Accept-Language modified per container
|
||||
- Client Hints header stripping (Sec-CH-UA, Sec-CH-UA-Platform)
|
||||
- Speech synthesis protection (getVoices returns empty)
|
||||
- matchMedia screen dimension override
|
||||
- Performance.now() precision reduction
|
||||
- navigator.storage.estimate() spoofing
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Coherent device profiles with 3 archetypes (Windows, Linux, macOS)
|
||||
- User-Agent spoofing with matching platform, oscpu, appVersion
|
||||
- Added data_collection_permissions for AMO submission
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial release
|
||||
- Per-site container isolation with automatic domain detection
|
||||
- 12 fingerprint vectors: Canvas, WebGL, Audio, Navigator, Screen, Timezone, WebRTC, Fonts, ClientRects, Plugins, Battery, Connection
|
||||
- Popup UI with per-container toggle, regenerate, prune, reset
|
||||
- Options page with vector toggles, domain whitelist, container management
|
||||
@@ -12,6 +12,8 @@ Every website you visit is automatically placed in its own isolated container wi
|
||||
- **Cross-site navigation** — clicking a link to a different domain automatically switches to the correct container
|
||||
- **HTTP header spoofing** — User-Agent, Accept-Language, and Client Hints headers match each container's identity
|
||||
- **Configurable** — toggle individual fingerprint vectors, whitelist domains, manage containers from the options page
|
||||
- **Auto-prune** — automatically remove inactive containers after configurable days
|
||||
- **Import/export** — backup and restore all settings, seeds, and whitelist
|
||||
- **Zero configuration** — install and browse, everything is automatic
|
||||
|
||||
## Fingerprint vectors protected
|
||||
@@ -36,6 +38,8 @@ Every website you visit is automatically placed in its own isolated container wi
|
||||
| matchMedia | Screen dimension queries return spoofed values |
|
||||
| Performance | performance.now() precision reduced to 0.1ms |
|
||||
| Storage | navigator.storage.estimate() returns generic values |
|
||||
| Gamepad | navigator.getGamepads() returns empty |
|
||||
| WebGL readPixels | Seeded noise on framebuffer reads |
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -151,7 +155,7 @@ A built-in test page is included at `test/fingerprint-test.html`. To use it:
|
||||
```
|
||||
manifest.json MV2 extension manifest
|
||||
background.js Container management, navigation, HTTP header spoofing
|
||||
inject.js Fingerprint overrides (exportFunction-based, 18 vectors)
|
||||
inject.js Fingerprint overrides (exportFunction-based, 20 vectors)
|
||||
lib/
|
||||
prng.js Mulberry32 seeded PRNG
|
||||
fingerprint-gen.js Deterministic seed → device profile generator
|
||||
|
||||
@@ -155,6 +155,7 @@ async function getOrCreateContainerForDomain(baseDomain) {
|
||||
await browser.storage.local.set({ containerSeeds: seeds });
|
||||
|
||||
await buildProfileAndRegister(cookieStoreId, seeds[cookieStoreId]);
|
||||
await updateBadge();
|
||||
|
||||
return cookieStoreId;
|
||||
}
|
||||
@@ -251,6 +252,14 @@ browser.tabs.onRemoved.addListener((tabId) => {
|
||||
delete pendingTabs[tabId];
|
||||
});
|
||||
|
||||
// --- Badge: show active container count ---
|
||||
|
||||
async function updateBadge() {
|
||||
const count = Object.keys(domainMap).length;
|
||||
browser.browserAction.setBadgeText({ text: count > 0 ? String(count) : "" });
|
||||
browser.browserAction.setBadgeBackgroundColor({ color: "#4a9eff" });
|
||||
}
|
||||
|
||||
// --- Message Handling (from popup and options page) ---
|
||||
|
||||
browser.runtime.onMessage.addListener((message, sender) => {
|
||||
@@ -265,6 +274,10 @@ browser.runtime.onMessage.addListener((message, sender) => {
|
||||
if (message.type === "setWhitelist") return handleSetWhitelist(message.whitelist);
|
||||
if (message.type === "getVectorSettings") return handleGetVectorSettings();
|
||||
if (message.type === "setVectorSettings") return handleSetVectorSettings(message.vectorSettings);
|
||||
if (message.type === "exportSettings") return handleExportSettings();
|
||||
if (message.type === "importSettings") return handleImportSettings(message.data);
|
||||
if (message.type === "getAutoPruneSettings") return handleGetAutoPruneSettings();
|
||||
if (message.type === "setAutoPruneSettings") return handleSetAutoPruneSettings(message.settings);
|
||||
});
|
||||
|
||||
async function handleGetContainerList() {
|
||||
@@ -362,6 +375,7 @@ async function handleResetAll() {
|
||||
managedContainerIds.clear();
|
||||
for (const key of Object.keys(containerProfiles)) delete containerProfiles[key];
|
||||
await browser.storage.local.clear();
|
||||
await updateBadge();
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -381,9 +395,9 @@ async function handlePruneContainers() {
|
||||
await browser.contextualIdentities.remove(c.cookieStoreId);
|
||||
pruned++;
|
||||
} catch(e) {}
|
||||
// onRemoved listener handles domainMap + managedContainerIds cleanup
|
||||
}
|
||||
}
|
||||
await updateBadge();
|
||||
return { pruned };
|
||||
}
|
||||
|
||||
@@ -422,6 +436,82 @@ async function handleSetVectorSettings(vectorSettings) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Import/Export ---
|
||||
|
||||
async function handleExportSettings() {
|
||||
const stored = await browser.storage.local.get(null); // get everything
|
||||
return {
|
||||
version: browser.runtime.getManifest().version,
|
||||
exportedAt: new Date().toISOString(),
|
||||
domainMap,
|
||||
containerSeeds: stored.containerSeeds || {},
|
||||
containerSettings: stored.containerSettings || {},
|
||||
vectorSettings: stored.vectorSettings || {},
|
||||
whitelist: stored.whitelist || [],
|
||||
autoPrune: stored.autoPrune || { enabled: false, days: 30 }
|
||||
};
|
||||
}
|
||||
|
||||
async function handleImportSettings(data) {
|
||||
if (!data || !data.containerSeeds) return { ok: false, error: "Invalid data" };
|
||||
await browser.storage.local.set({
|
||||
containerSeeds: data.containerSeeds,
|
||||
containerSettings: data.containerSettings || {},
|
||||
vectorSettings: data.vectorSettings || {},
|
||||
whitelist: data.whitelist || [],
|
||||
autoPrune: data.autoPrune || { enabled: false, days: 30 }
|
||||
});
|
||||
cachedWhitelist = data.whitelist || [];
|
||||
if (data.domainMap) {
|
||||
domainMap = data.domainMap;
|
||||
await saveDomainMap();
|
||||
}
|
||||
await registerAllKnownContainers();
|
||||
await updateBadge();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Auto-Prune ---
|
||||
|
||||
async function handleGetAutoPruneSettings() {
|
||||
const stored = await browser.storage.local.get("autoPrune");
|
||||
return stored.autoPrune || { enabled: false, days: 30 };
|
||||
}
|
||||
|
||||
async function handleSetAutoPruneSettings(settings) {
|
||||
await browser.storage.local.set({ autoPrune: settings });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function runAutoPrune() {
|
||||
const stored = await browser.storage.local.get("autoPrune");
|
||||
const settings = stored.autoPrune || { enabled: false, days: 30 };
|
||||
if (!settings.enabled) return;
|
||||
|
||||
const tabs = await browser.tabs.query({});
|
||||
const activeContainers = new Set(tabs.map(t => t.cookieStoreId));
|
||||
|
||||
// Track last activity per container
|
||||
const actStored = await browser.storage.local.get("containerActivity");
|
||||
const activity = actStored.containerActivity || {};
|
||||
const now = Date.now();
|
||||
const cutoff = now - (settings.days * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const cid of managedContainerIds) {
|
||||
if (activeContainers.has(cid)) {
|
||||
activity[cid] = now; // update last active
|
||||
} else if (!activity[cid]) {
|
||||
activity[cid] = now; // first seen, set to now
|
||||
} else if (activity[cid] < cutoff) {
|
||||
// Inactive beyond threshold — prune
|
||||
try {
|
||||
await browser.contextualIdentities.remove(cid);
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
await browser.storage.local.set({ containerActivity: activity });
|
||||
}
|
||||
|
||||
// --- Container Lifecycle ---
|
||||
|
||||
browser.contextualIdentities.onRemoved.addListener(async ({ contextualIdentity }) => {
|
||||
@@ -507,6 +597,11 @@ async function init() {
|
||||
}
|
||||
cachedWhitelist = stored.whitelist || [];
|
||||
await registerAllKnownContainers();
|
||||
await updateBadge();
|
||||
|
||||
// Run auto-prune on startup and every 6 hours
|
||||
await runAutoPrune();
|
||||
setInterval(runAutoPrune, 6 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
40
inject.js
40
inject.js
@@ -675,6 +675,46 @@
|
||||
|
||||
patchWebGLExtensions("WebGLRenderingContext");
|
||||
patchWebGLExtensions("WebGL2RenderingContext");
|
||||
|
||||
// --- readPixels noise ---
|
||||
// Like canvas noise, adds tiny seeded perturbation to WebGL framebuffer reads
|
||||
function patchWebGLReadPixels(protoName) {
|
||||
const pageProto = pageWindow[protoName];
|
||||
if (!pageProto) return;
|
||||
const origProto = window[protoName];
|
||||
if (!origProto) return;
|
||||
|
||||
const origReadPixels = origProto.prototype.readPixels;
|
||||
exportFunction(function(x, y, width, height, format, type, pixels) {
|
||||
origReadPixels.call(this, x, y, width, height, format, type, pixels);
|
||||
if (pixels && pixels.length > 0 && CONFIG.canvasSeed) {
|
||||
const rng = mulberry32(CONFIG.canvasSeed);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
if (rng() < 0.1) {
|
||||
const ch = (rng() * 3) | 0;
|
||||
const delta = rng() < 0.5 ? -1 : 1;
|
||||
pixels[i + ch] = Math.max(0, Math.min(255, pixels[i + ch] + delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, pageProto.prototype, { defineAs: "readPixels" });
|
||||
}
|
||||
|
||||
patchWebGLReadPixels("WebGLRenderingContext");
|
||||
patchWebGLReadPixels("WebGL2RenderingContext");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GAMEPAD API PROTECTION
|
||||
// =========================================================================
|
||||
// navigator.getGamepads() reveals connected game controllers (count, IDs)
|
||||
|
||||
if (vectorEnabled("navigator") && pageWindow.navigator.getGamepads) {
|
||||
try {
|
||||
exportFunction(function() {
|
||||
return cloneInto([null, null, null, null], pageWindow);
|
||||
}, pageWindow.Navigator.prototype, { defineAs: "getGamepads" });
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "ContainSite",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"description": "Per-container fingerprint isolation — each container gets its own device identity",
|
||||
"permissions": [
|
||||
"contextualIdentities",
|
||||
|
||||
@@ -64,6 +64,13 @@ tr:hover td { background: #2a2a3a; }
|
||||
|
||||
.td-actions { display: flex; gap: 4px; justify-content: flex-end; }
|
||||
|
||||
/* Auto-prune */
|
||||
.auto-prune-row { display: flex; align-items: center; gap: 16px; }
|
||||
.toggle-label { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; }
|
||||
.prune-days { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aaa; }
|
||||
.days-input { width: 50px; padding: 4px 6px; background: #2a2a3a; border: 1px solid #444; border-radius: 4px; color: #e0e0e0; font-size: 12px; text-align: center; }
|
||||
.days-input:focus { border-color: #4a9eff; outline: none; }
|
||||
|
||||
/* Bulk actions */
|
||||
.bulk { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bulk #regen-all { width: 100%; }
|
||||
|
||||
@@ -46,6 +46,32 @@
|
||||
<div id="no-containers" class="empty" hidden>No containers yet. Browse a website to create one.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Auto-Prune</h2>
|
||||
<p class="desc">Automatically remove inactive containers with no open tabs after a set number of days.</p>
|
||||
<div class="auto-prune-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="auto-prune-enabled" class="toggle">
|
||||
<span>Enable auto-prune</span>
|
||||
</label>
|
||||
<div class="prune-days">
|
||||
<span>after</span>
|
||||
<input type="number" id="auto-prune-days" min="1" max="365" value="30" class="days-input">
|
||||
<span>days</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Import / Export</h2>
|
||||
<p class="desc">Backup or restore all settings, seeds, and whitelist.</p>
|
||||
<div class="bulk-row">
|
||||
<button id="export-btn" class="btn">Export Settings</button>
|
||||
<button id="import-btn" class="btn secondary">Import Settings</button>
|
||||
<input type="file" id="import-file" accept=".json" hidden>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Bulk Actions</h2>
|
||||
<div class="bulk">
|
||||
|
||||
@@ -215,12 +215,74 @@ document.getElementById("reset").addEventListener("click", async (e) => {
|
||||
}, 1200);
|
||||
});
|
||||
|
||||
// --- Auto-Prune ---
|
||||
|
||||
async function loadAutoPrune() {
|
||||
const settings = await browser.runtime.sendMessage({ type: "getAutoPruneSettings" });
|
||||
document.getElementById("auto-prune-enabled").checked = settings.enabled;
|
||||
document.getElementById("auto-prune-days").value = settings.days || 30;
|
||||
}
|
||||
|
||||
async function saveAutoPrune() {
|
||||
const enabled = document.getElementById("auto-prune-enabled").checked;
|
||||
const days = parseInt(document.getElementById("auto-prune-days").value) || 30;
|
||||
await browser.runtime.sendMessage({
|
||||
type: "setAutoPruneSettings",
|
||||
settings: { enabled, days: Math.max(1, Math.min(365, days)) }
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("auto-prune-enabled").addEventListener("change", saveAutoPrune);
|
||||
document.getElementById("auto-prune-days").addEventListener("change", saveAutoPrune);
|
||||
|
||||
// --- Import / Export ---
|
||||
|
||||
document.getElementById("export-btn").addEventListener("click", async (e) => {
|
||||
e.target.textContent = "Exporting...";
|
||||
const data = await browser.runtime.sendMessage({ type: "exportSettings" });
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `containsite-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
e.target.textContent = "Exported!";
|
||||
setTimeout(() => { e.target.textContent = "Export Settings"; }, 1200);
|
||||
});
|
||||
|
||||
document.getElementById("import-btn").addEventListener("click", () => {
|
||||
document.getElementById("import-file").click();
|
||||
});
|
||||
|
||||
document.getElementById("import-file").addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const btn = document.getElementById("import-btn");
|
||||
btn.textContent = "Importing...";
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const result = await browser.runtime.sendMessage({ type: "importSettings", data });
|
||||
if (result.ok) {
|
||||
btn.textContent = "Imported!";
|
||||
await Promise.all([loadVectors(), loadWhitelist(), loadContainers(), loadAutoPrune()]);
|
||||
} else {
|
||||
btn.textContent = "Error: " + (result.error || "Unknown");
|
||||
}
|
||||
} catch(err) {
|
||||
btn.textContent = "Invalid file";
|
||||
}
|
||||
e.target.value = "";
|
||||
setTimeout(() => { btn.textContent = "Import Settings"; }, 2000);
|
||||
});
|
||||
|
||||
// --- Init ---
|
||||
|
||||
async function init() {
|
||||
const manifest = browser.runtime.getManifest();
|
||||
document.getElementById("version").textContent = `v${manifest.version}`;
|
||||
await Promise.all([loadVectors(), loadWhitelist(), loadContainers()]);
|
||||
await Promise.all([loadVectors(), loadWhitelist(), loadContainers(), loadAutoPrune()]);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -200,6 +200,18 @@
|
||||
<div class="section-body" id="clienthints-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Gamepad API -->
|
||||
<div class="section" id="sec-gamepad">
|
||||
<div class="section-header"><span class="status-dot pending" id="dot-gamepad"></span><h2>Gamepad API</h2></div>
|
||||
<div class="section-body" id="gamepad-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- WebGL readPixels -->
|
||||
<div class="section" id="sec-readpixels">
|
||||
<div class="section-header"><span class="status-dot pending" id="dot-readpixels"></span><h2>WebGL readPixels</h2></div>
|
||||
<div class="section-body" id="readpixels-results"></div>
|
||||
</div>
|
||||
|
||||
<button class="copy-btn" onclick="copyReport()">Copy Full Report to Clipboard</button>
|
||||
|
||||
<canvas id="test-canvas" width="300" height="60"></canvas>
|
||||
@@ -981,6 +993,60 @@ async function testClientHints() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gamepad API ──
|
||||
function testGamepad() {
|
||||
const el = document.getElementById("gamepad-results");
|
||||
if (!navigator.getGamepads) {
|
||||
report.gamepad = { status: "API not available" };
|
||||
el.innerHTML = row("Status", "navigator.getGamepads not available", "");
|
||||
sectionStatus.gamepad = "warn";
|
||||
setDot("dot-gamepad", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const gamepads = navigator.getGamepads();
|
||||
const count = Array.from(gamepads).filter(g => g !== null).length;
|
||||
report.gamepad = { totalSlots: gamepads.length, connectedCount: count };
|
||||
el.innerHTML = row("Gamepad slots", gamepads.length) +
|
||||
row("Connected gamepads", count, count === 0 ? "spoofed" : "real");
|
||||
sectionStatus.gamepad = count === 0 ? "pass" : "warn";
|
||||
setDot("dot-gamepad", sectionStatus.gamepad);
|
||||
}
|
||||
|
||||
// ── WebGL readPixels ──
|
||||
async function testReadPixels() {
|
||||
const el = document.getElementById("readpixels-results");
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 64;
|
||||
c.height = 64;
|
||||
const gl = c.getContext("webgl");
|
||||
if (!gl) {
|
||||
report.readpixels = { status: "WebGL not available" };
|
||||
el.innerHTML = row("Status", "WebGL not available", "");
|
||||
sectionStatus.readpixels = "warn";
|
||||
setDot("dot-readpixels", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw something
|
||||
gl.clearColor(0.5, 0.3, 0.7, 1.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const pixels = new Uint8Array(64 * 64 * 4);
|
||||
gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
|
||||
// Hash the pixel data
|
||||
let sum = 0;
|
||||
for (let i = 0; i < pixels.length; i++) sum += pixels[i];
|
||||
const hash = await sha256Short(sum.toString());
|
||||
|
||||
report.readpixels = { pixelSum: sum, hash };
|
||||
el.innerHTML = row("readPixels hash", hash) +
|
||||
row("Pixel sum", sum);
|
||||
sectionStatus.readpixels = "pass";
|
||||
setDot("dot-readpixels", "pass");
|
||||
}
|
||||
|
||||
// ── Summary ──
|
||||
function updateSummary() {
|
||||
let pass = 0, fail = 0, warn = 0;
|
||||
@@ -1060,6 +1126,7 @@ async function runAll() {
|
||||
try { testPerf(); } catch(e) { console.error("perf test:", e); }
|
||||
try { testFontDOM(); } catch(e) { console.error("fontdom test:", e); }
|
||||
try { testDocFonts(); } catch(e) { console.error("docfonts test:", e); }
|
||||
try { testGamepad(); } catch(e) { console.error("gamepad test:", e); }
|
||||
|
||||
// Async tests — run in parallel with individual error handling
|
||||
await Promise.allSettled([
|
||||
@@ -1070,6 +1137,7 @@ async function runAll() {
|
||||
testHeaders(),
|
||||
testStorage(),
|
||||
testClientHints(),
|
||||
testReadPixels(),
|
||||
]);
|
||||
|
||||
updateSummary();
|
||||
|
||||
Reference in New Issue
Block a user