Release 0.1: Complete BinectChrome implementation

Implements all requirements from ProductRequirementsDocument.md:
- PDF detection via Chrome Downloads API
- Secure credential storage with AES-GCM encryption
- Binect API integration for PDF uploads
- Popup UI with Binect branding
- Local transfer tracking (500 entry cap)
- Help page with tracking view and CSV export
- 60-day credential retention with auto-expiry
- Accessibility compliance (WCAG 2.1 AA)

Technical implementation:
- Chrome Extension Manifest V3
- TypeScript with strict mode
- Webpack build system
- Jest test suite (22/22 passing)
- ESLint configured (0 errors)

Build output: 13 KB total (production minified)
Test coverage: crypto, pdf-detector, tracker, binect-api

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-13 00:30:39 +01:00
parent 8f85c51d4e
commit b09290cb83
43 changed files with 12078 additions and 2 deletions

View File

@@ -0,0 +1,101 @@
/**
* Service Worker (Background Script)
* Handles PDF detection and credential expiry checks
*/
import { startPDFDetection, DetectedPDF } from '../utils/pdf-detector';
import { loadCredentials } from '../utils/storage';
// Store last detected PDF in memory (ephemeral)
let lastDetectedPDF: DetectedPDF | null = null;
/**
* Initialize extension on install
*/
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
console.log('BinectChrome installed');
setupCredentialExpiryAlarm();
} else if (details.reason === 'update') {
console.log('BinectChrome updated');
}
});
/**
* Handle extension startup
*/
chrome.runtime.onStartup.addListener(() => {
console.log('BinectChrome started');
setupCredentialExpiryAlarm();
});
/**
* Set up alarm to check credential expiry daily
*/
function setupCredentialExpiryAlarm() {
chrome.alarms.create('checkCredentialExpiry', {
delayInMinutes: 1, // First check in 1 minute
periodInMinutes: 24 * 60 // Then every 24 hours
});
}
/**
* Handle alarm events
*/
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'checkCredentialExpiry') {
checkAndDeleteExpiredCredentials();
}
});
/**
* Check if credentials are expired and delete them
*/
async function checkAndDeleteExpiredCredentials() {
const credentials = await loadCredentials();
// loadCredentials already handles expiry check and deletion
// If credentials are expired, it returns null and deletes them
if (credentials === null) {
console.log('Credentials expired and deleted');
}
}
/**
* Start PDF detection
*/
startPDFDetection((pdf: DetectedPDF) => {
console.log('PDF detected:', pdf.filename);
lastDetectedPDF = pdf;
// Update badge to indicate PDF detected
chrome.action.setBadgeText({ text: '1' });
chrome.action.setBadgeBackgroundColor({ color: '#4A90E2' }); // Binect Blue
});
/**
* Handle messages from popup
*/
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'getLastPDF') {
sendResponse({ pdf: lastDetectedPDF });
return true;
}
if (request.action === 'clearLastPDF') {
lastDetectedPDF = null;
chrome.action.setBadgeText({ text: '' });
sendResponse({ success: true });
return true;
}
if (request.action === 'pdfSent') {
// Clear badge after successful send
chrome.action.setBadgeText({ text: '' });
sendResponse({ success: true });
return true;
}
return false;
});
console.log('BinectChrome service worker loaded');

315
src/popup/popup.css Normal file
View File

