generated from coulomb/repo-seed
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:
137
src/utils/binect-api.ts
Normal file
137
src/utils/binect-api.ts
Normal 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
129
src/utils/crypto.ts
Normal 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
125
src/utils/pdf-detector.ts
Normal 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
117
src/utils/storage.ts
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user