diff --git a/package-lock.json b/package-lock.json index 0242a24..9d4e74d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "graphiti-pubky-extension", "version": "1.0.0", "dependencies": { - "@synonymdev/pubky": "latest", + "@synonymdev/pubky": "^0.5.4", + "@types/dompurify": "^3.0.5", "dom-anchor-text-position": "^5.0.0", "dom-anchor-text-quote": "^4.0.2", + "dompurify": "^3.3.1", "pubky-app-specs": "^0.4.0", "qrcode": "^1.5.3", "react": "^18.3.1", @@ -1534,6 +1536,15 @@ "@types/har-format": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1614,6 +1625,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2771,6 +2788,15 @@ "integrity": "sha512-1strSwd201Gfhfkfsk77SX9xyJGzu12gqUo5Q0W3Njtj2QxcfQTwCDOynZ6npZ4ASUFRQq0asjYDRlFxYPKwTA==", "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3251,9 +3277,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index c9296d9..aa1a4fa 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "test:e2e:errors": "node run-e2e-with-error-capture.js" }, "dependencies": { - "@synonymdev/pubky": "latest", + "@synonymdev/pubky": "^0.5.4", + "@types/dompurify": "^3.0.5", "dom-anchor-text-position": "^5.0.0", "dom-anchor-text-quote": "^4.0.2", + "dompurify": "^3.3.1", "pubky-app-specs": "^0.4.0", "qrcode": "^1.5.3", "react": "^18.3.1", @@ -31,6 +33,7 @@ "react-image-crop": "^10.1.8" }, "devDependencies": { + "@playwright/test": "^1.40.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", "@types/chrome": "^0.0.268", @@ -47,7 +50,6 @@ "tailwindcss": "^3.4.4", "typescript": "^5.5.3", "vite": "^5.3.3", - "vitest": "^1.3.1", - "@playwright/test": "^1.40.0" + "vitest": "^1.3.1" } } diff --git a/src/content/AnnotationManager.ts b/src/content/AnnotationManager.ts index 1a34b5e..1f8e1ce 100644 --- a/src/content/AnnotationManager.ts +++ b/src/content/AnnotationManager.ts @@ -10,6 +10,7 @@ import { } from '../utils/validation'; import { ANNOTATION_CONSTANTS, MESSAGE_TYPES, TIMING_CONSTANTS, UI_CONSTANTS } from '../utils/constants'; import { isHTMLButtonElement } from '../utils/type-guards'; +import DOMPurify from 'dompurify'; /** * Annotation data structure @@ -460,12 +461,14 @@ export class AnnotationManager { const button = document.createElement('button'); button.className = 'pubky-annotation-button'; - button.innerHTML = ` + // Use DOMPurify to sanitize HTML (defense-in-depth, even though this is static) + const buttonHtml = ` Add Annotation `; + button.innerHTML = DOMPurify.sanitize(buttonHtml); // Position button to the right and slightly below the selection // Account for button width (approximately 140px) and add some padding @@ -572,7 +575,8 @@ export class AnnotationManager { modal.className = 'pubky-annotation-modal'; modal.onclick = (e) => e.stopPropagation(); - modal.innerHTML = ` + // Sanitize modal HTML with DOMPurify + const modalHtml = `

Add Annotation

"${this.escapeHtml(this.currentSelection.text)}"
@@ -585,6 +589,7 @@ export class AnnotationManager { `; + modal.innerHTML = DOMPurify.sanitize(modalHtml); const textarea = modal.querySelector('textarea')!; const cancelBtn = modal.querySelector('.cancel-btn')!; @@ -920,6 +925,7 @@ export class AnnotationManager { private handleHighlightClick(annotation: Annotation) { logger.info('ContentScript', 'Highlight clicked', { id: annotation.id }); + // Highlight the clicked annotation on the page document.querySelectorAll(`.${this.activeHighlightClass}`).forEach((el) => { el.classList.remove(this.activeHighlightClass); }); @@ -927,11 +933,21 @@ export class AnnotationManager { const highlight = document.querySelector(`[data-annotation-id="${annotation.id}"]`); if (highlight) { highlight.classList.add(this.activeHighlightClass); + // Scroll the highlight into view + highlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); } + // Send message to background to open sidepanel + // Must be synchronous to preserve user gesture context chrome.runtime.sendMessage({ - type: 'SHOW_ANNOTATION', + type: 'OPEN_SIDE_PANEL_FOR_ANNOTATION', annotationId: annotation.id, + }, () => { + if (chrome.runtime.lastError) { + logger.warn('ContentScript', 'Failed to send open sidepanel message', new Error(chrome.runtime.lastError.message)); + } else { + logger.info('ContentScript', 'Sidepanel open requested', { annotationId: annotation.id }); + } }); } diff --git a/src/content/DrawingManager.ts b/src/content/DrawingManager.ts index d854109..08eed77 100644 --- a/src/content/DrawingManager.ts +++ b/src/content/DrawingManager.ts @@ -2,6 +2,7 @@ import { contentLogger as logger } from './logger'; import { compressCanvas, getRecommendedQuality, formatBytes } from '../utils/image-compression'; import { DRAWING_CONSTANTS, DRAWING_UI_CONSTANTS, MESSAGE_TYPES, UI_CONSTANTS } from '../utils/constants'; import { isHTMLInputElement } from '../utils/type-guards'; +import DOMPurify from 'dompurify'; export class DrawingManager { private canvas: HTMLCanvasElement | null = null; @@ -202,7 +203,7 @@ export class DrawingManager { backdrop-filter: blur(10px); `; - this.toolbar.innerHTML = ` + const toolbarHtml = `
Graphiti Drawing
@@ -284,6 +285,7 @@ export class DrawingManager { ">Save
`; + this.toolbar.innerHTML = DOMPurify.sanitize(toolbarHtml); document.body.appendChild(this.toolbar); diff --git a/src/content/PubkyURLHandler.ts b/src/content/PubkyURLHandler.ts index 3a5e12a..bd0b341 100644 --- a/src/content/PubkyURLHandler.ts +++ b/src/content/PubkyURLHandler.ts @@ -1,4 +1,5 @@ import { contentLogger as logger } from './logger'; +import DOMPurify from 'dompurify'; export class PubkyURLHandler { constructor() { @@ -165,10 +166,12 @@ export class PubkyURLHandler { const normalizedUrl = url.replace(/^pubky:(?!\/\/)/, 'pubky://'); button.setAttribute('data-pubky-url', normalizedUrl); - button.innerHTML = ` + // Sanitize button HTML with DOMPurify (synchronous import) + const buttonHtml = ` 🔗 ${url.length > 30 ? url.substring(0, 30) + '...' : url} `; + button.innerHTML = DOMPurify.sanitize(buttonHtml); fragments.push(button); lastIndex = matchIndex + url.length; diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index 45a78c7..8ee1573 100644 --- a/src/offscreen/offscreen.ts +++ b/src/offscreen/offscreen.ts @@ -54,8 +54,8 @@ class OffscreenHandler { try { console.log('[Graphiti Offscreen] Initializing Pubky client...'); - const { Client } = await import('@synonymdev/pubky'); - this.client = new Client(); + const { getPubkyClientAsync } = await import('../utils/pubky-client-factory'); + this.client = await getPubkyClientAsync(); this.isInitialized = true; console.log('[Graphiti Offscreen] Pubky client initialized successfully'); @@ -287,7 +287,14 @@ class OffscreenHandler { const allAnnotations = await annotationStorage.getAllAnnotations(); for (const url in allAnnotations) { for (const annotation of allAnnotations[url]) { - if (!annotation.postUri && annotation.author) { + // Sync if: no postUri AND (has author OR we can assign current session as author) + if (!annotation.postUri) { + // If annotation was created while logged out, assign current session as author + if (!annotation.author || annotation.author === '') { + annotation.author = session.pubky; + await annotationStorage.saveAnnotation(annotation); + } + const result = await this.syncAnnotation({ url: annotation.url, selectedText: annotation.selectedText, diff --git a/src/profile/profile-renderer.ts b/src/profile/profile-renderer.ts index 1b972dc..260d0d2 100644 --- a/src/profile/profile-renderer.ts +++ b/src/profile/profile-renderer.ts @@ -4,6 +4,7 @@ */ import { imageHandler } from '../utils/image-handler'; +import DOMPurify from 'dompurify'; // Inline logger for profile renderer class ProfileRendererLogger { @@ -76,9 +77,9 @@ class ProfileRenderer { private async initializePubky() { try { - const { Client } = await import('@synonymdev/pubky'); - this.pubky = new Client(); - rendererLogger.info('Pubky Client initialized'); + const { getPubkyClientAsync } = await import('../utils/pubky-client-factory'); + this.pubky = await getPubkyClientAsync(); + rendererLogger.info('Pubky Client initialized via singleton'); } catch (error) { rendererLogger.error('Failed to initialize Pubky Client', error); throw new Error('Failed to initialize Pubky client'); @@ -255,7 +256,7 @@ class ProfileRenderer { } } - private showContent(html: string, title: string) { + private async showContent(html: string, title: string) { // Update document title document.title = title; @@ -263,8 +264,8 @@ class ProfileRenderer { this.loadingEl.classList.add('hidden'); this.errorEl.classList.add('hidden'); - // Show content - this.contentEl.innerHTML = html; + // Sanitize HTML from homeserver before displaying (critical security fix) + this.contentEl.innerHTML = DOMPurify.sanitize(html); this.contentEl.classList.remove('hidden'); } diff --git a/src/utils/auth-sdk.ts b/src/utils/auth-sdk.ts index 232d097..7fae3c4 100644 --- a/src/utils/auth-sdk.ts +++ b/src/utils/auth-sdk.ts @@ -1,6 +1,7 @@ import { logger } from './logger'; import { storage, Session } from './storage'; import { profileManager } from './profile-manager'; +import { getPubkyClientAsync } from './pubky-client-factory'; /** * Pubky authentication using official @synonymdev/pubky SDK @@ -25,7 +26,21 @@ class AuthManagerSDK { private currentAuthRequest: AuthRequest | null = null; private constructor() { - this.initializePubky(); + // Client will be initialized lazily via ensureClient() + } + + /** + * Validate capabilities format before use + */ + private validateCapabilities(capabilities: string): string { + if (!capabilities || typeof capabilities !== 'string') { + throw new Error('Capabilities must be a non-empty string'); + } + // Validate format - must start with /pub/ + if (!capabilities.startsWith('/pub/')) { + throw new Error('Capabilities must start with /pub/'); + } + return capabilities; } static getInstance(): AuthManagerSDK { @@ -36,28 +51,14 @@ class AuthManagerSDK { } /** - * Initialize Pubky client - */ - private async initializePubky() { - try { - // Dynamic import to handle the SDK - const { Client } = await import('@synonymdev/pubky'); - this.client = new Client(); - logger.info('AuthSDK', 'Pubky Client initialized'); - } catch (error) { - logger.error('AuthSDK', 'Failed to initialize Pubky Client', error as Error); - throw error; - } - } - - /** - * Ensure Client is initialized + * Ensure Client is initialized using singleton factory */ private async ensureClient(): Promise { if (!this.client) { - await this.initializePubky(); + this.client = await getPubkyClientAsync(); + logger.info('AuthSDK', 'Pubky Client initialized via singleton'); } - return this.client!; + return this.client; } /** @@ -69,8 +70,11 @@ class AuthManagerSDK { const client = await this.ensureClient(); - // Create auth request with capabilities - this.currentAuthRequest = client.authRequest(RELAY_URL, REQUIRED_CAPABILITIES); + // Validate capabilities before creating auth request + const validatedCapabilities = this.validateCapabilities(REQUIRED_CAPABILITIES); + + // Create auth request with validated capabilities + this.currentAuthRequest = client.authRequest(RELAY_URL, validatedCapabilities); // Get authorization URL (pubkyauth:// URL) const authUrl = this.currentAuthRequest.url(); diff --git a/src/utils/image-handler.ts b/src/utils/image-handler.ts index 896b6eb..9e0e2fd 100644 --- a/src/utils/image-handler.ts +++ b/src/utils/image-handler.ts @@ -24,8 +24,8 @@ export class ImageHandler { private async ensureClient(): Promise { if (!this.client) { - const { Client } = await import('@synonymdev/pubky'); - this.client = new Client(); + const { getPubkyClientAsync } = await import('./pubky-client-factory'); + this.client = await getPubkyClientAsync(); } return this.client; } diff --git a/src/utils/profile-manager.ts b/src/utils/profile-manager.ts index f63db0b..b864eed 100644 --- a/src/utils/profile-manager.ts +++ b/src/utils/profile-manager.ts @@ -28,12 +28,12 @@ export class ProfileManager { } /** - * Initialize the Pubky client + * Initialize the Pubky client using singleton factory */ private async ensureClient(): Promise { if (!this.client) { - const { Client } = await import('@synonymdev/pubky'); - this.client = new Client(); + const { getPubkyClientAsync } = await import('./pubky-client-factory'); + this.client = await getPubkyClientAsync(); } return this.client; } diff --git a/src/utils/pubky-api-sdk.ts b/src/utils/pubky-api-sdk.ts index a3758b1..a7253ce 100644 --- a/src/utils/pubky-api-sdk.ts +++ b/src/utils/pubky-api-sdk.ts @@ -49,24 +49,15 @@ class PubkyAPISDK { } try { - // Dynamic import with error handling - the package itself might access window - const pubkyModule = await import('@synonymdev/pubky').catch((importError) => { - // If import fails due to window issues, wrap the error - if (importError.message?.includes('window') || !this.isClientContextAvailable()) { - throw new Error('Pubky Client requires window object (not available in service workers)'); - } - throw importError; - }); - - const { Client } = pubkyModule; - - // Check window again before creating Client (it might access window in constructor) + // Check window before getting Client (it might access window in constructor) if (!this.isClientContextAvailable()) { throw new Error('Pubky Client requires window object (not available in service workers)'); } - this.pubky = new Client(); - logger.info('PubkyAPISDK', 'Pubky Client initialized'); + // Use singleton factory instead of creating new instance + const { getPubkyClientAsync } = await import('./pubky-client-factory'); + this.pubky = await getPubkyClientAsync(); + logger.info('PubkyAPISDK', 'Pubky Client initialized via singleton'); } catch (error) { // Use console directly to avoid circular logger issues console.error('[PubkyAPISDK] Failed to initialize Pubky Client', error); diff --git a/src/utils/pubky-client-factory.ts b/src/utils/pubky-client-factory.ts new file mode 100644 index 0000000..464f93b --- /dev/null +++ b/src/utils/pubky-client-factory.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Pubky Client singleton factory + * + * Provides a single shared instance of the Pubky Client across the extension + * to prevent memory leaks and ensure consistent state. + */ + +import { logger } from './logger'; + +type Client = any; + +let clientInstance: Client | null = null; + +let initializationPromise: Promise | null = null; + +/** + * Get or create the singleton Pubky Client instance + * Note: This is synchronous but may return null if not yet initialized + * Use getPubkyClientAsync() for guaranteed initialization + * @returns The shared Pubky Client instance or null if not initialized + */ +export function getPubkyClient(): Client | null { + return clientInstance; +} + +/** + * Initialize the Pubky Client singleton + * Call this before using getPubkyClient() if you need synchronous access + */ +export async function initializePubkyClient(): Promise { + if (clientInstance) { + return clientInstance; + } + + if (initializationPromise) { + return initializationPromise; + } + + initializationPromise = (async () => { + const { Client } = await import('@synonymdev/pubky'); + clientInstance = new Client(); + logger.info('PubkyClientFactory', 'Pubky Client singleton initialized'); + return clientInstance; + })(); + + return initializationPromise; +} + +/** + * Get or create the singleton Pubky Client instance (async version) + * Use this when you need to ensure the SDK is fully loaded + * @returns Promise that resolves to the shared Pubky Client instance + */ +export async function getPubkyClientAsync(): Promise { + return initializePubkyClient(); +} + +/** + * Reset the singleton instance (useful for testing) + */ +export function resetPubkyClient(): void { + clientInstance = null; + initializationPromise = null; +} +