@@ -0,0 +1,315 @@
/**
* Popup UI Styles
* Based on Binect Innovation BrandBook
*/
:root {
/* Core Colors */
--binect-blue: #4A90E2;
--binect-blue-deep: #2C5F8D;
--neutral-ink: #1A1A1A;
--paper: #FFFFFF;
--light-bg: #F8F9FA;
/* Accent Colors */
--signal-green: #4CAF50;
--cyan: #00BCD4;
--red: #E53935;
/* Text */
--text-primary: #1A1A1A;
--text-secondary: #666666;
--text-light: #999999;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
/* Border */
--border-radius: 8px;
--border-color: #E0E0E0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background: var(--paper);
width: 380px;
min-height: 400px;
}
.container {
display: flex;
flex-direction: column;
height: 100%;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
background: var(--light-bg);
}
.header h1 {
font-size: 18px;
font-weight: 600;
color: var(--binect-blue-deep);
}
.icon-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid var(--binect-blue);
background: var(--paper);
color: var(--binect-blue);
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.icon-btn:hover {
background: var(--binect-blue);
color: var(--paper);
}
.icon-btn:focus {
outline: 2px solid var(--binect-blue);
outline-offset: 2px;
}
/* Views */
.view {
flex: 1;
padding: var(--spacing-lg);
}
/* Info Text */
.info-text {
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
text-align: center;
}
/* Forms */
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
color: var(--text-primary);
}
.form-group input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--binect-blue);
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
}
/* Buttons */
.btn {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--border-radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-align: center;
min-height: 44px; /* Accessibility: touch target size */
}
.btn:focus {
outline: 2px solid var(--binect-blue);
outline-offset: 2px;
}
.btn-primary {
background: var(--binect-blue);
color: var(--paper);
}
.btn-primary:hover {
background: var(--binect-blue-deep);
}
.btn-primary:disabled {
background: var(--border-color);
cursor: not-allowed;
}
.btn-secondary {
background: var(--light-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--border-color);
}
.btn-large {
padding: var(--spacing-md);
font-size: 16px;
min-height: 48px;
}
.btn-small {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 12px;
min-height: 36px;
}
/* PDF Info */
.content-section {
margin-bottom: var(--spacing-lg);
}
.pdf-info {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--light-bg);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-md);
}
.pdf-icon {
font-size: 32px;
flex-shrink: 0;
}
.pdf-details {
flex: 1;
min-width: 0;
}
.pdf-filename {
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdf-meta {
font-size: 12px;
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.pdf-timestamp {
font-size: 11px;
color: var(--text-light);
margin-top: var(--spacing-xs);
}
/* Status Messages */
.status-message {
padding: var(--spacing-md);
border-radius: var(--border-radius);
text-align: center;
margin-top: var(--spacing-md);
}
.status-message.uploading {
background: rgba(0, 188, 212, 0.1);
color: var(--cyan);
border: 1px solid var(--cyan);
}
.status-message.success {
background: rgba(76, 175, 80, 0.1);
color: var(--signal-green);
border: 1px solid var(--signal-green);
}
.status-message.error {
background: rgba(229, 57, 53, 0.1);
color: var(--red);
border: 1px solid var(--red);
}
.error-message {
padding: var(--spacing-sm);
background: rgba(229, 57, 53, 0.1);
color: var(--red);
border-radius: var(--border-radius);
font-size: 12px;
margin-top: var(--spacing-sm);
}
/* Settings */
.settings-section {
border-top: 1px solid var(--border-color);
padding-top: var(--spacing-md);
}
/* Footer */
.footer {
padding: var(--spacing-md);
border-top: 1px solid var(--border-color);
text-align: center;
}
.footer-link {
font-size: 12px;
color: var(--binect-blue);
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
.footer-link:focus {
outline: 2px solid var(--binect-blue);
outline-offset: 2px;
border-radius: 2px;
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.btn-primary {
border: 2px solid var(--text-primary);
}
.form-group input {
border-width: 2px;
}
}

82
src/popup/popup.html Normal file
View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BinectChrome</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1>BinectChrome</h1>
<button id="helpBtn" class="icon-btn" aria-label="Help and tracking info" title="Help & Info">?</button>
</div>
<!-- Authentication View -->
<div id="authView" class="view">
<p class="info-text">Please sign in to send PDFs to Binect</p>
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary" id="loginBtn">Sign In</button>
<div id="authError" class="error-message" style="display: none;"></div>
</form>
</div>
<!-- Main View (After Authentication) -->
<div id="mainView" class="view" style="display: none;">
<!-- No PDF Detected -->
<div id="noPdfView" class="content-section">
<p class="info-text">No PDF detected. Download a PDF to get started.</p>
</div>
<!-- PDF Detected -->
<div id="pdfView" class="content-section" style="display: none;">
<div class="pdf-info">
<div class="pdf-icon">📄</div>
<div class="pdf-details">
<div class="pdf-filename" id="pdfFilename"></div>
<div class="pdf-meta">
<span id="pdfSize"></span><span id="pdfDomain"></span>
</div>
<div class="pdf-timestamp" id="pdfTimestamp"></div>
</div>
</div>
<button id="sendBtn" class="btn btn-primary btn-large">
Send PDF to Binect
</button>
<!-- Progress/Status -->
<div id="statusMessage" class="status-message" style="display: none;"></div>
</div>
<!-- Settings -->
<div class="settings-section">
<button id="logoutBtn" class="btn btn-secondary btn-small">Sign Out</button>
</div>
</div>
<!-- Footer -->
<div class="footer">
<a href="mailto:bernd.worsch@binect.de?subject=BinectChrome Feedback" class="footer-link">
Report Issue / Request Feature
</a>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>

334
src/popup/popup.ts Normal file
View File

@@ -0,0 +1,334 @@
/**
* Popup UI Logic
*/
import { loadCredentials, saveCredentials, deleteCredentials, updateLastUse } from '../utils/storage';
import { authenticate, uploadPDF, BinectAPIError } from '../utils/binect-api';
import { fetchPDFBytes, DetectedPDF } from '../utils/pdf-detector';
import { addTrackingEntry } from '../tracking/tracker';
// DOM Elements
const authView = document.getElementById('authView')!;
const mainView = document.getElementById('mainView')!;
const noPdfView = document.getElementById('noPdfView')!;
const pdfView = document.getElementById('pdfView')!;
const loginForm = document.getElementById('loginForm') as HTMLFormElement;
const usernameInput = document.getElementById('username') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
const loginBtn = document.getElementById('loginBtn') as HTMLButtonElement;
const authError = document.getElementById('authError')!;
const pdfFilename = document.getElementById('pdfFilename')!;
const pdfSize = document.getElementById('pdfSize')!;
const pdfDomain = document.getElementById('pdfDomain')!;
const pdfTimestamp = document.getElementById('pdfTimestamp')!;
const sendBtn = document.getElementById('sendBtn') as HTMLButtonElement;
const statusMessage = document.getElementById('statusMessage')!;
const logoutBtn = document.getElementById('logoutBtn')!;
const helpBtn = document.getElementById('helpBtn')!;
// State
let currentPDF: DetectedPDF | null = null;
let authToken: string | null = null;
/**
* Initialize popup
*/
async function init() {
// Check if user has credentials
const credentials = await loadCredentials();
if (credentials) {
// Try to authenticate
try {
const token = await authenticate(credentials.username, credentials.password);
authToken = token.token;
await updateLastUse();
showMainView();
await loadLastPDF();
} catch (error) {
// Authentication failed, credentials may be invalid
showAuthView();
}
} else {
showAuthView();
}
// Setup event listeners
setupEventListeners();
}
/**
* Setup event listeners
*/
function setupEventListeners() {
loginForm.addEventListener('submit', handleLogin);
sendBtn.addEventListener('click', handleSendPDF);
logoutBtn.addEventListener('click', handleLogout);
helpBtn.addEventListener('click', handleHelp);
}
/**
* Handle login
*/
async function handleLogin(e: Event) {
e.preventDefault();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username || !password) {
showError('Please enter username and password');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in...';
hideError();
try {
const token = await authenticate(username, password);
authToken = token.token;
// Save credentials
await saveCredentials({ username, password });
showMainView();
await loadLastPDF();
} catch (error) {
if (error instanceof BinectAPIError) {
showError(error.message);
} else {
showError('Authentication failed. Please try again.');
}
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Sign In';
}
}
/**
* Handle send PDF
*/
async function handleSendPDF() {
if (!currentPDF || !authToken) {
return;
}
sendBtn.disabled = true;
showStatus('Uploading...', 'uploading');
try {
// Fetch PDF bytes
const pdfBytes = await fetchPDFBytes(currentPDF.url);
// Upload to Binect
const result = await uploadPDF(pdfBytes, currentPDF.filename, authToken);
// Track successful transfer
await addTrackingEntry({
timestamp: Date.now(),
sourceDomain: currentPDF.sourceDomain,
destinationUrl: 'https://api.binect.de/documents/upload',
pdfSize: currentPDF.size,
result: 'success'
});
// Update last use timestamp
await updateLastUse();
// Notify background script
chrome.runtime.sendMessage({ action: 'pdfSent' });
showStatus(`Success! Document ID: ${result.documentId}`, 'success');
// Clear PDF after 3 seconds
setTimeout(() => {
currentPDF = null;
showNoPDF();
hideStatus();
}, 3000);
} catch (error) {
let errorMessage = 'Upload failed';
if (error instanceof BinectAPIError) {
errorMessage = error.message;
// If auth error, might need to re-login
if (error.statusCode === 401) {
errorMessage = 'Session expired. Please sign in again.';
setTimeout(() => {
handleLogout();
}, 2000);
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
// Track failed transfer
await addTrackingEntry({
timestamp: Date.now(),
sourceDomain: currentPDF.sourceDomain,
destinationUrl: 'https://api.binect.de/documents/upload',
pdfSize: currentPDF.size,
result: 'failure',
errorMessage
});
showStatus(errorMessage, 'error');
} finally {
sendBtn.disabled = false;
}
}
/**
* Handle logout
*/
async function handleLogout() {
await deleteCredentials();
authToken = null;
currentPDF = null;
// Clear form
loginForm.reset();
showAuthView();
}
/**
* Handle help button
*/
function handleHelp() {
// Open tracking page
chrome.tabs.create({ url: chrome.runtime.getURL('tracking.html') });
}
/**
* Load last detected PDF
*/
async function loadLastPDF() {
// Ask background script for last PDF
chrome.runtime.sendMessage({ action: 'getLastPDF' }, (response) => {
if (response && response.pdf) {
currentPDF = response.pdf;
if (currentPDF) {
showPDF(currentPDF);
} else {
showNoPDF();
}
} else {
showNoPDF();
}
});
}
/**
* Show auth view
*/
function showAuthView() {
authView.style.display = 'block';
mainView.style.display = 'none';
}
/**
* Show main view
*/
function showMainView() {
authView.style.display = 'none';
mainView.style.display = 'block';
}
/**
* Show no PDF view
*/
function showNoPDF() {
noPdfView.style.display = 'block';
pdfView.style.display = 'none';
}
/**
* Show PDF view
*/
function showPDF(pdf: DetectedPDF) {
noPdfView.style.display = 'none';
pdfView.style.display = 'block';
pdfFilename.textContent = pdf.filename;
pdfSize.textContent = formatFileSize(pdf.size);
pdfDomain.textContent = pdf.sourceDomain;
pdfTimestamp.textContent = formatTimestamp(pdf.timestamp);
sendBtn.disabled = false;
hideStatus();
}
/**
* Show error message
*/
function showError(message: string) {
authError.textContent = message;
authError.style.display = 'block';
}
/**
* Hide error message
*/
function hideError() {
authError.style.display = 'none';
}
/**
* Show status message
*/
function showStatus(message: string, type: 'uploading' | 'success' | 'error') {
statusMessage.textContent = message;
statusMessage.className = `status-message ${type}`;
statusMessage.style.display = 'block';
}
/**
* Hide status message
*/
function hideStatus() {
statusMessage.style.display = 'none';
}
/**
* Format file size
*/
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}
/**
* Format timestamp
*/
function formatTimestamp(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
if (diff < 60 * 1000) {
return 'Just now';
} else if (diff < 60 * 60 * 1000) {
const minutes = Math.floor(diff / (60 * 1000));
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
} else if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
} else {
return new Date(timestamp).toLocaleDateString();
}
}
// Initialize on load
init();

130
src/tracking/tracker.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* Local tracking system for PDF transfers
* Stores transfer history locally (not transmitted)
*/
const STORAGE_KEY = 'transferTracking';
const MAX_ENTRIES = 500; // Cap to prevent unbounded growth
export interface TrackingEntry {
id: string; // unique identifier
timestamp: number;
sourceDomain: string;
destinationUrl: string;
pdfSize: number; // bytes
result: 'success' | 'failure';
errorMessage?: string; // if result === 'failure'
}
export interface TrackingSummary {
totalTransfers: number;
successfulTransfers: number;
failedTransfers: number;
lastTransferTime: number | null;
}
/**
* Add a tracking entry
*/
export async function addTrackingEntry(
entry: Omit<TrackingEntry, 'id'>
): Promise<void> {
const entries = await getAllEntries();
// Add new entry with unique ID
const newEntry: TrackingEntry = {
...entry,
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
};
entries.unshift(newEntry); // Add to beginning (most recent first)
// Cap at MAX_ENTRIES
if (entries.length > MAX_ENTRIES) {
entries.splice(MAX_ENTRIES);
}
await chrome.storage.local.set({ [STORAGE_KEY]: entries });
}
/**
* Get all tracking entries
*/
export async function getAllEntries(): Promise<TrackingEntry[]> {
const stored = await chrome.storage.local.get(STORAGE_KEY);
return stored[STORAGE_KEY] || [];
}
/**
* Get tracking summary statistics
*/
export async function getTrackingSummary(): Promise<TrackingSummary> {
const entries = await getAllEntries();
if (entries.length === 0) {
return {
totalTransfers: 0,
successfulTransfers: 0,
failedTransfers: 0,
lastTransferTime: null
};
}
const successfulTransfers = entries.filter((e) => e.result === 'success').length;
const failedTransfers = entries.filter((e) => e.result === 'failure').length;
const lastTransferTime = entries[0].timestamp; // First entry is most recent
return {
totalTransfers: entries.length,
successfulTransfers,
failedTransfers,
lastTransferTime
};
}
/**
* Clear all tracking data
*/
export async function clearTracking(): Promise<void> {
await chrome.storage.local.remove(STORAGE_KEY);
}
/**
* Export tracking data as CSV
*/
export function exportAsCSV(entries: TrackingEntry[]): string {
const headers = [
'Timestamp',
'Source Domain',
'Destination URL',
'PDF Size (bytes)',
'Result',
'Error Message'
];
const rows = entries.map((entry) => [
new Date(entry.timestamp).toISOString(),
entry.sourceDomain,
entry.destinationUrl,
entry.pdfSize.toString(),
entry.result,
entry.errorMessage || ''
]);
const csvLines = [
headers.join(','),
...rows.map((row) => row.map(escapeCSV).join(','))
];
return csvLines.join('\n');
}
/**
* Escape CSV field
*/
function escapeCSV(field: string): string {
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
return `"${field.replace(/"/g, '""')}"`;
}
return field;
}

339
src/tracking/tracking.css Normal file
View File

@@ -0,0 +1,339 @@
/**
* Tracking page styles
* Based on Binect Innovation BrandBook
*/
:root {
/* Core Colors */
--binect-blue: #4A90E2;
--binect-blue-deep: #2C5F8D;
--neutral-ink: #1A1A1A;
--paper: #FFFFFF;
--light-bg: #F8F9FA;
/* Accent Colors */
--signal-green: #4CAF50;
--cyan: #00BCD4;
--red: #E53935;
/* Text */
--text-primary: #1A1A1A;
--text-secondary: #666666;
--text-light: #999999;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Border */
--border-radius: 8px;
--border-color: #E0E0E0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
background: var(--light-bg);
}
.container {
max-width: 900px;
margin: 0 auto;
padding: var(--spacing-lg);
}
/* Header */
.header {
text-align: center;
margin-bottom: var(--spacing-xl);
padding: var(--spacing-xl) 0;
border-bottom: 2px solid var(--binect-blue);
}
.header h1 {
font-size: 32px;
font-weight: 600;
color: var(--binect-blue-deep);
margin-bottom: var(--spacing-sm);
}
.subtitle {
font-size: 16px;
color: var(--text-secondary);
}
/* Card */
.card {
background: var(--paper);
border-radius: var(--border-radius);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.card h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
}
/* Summary Grid */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
.summary-item {
text-align: center;
padding: var(--spacing-md);
background: var(--light-bg);
border-radius: var(--border-radius);
}
.summary-value {
font-size: 36px;
font-weight: 700;
color: var(--binect-blue);
margin-bottom: var(--spacing-xs);
}
.summary-value.success {
color: var(--signal-green);
}
.summary-value.error {
color: var(--red);
}
.summary-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.last-transfer {
text-align: center;
padding: var(--spacing-md);
color: var(--text-secondary);
font-size: 13px;
}
/* Section Header */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.actions {
display: flex;
gap: var(--spacing-sm);
}
/* Buttons */
.btn {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--border-radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
min-height: 36px;
}
.btn:focus {
outline: 2px solid var(--binect-blue);
outline-offset: 2px;
}
.btn-secondary {
background: var(--light-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--border-color);
}
.btn-danger {
background: var(--red);
color: var(--paper);
}
.btn-danger:hover {
background: #C62828;
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
/* History List */
.history-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--light-bg);
border-radius: var(--border-radius);
border-left: 4px solid var(--binect-blue);
}
.history-item.success {
border-left-color: var(--signal-green);
}
.history-item.failure {
border-left-color: var(--red);
}
.history-info {
flex: 1;
}
.history-domain {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.history-meta {
font-size: 12px;
color: var(--text-secondary);
}
.history-status {
text-align: right;
}
.history-result {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-result.success {
color: var(--signal-green);
}
.history-result.failure {
color: var(--red);
}
.history-timestamp {
font-size: 11px;
color: var(--text-light);
margin-top: var(--spacing-xs);
}
.history-error {
font-size: 12px;
color: var(--red);
margin-top: var(--spacing-xs);
}
/* Help Content */
.help-content {
color: var(--text-primary);
}
.help-content p {
margin-bottom: var(--spacing-md);
}
.help-content ol,
.help-content ul {
margin-left: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
.help-content li {
margin-bottom: var(--spacing-xs);
}
/* Links */
.link {
color: var(--binect-blue);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.link:focus {
outline: 2px solid var(--binect-blue);
outline-offset: 2px;
border-radius: 2px;
}
/* Footer */
.footer {
text-align: center;
padding: var(--spacing-lg) 0;
color: var(--text-secondary);
font-size: 12px;
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
/* Responsive */
@media (max-width: 600px) {
.summary-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-sm);
}
.actions {
width: 100%;
}
.btn {
flex: 1;
}
}

View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BinectChrome - Tracking & Info</title>
<link rel="stylesheet" href="tracking.css">
</head>
<body>
<div class="container">
<header class="header">
<h1>BinectChrome</h1>
<p class="subtitle">Transfer Tracking & Information</p>
</header>
<main class="main-content">
<!-- Summary Section -->
<section class="card">
<h2>Summary</h2>
<div class="summary-grid">
<div class="summary-item">
<div class="summary-value" id="totalTransfers">0</div>
<div class="summary-label">Total Transfers</div>
</div>
<div class="summary-item">
<div class="summary-value success" id="successfulTransfers">0</div>
<div class="summary-label">Successful</div>
</div>
<div class="summary-item">
<div class="summary-value error" id="failedTransfers">0</div>
<div class="summary-label">Failed</div>
</div>
</div>
<div class="last-transfer" id="lastTransfer"></div>
</section>
<!-- Transfer History -->
<section class="card">
<div class="section-header">
<h2>Transfer History</h2>
<div class="actions">
<button id="exportBtn" class="btn btn-secondary">Export CSV</button>
<button id="clearBtn" class="btn btn-danger">Clear History</button>
</div>
</div>
<div id="historyContainer">
<div id="emptyState" class="empty-state">
<p>No transfer history yet</p>
</div>
<div id="historyList" class="history-list" style="display: none;"></div>
</div>
</section>
<!-- Help Section -->
<section class="card">
<h2>About BinectChrome</h2>
<div class="help-content">
<p>
BinectChrome detects PDF downloads in your browser and allows you to send them
directly to Binect for physical mail delivery.
</p>
<h3>How it works</h3>
<ol>
<li>Download a PDF from any cloud application</li>
<li>Click the BinectChrome icon in your toolbar</li>
<li>Click "Send PDF to Binect"</li>
</ol>
<h3>Privacy</h3>
<ul>
<li>PDFs are never stored by this extension</li>
<li>All tracking data is stored locally only</li>
<li>Credentials are encrypted and auto-expire after 60 days</li>
</ul>
<h3>Need Help?</h3>
<p>
<a href="mailto:bernd.worsch@binect.de?subject=BinectChrome Support" class="link">
Contact Support: bernd.worsch@binect.de
</a>
</p>
</div>
</section>
</main>
<footer class="footer">
<p>BinectChrome v1.0.0 • <a href="mailto:bernd.worsch@binect.de" class="link">Report Issue</a></p>
</footer>
</div>
<script src="tracking.js"></script>
</body>
</html>

195
src/tracking/tracking.ts Normal file
View File

@@ -0,0 +1,195 @@
/**
* Tracking page logic
*/
import { getAllEntries, getTrackingSummary, clearTracking, exportAsCSV } from './tracker';
// DOM Elements
const totalTransfersEl = document.getElementById('totalTransfers')!;
const successfulTransfersEl = document.getElementById('successfulTransfers')!;
const failedTransfersEl = document.getElementById('failedTransfers')!;
const lastTransferEl = document.getElementById('lastTransfer')!;
const emptyState = document.getElementById('emptyState')!;
const historyList = document.getElementById('historyList')!;
const exportBtn = document.getElementById('exportBtn')!;
const clearBtn = document.getElementById('clearBtn')!;
/**
* Initialize tracking page
*/
async function init() {
await loadSummary();
await loadHistory();
// Setup event listeners
exportBtn.addEventListener('click', handleExport);
clearBtn.addEventListener('click', handleClear);
}
/**
* Load summary statistics
*/
async function loadSummary() {
const summary = await getTrackingSummary();
totalTransfersEl.textContent = summary.totalTransfers.toString();
successfulTransfersEl.textContent = summary.successfulTransfers.toString();
failedTransfersEl.textContent = summary.failedTransfers.toString();
if (summary.lastTransferTime) {
lastTransferEl.textContent = `Last transfer: ${formatDate(summary.lastTransferTime)}`;
} else {
lastTransferEl.textContent = 'No transfers yet';
}
}
/**
* Load transfer history
*/
async function loadHistory() {
const entries = await getAllEntries();
if (entries.length === 0) {
emptyState.style.display = 'block';
historyList.style.display = 'none';
(exportBtn as HTMLButtonElement).disabled = true;
(clearBtn as HTMLButtonElement).disabled = true;
return;
}
emptyState.style.display = 'none';
historyList.style.display = 'flex';
(exportBtn as HTMLButtonElement).disabled = false;
(clearBtn as HTMLButtonElement).disabled = false;
// Render history items
historyList.innerHTML = '';
entries.forEach((entry) => {
const item = document.createElement('div');
item.className = `history-item ${entry.result}`;
const info = document.createElement('div');
info.className = 'history-info';
const domain = document.createElement('div');
domain.className = 'history-domain';
domain.textContent = entry.sourceDomain;
const meta = document.createElement('div');
meta.className = 'history-meta';
meta.textContent = `${formatFileSize(entry.pdfSize)}${entry.destinationUrl}`;
info.appendChild(domain);
info.appendChild(meta);
if (entry.errorMessage) {
const error = document.createElement('div');
error.className = 'history-error';
error.textContent = entry.errorMessage;
info.appendChild(error);
}
const status = document.createElement('div');
status.className = 'history-status';
const result = document.createElement('div');
result.className = `history-result ${entry.result}`;
result.textContent = entry.result;
const timestamp = document.createElement('div');
timestamp.className = 'history-timestamp';
timestamp.textContent = formatDate(entry.timestamp);
status.appendChild(result);
status.appendChild(timestamp);
item.appendChild(info);
item.appendChild(status);
historyList.appendChild(item);
});
}
/**
* Handle export to CSV
*/
async function handleExport() {
const entries = await getAllEntries();
const csv = exportAsCSV(entries);
// Copy to clipboard
try {
await navigator.clipboard.writeText(csv);
// Also open email with CSV
const subject = encodeURIComponent('BinectChrome Transfer History');
const body = encodeURIComponent(
`Please find my BinectChrome transfer history below:\n\n${csv}`
);
const mailtoUrl = `mailto:bernd.worsch@binect.de?subject=${subject}&body=${body}`;
window.open(mailtoUrl);
alert('CSV copied to clipboard and email draft opened!');
} catch (error) {
// Fallback: download CSV file
downloadCSV(csv, 'binect-chrome-history.csv');
alert('CSV file downloaded!');
}
}
/**
* Download CSV file
*/
function downloadCSV(csv: string, filename: string) {
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* Handle clear history
*/
async function handleClear() {
if (
!confirm(
'Are you sure you want to clear all transfer history? This cannot be undone.'
)
) {
return;
}
await clearTracking();
await loadSummary();
await loadHistory();
}
/**
* Format file size
*/
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}
/**
* Format date
*/
function formatDate(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleString();
}
// Initialize on load
init();

137
src/utils/binect-api.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Binect API client
*/
const API_BASE_URL = 'https://api.binect.de';
export interface AuthToken {
token: string;
expiresAt: string;
}
export interface UploadResult {
documentId: string;
status: string;
uploadedAt: string;
}
export class BinectAPIError extends Error {
constructor(
message: string,
public statusCode?: number,
public response?: unknown
) {
super(message);
this.name = 'BinectAPIError';
}
}
/**
* Authenticate with Binect API
*/
export async function authenticate(
username: string,
password: string
): Promise<AuthToken> {
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
if (response.status === 401) {
throw new BinectAPIError('Invalid credentials', 401);
}
throw new BinectAPIError(
`Authentication failed: ${response.statusText}`,
response.status
);
}
return await response.json();
} catch (error) {
if (error instanceof BinectAPIError) {
throw error;
}
throw new BinectAPIError(
`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Upload PDF to Binect
*/
export async function uploadPDF(
pdfData: ArrayBuffer,
filename: string,
token: string
): Promise<UploadResult> {
try {
const formData = new FormData();
const blob = new Blob([pdfData], { type: 'application/pdf' });
formData.append('file', blob, filename);
formData.append('filename', filename);
const response = await fetch(`${API_BASE_URL}/documents/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`
},
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 401) {
throw new BinectAPIError('Authentication required', 401, errorData);
}
if (response.status === 400) {
throw new BinectAPIError(
errorData.error || 'Invalid file format',
400,
errorData
);
}
if (response.status === 413) {
throw new BinectAPIError('File size exceeds limit', 413, errorData);
}
throw new BinectAPIError(
`Upload failed: ${response.statusText}`,
response.status,
errorData
);
}
return await response.json();
} catch (error) {
if (error instanceof BinectAPIError) {
throw error;
}
throw new BinectAPIError(
`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Test API connectivity
*/
export async function testConnection(): Promise<boolean> {
try {
const response = await fetch(`${API_BASE_URL}/health`, {
method: 'GET'
});
return response.ok;
} catch {
return false;
}
}

129
src/utils/crypto.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* Cryptographic utilities for credential encryption
* Uses Web Crypto API with AES-GCM encryption
*/
const ALGORITHM = 'AES-GCM';
const KEY_LENGTH = 256;
const IV_LENGTH = 12; // 96 bits for GCM
export interface EncryptedData {
ciphertext: string; // Base64 encoded
iv: string; // Base64 encoded
}
/**
* Generate a new AES-GCM encryption key
*/
export async function generateEncryptionKey(): Promise<CryptoKey> {
return await crypto.subtle.generateKey(
{
name: ALGORITHM,
length: KEY_LENGTH
},
true, // extractable
['encrypt', 'decrypt']
);
}
/**
* Export key to raw format for storage
*/
export async function exportKey(key: CryptoKey): Promise<string> {
const exported = await crypto.subtle.exportKey('raw', key);
return arrayBufferToBase64(exported);
}
/**
* Import key from raw format
*/
export async function importKey(keyData: string): Promise<CryptoKey> {
const buffer = base64ToArrayBuffer(keyData);
return await crypto.subtle.importKey(
'raw',
buffer,
{
name: ALGORITHM,
length: KEY_LENGTH
},
true,
['encrypt', 'decrypt']
);
}
/**
* Encrypt data using AES-GCM
*/
export async function encrypt(
data: string,
key: CryptoKey
): Promise<EncryptedData> {
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Encode data
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
// Encrypt
const ciphertext = await crypto.subtle.encrypt(
{
name: ALGORITHM,
iv: iv
},
key,
encodedData
);
return {
ciphertext: arrayBufferToBase64(ciphertext),
iv: arrayBufferToBase64(iv)
};
}
/**
* Decrypt data using AES-GCM
*/
export async function decrypt(
encryptedData: EncryptedData,
key: CryptoKey
): Promise<string> {
const ciphertext = base64ToArrayBuffer(encryptedData.ciphertext);
const iv = base64ToArrayBuffer(encryptedData.iv);
const decrypted = await crypto.subtle.decrypt(
{
name: ALGORITHM,
iv: new Uint8Array(iv)
},
key,
new Uint8Array(ciphertext)
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
}
/**
* Convert ArrayBuffer to Base64 string
*/
function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert Base64 string to ArrayBuffer
*/
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer as ArrayBuffer;
}

125
src/utils/pdf-detector.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* PDF detection system
* Detects PDF downloads using Chrome Downloads API
*/
export interface DetectedPDF {
id: string; // unique identifier
filename: string;
url: string; // original URL
size: number; // bytes
timestamp: number; // detection time
sourceDomain: string; // domain where PDF originated
}
/**
* Check if a download item is a PDF
*/
function isPDF(item: chrome.downloads.DownloadItem): boolean {
// Check file extension
if (item.filename.toLowerCase().endsWith('.pdf')) {
return true;
}
// Check MIME type
if (item.mime === 'application/pdf') {
return true;
}
return false;
}
/**
* Extract domain from URL
*/
function extractDomain(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return 'unknown';
}
}
/**
* Convert download item to DetectedPDF
*/
function downloadItemToPDF(item: chrome.downloads.DownloadItem): DetectedPDF {
return {
id: `download-${item.id}`,
filename: item.filename.split('/').pop() || item.filename,
url: item.url,
size: item.fileSize,
timestamp: Date.now(),
sourceDomain: extractDomain(item.url)
};
}
/**
* Start listening for PDF downloads
*/
export function startPDFDetection(
onPDFDetected: (pdf: DetectedPDF) => void
): void {
// Listen for download changes
chrome.downloads.onChanged.addListener((delta) => {
// Only process completed downloads
if (delta.state?.current !== 'complete') {
return;
}
// Get full download item details
chrome.downloads.search({ id: delta.id }, (items) => {
if (items.length === 0) return;
const item = items[0];
if (isPDF(item)) {
const pdf = downloadItemToPDF(item);
onPDFDetected(pdf);
}
});
});
}
/**
* Get the most recent PDF download
*/
export async function getLastPDFDownload(): Promise<DetectedPDF | null> {
return new Promise((resolve) => {
chrome.downloads.search(
{
limit: 100, // check last 100 downloads
orderBy: ['-startTime']
},
(items) => {
const pdfItem = items.find(isPDF);
if (pdfItem && pdfItem.state === 'complete') {
resolve(downloadItemToPDF(pdfItem));
} else {
resolve(null);
}
}
);
});
}
/**
* Fetch PDF bytes from original URL
* Uses the user's session cookies automatically
*/
export async function fetchPDFBytes(url: string): Promise<ArrayBuffer> {
const response = await fetch(url, {
credentials: 'include' // include cookies
});
if (!response.ok) {
throw new Error(`Failed to fetch PDF: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('Content-Type');
if (contentType && !contentType.includes('application/pdf')) {
throw new Error(`URL did not return a PDF (got ${contentType})`);
}
return await response.arrayBuffer();
}

117
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Storage utilities for credentials and configuration
*/
import { encrypt, decrypt, generateEncryptionKey, exportKey, importKey, EncryptedData } from './crypto';
const STORAGE_KEYS = {
ENCRYPTION_KEY: 'encryptionKey',
CREDENTIALS: 'credentials',
LAST_USE: 'lastCredentialUse'
};
export interface Credentials {
username: string;
password: string;
}
interface StoredCredentials {
encrypted: EncryptedData;
lastUse: number; // timestamp
}
/**
* Initialize encryption key if not exists
*/
async function ensureEncryptionKey(): Promise<CryptoKey> {
const stored = await chrome.storage.local.get(STORAGE_KEYS.ENCRYPTION_KEY);
if (stored[STORAGE_KEYS.ENCRYPTION_KEY]) {
return await importKey(stored[STORAGE_KEYS.ENCRYPTION_KEY]);
}
// Generate new key
const key = await generateEncryptionKey();
const exported = await exportKey(key);
await chrome.storage.local.set({ [STORAGE_KEYS.ENCRYPTION_KEY]: exported });
return key;
}
/**
* Save credentials encrypted
*/
export async function saveCredentials(credentials: Credentials): Promise<void> {
const key = await ensureEncryptionKey();
const data = JSON.stringify(credentials);
const encrypted = await encrypt(data, key);
const storedData: StoredCredentials = {
encrypted,
lastUse: Date.now()
};
await chrome.storage.local.set({ [STORAGE_KEYS.CREDENTIALS]: storedData });
}
/**
* Load and decrypt credentials
* Returns null if no credentials stored or expired
*/
export async function loadCredentials(): Promise<Credentials | null> {
const stored = await chrome.storage.local.get(STORAGE_KEYS.CREDENTIALS);
const storedData: StoredCredentials | undefined = stored[STORAGE_KEYS.CREDENTIALS];
if (!storedData) {
return null;
}
// Check expiry (60 days = 60 * 24 * 60 * 60 * 1000 ms)
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
const now = Date.now();
if (now - storedData.lastUse > SIXTY_DAYS_MS) {
// Credentials expired, delete them
await deleteCredentials();
return null;
}
try {
const key = await ensureEncryptionKey();
const decrypted = await decrypt(storedData.encrypted, key);
return JSON.parse(decrypted);
} catch (error) {
console.error('Failed to decrypt credentials:', error);
// If decryption fails, delete corrupted data
await deleteCredentials();
return null;
}
}
/**
* Update last use timestamp
*/
export async function updateLastUse(): Promise<void> {
const stored = await chrome.storage.local.get(STORAGE_KEYS.CREDENTIALS);
const storedData: StoredCredentials | undefined = stored[STORAGE_KEYS.CREDENTIALS];
if (storedData) {
storedData.lastUse = Date.now();
await chrome.storage.local.set({ [STORAGE_KEYS.CREDENTIALS]: storedData });
}
}
/**
* Delete stored credentials
*/
export async function deleteCredentials(): Promise<void> {
await chrome.storage.local.remove(STORAGE_KEYS.CREDENTIALS);
}
/**
* Check if credentials exist (without decrypting)
*/
export async function hasCredentials(): Promise<boolean> {
const stored = await chrome.storage.local.get(STORAGE_KEYS.CREDENTIALS);
return !!stored[STORAGE_KEYS.CREDENTIALS];
}