mirror of
https://github.com/zadam/trilium.git
synced 2025-12-05 23:14:24 +01:00
feat: add HTML sanitization module using DOMPurify
This commit is contained in:
parent
c707af2663
commit
022c697a2b
313
apps/web-clipper-manifestv3/src/shared/html-sanitizer.ts
Normal file
313
apps/web-clipper-manifestv3/src/shared/html-sanitizer.ts
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* HTML Sanitization module using DOMPurify
|
||||||
|
*
|
||||||
|
* Implements the security recommendations from Mozilla Readability documentation
|
||||||
|
* to sanitize HTML content and prevent script injection attacks.
|
||||||
|
*
|
||||||
|
* This is Phase 3 of the processing pipeline (after Readability and Cheerio).
|
||||||
|
*
|
||||||
|
* Note: This module should be used in contexts where the DOM is available (content scripts).
|
||||||
|
* For background scripts, the sanitization happens in the content script before sending data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import type { Config } from 'dompurify';
|
||||||
|
import { Logger } from './utils';
|
||||||
|
|
||||||
|
const logger = Logger.create('HTMLSanitizer', 'content');
|
||||||
|
|
||||||
|
export interface SanitizeOptions {
|
||||||
|
/**
|
||||||
|
* Allow images in the sanitized HTML
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
allowImages?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow external links in the sanitized HTML
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
allowLinks?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow data URIs in image sources
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
allowDataUri?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom allowed tags (extends defaults)
|
||||||
|
*/
|
||||||
|
extraAllowedTags?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom allowed attributes (extends defaults)
|
||||||
|
*/
|
||||||
|
extraAllowedAttrs?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom configuration for DOMPurify
|
||||||
|
*/
|
||||||
|
customConfig?: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration for DOMPurify
|
||||||
|
* Designed for Trilium note content (HTML notes and CKEditor compatibility)
|
||||||
|
*/
|
||||||
|
const DEFAULT_CONFIG: Config = {
|
||||||
|
// Allow safe HTML tags commonly used in notes
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
// Text formatting
|
||||||
|
'p', 'br', 'span', 'div',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup',
|
||||||
|
'mark', 'small', 'del', 'ins',
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
|
||||||
|
// Links and media
|
||||||
|
'a', 'img', 'figure', 'figcaption',
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'col', 'colgroup',
|
||||||
|
|
||||||
|
// Code
|
||||||
|
'code', 'pre', 'kbd', 'samp', 'var',
|
||||||
|
|
||||||
|
// Quotes and citations
|
||||||
|
'blockquote', 'q', 'cite',
|
||||||
|
|
||||||
|
// Structural
|
||||||
|
'article', 'section', 'header', 'footer', 'main', 'aside', 'nav',
|
||||||
|
'details', 'summary',
|
||||||
|
|
||||||
|
// Definitions
|
||||||
|
'dl', 'dt', 'dd',
|
||||||
|
|
||||||
|
// Other
|
||||||
|
'hr', 'time', 'abbr', 'address'
|
||||||
|
],
|
||||||
|
|
||||||
|
// Allow safe attributes
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href', 'src', 'alt', 'title', 'class', 'id',
|
||||||
|
'width', 'height', 'style',
|
||||||
|
'target', 'rel',
|
||||||
|
'colspan', 'rowspan',
|
||||||
|
'datetime',
|
||||||
|
'start', 'reversed', 'type',
|
||||||
|
'data-*' // Allow data attributes for Trilium features
|
||||||
|
],
|
||||||
|
|
||||||
|
// Allow data URIs for images (base64 encoded images)
|
||||||
|
ALLOW_DATA_ATTR: true,
|
||||||
|
|
||||||
|
// Allow safe URI schemes
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
|
|
||||||
|
// Keep safe HTML and remove dangerous content
|
||||||
|
KEEP_CONTENT: true,
|
||||||
|
|
||||||
|
// Return a DOM object instead of string (better for processing)
|
||||||
|
RETURN_DOM: false,
|
||||||
|
RETURN_DOM_FRAGMENT: false,
|
||||||
|
|
||||||
|
// Force body context
|
||||||
|
FORCE_BODY: false,
|
||||||
|
|
||||||
|
// Sanitize in place
|
||||||
|
IN_PLACE: false,
|
||||||
|
|
||||||
|
// Safe for HTML context
|
||||||
|
SAFE_FOR_TEMPLATES: true,
|
||||||
|
|
||||||
|
// Allow style attributes (Trilium uses inline styles)
|
||||||
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||||
|
|
||||||
|
// Whole document mode
|
||||||
|
WHOLE_DOCUMENT: false
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize HTML content using DOMPurify
|
||||||
|
* This implements the security layer recommended by Mozilla Readability
|
||||||
|
*
|
||||||
|
* @param html - Raw HTML string to sanitize
|
||||||
|
* @param options - Sanitization options
|
||||||
|
* @returns Sanitized HTML string safe for insertion into Trilium
|
||||||
|
*/
|
||||||
|
export function sanitizeHtml(html: string, options: SanitizeOptions = {}): string {
|
||||||
|
const {
|
||||||
|
allowImages = true,
|
||||||
|
allowLinks = true,
|
||||||
|
allowDataUri = true,
|
||||||
|
extraAllowedTags = [],
|
||||||
|
extraAllowedAttrs = [],
|
||||||
|
customConfig = {}
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build configuration
|
||||||
|
const config: Config = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...customConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adjust allowed tags based on options
|
||||||
|
if (!allowImages && config.ALLOWED_TAGS) {
|
||||||
|
config.ALLOWED_TAGS = config.ALLOWED_TAGS.filter((tag: string) =>
|
||||||
|
tag !== 'img' && tag !== 'figure' && tag !== 'figcaption'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowLinks && config.ALLOWED_TAGS) {
|
||||||
|
config.ALLOWED_TAGS = config.ALLOWED_TAGS.filter((tag: string) => tag !== 'a');
|
||||||
|
if (config.ALLOWED_ATTR) {
|
||||||
|
config.ALLOWED_ATTR = config.ALLOWED_ATTR.filter((attr: string) =>
|
||||||
|
attr !== 'href' && attr !== 'target' && attr !== 'rel'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowDataUri) {
|
||||||
|
config.ALLOW_DATA_ATTR = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra allowed tags
|
||||||
|
if (extraAllowedTags.length > 0 && config.ALLOWED_TAGS) {
|
||||||
|
config.ALLOWED_TAGS = [...config.ALLOWED_TAGS, ...extraAllowedTags];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra allowed attributes
|
||||||
|
if (extraAllowedAttrs.length > 0 && config.ALLOWED_ATTR) {
|
||||||
|
config.ALLOWED_ATTR = [...config.ALLOWED_ATTR, ...extraAllowedAttrs];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track what DOMPurify removes via hooks
|
||||||
|
const removedElements: Array<{ tag: string; reason?: string }> = [];
|
||||||
|
const removedAttributes: Array<{ element: string; attr: string }> = [];
|
||||||
|
|
||||||
|
// Add hooks to track DOMPurify's actions
|
||||||
|
DOMPurify.addHook('uponSanitizeElement', (_node, data) => {
|
||||||
|
if (data.allowedTags && !data.allowedTags[data.tagName]) {
|
||||||
|
removedElements.push({
|
||||||
|
tag: data.tagName,
|
||||||
|
reason: 'not in allowed tags'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||||
|
if (data.attrName && data.keepAttr === false) {
|
||||||
|
removedAttributes.push({
|
||||||
|
element: node.nodeName.toLowerCase(),
|
||||||
|
attr: data.attrName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanitize the HTML using isomorphic-dompurify
|
||||||
|
// Works in both browser and service worker contexts
|
||||||
|
const cleanHtml = DOMPurify.sanitize(html, config) as string;
|
||||||
|
|
||||||
|
// Remove hooks after sanitization
|
||||||
|
DOMPurify.removeAllHooks();
|
||||||
|
|
||||||
|
// Aggregate stats
|
||||||
|
const tagCounts: Record<string, number> = {};
|
||||||
|
removedElements.forEach(({ tag }) => {
|
||||||
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const attrCounts: Record<string, number> = {};
|
||||||
|
removedAttributes.forEach(({ attr }) => {
|
||||||
|
attrCounts[attr] = (attrCounts[attr] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('DOMPurify sanitization complete', {
|
||||||
|
originalLength: html.length,
|
||||||
|
cleanLength: cleanHtml.length,
|
||||||
|
bytesRemoved: html.length - cleanHtml.length,
|
||||||
|
reductionPercent: Math.round(((html.length - cleanHtml.length) / html.length) * 100),
|
||||||
|
elementsRemoved: removedElements.length,
|
||||||
|
attributesRemoved: removedAttributes.length,
|
||||||
|
removedTags: Object.keys(tagCounts).length > 0 ? tagCounts : undefined,
|
||||||
|
removedAttrs: Object.keys(attrCounts).length > 0 ? attrCounts : undefined,
|
||||||
|
config: {
|
||||||
|
allowImages,
|
||||||
|
allowLinks,
|
||||||
|
allowDataUri,
|
||||||
|
extraAllowedTags: extraAllowedTags.length > 0 ? extraAllowedTags : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleanHtml;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to sanitize HTML', error as Error, {
|
||||||
|
htmlLength: html.length,
|
||||||
|
options
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return empty string on error (fail safe)
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick sanitization for simple text content
|
||||||
|
* Strips all HTML tags except basic formatting
|
||||||
|
*/
|
||||||
|
export function sanitizeSimpleText(html: string): string {
|
||||||
|
return sanitizeHtml(html, {
|
||||||
|
allowImages: false,
|
||||||
|
allowLinks: true,
|
||||||
|
customConfig: {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'b', 'i', 'u', 'a', 'code', 'pre']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggressive sanitization - strips almost everything
|
||||||
|
* Use for untrusted or potentially dangerous content
|
||||||
|
*/
|
||||||
|
export function sanitizeAggressive(html: string): string {
|
||||||
|
return sanitizeHtml(html, {
|
||||||
|
allowImages: false,
|
||||||
|
allowLinks: false,
|
||||||
|
customConfig: {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize URLs to prevent javascript: and data: injection
|
||||||
|
*/
|
||||||
|
export function sanitizeUrl(url: string): string {
|
||||||
|
const cleaned = DOMPurify.sanitize(url, {
|
||||||
|
ALLOWED_TAGS: [],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
}) as string;
|
||||||
|
|
||||||
|
// Block dangerous protocols
|
||||||
|
const dangerousProtocols = ['javascript:', 'data:', 'vbscript:', 'file:'];
|
||||||
|
const lowerUrl = cleaned.toLowerCase().trim();
|
||||||
|
|
||||||
|
for (const protocol of dangerousProtocols) {
|
||||||
|
if (lowerUrl.startsWith(protocol)) {
|
||||||
|
logger.warn('Blocked dangerous URL protocol', { url, protocol });
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}export const HTMLSanitizer = {
|
||||||
|
sanitize: sanitizeHtml,
|
||||||
|
sanitizeSimpleText,
|
||||||
|
sanitizeAggressive,
|
||||||
|
sanitizeUrl
|
||||||
|
};
|
||||||
663
apps/web-clipper-manifestv3/src/shared/trilium-server.ts
Normal file
663
apps/web-clipper-manifestv3/src/shared/trilium-server.ts
Normal file
@ -0,0 +1,663 @@
|
|||||||
|
/**
|
||||||
|
* Modern Trilium Server Communication Layer for Manifest V3
|
||||||
|
* Handles connection discovery, authentication, and API communication
|
||||||
|
* with both desktop client and server instances
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from './utils';
|
||||||
|
import { TriliumResponse, ClipData } from './types';
|
||||||
|
|
||||||
|
const logger = Logger.create('TriliumServer', 'background');
|
||||||
|
|
||||||
|
// Protocol version for compatibility checking
|
||||||
|
const PROTOCOL_VERSION_MAJOR = 1;
|
||||||
|
|
||||||
|
export type ConnectionStatus =
|
||||||
|
| 'searching'
|
||||||
|
| 'found-desktop'
|
||||||
|
| 'found-server'
|
||||||
|
| 'not-found'
|
||||||
|
| 'version-mismatch';
|
||||||
|
|
||||||
|
export interface TriliumSearchResult {
|
||||||
|
status: ConnectionStatus;
|
||||||
|
url?: string;
|
||||||
|
port?: number;
|
||||||
|
token?: string;
|
||||||
|
extensionMajor?: number;
|
||||||
|
triliumMajor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriliumHandshakeResponse {
|
||||||
|
appName: string;
|
||||||
|
protocolVersion: string;
|
||||||
|
appVersion?: string;
|
||||||
|
clipperProtocolVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriliumConnectionConfig {
|
||||||
|
serverUrl?: string;
|
||||||
|
authToken?: string;
|
||||||
|
desktopPort?: string;
|
||||||
|
enableServer?: boolean;
|
||||||
|
enableDesktop?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modern Trilium Server Facade
|
||||||
|
* Provides unified interface for communicating with Trilium instances
|
||||||
|
*/
|
||||||
|
export class TriliumServerFacade {
|
||||||
|
private triliumSearch: TriliumSearchResult = { status: 'not-found' };
|
||||||
|
private searchPromise: Promise<void> | null = null;
|
||||||
|
private listeners: Array<(result: TriliumSearchResult) => void> = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize(): Promise<void> {
|
||||||
|
logger.info('Initializing Trilium server facade');
|
||||||
|
|
||||||
|
// Start initial search
|
||||||
|
await this.triggerSearchForTrilium();
|
||||||
|
|
||||||
|
// Set up periodic connection monitoring
|
||||||
|
setInterval(() => {
|
||||||
|
this.triggerSearchForTrilium().catch(error => {
|
||||||
|
logger.error('Periodic connection check failed', error);
|
||||||
|
});
|
||||||
|
}, 60 * 1000); // Check every minute
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current connection status
|
||||||
|
*/
|
||||||
|
public getConnectionStatus(): TriliumSearchResult {
|
||||||
|
return { ...this.triliumSearch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add listener for connection status changes
|
||||||
|
*/
|
||||||
|
public addConnectionListener(listener: (result: TriliumSearchResult) => void): () => void {
|
||||||
|
this.listeners.push(listener);
|
||||||
|
|
||||||
|
// Send current status immediately
|
||||||
|
listener(this.getConnectionStatus());
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
const index = this.listeners.indexOf(listener);
|
||||||
|
if (index > -1) {
|
||||||
|
this.listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger search for Trilium connections
|
||||||
|
*/
|
||||||
|
public async triggerSearchForTrilium(): Promise<void> {
|
||||||
|
// Prevent multiple simultaneous searches
|
||||||
|
if (this.searchPromise) {
|
||||||
|
return this.searchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchPromise = this.performTriliumSearch();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.searchPromise;
|
||||||
|
} finally {
|
||||||
|
this.searchPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performTriliumSearch(): Promise<void> {
|
||||||
|
this.setTriliumSearch({ status: 'searching' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get connection configuration
|
||||||
|
const config = await this.getConnectionConfig();
|
||||||
|
|
||||||
|
// Try desktop client first (if enabled)
|
||||||
|
if (config.enableDesktop !== false) { // Default to true if not specified
|
||||||
|
const desktopResult = await this.tryDesktopConnection(config.desktopPort);
|
||||||
|
if (desktopResult) {
|
||||||
|
return; // Success, exit early
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try server connection (if enabled and configured)
|
||||||
|
if (config.enableServer && config.serverUrl && config.authToken) {
|
||||||
|
const serverResult = await this.tryServerConnection(config.serverUrl, config.authToken);
|
||||||
|
if (serverResult) {
|
||||||
|
return; // Success, exit early
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, no connections were successful
|
||||||
|
this.setTriliumSearch({ status: 'not-found' });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Connection search failed', error as Error);
|
||||||
|
this.setTriliumSearch({ status: 'not-found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryDesktopConnection(configuredPort?: string): Promise<boolean> {
|
||||||
|
const port = configuredPort ? parseInt(configuredPort) : this.getDefaultDesktopPort();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug('Trying desktop connection', { port });
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/api/clipper/handshake`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TriliumHandshakeResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.appName === 'trilium') {
|
||||||
|
this.setTriliumSearchWithVersionCheck(data, {
|
||||||
|
status: 'found-desktop',
|
||||||
|
port: port,
|
||||||
|
url: `http://127.0.0.1:${port}`
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Desktop connection failed', error, { port });
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryServerConnection(serverUrl: string, authToken: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
logger.debug('Trying server connection', { serverUrl });
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(`${serverUrl}/api/clipper/handshake`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': authToken
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TriliumHandshakeResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.appName === 'trilium') {
|
||||||
|
this.setTriliumSearchWithVersionCheck(data, {
|
||||||
|
status: 'found-server',
|
||||||
|
url: serverUrl,
|
||||||
|
token: authToken
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Server connection failed', error, { serverUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setTriliumSearch(result: TriliumSearchResult): void {
|
||||||
|
this.triliumSearch = { ...result };
|
||||||
|
|
||||||
|
// Notify all listeners
|
||||||
|
this.listeners.forEach(listener => {
|
||||||
|
try {
|
||||||
|
listener(this.getConnectionStatus());
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in connection listener', error as Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Connection status updated', { status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
private setTriliumSearchWithVersionCheck(handshake: TriliumHandshakeResponse, result: TriliumSearchResult): void {
|
||||||
|
const [major] = handshake.protocolVersion.split('.').map(chunk => parseInt(chunk));
|
||||||
|
|
||||||
|
if (major !== PROTOCOL_VERSION_MAJOR) {
|
||||||
|
this.setTriliumSearch({
|
||||||
|
status: 'version-mismatch',
|
||||||
|
extensionMajor: PROTOCOL_VERSION_MAJOR,
|
||||||
|
triliumMajor: major
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setTriliumSearch(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getConnectionConfig(): Promise<TriliumConnectionConfig> {
|
||||||
|
try {
|
||||||
|
const data = await chrome.storage.sync.get([
|
||||||
|
'triliumServerUrl',
|
||||||
|
'authToken',
|
||||||
|
'triliumDesktopPort',
|
||||||
|
'enableServer',
|
||||||
|
'enableDesktop'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverUrl: data.triliumServerUrl,
|
||||||
|
authToken: data.authToken,
|
||||||
|
desktopPort: data.triliumDesktopPort,
|
||||||
|
enableServer: data.enableServer,
|
||||||
|
enableDesktop: data.enableDesktop
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get connection config', error as Error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultDesktopPort(): number {
|
||||||
|
// Check if this is a development environment
|
||||||
|
const isDev = chrome.runtime.getManifest().name?.endsWith('(dev)');
|
||||||
|
return isDev ? 37740 : 37840;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for Trilium connection to be established
|
||||||
|
*/
|
||||||
|
public async waitForTriliumConnection(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const checkStatus = () => {
|
||||||
|
if (this.triliumSearch.status === 'searching') {
|
||||||
|
setTimeout(checkStatus, 500);
|
||||||
|
} else if (this.triliumSearch.status === 'not-found' || this.triliumSearch.status === 'version-mismatch') {
|
||||||
|
reject(new Error(`Trilium connection not available: ${this.triliumSearch.status}`));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call Trilium API endpoint
|
||||||
|
*/
|
||||||
|
public async callService(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure we have a connection
|
||||||
|
await this.waitForTriliumConnection();
|
||||||
|
|
||||||
|
// Add authentication if available
|
||||||
|
if (this.triliumSearch.token) {
|
||||||
|
(fetchOptions.headers as Record<string, string>)['Authorization'] = this.triliumSearch.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add trilium-specific headers
|
||||||
|
(fetchOptions.headers as Record<string, string>)['trilium-local-now-datetime'] = this.getLocalNowDateTime();
|
||||||
|
|
||||||
|
const url = `${this.triliumSearch.url}/api/clipper/${path}`;
|
||||||
|
|
||||||
|
logger.debug('Making API request', { method, url, path });
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(url, fetchOptions, 30000);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Trilium API call failed', error as Error, { method, path });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new note in Trilium
|
||||||
|
*/
|
||||||
|
public async createNote(
|
||||||
|
clipData: ClipData,
|
||||||
|
forceNew = false,
|
||||||
|
options?: { type?: string; mime?: string }
|
||||||
|
): Promise<TriliumResponse> {
|
||||||
|
try {
|
||||||
|
logger.info('Creating note in Trilium', {
|
||||||
|
title: clipData.title,
|
||||||
|
type: clipData.type,
|
||||||
|
contentLength: clipData.content?.length || 0,
|
||||||
|
url: clipData.url,
|
||||||
|
forceNew,
|
||||||
|
noteType: options?.type,
|
||||||
|
mime: options?.mime
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server expects pageUrl, clipType, and other fields at top level
|
||||||
|
const noteData = {
|
||||||
|
title: clipData.title || 'Untitled Clip',
|
||||||
|
content: clipData.content || '',
|
||||||
|
pageUrl: clipData.url || '', // Top-level field - used for duplicate detection
|
||||||
|
clipType: clipData.type || 'unknown', // Top-level field - used for note categorization
|
||||||
|
images: clipData.images || [], // Images to process
|
||||||
|
forceNew, // Pass to server to force new note even if URL exists
|
||||||
|
type: options?.type, // Optional note type (e.g., 'code' for markdown)
|
||||||
|
mime: options?.mime, // Optional MIME type (e.g., 'text/markdown')
|
||||||
|
labels: {
|
||||||
|
// Additional labels can go here if needed
|
||||||
|
clipDate: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('Sending note data to server', {
|
||||||
|
pageUrl: noteData.pageUrl,
|
||||||
|
clipType: noteData.clipType,
|
||||||
|
hasImages: noteData.images.length > 0,
|
||||||
|
noteType: noteData.type,
|
||||||
|
mime: noteData.mime
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.callService('POST', 'clippings', noteData) as { noteId: string };
|
||||||
|
|
||||||
|
logger.info('Note created successfully', { noteId: result.noteId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
noteId: result.noteId
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create note', error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a child note under an existing parent note
|
||||||
|
*/
|
||||||
|
public async createChildNote(
|
||||||
|
parentNoteId: string,
|
||||||
|
noteData: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
type?: string;
|
||||||
|
url?: string;
|
||||||
|
attributes?: Array<{ type: string; name: string; value: string }>;
|
||||||
|
}
|
||||||
|
): Promise<TriliumResponse> {
|
||||||
|
try {
|
||||||
|
logger.info('Creating child note', {
|
||||||
|
parentNoteId,
|
||||||
|
title: noteData.title,
|
||||||
|
contentLength: noteData.content.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const childNoteData = {
|
||||||
|
title: noteData.title,
|
||||||
|
content: noteData.content,
|
||||||
|
type: 'code', // Markdown notes are typically 'code' type
|
||||||
|
mime: 'text/markdown',
|
||||||
|
attributes: noteData.attributes || []
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.callService(
|
||||||
|
'POST',
|
||||||
|
`notes/${parentNoteId}/children`,
|
||||||
|
childNoteData
|
||||||
|
) as { note: { noteId: string } };
|
||||||
|
|
||||||
|
logger.info('Child note created successfully', {
|
||||||
|
childNoteId: result.note.noteId,
|
||||||
|
parentNoteId
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
noteId: result.note.noteId
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create child note', error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append content to an existing note
|
||||||
|
*/
|
||||||
|
public async appendToNote(noteId: string, clipData: ClipData): Promise<TriliumResponse> {
|
||||||
|
try {
|
||||||
|
logger.info('Appending to existing note', {
|
||||||
|
noteId,
|
||||||
|
contentLength: clipData.content?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const appendData = {
|
||||||
|
content: clipData.content || '',
|
||||||
|
images: clipData.images || [],
|
||||||
|
clipType: clipData.type || 'unknown',
|
||||||
|
clipDate: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.callService('PUT', `clippings/${noteId}/append`, appendData);
|
||||||
|
|
||||||
|
logger.info('Content appended successfully', { noteId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
noteId
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to append to note', error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a note exists for the given URL
|
||||||
|
*/
|
||||||
|
public async checkForExistingNote(url: string): Promise<{
|
||||||
|
exists: boolean;
|
||||||
|
noteId?: string;
|
||||||
|
title?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
const result = await this.callService('GET', `notes-by-url/${encodedUrl}`) as { noteId: string | null };
|
||||||
|
|
||||||
|
if (result.noteId) {
|
||||||
|
logger.info('Found existing note for URL', { url, noteId: result.noteId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
noteId: result.noteId,
|
||||||
|
title: 'Existing clipping', // Title will be fetched by popup if needed
|
||||||
|
createdAt: new Date().toISOString() // API doesn't return this currently
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exists: false };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check for existing note', error as Error);
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a note in Trilium
|
||||||
|
* Sends a request to open the note in the Trilium app
|
||||||
|
*/
|
||||||
|
public async openNote(noteId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info('Opening note in Trilium', { noteId });
|
||||||
|
|
||||||
|
await this.callService('GET', `open/${noteId}`);
|
||||||
|
|
||||||
|
logger.info('Note open request sent successfully', { noteId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to open note in Trilium', error as Error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Trilium instance using the same endpoints as automatic discovery
|
||||||
|
* This ensures consistency between background monitoring and manual testing
|
||||||
|
*/
|
||||||
|
public async testConnection(serverUrl?: string, authToken?: string, desktopPort?: string): Promise<{
|
||||||
|
server?: { connected: boolean; version?: string; error?: string };
|
||||||
|
desktop?: { connected: boolean; version?: string; error?: string };
|
||||||
|
}> {
|
||||||
|
const results: {
|
||||||
|
server?: { connected: boolean; version?: string; error?: string };
|
||||||
|
desktop?: { connected: boolean; version?: string; error?: string };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// Test server if provided - use the same clipper handshake endpoint as automatic discovery
|
||||||
|
if (serverUrl) {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Accept': 'application/json' };
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(`${serverUrl}/api/clipper/handshake`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: TriliumHandshakeResponse = await response.json();
|
||||||
|
if (data.appName === 'trilium') {
|
||||||
|
results.server = {
|
||||||
|
connected: true,
|
||||||
|
version: data.appVersion || 'Unknown'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
results.server = {
|
||||||
|
connected: false,
|
||||||
|
error: 'Invalid response - not a Trilium instance'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.server = {
|
||||||
|
connected: false,
|
||||||
|
error: `HTTP ${response.status}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.server = {
|
||||||
|
connected: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Connection failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test desktop client - use the same clipper handshake endpoint as automatic discovery
|
||||||
|
if (desktopPort || !serverUrl) { // Test desktop by default if no server specified
|
||||||
|
const port = desktopPort ? parseInt(desktopPort) : this.getDefaultDesktopPort();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/api/clipper/handshake`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: TriliumHandshakeResponse = await response.json();
|
||||||
|
if (data.appName === 'trilium') {
|
||||||
|
results.desktop = {
|
||||||
|
connected: true,
|
||||||
|
version: data.appVersion || 'Unknown'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
results.desktop = {
|
||||||
|
connected: false,
|
||||||
|
error: 'Invalid response - not a Trilium instance'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.desktop = {
|
||||||
|
connected: false,
|
||||||
|
error: `HTTP ${response.status}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.desktop = {
|
||||||
|
connected: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Connection failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} private getLocalNowDateTime(): string {
|
||||||
|
const date = new Date();
|
||||||
|
const offset = date.getTimezoneOffset();
|
||||||
|
const absOffset = Math.abs(offset);
|
||||||
|
|
||||||
|
return (
|
||||||
|
new Date(date.getTime() - offset * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.substr(0, 23)
|
||||||
|
.replace('T', ' ') +
|
||||||
|
(offset > 0 ? '-' : '+') +
|
||||||
|
Math.floor(absOffset / 60).toString().padStart(2, '0') + ':' +
|
||||||
|
(absOffset % 60).toString().padStart(2, '0')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const triliumServerFacade = new TriliumServerFacade();
|
||||||
Loading…
x
Reference in New Issue
Block a user