mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Restructure code
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
import nacl from "tweetnacl";
|
||||
import { hkdfExtractAndExpand } from "../crypto/kdf";
|
||||
|
||||
export interface X25519KeyPair {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export type KeyPair = X25519KeyPair;
|
||||
|
||||
export function generateX25519KeyPair(): X25519KeyPair {
|
||||
const kp = nacl.box.keyPair();
|
||||
return { publicKey: kp.publicKey, privateKey: kp.secretKey };
|
||||
}
|
||||
|
||||
export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array {
|
||||
// nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF.
|
||||
return nacl.box.before(theirPublicKey, myPrivateKey);
|
||||
}
|
||||
|
||||
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
|
||||
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
|
||||
import { importPassword, deriveKEK, randomBytes } from "./kdf";
|
||||
|
||||
export interface PrivateKeyBundle {
|
||||
version: 1;
|
||||
privateKey: Uint8Array; // X25519 private key
|
||||
}
|
||||
|
||||
export interface EncryptedBackupBlob {
|
||||
salt: Uint8Array; // for PBKDF2 derivation of KEK
|
||||
iv: Uint8Array; // AES-GCM IV
|
||||
ciphertext: Uint8Array; // encrypted serialized PrivateKeyBundle
|
||||
}
|
||||
|
||||
export function serializeBundle(bundle: PrivateKeyBundle): Uint8Array {
|
||||
const header = new Uint8Array([bundle.version]);
|
||||
const len = new Uint8Array(new Uint32Array([bundle.privateKey.length]).buffer);
|
||||
const out = new Uint8Array(1 + 4 + bundle.privateKey.length);
|
||||
out.set(header, 0);
|
||||
out.set(len, 1);
|
||||
out.set(bundle.privateKey, 5);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function deserializeBundle(data: Uint8Array): PrivateKeyBundle {
|
||||
const version = data[0] as 1;
|
||||
const len = new Uint32Array(data.slice(1, 5).buffer)[0];
|
||||
const pk = data.slice(5, 5 + len);
|
||||
return { version, privateKey: pk };
|
||||
}
|
||||
|
||||
export async function encryptBackupWithPassword(password: string, bundle: PrivateKeyBundle): Promise<EncryptedBackupBlob> {
|
||||
const salt = randomBytes(16);
|
||||
const pw = await importPassword(password);
|
||||
const kek = await deriveKEK(pw, salt);
|
||||
const serialized = serializeBundle(bundle);
|
||||
const { iv, ciphertext } = await aesGcmEncrypt(kek, serialized);
|
||||
return { salt, iv, ciphertext };
|
||||
}
|
||||
|
||||
export async function decryptBackupWithPassword(password: string, blob: EncryptedBackupBlob): Promise<PrivateKeyBundle> {
|
||||
const pw = await importPassword(password);
|
||||
const kek = await deriveKEK(pw, blob.salt);
|
||||
const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext);
|
||||
return deserializeBundle(plaintext);
|
||||
}
|
||||
|
||||
export function encodeBlob(blob: EncryptedBackupBlob): string {
|
||||
function b64(a: Uint8Array) { return btoa(String.fromCharCode(...a)); }
|
||||
return JSON.stringify({
|
||||
salt: b64(blob.salt),
|
||||
iv: b64(blob.iv),
|
||||
ciphertext: b64(blob.ciphertext)
|
||||
});
|
||||
}
|
||||
|
||||
export function decodeBlob(json: string): EncryptedBackupBlob {
|
||||
function ub64(s: string) {
|
||||
const bin = atob(s);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
const obj = JSON.parse(json);
|
||||
return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
export async function importPassword(password: string): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
|
||||
}
|
||||
|
||||
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
|
||||
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
|
||||
passwordKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
|
||||
const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial;
|
||||
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||
const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info;
|
||||
|
||||
const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export function randomBytes(length: number): Uint8Array {
|
||||
const out = new Uint8Array(length);
|
||||
crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export interface AesGcmCiphertext {
|
||||
iv: Uint8Array;
|
||||
ciphertext: Uint8Array;
|
||||
}
|
||||
|
||||
export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | ArrayBuffer): Promise<AesGcmCiphertext> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintextBuffer = plaintext instanceof Uint8Array ? plaintext.buffer as ArrayBuffer : plaintext;
|
||||
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintextBuffer);
|
||||
return { iv, ciphertext: new Uint8Array(ct) };
|
||||
}
|
||||
|
||||
export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
|
||||
// Normalize IV to ArrayBuffer (12 bytes for AES-GCM)
|
||||
const ivBuf: ArrayBuffer = iv instanceof Uint8Array
|
||||
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
|
||||
: (iv as ArrayBuffer);
|
||||
|
||||
// Normalize ciphertext to a contiguous ArrayBuffer slice
|
||||
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
|
||||
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
|
||||
: (ciphertext as ArrayBuffer);
|
||||
|
||||
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuf }, key, ctBuf);
|
||||
return new Uint8Array(pt as ArrayBuffer);
|
||||
}
|
||||
|
||||
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
|
||||
const keyBuffer = rawKey instanceof Uint8Array ? rawKey.buffer as ArrayBuffer : rawKey;
|
||||
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* @fileoverview MDUI component imports and configuration
|
||||
* @description Imports all required MDUI components and sets up the theme
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import 'mdui/components/tabs';
|
||||
import 'mdui/components/tab';
|
||||
import 'mdui/components/tab-panel';
|
||||
import 'mdui/components/list';
|
||||
import 'mdui/components/list-item';
|
||||
import 'mdui/components/bottom-app-bar';
|
||||
import 'mdui/components/button-icon';
|
||||
import 'mdui/components/fab';
|
||||
import 'mdui/components/dialog';
|
||||
import 'mdui/components/button';
|
||||
import 'mdui/components/text-field';
|
||||
import 'mdui/components/button-icon';
|
||||
import 'mdui/components/top-app-bar';
|
||||
import 'mdui/components/top-app-bar-title';
|
||||
import 'mdui/components/switch';
|
||||
import 'mdui/components/chip';
|
||||
|
||||
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
||||
|
||||
setColorScheme("#91cef4");
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* @fileoverview User notification system
|
||||
* @description Provides toast-style notifications for user feedback
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Notification type enumeration
|
||||
* @typedef {'success' | 'error'} NotificationType
|
||||
*/
|
||||
export type NotificationType = 'success' | 'error';
|
||||
|
||||
/**
|
||||
* Shows a notification with the specified message and type
|
||||
* @param {string} message - The message to display
|
||||
* @param {NotificationType} type - The type of notification (success or error)
|
||||
* @private
|
||||
*/
|
||||
function showNotification(message: string, type: NotificationType): void {
|
||||
const notification = document.createElement('div');
|
||||
notification.textContent = message;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
background: ${type === 'success' ? '#4caf50' : '#f44336'};
|
||||
z-index: 10000;
|
||||
font-family: inherit;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Fade out and remove
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a success notification
|
||||
* @param {string} message - The success message to display
|
||||
*/
|
||||
export function showSuccess(message: string): void {
|
||||
showNotification(message, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error notification
|
||||
* @param {string} message - The error message to display
|
||||
*/
|
||||
export function showError(message: string): void {
|
||||
showNotification(message, 'error');
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { isElectron } from "../electron/electron";
|
||||
import { websocket } from "../core/websocket";
|
||||
import type { NewMessageWebSocketMessage, WebSocketMessage } from "../core/types";
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Global state
|
||||
let isInitialized = false;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let subscription: PushSubscription | null = null;
|
||||
let isElectronReceiverRunning = false;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
// Helper functions
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
async function subscribeToWebPush(): Promise<PushSubscription | null> {
|
||||
if (!registration) {
|
||||
throw new Error("Service Worker not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(
|
||||
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
|
||||
).slice().buffer
|
||||
});
|
||||
|
||||
console.log("Push subscription successful");
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error("Push subscription failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSubscriptionToServer(token: string): Promise<boolean> {
|
||||
if (!subscription) {
|
||||
throw new Error("No push subscription available");
|
||||
}
|
||||
|
||||
const subscriptionData: PushSubscriptionData = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!),
|
||||
auth: arrayBufferToBase64(subscription.getKey("auth")!)
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(subscriptionData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("Failed to send subscription to server:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function showMessageNotification(message: any): Promise<void> {
|
||||
try {
|
||||
await showNotification({
|
||||
title: `New message from ${message.username}`,
|
||||
body: message.content.length > 100
|
||||
? message.content.substring(0, 100) + "..."
|
||||
: message.content,
|
||||
icon: message.profile_picture || "/logo.png",
|
||||
tag: `message_${message.id}`,
|
||||
data: {
|
||||
type: "public_message",
|
||||
message_id: message.id,
|
||||
sender_id: message.user_id,
|
||||
sender_username: message.username
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show message notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
|
||||
// Handle notifications for new messages
|
||||
if (response.type === "newMessage" && response.data) {
|
||||
const newResponse = response as NewMessageWebSocketMessage;
|
||||
await showMessageNotification(newResponse.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API functions
|
||||
export async function initialize(): Promise<boolean> {
|
||||
if (isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isElectron) {
|
||||
// For Electron, we just need to request permission
|
||||
const permission = await window.electronInterface.notifications.requestPermission();
|
||||
isInitialized = permission === "granted";
|
||||
return isInitialized;
|
||||
} else {
|
||||
// For web browsers, initialize service worker and push manager
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
console.log("Push messaging is not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register("/assets/serviceWorker.js");
|
||||
console.log("Service Worker registered successfully");
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "granted") {
|
||||
await subscribeToWebPush();
|
||||
isInitialized = true;
|
||||
}
|
||||
return isInitialized;
|
||||
} catch (error) {
|
||||
console.error("Service Worker registration failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize notification service:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribe(token: string): Promise<boolean> {
|
||||
if (!isInitialized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need server-side subscription
|
||||
return true;
|
||||
}
|
||||
|
||||
return await sendSubscriptionToServer(token);
|
||||
}
|
||||
|
||||
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
try {
|
||||
return await window.electronInterface.notifications.show({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
icon: payload.icon,
|
||||
tag: payload.tag
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show Electron notification:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For web browsers, notifications are handled by the service worker
|
||||
// when push messages are received from the server
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function unsubscribe(): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need to unsubscribe from server
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to unsubscribe:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupported(): boolean {
|
||||
if (isElectron) {
|
||||
return true; // Electron always supports notifications
|
||||
}
|
||||
return "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
// Electron-specific functions
|
||||
export async function startElectronReceiver(): Promise<void> {
|
||||
if (!isElectron || isElectronReceiverRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = true;
|
||||
|
||||
// Add our own message listener to the existing WebSocket
|
||||
messageListener = (event: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(event.data);
|
||||
handleWebSocketMessage(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener('message', messageListener);
|
||||
}
|
||||
|
||||
export function stopElectronReceiver(): void {
|
||||
if (!isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = false;
|
||||
|
||||
// Remove our message listener
|
||||
if (messageListener) {
|
||||
websocket.removeEventListener('message', messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Utility functions used throughout the application
|
||||
* @description Contains helper functions for common operations
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats a timestamp string to HH:MM format
|
||||
* @param {string} dateString - ISO timestamp string to format
|
||||
* @returns {string} Formatted time string in HH:MM format
|
||||
* @example
|
||||
* formatTime('2024-01-15T14:30:00Z'); // Returns "14:30"
|
||||
*/
|
||||
export function formatTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
let hours = date.getHours();
|
||||
let minutes = date.getMinutes();
|
||||
const hoursString = hours < 10 ? '0' + hours : hours;
|
||||
const minutesString = minutes < 10 ? '0' + minutes : minutes;
|
||||
return hoursString + ':' + minutesString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise that resolves after a specified delay
|
||||
* @param {number} ms - Delay time in milliseconds
|
||||
* @returns {Promise<void>} Promise that resolves after the delay
|
||||
* @example
|
||||
* await delay(1000); // Wait for 1 second
|
||||
*/
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
|
||||
export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
|
||||
export function ub64(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function id<T extends Element = HTMLElement>(id: string): T {
|
||||
return document.getElementById(id) as unknown as T
|
||||
}
|
||||
Reference in New Issue
Block a user