Telegram Web K with changes to work inside I2P
https://web.telegram.i2p/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
666 lines
20 KiB
666 lines
20 KiB
/* |
|
* https://github.com/morethanwords/tweb |
|
* Copyright (C) 2019-2021 Eduard Kuzmenko |
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE |
|
*/ |
|
|
|
import type { LocalStorageProxyTask, LocalStorageProxyTaskResponse } from '../localStorage'; |
|
//import type { LocalStorageProxyDeleteTask, LocalStorageProxySetTask } from '../storage'; |
|
import type { Awaited, InvokeApiOptions, WorkerTaskVoidTemplate } from '../../types'; |
|
import type { Config, InputFile, MethodDeclMap, User } from '../../layer'; |
|
import MTProtoWorker from 'worker-loader!./mtproto.worker'; |
|
//import './mtproto.worker'; |
|
import { isObject } from '../../helpers/object'; |
|
import CryptoWorkerMethods from '../crypto/crypto_methods'; |
|
import { logger } from '../logger'; |
|
import rootScope from '../rootScope'; |
|
import webpWorkerController from '../webp/webpWorkerController'; |
|
import { ApiFileManager, DownloadOptions } from './apiFileManager'; |
|
import type { RequestFilePartTask, RequestFilePartTaskResponse, ServiceWorkerTask } from '../serviceWorker/index.service'; |
|
import { UserAuth } from './mtproto_config'; |
|
import type { MTMessage } from './networker'; |
|
import DEBUG, { MOUNT_CLASS_TO } from '../../config/debug'; |
|
import Socket from './transports/websocket'; |
|
import singleInstance from './singleInstance'; |
|
import sessionStorage from '../sessionStorage'; |
|
import webPushApiManager from './webPushApiManager'; |
|
import AppStorage from '../storage'; |
|
import appRuntimeManager from '../appManagers/appRuntimeManager'; |
|
import { SocketProxyTask } from './transports/socketProxied'; |
|
import telegramMeWebManager from './telegramMeWebManager'; |
|
import { CacheStorageDbName } from '../cacheStorage'; |
|
import { pause } from '../../helpers/schedulers/pause'; |
|
import IS_WEBP_SUPPORTED from '../../environment/webpSupport'; |
|
import type { ApiError } from './apiManager'; |
|
|
|
type Task = { |
|
taskId: number, |
|
task: string, |
|
args: any[] |
|
}; |
|
|
|
type HashResult = { |
|
hash: number, |
|
result: any |
|
}; |
|
|
|
type HashOptions = { |
|
[queryJSON: string]: HashResult |
|
}; |
|
|
|
export interface ToggleStorageTask extends WorkerTaskVoidTemplate { |
|
type: 'toggleStorage', |
|
payload: boolean |
|
}; |
|
|
|
export class ApiManagerProxy extends CryptoWorkerMethods { |
|
public worker: /* Window */Worker; |
|
private afterMessageIdTemp = 0; |
|
|
|
private taskId = 0; |
|
private awaiting: { |
|
[id: number]: { |
|
resolve: any, |
|
reject: any, |
|
taskName: string |
|
} |
|
} = {} as any; |
|
private pending: Array<Task> = []; |
|
|
|
public updatesProcessor: (obj: any) => void = null; |
|
|
|
private log = logger('API-PROXY'); |
|
|
|
private hashes: {[method: string]: HashOptions} = {}; |
|
|
|
private apiPromisesSingleProcess: { |
|
[q: string]: Map<any, Promise<any>> |
|
} = {}; |
|
private apiPromisesSingle: { |
|
[q: string]: Promise<any> |
|
} = {}; |
|
private apiPromisesCacheable: { |
|
[method: string]: { |
|
[queryJSON: string]: { |
|
timestamp: number, |
|
promise: Promise<any>, |
|
fulfilled: boolean, |
|
timeout?: number, |
|
params: any |
|
} |
|
} |
|
} = {}; |
|
|
|
private isSWRegistered = true; |
|
|
|
private debug = DEBUG /* && false */; |
|
|
|
private sockets: Map<number, Socket> = new Map(); |
|
|
|
private taskListeners: {[taskType: string]: (task: any) => void} = {}; |
|
private taskListenersSW: {[taskType: string]: (task: any) => void} = {}; |
|
|
|
public onServiceWorkerFail: () => void; |
|
|
|
private postMessagesWaiting: any[][] = []; |
|
|
|
private getConfigPromise: Promise<Config.config>; |
|
|
|
constructor() { |
|
super(); |
|
this.log('constructor'); |
|
|
|
singleInstance.start(); |
|
|
|
this.registerServiceWorker(); |
|
|
|
this.addTaskListener('clear', () => { |
|
const toClear: CacheStorageDbName[] = ['cachedFiles', 'cachedStreamChunks']; |
|
Promise.all([ |
|
AppStorage.toggleStorage(false), |
|
sessionStorage.clear(), |
|
Promise.race([ |
|
telegramMeWebManager.setAuthorized(false), |
|
pause(3000) |
|
]), |
|
webPushApiManager.forceUnsubscribe(), |
|
Promise.all(toClear.map(cacheName => caches.delete(cacheName))) |
|
]).finally(() => { |
|
appRuntimeManager.reload(); |
|
}); |
|
}); |
|
|
|
this.addTaskListener('connectionStatusChange', (task: any) => { |
|
rootScope.dispatchEvent('connection_status_change', task.payload); |
|
}); |
|
|
|
this.addTaskListener('convertWebp', (task) => { |
|
webpWorkerController.postMessage(task); |
|
}); |
|
|
|
this.addTaskListener('socketProxy', (task: SocketProxyTask) => { |
|
const socketTask = task.payload; |
|
const id = socketTask.id; |
|
//console.log('socketProxy', socketTask, id); |
|
|
|
if(socketTask.type === 'send') { |
|
const socket = this.sockets.get(id); |
|
socket.send(socketTask.payload); |
|
} else if(socketTask.type === 'close') { // will remove from map in onClose |
|
const socket = this.sockets.get(id); |
|
socket.close(); |
|
} else if(socketTask.type === 'setup') { |
|
const socket = new Socket(socketTask.payload.dcId, socketTask.payload.url, socketTask.payload.logSuffix); |
|
|
|
const onOpen = () => { |
|
//console.log('socketProxy onOpen'); |
|
this.postMessage({ |
|
type: 'socketProxy', |
|
payload: { |
|
type: 'open', |
|
id |
|
} |
|
}); |
|
}; |
|
const onClose = () => { |
|
this.postMessage({ |
|
type: 'socketProxy', |
|
payload: { |
|
type: 'close', |
|
id |
|
} |
|
}); |
|
|
|
socket.removeEventListener('open', onOpen); |
|
socket.removeEventListener('close', onClose); |
|
socket.removeEventListener('message', onMessage); |
|
this.sockets.delete(id); |
|
}; |
|
const onMessage = (buffer: ArrayBuffer) => { |
|
this.postMessage({ |
|
type: 'socketProxy', |
|
payload: { |
|
type: 'message', |
|
id, |
|
payload: buffer |
|
} |
|
}); |
|
}; |
|
|
|
socket.addEventListener('open', onOpen); |
|
socket.addEventListener('close', onClose); |
|
socket.addEventListener('message', onMessage); |
|
this.sockets.set(id, socket); |
|
} |
|
}); |
|
|
|
this.addTaskListener('localStorageProxy', (task: LocalStorageProxyTask) => { |
|
const storageTask = task.payload; |
|
// @ts-ignore |
|
sessionStorage[storageTask.type](...storageTask.args).then(res => { |
|
this.postMessage({ |
|
type: 'localStorageProxy', |
|
id: task.id, |
|
payload: res |
|
} as LocalStorageProxyTaskResponse); |
|
}); |
|
}); |
|
|
|
rootScope.addEventListener('language_change', (language) => { |
|
this.performTaskWorkerVoid('setLanguage', language); |
|
}); |
|
|
|
window.addEventListener('online', (event) => { |
|
this.forceReconnectTimeout(); |
|
}); |
|
|
|
/// #if !MTPROTO_SW |
|
this.registerWorker(); |
|
/// #endif |
|
|
|
setTimeout(() => { |
|
this.getConfig(); |
|
}, 5000); |
|
} |
|
|
|
public isServiceWorkerOnline() { |
|
return this.isSWRegistered; |
|
} |
|
|
|
private registerServiceWorker() { |
|
if(!('serviceWorker' in navigator)) return; |
|
|
|
const worker = navigator.serviceWorker; |
|
worker.register('./sw.js', {scope: './'}).then(registration => { |
|
this.log('SW registered', registration); |
|
this.isSWRegistered = true; |
|
|
|
const sw = registration.installing || registration.waiting || registration.active; |
|
sw.addEventListener('statechange', (e) => { |
|
this.log('SW statechange', e); |
|
}); |
|
|
|
//this.postSWMessage = worker.controller.postMessage.bind(worker.controller); |
|
|
|
/// #if MTPROTO_SW |
|
const controller = worker.controller || registration.installing || registration.waiting || registration.active; |
|
this.onWorkerFirstMessage(controller); |
|
/// #endif |
|
}, (err) => { |
|
this.isSWRegistered = false; |
|
this.log.error('SW registration failed!', err); |
|
|
|
if(this.onServiceWorkerFail) { |
|
this.onServiceWorkerFail(); |
|
} |
|
}); |
|
|
|
worker.addEventListener('controllerchange', () => { |
|
this.log.warn('controllerchange'); |
|
this.releasePending(); |
|
|
|
worker.controller.addEventListener('error', (e) => { |
|
this.log.error('controller error:', e); |
|
}); |
|
}); |
|
|
|
/// #if MTPROTO_SW |
|
worker.addEventListener('message', this.onWorkerMessage); |
|
/// #else |
|
worker.addEventListener('message', (e) => { |
|
const task: ServiceWorkerTask = e.data; |
|
if(!isObject(task)) { |
|
return; |
|
} |
|
|
|
const callback = this.taskListenersSW[task.type]; |
|
if(callback) { |
|
callback(task); |
|
} |
|
}); |
|
|
|
this.addServiceWorkerTaskListener('requestFilePart', (task: RequestFilePartTask) => { |
|
const responseTask: RequestFilePartTaskResponse = { |
|
type: task.type, |
|
id: task.id |
|
}; |
|
|
|
this.performTaskWorker<Awaited<ReturnType<ApiFileManager['requestFilePart']>>>('requestFilePart', ...task.payload) |
|
.then((uploadFile) => { |
|
responseTask.payload = uploadFile; |
|
this.postSWMessage(responseTask); |
|
}, (err) => { |
|
responseTask.originalPayload = task.payload; |
|
responseTask.error = err; |
|
this.postSWMessage(responseTask); |
|
}); |
|
}); |
|
|
|
/// #endif |
|
|
|
worker.addEventListener('messageerror', (e) => { |
|
this.log.error('SW messageerror:', e); |
|
}); |
|
} |
|
|
|
public postMessage(...args: any[]) { |
|
this.postMessagesWaiting.push(args); |
|
} |
|
|
|
public postSWMessage(message: any) { |
|
if(navigator.serviceWorker.controller) { |
|
navigator.serviceWorker.controller.postMessage(message); |
|
} |
|
} |
|
|
|
private onWorkerFirstMessage(worker: any) { |
|
if(!this.worker) { |
|
this.worker = worker; |
|
this.log('set webWorker'); |
|
|
|
this.postMessage = this.worker.postMessage.bind(this.worker); |
|
|
|
this.postMessagesWaiting.forEach(args => this.postMessage(...args)); |
|
this.postMessagesWaiting.length = 0; |
|
|
|
const isWebpSupported = IS_WEBP_SUPPORTED; |
|
this.log('WebP supported:', isWebpSupported); |
|
this.postMessage({type: 'webpSupport', payload: isWebpSupported}); |
|
this.postMessage({type: 'userAgent', payload: navigator.userAgent}); |
|
|
|
this.releasePending(); |
|
} |
|
} |
|
|
|
public addTaskListener(name: keyof ApiManagerProxy['taskListeners'], callback: ApiManagerProxy['taskListeners'][typeof name]) { |
|
this.taskListeners[name] = callback; |
|
} |
|
|
|
public addServiceWorkerTaskListener(name: keyof ApiManagerProxy['taskListenersSW'], callback: ApiManagerProxy['taskListenersSW'][typeof name]) { |
|
this.taskListenersSW[name] = callback; |
|
} |
|
|
|
private onWorkerMessage = (e: MessageEvent) => { |
|
//this.log('got message from worker:', e.data); |
|
|
|
const task = e.data; |
|
|
|
if(!isObject(task)) { |
|
return; |
|
} |
|
|
|
const callback = this.taskListeners[task.type]; |
|
if(callback) { |
|
callback(task); |
|
return; |
|
} |
|
|
|
if(task.update) { |
|
if(this.updatesProcessor) { |
|
this.updatesProcessor(task.update); |
|
} |
|
} else if(task.progress) { |
|
rootScope.dispatchEvent('download_progress', task.progress); |
|
} else if(task.hasOwnProperty('result') || task.hasOwnProperty('error')) { |
|
this.finalizeTask(task.taskId, task.result, task.error); |
|
} |
|
}; |
|
|
|
/// #if !MTPROTO_SW |
|
private registerWorker() { |
|
// return; |
|
|
|
const worker = new MTProtoWorker(); |
|
//const worker = window; |
|
worker.addEventListener('message', this.onWorkerFirstMessage.bind(this, worker), {once: true}); |
|
worker.addEventListener('message', this.onWorkerMessage); |
|
|
|
worker.addEventListener('error', (err) => { |
|
this.log.error('WORKER ERROR', err); |
|
}); |
|
} |
|
/// #endif |
|
|
|
private finalizeTask(taskId: number, result: any, error: any) { |
|
const deferred = this.awaiting[taskId]; |
|
if(deferred !== undefined) { |
|
this.debug && this.log.debug('done', deferred.taskName, result, error); |
|
error ? deferred.reject(error) : deferred.resolve(result); |
|
delete this.awaiting[taskId]; |
|
} |
|
} |
|
|
|
public performTaskWorkerVoid(task: string, ...args: any[]) { |
|
const params = { |
|
task, |
|
taskId: this.taskId, |
|
args |
|
}; |
|
|
|
this.pending.push(params); |
|
this.releasePending(); |
|
|
|
this.taskId++; |
|
} |
|
|
|
public performTaskWorker<T>(task: string, ...args: any[]) { |
|
this.debug && this.log.debug('start', task, args); |
|
|
|
return new Promise<T>((resolve, reject) => { |
|
this.awaiting[this.taskId] = {resolve, reject, taskName: task}; |
|
this.performTaskWorkerVoid(task, ...args); |
|
}); |
|
} |
|
|
|
private releasePending() { |
|
//return; |
|
|
|
if(this.postMessage) { |
|
this.debug && this.log.debug('releasing tasks, length:', this.pending.length); |
|
this.pending.forEach(pending => { |
|
this.postMessage(pending); |
|
}); |
|
|
|
this.debug && this.log.debug('released tasks'); |
|
this.pending.length = 0; |
|
} |
|
} |
|
|
|
public setUpdatesProcessor(callback: (obj: any) => void) { |
|
this.updatesProcessor = callback; |
|
} |
|
|
|
public invokeApi<T extends keyof MethodDeclMap>(method: T, params: MethodDeclMap[T]['req'] = {}, options: InvokeApiOptions = {}): Promise<MethodDeclMap[T]['res']> { |
|
//console.log('will invokeApi:', method, params, options); |
|
return this.performTaskWorker('invokeApi', method, params, options); |
|
} |
|
|
|
public invokeApiAfter<T extends keyof MethodDeclMap>(method: T, params: MethodDeclMap[T]['req'] = {}, options: InvokeApiOptions = {}): Promise<MethodDeclMap[T]['res']> { |
|
let o = options; |
|
o.prepareTempMessageId = '' + ++this.afterMessageIdTemp; |
|
|
|
o = {...options}; |
|
(options as MTMessage).messageId = o.prepareTempMessageId; |
|
|
|
//console.log('will invokeApi:', method, params, options); |
|
return this.invokeApi(method, params, o); |
|
} |
|
|
|
public invokeApiHashable<T extends keyof MethodDeclMap>(method: T, params: Omit<MethodDeclMap[T]['req'], 'hash'> = {} as any, options: InvokeApiOptions = {}): Promise<MethodDeclMap[T]['res']> { |
|
//console.log('will invokeApi:', method, params, options); |
|
|
|
const queryJSON = JSON.stringify(params); |
|
let cached: HashResult; |
|
if(this.hashes[method]) { |
|
cached = this.hashes[method][queryJSON]; |
|
if(cached) { |
|
(params as any).hash = cached.hash; |
|
} |
|
} |
|
|
|
return this.invokeApi(method, params, options).then((result: any) => { |
|
if(result._.includes('NotModified')) { |
|
this.debug && this.log.warn('NotModified saved!', method, queryJSON); |
|
return cached.result; |
|
} |
|
|
|
if(result.hash/* || result.messages */) { |
|
const hash = result.hash/* || this.computeHash(result.messages) */; |
|
|
|
if(!this.hashes[method]) this.hashes[method] = {}; |
|
this.hashes[method][queryJSON] = { |
|
hash, |
|
result |
|
}; |
|
} |
|
|
|
return result; |
|
}); |
|
} |
|
|
|
public invokeApiSingle<T extends keyof MethodDeclMap>(method: T, params: MethodDeclMap[T]['req'] = {} as any, options: InvokeApiOptions = {}): Promise<MethodDeclMap[T]['res']> { |
|
const q = method + '-' + JSON.stringify(params); |
|
const cache = this.apiPromisesSingle; |
|
if(cache[q]) { |
|
return cache[q]; |
|
} |
|
|
|
return cache[q] = this.invokeApi(method, params, options).finally(() => { |
|
delete cache[q]; |
|
}); |
|
} |
|
|
|
public invokeApiSingleProcess<T extends keyof MethodDeclMap, R>(o: { |
|
method: T, |
|
processResult: (response: MethodDeclMap[T]['res']) => R, |
|
processError?: (error: ApiError) => any, |
|
params?: MethodDeclMap[T]['req'], |
|
options?: InvokeApiOptions & {cacheKey?: string} |
|
}): Promise<R> { |
|
o.params ??= {}; |
|
o.options ??= {}; |
|
|
|
const {method, processResult, processError, params, options} = o; |
|
const cache = this.apiPromisesSingleProcess; |
|
const cacheKey = options.cacheKey || JSON.stringify(params); |
|
const map = cache[method] ?? (cache[method] = new Map()); |
|
const oldPromise = map.get(cacheKey); |
|
if(oldPromise) { |
|
return oldPromise; |
|
} |
|
|
|
const originalPromise = this.invokeApi(method, params, options); |
|
const newPromise: Promise<R> = originalPromise.then(processResult, processError); |
|
|
|
const p = newPromise.finally(() => { |
|
map.delete(cacheKey); |
|
if(!map.size) { |
|
delete cache[method]; |
|
} |
|
}); |
|
|
|
map.set(cacheKey, p); |
|
return p; |
|
} |
|
|
|
public invokeApiCacheable<T extends keyof MethodDeclMap>(method: T, params: MethodDeclMap[T]['req'] = {} as any, options: InvokeApiOptions & Partial<{cacheSeconds: number, override: boolean}> = {}): Promise<MethodDeclMap[T]['res']> { |
|
const cache = this.apiPromisesCacheable[method] ?? (this.apiPromisesCacheable[method] = {}); |
|
const queryJSON = JSON.stringify(params); |
|
const item = cache[queryJSON]; |
|
if(item && (!options.override || !item.fulfilled)) { |
|
return item.promise; |
|
} |
|
|
|
if(options.override) { |
|
if(item && item.timeout) { |
|
clearTimeout(item.timeout); |
|
delete item.timeout; |
|
} |
|
|
|
delete options.override; |
|
} |
|
|
|
let timeout: number; |
|
if(options.cacheSeconds) { |
|
timeout = window.setTimeout(() => { |
|
delete cache[queryJSON]; |
|
}, options.cacheSeconds * 1000); |
|
delete options.cacheSeconds; |
|
} |
|
|
|
const promise = this.invokeApi(method, params, options); |
|
|
|
cache[queryJSON] = { |
|
timestamp: Date.now(), |
|
fulfilled: false, |
|
timeout, |
|
promise, |
|
params |
|
}; |
|
|
|
return promise; |
|
} |
|
|
|
public clearCache<T extends keyof MethodDeclMap>(method: T, verify: (params: MethodDeclMap[T]['req']) => boolean) { |
|
const cache = this.apiPromisesCacheable[method]; |
|
if(cache) { |
|
for(const queryJSON in cache) { |
|
const item = cache[queryJSON]; |
|
try { |
|
if(verify(item.params)) { |
|
if(item.timeout) { |
|
clearTimeout(item.timeout); |
|
} |
|
|
|
delete cache[queryJSON]; |
|
} |
|
} catch(err) { |
|
this.log.error('clearCache error:', err, queryJSON, item); |
|
} |
|
} |
|
} |
|
} |
|
|
|
/* private computeHash(smth: any[]) { |
|
smth = smth.slice().sort((a, b) => a.id - b.id); |
|
//return smth.reduce((hash, v) => (((hash * 0x4F25) & 0x7FFFFFFF) + v.id) & 0x7FFFFFFF, 0); |
|
return smth.reduce((hash, v) => ((hash * 20261) + 0x80000000 + v.id) % 0x80000000, 0); |
|
} */ |
|
|
|
public setBaseDcId(dcId: number) { |
|
return this.performTaskWorker('setBaseDcId', dcId); |
|
} |
|
|
|
public setQueueId(queueId: number) { |
|
return this.performTaskWorker('setQueueId', queueId); |
|
} |
|
|
|
public setUserAuth(userAuth: UserAuth | UserId) { |
|
if(typeof(userAuth) === 'string' || typeof(userAuth) === 'number') { |
|
userAuth = {dcID: 0, date: Date.now() / 1000 | 0, id: userAuth.toPeerId(false)}; |
|
} |
|
|
|
rootScope.dispatchEvent('user_auth', userAuth); |
|
return this.performTaskWorker('setUserAuth', userAuth); |
|
} |
|
|
|
public setUser(user: User) { |
|
// appUsersManager.saveApiUser(user); |
|
return this.setUserAuth(user.id); |
|
} |
|
|
|
public getNetworker(dc_id: number, options?: InvokeApiOptions) { |
|
return this.performTaskWorker('getNetworker', dc_id, options); |
|
} |
|
|
|
public logOut(): Promise<void> { |
|
// AppStorage.toggleStorage(false); |
|
return this.performTaskWorker('logOut'); |
|
} |
|
|
|
public cancelDownload(fileName: string) { |
|
return this.performTaskWorker('cancelDownload', fileName); |
|
} |
|
|
|
public downloadFile(options: DownloadOptions) { |
|
return this.performTaskWorker<Blob>('downloadFile', options); |
|
} |
|
|
|
public uploadFile(options: {file: Blob | File, fileName: string}) { |
|
return this.performTaskWorker<InputFile>('uploadFile', options); |
|
} |
|
|
|
public toggleStorage(enabled: boolean) { |
|
const task: ToggleStorageTask = {type: 'toggleStorage', payload: enabled}; |
|
this.postMessage(task); |
|
this.postSWMessage(task); |
|
} |
|
|
|
public stopAll() { |
|
return this.performTaskWorkerVoid('stopAll'); |
|
} |
|
|
|
public startAll() { |
|
return this.performTaskWorkerVoid('startAll'); |
|
} |
|
|
|
public forceReconnectTimeout() { |
|
this.postMessage({type: 'online'}); |
|
} |
|
|
|
public forceReconnect() { |
|
this.postMessage({type: 'forceReconnect'}); |
|
} |
|
|
|
public getConfig() { |
|
if(this.getConfigPromise) return this.getConfigPromise; |
|
return this.getConfigPromise = this.invokeApi('help.getConfig').then(config => { |
|
rootScope.config = config; |
|
return config; |
|
}); |
|
} |
|
} |
|
|
|
const apiManagerProxy = new ApiManagerProxy(); |
|
MOUNT_CLASS_TO.apiManagerProxy = apiManagerProxy; |
|
export default apiManagerProxy;
|
|
|