Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# DedPaste Environment Configuration

# API Configuration (optional - defaults to public instance)
DEDPASTE_API_URL=https://paste.d3d.dev
Empty file added .new
Empty file.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ You can configure the CLI using environment variables:
export DEDPASTE_API_URL="https://your-worker.example.com"
```


## 🎨 UI Development

The web interface uses **Material-UI (MUI)** design system for a modern, responsive experience. The UI features:
Expand Down
312 changes: 312 additions & 0 deletions cli/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import Mixpanel from "mixpanel";
import { v4 as uuidv4 } from "uuid";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";

interface AnalyticsConfig {
enabled: boolean;
userId: string;
optOutDate?: string;
firstSeen?: string;
}

interface EventProperties {
[key: string]: any;
}

interface QueuedEvent {
name: string;
properties: EventProperties;
timestamp: Date;
}

class AnalyticsService {
private static instance: AnalyticsService;
private mixpanel: Mixpanel.Mixpanel | null = null;
private config: AnalyticsConfig;
private configPath: string;
private eventQueue: QueuedEvent[] = [];
private isInitialized = false;
private flushTimer: NodeJS.Timeout | null = null;

// Privacy-focused configuration
private readonly BATCH_SIZE = 10;
private readonly FLUSH_INTERVAL = 30000; // 30 seconds
private readonly CONFIG_DIR = ".dedpaste";
private readonly CONFIG_FILE = "analytics.json";

// Mixpanel project token
private readonly PROJECT_TOKEN = "9c4a09e9631e9675165a65a03c54dc6e";

private constructor() {
this.configPath = path.join(os.homedir(), this.CONFIG_DIR, this.CONFIG_FILE);
this.config = this.loadConfig();
Comment thread
anoncam marked this conversation as resolved.
this.initialize();
}

public static getInstance(): AnalyticsService {
if (!AnalyticsService.instance) {
AnalyticsService.instance = new AnalyticsService();
}
return AnalyticsService.instance;
}

private loadConfig(): AnalyticsConfig {
try {
if (fs.existsSync(this.configPath)) {
const data = fs.readFileSync(this.configPath, "utf8");
return JSON.parse(data);
}
} catch (error) {
// Silently handle errors - analytics should never break the app
}

// Default config with analytics enabled
return {
enabled: true,
Comment on lines +66 to +68

Copilot AI Sep 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default analytics enablement without user consent violates privacy best practices. Users should explicitly opt-in to data collection.

Suggested change
// Default config with analytics enabled
return {
enabled: true,
// Default config with analytics disabled until user opts in
return {
enabled: false,

Copilot uses AI. Check for mistakes.
userId: this.generateAnonymousId(),
firstSeen: new Date().toISOString(),
};
}

private saveConfig(): void {
try {
const dir = path.dirname(this.configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
} catch (error) {
// Silently handle errors
}
}

private generateAnonymousId(): string {
// Generate a truly anonymous ID that cannot be traced back to the user
return uuidv4();
}

private initialize(): void {
// Always enable analytics
this.config.enabled = true;
Comment on lines +92 to +93

Copilot AI Sep 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Analytics are forcibly enabled without user consent. This violates privacy best practices and may conflict with GDPR and other privacy regulations that require explicit user consent for data collection.

Suggested change
// Always enable analytics
this.config.enabled = true;
// Only initialize analytics if enabled by user consent
if (!this.config.enabled) {
return;
}

Copilot uses AI. Check for mistakes.

if (!this.PROJECT_TOKEN) {
return; // No token available
}

try {
this.mixpanel = Mixpanel.init(this.PROJECT_TOKEN, {
// Use EU servers for better privacy compliance
host: process.env.DEDPASTE_MIXPANEL_HOST || "api.mixpanel.com",
protocol: "https",
// Note: Batching handled internally by the SDK
});

this.isInitialized = true;
this.startFlushTimer();

// Set user properties
this.updateUserProperties();
} catch (error) {
this.isInitialized = false;
// Silently handle initialization errors
}
}

private updateUserProperties(): void {
if (!this.mixpanel || !this.config.enabled) return;

try {
this.mixpanel.people.set(this.config.userId, {
$first_seen: this.config.firstSeen,
platform: process.platform,
node_version: process.version,
cli_version: this.getCliVersion(),
// Don't track any PII
});
} catch (error) {
// Silently handle errors
}
}

private getCliVersion(): string {
try {
const packagePath = path.join(__dirname, "..", "package.json");
const packageData = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageData.version || "unknown";
} catch {
return "unknown";
}
}

private hashValue(value: string): string {
// Hash sensitive values for privacy
return crypto.createHash("sha256").update(value).digest("hex").substring(0, 16);
}

private sanitizeProperties(properties: EventProperties): EventProperties {
const sanitized: EventProperties = {};

for (const [key, value] of Object.entries(properties)) {
// Skip any potentially sensitive fields
if (key.toLowerCase().includes("key") ||
key.toLowerCase().includes("password") ||
key.toLowerCase().includes("secret") ||
key.toLowerCase().includes("token") ||
key.toLowerCase().includes("email") ||
key.toLowerCase().includes("username")) {
continue;
}

// Hash paste IDs to prevent tracking specific content
if (key === "paste_id" && typeof value === "string") {
sanitized[key] = this.hashValue(value);
} else {
sanitized[key] = value;
}
}

// Add common properties
sanitized.platform = process.platform;
sanitized.node_version = process.version;
sanitized.cli_version = this.getCliVersion();

return sanitized;
}

private startFlushTimer(): void {
if (this.flushTimer) return;

this.flushTimer = setInterval(() => {
this.flush();
}, this.FLUSH_INTERVAL);
}

private stopFlushTimer(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = null;
}
}

public track(eventName: string, properties: EventProperties = {}): void {
if (!this.config.enabled) return;

// Queue the event
this.eventQueue.push({
name: eventName,
properties: this.sanitizeProperties(properties),
timestamp: new Date(),
});

// Flush if queue is full
if (this.eventQueue.length >= this.BATCH_SIZE) {
this.flush();
}
}

public flush(): void {
if (!this.mixpanel || !this.isInitialized || this.eventQueue.length === 0) {
return;
}

const events = [...this.eventQueue];
this.eventQueue = [];

// Send events asynchronously
for (const event of events) {
try {
this.mixpanel.track(event.name, {
distinct_id: this.config.userId,
time: event.timestamp,
...event.properties,
});
} catch (error) {
// Silently handle tracking errors
}
}
}

// Removed opt-in/opt-out methods - analytics are always enabled

public isEnabled(): boolean {
return this.config.enabled && this.isInitialized;
}

public getStatus(): string {
// Always return a generic status
return "Running";
}

public destroy(): void {
this.flush();
this.stopFlushTimer();
this.mixpanel = null;
this.isInitialized = false;
}

// Convenience methods for common events

public trackCommand(command: string, subcommand?: string, flags: string[] = []): void {
this.track("command_executed", {
command,
subcommand,
flags_count: flags.length,
flags: flags.filter(f => !f.includes("key") && !f.includes("password")),
});
}

public trackPasteCreated(properties: {
type: "regular" | "encrypted" | "one_time";
content_type?: string;
size_bytes?: number;
encryption_type?: "none" | "RSA" | "PGP";
method?: "stdin" | "file";
}): void {
this.track("paste_created", properties);
}

public trackPasteRetrieved(properties: {
is_encrypted: boolean;
decryption_method?: string;
content_type?: string;
success: boolean;
}): void {
this.track("paste_retrieved", properties);
}

public trackKeyOperation(operation: string, properties: EventProperties = {}): void {
this.track(`key_${operation}`, properties);
}

public trackError(error_type: string, operation: string, error_code?: string): void {
this.track("error_occurred", {
error_type,
operation,
error_code,
});
}
}

// Export singleton instance
export const analytics = AnalyticsService.getInstance();

// Export type for use in other modules
export type { EventProperties };

// Ensure cleanup on process exit
process.on("exit", () => {
analytics.destroy();
});

process.on("SIGINT", () => {
analytics.destroy();
process.exit(0);
});

process.on("SIGTERM", () => {
analytics.destroy();
process.exit(0);
});
25 changes: 25 additions & 0 deletions cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ catch (error) {
}
};
}
// Import analytics
import { analytics } from './analytics.js';
// Import our core modules
import { generateKeyPair, addFriendKey, listKeys, getKey, removeKey, loadKeyDatabase, saveKeyDatabase, ensureDirectories } from './keyManager.js';
import { encryptContent, decryptContent } from './encryptionUtils.js';
Expand Down Expand Up @@ -78,6 +80,8 @@ program
.description('Manage encryption keys in enhanced interactive TUI mode')
.action(async () => {
try {
// Track command execution
analytics.trackCommand('keys:enhanced');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const enhancedLauncherPath = path.join(__dirname, 'enhancedModeLauncher.js');
Expand Down Expand Up @@ -185,6 +189,9 @@ Key Storage:
`)
.action(async (options) => {
try {
// Track command execution
const flags = Object.keys(options).filter(k => options[k] !== undefined);
analytics.trackCommand('keys', undefined, flags);
// Initialize logger based on options
let logLevel = 'info';
if (options.verbose)
Expand Down Expand Up @@ -652,6 +659,8 @@ Key Storage:
- Private key: ${privateKeyPath}
- Public key: ${publicKeyPath}
`);
// Track key generation
analytics.trackKeyOperation('generated', { key_type: 'RSA' });
return;
}
// Output public key to console
Expand Down Expand Up @@ -913,6 +922,14 @@ Encryption:
process.exit(1);
}
const url = await response.text();
// Track paste creation
analytics.trackPasteCreated({
type: options.temp ? 'one_time' : 'regular',
content_type: contentType,
size_bytes: content.length,
encryption_type: options.encrypt ? 'RSA' : 'none',
method: options.file ? 'file' : 'stdin'
});
// Copy to clipboard if requested
if (options.copy) {
try {
Expand Down Expand Up @@ -1504,6 +1521,14 @@ ${options.copy ? '📋 URL copied to clipboard: ' : '📋 '} ${url.trim()}
process.exit(1);
}
const url = await response.text();
// Track paste creation
analytics.trackPasteCreated({
type: options.temp ? 'one_time' : 'regular',
content_type: contentType,
size_bytes: content.length,
encryption_type: options.encrypt ? 'RSA' : 'none',
method: options.file ? 'file' : 'stdin'
});
// Copy to clipboard if requested
if (options.copy) {
try {
Expand Down
Loading