mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-18 04:43:55 +00:00
refac
This commit is contained in:
parent
613eac51c5
commit
7dc91de86c
39 changed files with 12438 additions and 84 deletions
|
|
@ -11,3 +11,7 @@ node_modules
|
|||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Generated and checksum-verified crypto distribution
|
||||
src/lib/encryption/vendor/
|
||||
crypto/encryption/target/
|
||||
|
|
|
|||
|
|
@ -311,4 +311,7 @@ cypress/screenshots
|
|||
|
||||
|
||||
|
||||
/static/*
|
||||
/static/*
|
||||
# Generated and checksum-verified crypto distribution
|
||||
src/lib/encryption/vendor/
|
||||
crypto/encryption/target/
|
||||
|
|
|
|||
|
|
@ -2055,6 +2055,8 @@ FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '')
|
|||
|
||||
ENABLE_CHANNELS = os.getenv('ENABLE_CHANNELS', 'False').lower() == 'true'
|
||||
|
||||
ENABLE_ENCRYPTED_CHANNELS = os.getenv('ENABLE_ENCRYPTED_CHANNELS', 'False').lower() == 'true'
|
||||
|
||||
ENABLE_APPS = os.getenv('ENABLE_APPS', 'False').lower() == 'true'
|
||||
|
||||
CHANNEL_MODEL_RESPONSE_MODE = os.getenv('CHANNEL_MODEL_RESPONSE_MODE', 'thread')
|
||||
|
|
@ -3179,6 +3181,7 @@ DEFAULT_CONFIG = {
|
|||
'folders.enable': ENABLE_FOLDERS,
|
||||
'folders.max_file_count': FOLDER_MAX_FILE_COUNT,
|
||||
'channels.enable': ENABLE_CHANNELS,
|
||||
'channels.encryption.enable': ENABLE_ENCRYPTED_CHANNELS,
|
||||
'apps.enable': ENABLE_APPS,
|
||||
'channels.model_response_mode': CHANNEL_MODEL_RESPONSE_MODE,
|
||||
'calendar.enable': ENABLE_CALENDAR,
|
||||
|
|
|
|||
|
|
@ -2338,6 +2338,7 @@ async def get_app_config(request: Request):
|
|||
'folders.enable',
|
||||
'folders.max_file_count',
|
||||
'channels.enable',
|
||||
'channels.encryption.enable',
|
||||
'apps.enable',
|
||||
'calendar.enable',
|
||||
'automations.enable',
|
||||
|
|
@ -2434,6 +2435,7 @@ async def get_app_config(request: Request):
|
|||
'enable_folders': config.get('folders.enable'),
|
||||
'folder_max_file_count': config.get('folders.max_file_count'),
|
||||
'enable_channels': config.get('channels.enable'),
|
||||
'enable_encrypted_channels': config.get('channels.encryption.enable'),
|
||||
'enable_apps': config.get('apps.enable'),
|
||||
'enable_calendar': config.get('calendar.enable'),
|
||||
'enable_automations': config.get('automations.enable'),
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ ADMIN_CONFIG_KEYS = {
|
|||
'AUTOMATION_MIN_INTERVAL': 'automations.min_interval',
|
||||
'ENABLE_AUTOMATIONS': 'automations.enable',
|
||||
'ENABLE_CHANNELS': 'channels.enable',
|
||||
'ENABLE_ENCRYPTED_CHANNELS': 'channels.encryption.enable',
|
||||
'ENABLE_APPS': 'apps.enable',
|
||||
'CHANNEL_MODEL_RESPONSE_MODE': 'channels.model_response_mode',
|
||||
'ENABLE_CALENDAR': 'calendar.enable',
|
||||
|
|
@ -1178,6 +1179,7 @@ class AdminConfig(MfaConfigForm):
|
|||
AUTOMATION_MIN_INTERVAL: int | str | None = None
|
||||
ENABLE_AUTOMATIONS: bool
|
||||
ENABLE_CHANNELS: bool
|
||||
ENABLE_ENCRYPTED_CHANNELS: bool = False
|
||||
ENABLE_APPS: bool = False
|
||||
CHANNEL_MODEL_RESPONSE_MODE: str = 'thread'
|
||||
ENABLE_CALENDAR: bool
|
||||
|
|
@ -1225,6 +1227,8 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep
|
|||
updates.pop(key, None)
|
||||
if 'ENABLE_LOGIN_FORM' not in form_data.model_fields_set:
|
||||
updates.pop('ui.enable_login_form', None)
|
||||
if 'ENABLE_ENCRYPTED_CHANNELS' not in form_data.model_fields_set:
|
||||
updates.pop('channels.encryption.enable', None)
|
||||
if 'ENABLE_APPS' not in form_data.model_fields_set:
|
||||
updates.pop('apps.enable', None)
|
||||
if 'I18N' not in form_data.model_fields_set:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from open_webui.models.users import (
|
|||
Users,
|
||||
)
|
||||
from open_webui.socket.main import (
|
||||
encryption,
|
||||
emit_to_users,
|
||||
enter_room_for_users,
|
||||
get_user_ids_from_room,
|
||||
|
|
@ -657,6 +658,7 @@ async def update_is_active_member_by_id_and_user_id(
|
|||
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
await encryption.invalidate(id)
|
||||
await Channels.update_member_active_status(channel.id, user.id, form_data.is_active, db=db)
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -753,6 +755,7 @@ async def remove_members_by_id(
|
|||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
await encryption.invalidate(id)
|
||||
deleted = await Channels.remove_members_from_channel(channel.id, form_data.user_ids, db=db)
|
||||
if channel.type == 'group':
|
||||
await leave_room_for_users(f'channel:{channel.id}', form_data.user_ids)
|
||||
|
|
@ -803,6 +806,7 @@ async def update_channel_by_id(
|
|||
previous_access_grants = channel.access_grants
|
||||
|
||||
try:
|
||||
await encryption.invalidate(id)
|
||||
channel = await Channels.update_channel_by_id(id, form_data, db=db)
|
||||
# Group and DM channels use membership instead of access grants.
|
||||
if form_data.access_grants is not None and channel.type not in ['group', 'dm']:
|
||||
|
|
@ -847,6 +851,7 @@ async def delete_channel_by_id(
|
|||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
await encryption.invalidate(id)
|
||||
await Channels.delete_channel_by_id(id, db=db)
|
||||
await sio.close_room(f'channel:{id}')
|
||||
await publish_event(
|
||||
|
|
|
|||
401
backend/open_webui/socket/encryption.py
Normal file
401
backend/open_webui/socket/encryption.py
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
"""Volatile packet relay. Only account bindings and liveness enter coordination storage."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from uuid import uuid4
|
||||
|
||||
from open_webui.env import (
|
||||
ENABLE_WEBSOCKET_SUPPORT,
|
||||
ENV,
|
||||
REDIS_KEY_PREFIX,
|
||||
WEBSOCKET_SERVER_ENGINEIO_LOGGING,
|
||||
WEBSOCKET_SERVER_LOGGING,
|
||||
)
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.access_control import has_permission
|
||||
|
||||
MAX_PACKET = 1024 * 1024
|
||||
TTL = 90
|
||||
|
||||
|
||||
class Rejected(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def require(condition):
|
||||
if not condition:
|
||||
raise Rejected()
|
||||
|
||||
|
||||
def packet(value):
|
||||
require(isinstance(value, str) and 0 < len(value) <= (MAX_PACKET + 2) // 3 * 4)
|
||||
try:
|
||||
decoded = base64.b64decode(value, validate=True)
|
||||
except (ValueError, TypeError):
|
||||
raise Rejected() from None
|
||||
require(0 < len(decoded) <= MAX_PACKET)
|
||||
return value
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self, redis=None):
|
||||
self.redis = redis
|
||||
self.values = {}
|
||||
self.lock = asyncio.Lock()
|
||||
self.lease = ContextVar('encryption_lease', default=None)
|
||||
|
||||
def key(self, channel):
|
||||
digest = hashlib.sha256(channel.encode()).hexdigest()
|
||||
return f'{REDIS_KEY_PREFIX}:encryption:{{{digest}}}'
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self, channel):
|
||||
key = self.key(channel)
|
||||
lock = self.redis.lock(key + ':lock', timeout=10, blocking_timeout=2) if self.redis else self.lock
|
||||
async with lock:
|
||||
raw = await self.redis.get(key) if self.redis else self.values.get(key)
|
||||
state = json.loads(raw) if raw else None
|
||||
if state and state['expires'] <= time.time():
|
||||
state = None
|
||||
token = self.lease.set(lock if self.redis else None)
|
||||
try:
|
||||
yield state
|
||||
finally:
|
||||
self.lease.reset(token)
|
||||
|
||||
async def save(self, channel, state):
|
||||
key = self.key(channel)
|
||||
if state is not None:
|
||||
state['expires'] = time.time() + TTL
|
||||
raw = json.dumps(state, separators=(',', ':')) if state is not None else ''
|
||||
if self.redis:
|
||||
lease = self.lease.get()
|
||||
require(lease is not None)
|
||||
# A stalled worker must never overwrite a newer owner's metadata.
|
||||
changed = await self.redis.eval(
|
||||
"if redis.call('get', KEYS[2]) ~= ARGV[1] then return 0 end "
|
||||
"if ARGV[2] == '' then redis.call('del', KEYS[1]) "
|
||||
"else redis.call('set', KEYS[1], ARGV[2], 'EX', ARGV[3]) end return 1",
|
||||
2,
|
||||
key,
|
||||
key + ':lock',
|
||||
lease.local.token,
|
||||
raw,
|
||||
TTL,
|
||||
)
|
||||
if not changed:
|
||||
raise RuntimeError('Coordination lease expired')
|
||||
elif state is None:
|
||||
self.values.pop(key, None)
|
||||
else:
|
||||
self.values[key] = raw
|
||||
|
||||
|
||||
class Relay:
|
||||
def __init__(self, sio, authenticate, redis=None):
|
||||
self.sio, self.authenticate = sio, authenticate
|
||||
self.registry = Registry(redis)
|
||||
self.local = {} # sid -> channel, never payloads
|
||||
self.rates = {}
|
||||
for action in ('start', 'status', 'join', 'bind', 'packet', 'leave', 'ping'):
|
||||
|
||||
async def handler(sid, data=None, action=action):
|
||||
return await self.handle(action, sid, data)
|
||||
|
||||
sio.on(f'encryption:{action}', handler)
|
||||
|
||||
async def authorize(self, sid, channel):
|
||||
require(await Config.get('channels.encryption.enable'))
|
||||
require(ENABLE_WEBSOCKET_SUPPORT and self.sio.transport(sid) == 'websocket')
|
||||
require(not WEBSOCKET_SERVER_LOGGING and not WEBSOCKET_SERVER_ENGINEIO_LOGGING)
|
||||
scope = (self.sio.get_environ(sid) or {}).get('asgi.scope', {})
|
||||
require(ENV != 'prod' or scope.get('scheme') in ('https', 'wss'))
|
||||
auth = await self.authenticate(sid, wait_for_disconnect=False)
|
||||
require(auth and auth.get('id'))
|
||||
user = await Users.get_user_by_id(auth['id'])
|
||||
require(user and user.role in ('user', 'admin'))
|
||||
require(await Config.get('channels.enable'))
|
||||
require(
|
||||
user.role == 'admin'
|
||||
or await has_permission(user.id, 'features.channels', await Config.get('user.permissions'))
|
||||
)
|
||||
record = await Channels.get_channel_by_id(channel)
|
||||
require(record and (record.type == 'dm' or (record.type == 'group' and record.is_private is True)))
|
||||
require(await Channels.is_user_channel_member(channel, user.id))
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def view(state):
|
||||
if not state:
|
||||
return None
|
||||
return {
|
||||
'channel': state['channel'],
|
||||
'session': state['session'],
|
||||
'host': state['host'],
|
||||
'bindings': [
|
||||
{
|
||||
'identity': {
|
||||
'channel': state['channel'],
|
||||
'session': state['session'],
|
||||
'user': p['user'],
|
||||
'participation': pid,
|
||||
},
|
||||
'key': p['key'],
|
||||
}
|
||||
for pid, p in state['members'].items()
|
||||
if p.get('key')
|
||||
],
|
||||
'participants': [
|
||||
{'participation': pid, 'user': p['user'], 'name': p['name']} for pid, p in state['members'].items()
|
||||
],
|
||||
}
|
||||
|
||||
async def invite(self, state, channel):
|
||||
await self.sio.emit(
|
||||
'encryption:available',
|
||||
{'channel': channel, 'session': state['session'] if state else None},
|
||||
room=f'channel:{channel}',
|
||||
)
|
||||
|
||||
async def deliver(self, state, recipients, event):
|
||||
# Per-recipient sequence detects missing Pub/Sub deliveries without storing packets.
|
||||
targets = []
|
||||
for pid in recipients:
|
||||
member = state['members'][pid]
|
||||
member['out'] += 1
|
||||
targets.append((member['sid'], member['out']))
|
||||
await self.registry.save(state['channel'], state)
|
||||
for sid, sequence in targets:
|
||||
await self.sio.emit('encryption:event', {**event, **self.view(state), 'sequence': sequence}, to=sid)
|
||||
|
||||
async def close(self, channel, state):
|
||||
await self.registry.save(channel, None)
|
||||
if state:
|
||||
for p in state['members'].values():
|
||||
self.local.pop(p['sid'], None)
|
||||
await self.sio.emit(
|
||||
'encryption:event', {'kind': 'ended', 'channel': channel, 'session': state['session']}, to=p['sid']
|
||||
)
|
||||
await self.invite(None, channel)
|
||||
|
||||
async def prune(self, state):
|
||||
if not state:
|
||||
return None
|
||||
members = await Channels.get_members_by_channel_id(state['channel'])
|
||||
active = {m.user_id for m in members if m.is_active}
|
||||
users = await Users.get_users_by_user_ids([p['user'] for p in state['members'].values()])
|
||||
humans = {u.id for u in users if u.role in ('user', 'admin')}
|
||||
stale = []
|
||||
for pid, p in state['members'].items():
|
||||
if p['seen'] < time.time() - 45 or p['user'] not in active or p['user'] not in humans:
|
||||
stale.append(pid)
|
||||
if state['host'] in stale:
|
||||
await self.close(state['channel'], state)
|
||||
return None
|
||||
for pid in stale:
|
||||
await self.remove(state, pid)
|
||||
return state
|
||||
|
||||
async def remove(self, state, pid):
|
||||
if pid == state['host']:
|
||||
await self.close(state['channel'], state)
|
||||
return
|
||||
p = state['members'].pop(pid)
|
||||
self.local.pop(p['sid'], None)
|
||||
await self.sio.emit(
|
||||
'encryption:event', {'kind': 'ended', 'channel': state['channel'], 'session': state['session']}, to=p['sid']
|
||||
)
|
||||
await self.deliver(
|
||||
state,
|
||||
[i for i, m in state['members'].items() if m['admitted']],
|
||||
{'kind': 'departed', 'participation': pid, 'admitted': p['admitted']},
|
||||
)
|
||||
|
||||
async def limit(self, user, action, size):
|
||||
now = time.monotonic()
|
||||
if len(self.rates) > 10000:
|
||||
self.rates = {k: v for k, v in self.rates.items() if now - v[0] < 60}
|
||||
if self.registry.redis:
|
||||
bucket = 'admission' if action in ('start', 'join') else 'traffic'
|
||||
period, maximum = (60, 10) if bucket == 'admission' else (1, 100)
|
||||
key = self.registry.key('rate:' + user + ':' + bucket)
|
||||
allowed = await self.registry.redis.eval(
|
||||
"local n = redis.call('hincrby', KEYS[1], 'count', 1) "
|
||||
"local b = redis.call('hincrby', KEYS[1], 'bytes', ARGV[1]) "
|
||||
"if n == 1 then redis.call('expire', KEYS[1], ARGV[2]) end "
|
||||
'if n > tonumber(ARGV[3]) or b > tonumber(ARGV[4]) then return 0 end return 1',
|
||||
1,
|
||||
key,
|
||||
size,
|
||||
period,
|
||||
maximum,
|
||||
4 * MAX_PACKET,
|
||||
)
|
||||
require(allowed)
|
||||
return
|
||||
# Account-wide admission bound also applies across tabs on this worker.
|
||||
key = (user, 'admission' if action in ('start', 'join') else 'traffic')
|
||||
period, maximum = (60, 10) if key[1] == 'admission' else (1, 100)
|
||||
start, count, total = self.rates.get(key, (now, 0, 0))
|
||||
if now - start >= period:
|
||||
start, count, total = now, 0, 0
|
||||
require(count < maximum and total + size <= 4 * MAX_PACKET)
|
||||
self.rates[key] = (start, count + 1, total + size)
|
||||
|
||||
async def reserve(self, action, sid, user, channel, data, state):
|
||||
# Another worker may have closed the session; its metadata is authoritative.
|
||||
if self.local.get(sid) == channel and (
|
||||
not state or not any(p['sid'] == sid for p in state['members'].values())
|
||||
):
|
||||
self.local.pop(sid, None)
|
||||
require(sid not in self.local)
|
||||
if action == 'start':
|
||||
require(state is None)
|
||||
pid = str(uuid4())
|
||||
state = {'channel': channel, 'session': str(uuid4()), 'host': pid, 'members': {}}
|
||||
else:
|
||||
require(state and data.get('session') == state['session'])
|
||||
require(len(state['members']) < 128 and not any(p['user'] == user.id for p in state['members'].values()))
|
||||
require(state['members'][state['host']].get('key'))
|
||||
pid = str(uuid4())
|
||||
state['members'][pid] = {
|
||||
'sid': sid,
|
||||
'user': user.id,
|
||||
'name': user.name,
|
||||
'key': None,
|
||||
'seen': time.time(),
|
||||
'out': 0,
|
||||
'admitted': action == 'start',
|
||||
}
|
||||
self.local[sid] = channel
|
||||
await self.registry.save(channel, state)
|
||||
return {'ok': True, **self.view(state), 'participation': pid}
|
||||
|
||||
async def bind(self, state, pid, member, data):
|
||||
require(member['key'] is None)
|
||||
key = packet(data.get('key'))
|
||||
require(len(base64.b64decode(key)) == 32)
|
||||
member['key'] = key
|
||||
if pid != state['host']:
|
||||
kp = packet(data.get('packet'))
|
||||
await self.deliver(state, [state['host']], {'kind': 'candidate', 'sender': pid, 'packet': kp})
|
||||
else:
|
||||
await self.registry.save(state['channel'], state)
|
||||
await self.invite(state, state['channel'])
|
||||
|
||||
async def forward(self, state, pid, member, data):
|
||||
require(member['key'] and member['admitted'])
|
||||
body = packet(data.get('packet'))
|
||||
if data.get('kind') == 'admit':
|
||||
require(pid == state['host'])
|
||||
target = data.get('target')
|
||||
joining = state['members'].get(target)
|
||||
require(joining and joining.get('key') and not joining['admitted'])
|
||||
welcome = packet(data.get('welcome'))
|
||||
existing = [i for i, p in state['members'].items() if i != pid and p['admitted']]
|
||||
joining['admitted'] = True
|
||||
await self.deliver(state, existing, {'kind': 'mls', 'sender': pid, 'packet': body})
|
||||
await self.deliver(state, [target], {'kind': 'welcome', 'sender': pid, 'packet': welcome})
|
||||
else:
|
||||
require(data.get('kind') == 'mls')
|
||||
recipients = [i for i, p in state['members'].items() if i != pid and p['admitted']]
|
||||
await self.deliver(state, recipients, {'kind': 'mls', 'sender': pid, 'packet': body})
|
||||
|
||||
async def handle(self, action, sid, data):
|
||||
channel = None
|
||||
authorized = False
|
||||
try:
|
||||
require(isinstance(data, dict))
|
||||
channel = data.get('channel')
|
||||
require(isinstance(channel, str) and 0 < len(channel) <= 128)
|
||||
size = len(json.dumps(data))
|
||||
require(size <= 3 * MAX_PACKET)
|
||||
user = await self.authorize(sid, channel)
|
||||
authorized = True
|
||||
await self.limit(user.id, action, size)
|
||||
async with self.registry.transaction(channel) as state:
|
||||
state = await self.prune(state)
|
||||
if action == 'status':
|
||||
return {
|
||||
'ok': True,
|
||||
'available': bool(state and state['members'][state['host']].get('key')),
|
||||
'session': state['session'] if state else None,
|
||||
}
|
||||
if action in ('start', 'join'):
|
||||
return await self.reserve(action, sid, user, channel, data, state)
|
||||
require(state and data.get('session') == state['session'] and self.local.get(sid) == channel)
|
||||
pid = data.get('participation')
|
||||
member = state['members'].get(pid)
|
||||
require(member and member['sid'] == sid and member['user'] == user.id)
|
||||
member['seen'] = time.time()
|
||||
if action == 'leave':
|
||||
await self.remove(state, pid)
|
||||
return {'ok': True}
|
||||
if action == 'bind':
|
||||
await self.bind(state, pid, member, data)
|
||||
elif action == 'ping':
|
||||
await self.registry.save(channel, state)
|
||||
elif action == 'packet':
|
||||
await self.forward(state, pid, member, data)
|
||||
else:
|
||||
raise Rejected()
|
||||
return {'ok': True, **self.view(state)}
|
||||
except Rejected:
|
||||
return {'ok': False, 'error': 'Private chat is unavailable or the session changed.'}
|
||||
except Exception:
|
||||
# Never log exceptions carrying packet input. Fail closed on coordinator failure.
|
||||
if authorized:
|
||||
await self.fail_local(channel)
|
||||
return {'ok': False, 'error': 'Private chat ended.'}
|
||||
|
||||
async def fail_local(self, channel):
|
||||
for sid, local_channel in list(self.local.items()):
|
||||
if channel is None or local_channel == channel:
|
||||
self.local.pop(sid, None)
|
||||
await self.sio.emit(
|
||||
'encryption:event', {'kind': 'ended', 'channel': local_channel}, to=sid, ignore_queue=True
|
||||
)
|
||||
|
||||
async def disconnect(self, sid):
|
||||
channel = self.local.pop(sid, None)
|
||||
if not channel:
|
||||
return
|
||||
try:
|
||||
async with self.registry.transaction(channel) as state:
|
||||
if state:
|
||||
for pid, p in list(state['members'].items()):
|
||||
if p['sid'] == sid:
|
||||
await self.remove(state, pid)
|
||||
break
|
||||
except Exception:
|
||||
await self.fail_local(channel)
|
||||
|
||||
async def invalidate(self, channel):
|
||||
# Channel access mutations end the session before any further private sends.
|
||||
try:
|
||||
async with self.registry.transaction(channel) as state:
|
||||
await self.close(channel, state)
|
||||
except Exception:
|
||||
await self.fail_local(channel)
|
||||
|
||||
async def sweep(self):
|
||||
for channel in set(self.local.values()):
|
||||
try:
|
||||
async with self.registry.transaction(channel) as state:
|
||||
if not state:
|
||||
await self.fail_local(channel)
|
||||
else:
|
||||
await self.prune(state)
|
||||
except Exception:
|
||||
await self.fail_local(channel)
|
||||
if not self.registry.redis:
|
||||
for key, raw in list(self.registry.values.items()):
|
||||
if json.loads(raw)['expires'] <= time.time():
|
||||
self.registry.values.pop(key, None)
|
||||
|
|
@ -42,6 +42,7 @@ from open_webui.models.config import Config
|
|||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.notes import Notes, NoteUpdateForm
|
||||
from open_webui.models.users import UserNameResponse, Users
|
||||
from open_webui.socket.encryption import Relay
|
||||
from open_webui.socket.redis_room_channels import AsyncRedisRoomChannelManager
|
||||
from open_webui.socket.utils import SOCKET_EVENT_LOCKS, CachedRedisDict, RedisDict, RedisLock, YdocManager
|
||||
from open_webui.tasks import (
|
||||
|
|
@ -355,6 +356,7 @@ LOCAL_AUTHENTICATED_SIDS: set[str] = set()
|
|||
async def periodic_socket_authentication():
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
await encryption.sweep()
|
||||
for sid in tuple(LOCAL_AUTHENTICATED_SIDS):
|
||||
await get_socket_session_user(sid)
|
||||
|
||||
|
|
@ -376,6 +378,9 @@ async def get_socket_session_user(sid: str, *, wait_for_disconnect: bool = True)
|
|||
return None
|
||||
|
||||
|
||||
encryption = Relay(sio, get_socket_session_user, REDIS)
|
||||
|
||||
|
||||
def get_session_ids_from_room(room):
|
||||
"""Get all session IDs from a specific room."""
|
||||
members = get_room_sid_map(sio.manager, '/', room)
|
||||
|
|
@ -1128,6 +1133,7 @@ async def yjs_awareness_update(sid, data):
|
|||
|
||||
@sio.event
|
||||
async def disconnect(sid, reason=None):
|
||||
await encryption.disconnect(sid)
|
||||
LOCAL_AUTHENTICATED_SIDS.discard(sid)
|
||||
if sid in SESSION_POOL:
|
||||
del SESSION_POOL[sid]
|
||||
|
|
|
|||
1
crypto/encryption/.gitignore
vendored
Normal file
1
crypto/encryption/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target/
|
||||
2026
crypto/encryption/Cargo.lock
generated
Normal file
2026
crypto/encryption/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
28
crypto/encryption/Cargo.toml
Normal file
28
crypto/encryption/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[package]
|
||||
name = "encryption"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openmls = { version = "=0.9.1", features = ["js"] }
|
||||
openmls_traits = "=0.6.0"
|
||||
openmls_rust_crypto = "=0.6.0"
|
||||
openmls_basic_credential = "=0.6.0"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
wasm-bindgen = "=0.2.126"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tls_codec = "0.5"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
4
crypto/encryption/rust-toolchain.toml
Normal file
4
crypto/encryption/rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[toolchain]
|
||||
channel = "1.95.0"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
profile = "minimal"
|
||||
267
crypto/encryption/src/lib.rs
Normal file
267
crypto/encryption/src/lib.rs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
//! The browser worker is the only owner. No persistence, export, or logging API.
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use openmls::prelude::*;
|
||||
use openmls_basic_credential::SignatureKeyPair;
|
||||
use openmls_rust_crypto::OpenMlsRustCrypto;
|
||||
use openmls_traits::OpenMlsProvider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tls_codec::{Deserialize as TlsDeserialize, Serialize as TlsSerialize};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
const SUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
|
||||
const MAX_PACKET: usize = 1024 * 1024;
|
||||
fn err<T>(_: T) -> String { "Invalid session operation".into() }
|
||||
fn require(ok: bool) -> Result<(), String> { if ok { Ok(()) } else { Err(err(())) } }
|
||||
fn encode<T: TlsSerialize>(v: &T) -> Result<String, String> { Ok(B64.encode(v.tls_serialize_detached().map_err(err)?)) }
|
||||
fn decode(v: &str) -> Result<Vec<u8>, String> {
|
||||
require(v.len() <= MAX_PACKET * 4 / 3 + 4)?;
|
||||
let bytes = B64.decode(v).map_err(err)?;
|
||||
require(bytes.len() <= MAX_PACKET)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Identity { channel: String, session: String, user: String, participation: String }
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Binding { identity: Identity, key: String }
|
||||
fn identity(cred: &Credential) -> Result<Identity, String> {
|
||||
let basic = BasicCredential::try_from(cred.clone()).map_err(err)?;
|
||||
serde_json::from_slice(basic.identity()).map_err(err)
|
||||
}
|
||||
fn check(cred: &Credential, key: &[u8], context: &Identity, roster: &[Binding]) -> Result<Identity, String> {
|
||||
let id = identity(cred)?;
|
||||
require(id.channel == context.channel && id.session == context.session)?;
|
||||
require(roster.iter().any(|b| b.identity == id && b.key == B64.encode(key)))?;
|
||||
Ok(id)
|
||||
}
|
||||
fn roster(value: &str, context: &Identity) -> Result<Vec<Binding>, String> {
|
||||
let roster: Vec<Binding> = serde_json::from_str(value).map_err(err)?;
|
||||
require(!roster.is_empty() && roster.len() <= 128)?;
|
||||
let mut users = std::collections::HashSet::new();
|
||||
let mut participations = std::collections::HashSet::new();
|
||||
for b in &roster {
|
||||
require(b.identity.channel == context.channel && b.identity.session == context.session
|
||||
&& users.insert(&b.identity.user) && participations.insert(&b.identity.participation)
|
||||
&& decode(&b.key)?.len() == 32)?;
|
||||
}
|
||||
Ok(roster)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Session {
|
||||
provider: OpenMlsRustCrypto,
|
||||
signer: SignatureKeyPair,
|
||||
credential: CredentialWithKey,
|
||||
id: Identity,
|
||||
host: String,
|
||||
group: Option<MlsGroup>,
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
impl Session {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(identity_json: &str, host: &str) -> Result<Session, String> {
|
||||
let id: Identity = serde_json::from_str(identity_json).map_err(err)?;
|
||||
require([&id.channel, &id.session, &id.user, &id.participation].iter()
|
||||
.all(|s| !s.is_empty() && s.len() <= 128) && !host.is_empty())?;
|
||||
let provider = OpenMlsRustCrypto::default();
|
||||
let signer = SignatureKeyPair::new(SignatureScheme::ED25519).map_err(err)?;
|
||||
signer.store(provider.storage()).map_err(err)?;
|
||||
let credential = CredentialWithKey {
|
||||
credential: BasicCredential::new(serde_json::to_vec(&id).map_err(err)?).into(),
|
||||
signature_key: signer.public().into(),
|
||||
};
|
||||
Ok(Self { provider, signer, credential, id, host: host.into(), group: None })
|
||||
}
|
||||
pub fn public_key(&self) -> String { B64.encode(self.signer.public()) }
|
||||
pub fn key_package(&self) -> Result<String, String> {
|
||||
require(self.group.is_none())?;
|
||||
let kp = KeyPackage::builder().build(SUITE, &self.provider, &self.signer, self.credential.clone()).map_err(err)?;
|
||||
encode(kp.key_package())
|
||||
}
|
||||
pub fn create(&mut self) -> Result<(), String> {
|
||||
require(self.group.is_none() && self.id.participation == self.host)?;
|
||||
self.group = Some(MlsGroup::builder().ciphersuite(SUITE)
|
||||
.use_ratchet_tree_extension(true)
|
||||
.set_past_epoch_deletion_policy(PastEpochDeletionPolicy::MaxEpochs(0))
|
||||
.with_group_id(GroupId::from_slice(self.id.session.as_bytes()))
|
||||
.build(&self.provider, &self.signer, self.credential.clone()).map_err(err)?);
|
||||
Ok(())
|
||||
}
|
||||
pub fn join(&mut self, welcome: &str, bindings: &str) -> Result<(), String> {
|
||||
require(self.group.is_none() && self.id.participation != self.host)?;
|
||||
let roster = roster(bindings, &self.id)?;
|
||||
let msg = MlsMessageIn::tls_deserialize_exact(decode(welcome)?).map_err(err)?;
|
||||
let welcome = match msg.extract() { MlsMessageBodyIn::Welcome(w) => w, _ => return Err(err(())) };
|
||||
let config = MlsGroupJoinConfig::builder().use_ratchet_tree_extension(true)
|
||||
.set_past_epoch_deletion_policy(PastEpochDeletionPolicy::MaxEpochs(0)).build();
|
||||
let group = StagedWelcome::new_from_welcome(&self.provider, &config, welcome, None).map_err(err)?
|
||||
.into_group(&self.provider).map_err(err)?;
|
||||
require(group.group_id().as_slice() == self.id.session.as_bytes() && group.ciphersuite() == SUITE)?;
|
||||
let mut users = std::collections::HashSet::new();
|
||||
let mut found_host = false;
|
||||
for m in group.members() {
|
||||
let id = check(&m.credential, &m.signature_key, &self.id, &roster)?;
|
||||
require(users.insert(id.user))?;
|
||||
found_host |= id.participation == self.host;
|
||||
}
|
||||
require(found_host)?;
|
||||
self.group = Some(group);
|
||||
Ok(())
|
||||
}
|
||||
pub fn add(&mut self, key_package: &str, expected: &str, bindings: &str) -> Result<String, String> {
|
||||
require(self.id.participation == self.host)?;
|
||||
let roster = roster(bindings, &self.id)?;
|
||||
let kp = KeyPackageIn::tls_deserialize_exact(decode(key_package)?).map_err(err)?
|
||||
.validate(self.provider.crypto(), ProtocolVersion::Mls10).map_err(err)?;
|
||||
let id = check(kp.leaf_node().credential(), kp.leaf_node().signature_key().as_slice(), &self.id, &roster)?;
|
||||
require(id.participation == expected)?;
|
||||
let group = self.group.as_mut().ok_or_else(|| err(()))?;
|
||||
require(group.members().count() < 128 && !group.members().any(|m| identity(&m.credential).is_ok_and(|i| i.user == id.user)))?;
|
||||
let (commit, welcome, _) = group.add_members(&self.provider, &self.signer, &[kp]).map_err(err)?;
|
||||
let result = json!({"commit": encode(&commit)?, "welcome": encode(&welcome)?});
|
||||
group.merge_pending_commit(&self.provider).map_err(err)?;
|
||||
Ok(result.to_string())
|
||||
}
|
||||
pub fn remove(&mut self, participation: &str, bindings: &str) -> Result<String, String> {
|
||||
require(self.id.participation == self.host && participation != self.host)?;
|
||||
let roster = roster(bindings, &self.id)?;
|
||||
require(!roster.iter().any(|b| b.identity.participation == participation))?;
|
||||
let group = self.group.as_mut().ok_or_else(|| err(()))?;
|
||||
let index = group.members().find(|m| identity(&m.credential).is_ok_and(|i| i.participation == participation))
|
||||
.ok_or_else(|| err(()))?.index;
|
||||
let (commit, _, _) = group.remove_members(&self.provider, &self.signer, &[index]).map_err(err)?;
|
||||
let out = encode(&commit)?;
|
||||
group.merge_pending_commit(&self.provider).map_err(err)?;
|
||||
Ok(out)
|
||||
}
|
||||
pub fn update(&mut self) -> Result<String, String> {
|
||||
let group = self.group.as_mut().ok_or_else(|| err(()))?;
|
||||
if self.id.participation == self.host {
|
||||
let bundle = group.self_update(&self.provider, &self.signer, LeafNodeParameters::default()).map_err(err)?;
|
||||
let out = encode(bundle.commit())?;
|
||||
group.merge_pending_commit(&self.provider).map_err(err)?;
|
||||
Ok(out)
|
||||
} else {
|
||||
let (proposal, _) = group.propose_self_update(&self.provider, &self.signer, LeafNodeParameters::default()).map_err(err)?;
|
||||
encode(&proposal)
|
||||
}
|
||||
}
|
||||
pub fn commit_updates(&mut self) -> Result<String, String> {
|
||||
require(self.id.participation == self.host)?;
|
||||
let group = self.group.as_mut().ok_or_else(|| err(()))?;
|
||||
let (commit, _, _) = group.commit_to_pending_proposals(&self.provider, &self.signer).map_err(err)?;
|
||||
let out = encode(&commit)?;
|
||||
group.merge_pending_commit(&self.provider).map_err(err)?;
|
||||
Ok(out)
|
||||
}
|
||||
pub fn encrypt(&mut self, text: &str) -> Result<String, String> {
|
||||
require(text.len() <= 100 * 1024)?; // Includes the authenticated receipt/message envelope.
|
||||
let group = self.group.as_mut().ok_or_else(|| err(()))?;
|
||||
encode(&group.create_message(&self.provider, &self.signer, text.as_bytes()).map_err(err)?)
|
||||
}
|
||||
pub fn process(&mut self, packet: &str, expected: &str, bindings: &str) -> Result<String, String> {
|
||||
let roster = roster(bindings, &self.id)?;
|
||||
let group = self.group.as_mut().ok_or_else(|| err(()))?;
|
||||
let msg = MlsMessageIn::tls_deserialize_exact(decode(packet)?).map_err(err)?;
|
||||
let processed = group.process_message(&self.provider, msg.try_into_protocol_message().map_err(err)?).map_err(err)?;
|
||||
let index = match processed.sender() { Sender::Member(i) => *i, _ => return Err(err(())) };
|
||||
let member = group.member_at(index).ok_or_else(|| err(()))?;
|
||||
let sender = check(processed.credential(), &member.signature_key, &self.id, &roster)?;
|
||||
require(sender.participation == expected)?;
|
||||
let result = match processed.into_content() {
|
||||
ProcessedMessageContent::ApplicationMessage(m) => {
|
||||
let bytes = m.into_bytes();
|
||||
require(bytes.len() <= 100 * 1024)?;
|
||||
json!({"kind":"application", "sender":sender, "text":String::from_utf8(bytes).map_err(err)?})
|
||||
},
|
||||
ProcessedMessageContent::ProposalMessage(p) => {
|
||||
match p.proposal() {
|
||||
Proposal::Update(u) => {
|
||||
let id = check(u.leaf_node().credential(), u.leaf_node().signature_key().as_slice(), &self.id, &roster)?;
|
||||
require(id == sender)?;
|
||||
},
|
||||
_ => return Err(err(())),
|
||||
}
|
||||
group.store_pending_proposal(self.provider.storage(), *p).map_err(err)?;
|
||||
json!({"kind":"proposal"})
|
||||
},
|
||||
ProcessedMessageContent::StagedCommitMessage(c) => {
|
||||
require(sender.participation == self.host)?;
|
||||
if let Some(leaf) = c.update_path_leaf_node() {
|
||||
require(check(leaf.credential(), leaf.signature_key().as_slice(), &self.id, &roster)? == sender)?;
|
||||
}
|
||||
let mut added_users = std::collections::HashSet::new();
|
||||
for p in c.queued_proposals() {
|
||||
match p.proposal() {
|
||||
Proposal::Add(a) => {
|
||||
let leaf = a.key_package().leaf_node();
|
||||
let id = check(leaf.credential(), leaf.signature_key().as_slice(), &self.id, &roster)?;
|
||||
require(added_users.insert(id.user.clone()) && !group.members().any(|m| identity(&m.credential).is_ok_and(|i| i.user == id.user)))?;
|
||||
},
|
||||
Proposal::Remove(r) => {
|
||||
let m = group.member_at(r.removed()).ok_or_else(|| err(()))?;
|
||||
let id = identity(&m.credential)?;
|
||||
require(id.participation != self.host && !roster.iter().any(|b| b.identity == id))?;
|
||||
},
|
||||
Proposal::Update(u) => {
|
||||
let idx = match p.sender() { Sender::Member(i) => *i, _ => return Err(err(())) };
|
||||
let old = group.member_at(idx).ok_or_else(|| err(()))?;
|
||||
require(check(u.leaf_node().credential(), u.leaf_node().signature_key().as_slice(), &self.id, &roster)? == identity(&old.credential)?)?;
|
||||
},
|
||||
_ => return Err(err(())),
|
||||
}
|
||||
}
|
||||
require(group.members().count() + c.add_proposals().count() - c.remove_proposals().count() <= 128)?;
|
||||
group.merge_staged_commit(&self.provider, *c).map_err(err)?;
|
||||
json!({"kind":"commit"})
|
||||
},
|
||||
_ => return Err(err(())),
|
||||
};
|
||||
Ok(result.to_string())
|
||||
}
|
||||
pub fn members(&self) -> Result<String, String> {
|
||||
let group = self.group.as_ref().ok_or_else(|| err(()))?;
|
||||
let mut members = Vec::<Value>::new();
|
||||
for m in group.members() {
|
||||
let id = identity(&m.credential)?;
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"Open WebUI temporary chat identity v1\0");
|
||||
hash.update(serde_json::to_vec(&id).map_err(err)?);
|
||||
hash.update(&m.signature_key);
|
||||
members.push(json!({"identity":id,"fingerprint":format!("{:x}",hash.finalize())}));
|
||||
}
|
||||
Ok(json!(members).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn client(n: usize, session: &str) -> Session {
|
||||
Session::new(&json!({"channel":"c","session":session,"user":format!("u{n}"),"participation":format!("p{n}")}).to_string(), "p0").unwrap()
|
||||
}
|
||||
fn bindings(clients: &[&Session]) -> String {
|
||||
json!(clients.iter().map(|c| json!({"identity":c.id,"key":c.public_key()})).collect::<Vec<_>>()).to_string()
|
||||
}
|
||||
#[test]
|
||||
fn bindings_membership_and_epochs() {
|
||||
let mut a = client(0,"s"); let mut b = client(1,"s"); let c = client(2,"other-session");
|
||||
a.create().unwrap();
|
||||
let roster = bindings(&[&a,&b]);
|
||||
assert!(a.add(&c.key_package().unwrap(), "p2", &roster).is_err());
|
||||
let old = a.encrypt("past epoch").unwrap();
|
||||
let added: Value = serde_json::from_str(&a.add(&b.key_package().unwrap(), "p1", &roster).unwrap()).unwrap();
|
||||
b.join(added["welcome"].as_str().unwrap(), &roster).unwrap();
|
||||
assert!(b.process(&old,"p0",&roster).is_err());
|
||||
let msg = a.encrypt("hello").unwrap();
|
||||
assert!(b.process(&msg,"p0",&roster).unwrap().contains("hello"));
|
||||
assert!(b.process(&msg,"p0",&roster).is_err());
|
||||
// A valid MLS commit from an ordinary member must still be rejected.
|
||||
let bundle = b.group.as_mut().unwrap().self_update(&b.provider,&b.signer,LeafNodeParameters::default()).unwrap();
|
||||
assert!(a.process(&encode(bundle.commit()).unwrap(),"p1",&roster).is_err());
|
||||
assert!(b.remove("p0", &roster).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,11 @@
|
|||
"version": "0.12.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"crypto:verify": "node scripts/encryption/artifact.mjs",
|
||||
"predev": "npm run crypto:verify",
|
||||
"predev:5050": "npm run crypto:verify",
|
||||
"prebuild": "npm run crypto:verify",
|
||||
"prebuild:watch": "npm run crypto:verify",
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
|
||||
"build": "npm run pyodide:fetch && vite build",
|
||||
|
|
|
|||
71
scripts/encryption/artifact.mjs
Normal file
71
scripts/encryption/artifact.mjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Normal builds only read committed files. No Rust or network required.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
const root = new URL('../../', import.meta.url).pathname;
|
||||
const source = 'crypto/encryption';
|
||||
const artifact = 'src/lib/encryption/vendor';
|
||||
const hash = (data) => createHash('sha256').update(data).digest('hex');
|
||||
function files(dir) {
|
||||
return readdirSync(join(root, dir), { withFileTypes: true })
|
||||
.flatMap((e) => {
|
||||
if (e.name === 'target' || e.name.startsWith('.')) return [];
|
||||
const path = join(dir, e.name);
|
||||
return e.isDirectory() ? files(path) : [path];
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
const inputs = [
|
||||
...files(source),
|
||||
'scripts/encryption/build.py',
|
||||
'scripts/encryption/artifact.mjs'
|
||||
].sort();
|
||||
const sourceFingerprint = hash(
|
||||
inputs.map((p) => `${p}\0${hash(readFileSync(join(root, p)))}`).join('\n')
|
||||
);
|
||||
const manifestPath = join(root, artifact, 'manifest.json');
|
||||
if (process.argv.includes('--write')) {
|
||||
const metadata = JSON.parse(
|
||||
readFileSync(process.argv[process.argv.indexOf('--metadata') + 1], 'utf8')
|
||||
);
|
||||
const checksums = Object.fromEntries(
|
||||
files(artifact)
|
||||
.filter((p) => !p.endsWith('/manifest.json'))
|
||||
.map((p) => [relative(artifact, p), hash(readFileSync(join(root, p)))])
|
||||
);
|
||||
writeFileSync(
|
||||
manifestPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
format: 1,
|
||||
sourceFingerprint,
|
||||
sourceRevision: process.env.CRYPTO_SOURCE_REVISION,
|
||||
toolchain: '1.95.0',
|
||||
wasmBindgen: '0.2.126',
|
||||
dependencies: metadata.packages.map(({ name, version, license }) => ({
|
||||
name,
|
||||
version,
|
||||
license
|
||||
})),
|
||||
checksums
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
} else {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest.sourceFingerprint !== sourceFingerprint)
|
||||
throw Error('Encryption source changed: rebuild and review the artifact.');
|
||||
const actual = files(artifact)
|
||||
.filter((p) => !p.endsWith('/manifest.json'))
|
||||
.map((p) => relative(artifact, p))
|
||||
.sort();
|
||||
if (JSON.stringify(actual) !== JSON.stringify(Object.keys(manifest.checksums).sort()))
|
||||
throw Error('Unexpected crypto artifact files');
|
||||
for (const [p, digest] of Object.entries(manifest.checksums)) {
|
||||
if (hash(readFileSync(join(root, artifact, p))) !== digest)
|
||||
throw Error(`Crypto artifact checksum mismatch: ${p}`);
|
||||
}
|
||||
console.log('Encryption artifact source and checksums verified');
|
||||
}
|
||||
136
scripts/encryption/build.py
Normal file
136
scripts/encryption/build.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Manual artifact release only; never invoked by the application build."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / 'crypto/encryption'
|
||||
OUTPUT = ROOT / 'src/lib/encryption/vendor'
|
||||
|
||||
|
||||
def run(*args, **kwargs):
|
||||
return subprocess.run(args, cwd=SOURCE, check=True, **kwargs)
|
||||
|
||||
|
||||
run('cargo', 'test', '--locked')
|
||||
run('cargo', 'build', '--locked', '--release', '--target', 'wasm32-unknown-unknown')
|
||||
run(
|
||||
'wasm-bindgen',
|
||||
'--target',
|
||||
'web',
|
||||
'--out-name',
|
||||
'encryption',
|
||||
'--out-dir',
|
||||
str(OUTPUT),
|
||||
str(SOURCE / 'target/wasm32-unknown-unknown/release/encryption.wasm'),
|
||||
)
|
||||
# Use Cargo's actual target build graph, excluding tests and unused optional crates.
|
||||
metadata = json.loads(run('cargo', 'metadata', '--locked', '--format-version', '1', capture_output=True).stdout)
|
||||
tree = run(
|
||||
'cargo',
|
||||
'tree',
|
||||
'--locked',
|
||||
'--target',
|
||||
'wasm32-unknown-unknown',
|
||||
'--edges',
|
||||
'normal,build',
|
||||
'--prefix',
|
||||
'none',
|
||||
'--format',
|
||||
'{p}',
|
||||
capture_output=True,
|
||||
).stdout.decode()
|
||||
used = {tuple(line.split()[:2]) for line in tree.splitlines()}
|
||||
metadata['packages'] = [p for p in metadata['packages'] if (p['name'], 'v' + p['version']) in used]
|
||||
assert {(p['name'], 'v' + p['version']) for p in metadata['packages']} == used
|
||||
|
||||
|
||||
@cache
|
||||
def download(url):
|
||||
with urlopen(url, timeout=30) as response:
|
||||
return response.read().decode('utf-8')
|
||||
|
||||
|
||||
texts = {}
|
||||
notices = [
|
||||
'Third-Party Licenses',
|
||||
'===================',
|
||||
'',
|
||||
'Scope: Encryption WebAssembly artifact and its build-time dependencies.',
|
||||
'Packages are listed alphabetically with references to the full license and notice texts below.',
|
||||
'Identical texts are included once; package-specific copyright notices are preserved.',
|
||||
'Source code, including MPL-2.0 dependencies, is available at each Source URL below.',
|
||||
]
|
||||
for package in sorted(metadata['packages'], key=lambda p: (p['name'], p['version'])):
|
||||
if package['source'] is None:
|
||||
continue # The local wrapper is not a third-party dependency.
|
||||
license = package['license'] or ''
|
||||
choices = {
|
||||
'MIT',
|
||||
'Apache-2.0',
|
||||
'BSD-2-Clause',
|
||||
'BSD-3-Clause',
|
||||
'ISC',
|
||||
'Unicode-3.0',
|
||||
'Zlib',
|
||||
'CC0-1.0',
|
||||
'Unlicense',
|
||||
'MPL-2.0',
|
||||
}
|
||||
if not any(x.strip(' ()') in choices for x in license.replace('/', ' OR ').split(' OR ')):
|
||||
raise RuntimeError(f'Review dependency license: {package["name"]} {license}')
|
||||
directory = Path(package['manifest_path']).parent
|
||||
documents = {
|
||||
path.name: path.read_text()
|
||||
for path in sorted(directory.iterdir())
|
||||
if path.is_file() and path.name.upper().startswith(('LICENSE', 'COPYING', 'NOTICE'))
|
||||
}
|
||||
if package.get('license_file'):
|
||||
path = directory / package['license_file']
|
||||
documents[path.name] = path.read_text()
|
||||
if not documents:
|
||||
# Some published crates omit the workspace license; use their exact source revision.
|
||||
if license == 'MPL-2.0':
|
||||
url = 'https://www.mozilla.org/media/MPL/2.0/index.txt'
|
||||
expected = 'Mozilla Public License Version 2.0'
|
||||
else:
|
||||
repository = urlparse(package['repository'])
|
||||
if repository.netloc != 'github.com' or license not in ('MIT', 'Apache-2.0'):
|
||||
raise RuntimeError(f'Missing license notice: {package["name"]}')
|
||||
repo = '/'.join(repository.path.strip('/').split('/')[:2])
|
||||
revision = json.loads((directory / '.cargo_vcs_info.json').read_text())['git']['sha1']
|
||||
url = f'https://raw.githubusercontent.com/{repo}/{revision}/LICENSE'
|
||||
expected = 'Permission is hereby granted' if license == 'MIT' else 'Apache License'
|
||||
documents[url] = download(url)
|
||||
if expected not in documents[url]:
|
||||
raise RuntimeError(f'Unexpected license notice: {url}')
|
||||
references = []
|
||||
for name, text in documents.items():
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise RuntimeError(f'Empty license notice: {package["name"]}/{name}')
|
||||
number = texts.setdefault(text, len(texts) + 1)
|
||||
references.append(f'{name}: [{number}]')
|
||||
notices.append(
|
||||
f'\nPackage: {package["name"]}\nVersion: {package["version"]}\nLicense: {license}\n'
|
||||
f'Source: https://crates.io/api/v1/crates/{package["name"]}/{package["version"]}/download\n'
|
||||
+ '\n'.join(references)
|
||||
)
|
||||
notices.extend(f'\nLicense and notice text [{number}]\n--------------------------------\n{text}' for text, number in texts.items())
|
||||
(OUTPUT / 'THIRD_PARTY_LICENSES.txt').write_text('\n'.join(notices) + '\n')
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as temp:
|
||||
json.dump(metadata, temp)
|
||||
temp.flush()
|
||||
env = dict(
|
||||
os.environ,
|
||||
CRYPTO_SOURCE_REVISION=subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT).decode().strip(),
|
||||
)
|
||||
subprocess.run(
|
||||
['node', str(ROOT / 'scripts/encryption/artifact.mjs'), '--write', '--metadata', temp.name], check=True, env=env
|
||||
)
|
||||
|
|
@ -352,6 +352,14 @@
|
|||
<Switch bind:state={adminConfig.ENABLE_CHANNELS} ariaLabelledbyId={labelId} />
|
||||
</AdminSettingRow>
|
||||
{#if adminConfig.ENABLE_CHANNELS}
|
||||
<AdminSettingRow
|
||||
label={$i18n.t('settings.admin.general.encryptedChannels.label')}
|
||||
description={$i18n.t('settings.admin.general.encryptedChannels.description')}
|
||||
labelClassName="text-gray-500 dark:text-gray-500"
|
||||
let:labelId
|
||||
>
|
||||
<Switch bind:state={adminConfig.ENABLE_ENCRYPTED_CHANNELS} ariaLabelledbyId={labelId} />
|
||||
</AdminSettingRow>
|
||||
<AdminSettingRow
|
||||
label={$i18n.t('settings.admin.general.modelResponseMode.label')}
|
||||
description={$i18n.t('settings.admin.general.modelResponseMode.description')}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
|
||||
import {
|
||||
config,
|
||||
chatId,
|
||||
channels,
|
||||
channelId as _channelId,
|
||||
|
|
@ -19,6 +21,7 @@
|
|||
import Messages from './Messages.svelte';
|
||||
import MessageInput from './MessageInput.svelte';
|
||||
import Navbar from './Navbar.svelte';
|
||||
import { connected, enter, request } from '$lib/encryption/client';
|
||||
import Drawer from '../common/Drawer.svelte';
|
||||
import ResizableSidePanel from '../common/ResizableSidePanel.svelte';
|
||||
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
|
||||
|
|
@ -28,6 +31,58 @@
|
|||
|
||||
export let id = '';
|
||||
|
||||
let mounted = false;
|
||||
let invitationSession: string | null = null;
|
||||
let stopInvitation = () => {};
|
||||
$: watchInvitation(
|
||||
$socket,
|
||||
channel?.id === id ? id : null,
|
||||
mounted && $config?.features?.enable_encrypted_channels
|
||||
);
|
||||
|
||||
function watchInvitation(
|
||||
connection: Socket | null,
|
||||
channelId: string | null,
|
||||
enabled: boolean | undefined
|
||||
) {
|
||||
stopInvitation();
|
||||
stopInvitation = () => {};
|
||||
invitationSession = null;
|
||||
if (!enabled || !connection || !channelId) return;
|
||||
let alive = true;
|
||||
let revision = 0;
|
||||
const receive = (event: { channel: string; session: string | null }) => {
|
||||
if (event.channel === channelId) {
|
||||
revision++;
|
||||
invitationSession = event.session;
|
||||
}
|
||||
};
|
||||
const clear = () => {
|
||||
revision++;
|
||||
invitationSession = null;
|
||||
};
|
||||
const query = () => {
|
||||
if (!connected(connection)) return;
|
||||
const issued = revision;
|
||||
void request(connection, 'status', { channel: channelId })
|
||||
.then((result) => {
|
||||
if (alive && revision === issued)
|
||||
invitationSession = result.available ? result.session : null;
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
connection.on('encryption:available', receive);
|
||||
connection.on('disconnect', clear);
|
||||
connection.on('connect', query);
|
||||
query();
|
||||
stopInvitation = () => {
|
||||
alive = false;
|
||||
connection.off('encryption:available', receive);
|
||||
connection.off('disconnect', clear);
|
||||
connection.off('connect', query);
|
||||
};
|
||||
}
|
||||
|
||||
let currentId = null;
|
||||
|
||||
let scrollEnd = true;
|
||||
|
|
@ -297,6 +352,7 @@
|
|||
};
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
if ($chatId) {
|
||||
chatId.set('');
|
||||
}
|
||||
|
|
@ -310,6 +366,7 @@
|
|||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopInvitation();
|
||||
// last read at
|
||||
updateLastReadAt(id);
|
||||
_channelId.set(null);
|
||||
|
|
@ -358,7 +415,30 @@
|
|||
return null;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<svelte:fragment slot="subtitle">
|
||||
{#if invitationSession}
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-1.5 text-[0.6875rem] font-normal text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span>{$i18n.t('Encrypted chat in progress')}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Join encrypted chat')}
|
||||
class="text-[0.6875rem] font-normal text-gray-700 underline-offset-2 hover:underline dark:text-gray-200"
|
||||
on:click={() => {
|
||||
if (!invitationSession) return;
|
||||
enter(id, { action: 'join', session: invitationSession });
|
||||
void goto(`/channels/${encodeURIComponent(id)}/encrypted`);
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Join')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Navbar>
|
||||
|
||||
{#if channel && messages !== null}
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { channels } from '$lib/stores';
|
||||
import { channels, config } from '$lib/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { enter } from '$lib/encryption/client';
|
||||
import { updateChannelMemberMuteStatusById } from '$lib/apis/channels';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
|
|
@ -11,12 +13,18 @@
|
|||
import BellSlash from '$lib/components/icons/BellSlash.svelte';
|
||||
import Settings from '$lib/components/icons/Settings.svelte';
|
||||
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
|
||||
const i18n = getContext<any>('i18n');
|
||||
// Keep the dynamic menu labels visible to i18next-parser.
|
||||
// $i18n.t('Conversation options');
|
||||
// $i18n.t('Channel options');
|
||||
export let channel: { id: string; type?: string | null; is_channel_muted?: boolean };
|
||||
export let channel: {
|
||||
id: string;
|
||||
type?: string | null;
|
||||
is_channel_muted?: boolean;
|
||||
is_private?: boolean | null;
|
||||
};
|
||||
export let sidebar = false;
|
||||
export let onEdit: (() => void) | undefined = undefined;
|
||||
export let onHide: (() => Promise<void>) | undefined = undefined;
|
||||
|
|
@ -65,6 +73,21 @@
|
|||
</button>
|
||||
<div slot="content">
|
||||
<DropdownMenu className="w-44 border-gray-200/30! dark:border-gray-700/30!">
|
||||
{#if $config?.features?.enable_encrypted_channels && (channel.type === 'dm' || (channel.type === 'group' && channel.is_private === true))}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
enter(channel.id, { action: 'start' });
|
||||
void goto(`/channels/${encodeURIComponent(channel.id)}/encrypted`);
|
||||
}}
|
||||
>
|
||||
<LockClosed className="size-3.5 shrink-0" />
|
||||
<span>{$i18n.t('Encrypted Chat')}</span>
|
||||
</button>
|
||||
<hr class="border-gray-200/30 dark:border-gray-700/30" />
|
||||
{/if}
|
||||
<div class="px-2 pt-1 pb-0.5 text-[0.6875rem] text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Notifications')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -945,7 +945,11 @@
|
|||
<div
|
||||
id="message-input-container"
|
||||
class="flex-1 flex flex-col relative w-full shadow-sm rounded-2xl border border-gray-100/60 dark:border-gray-800/50 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white dark:bg-gray-400/5 dark:text-gray-100"
|
||||
dir={$settings?.chatDirection ?? 'auto'}
|
||||
dir={$settings?.chatDirection === 'RTL'
|
||||
? 'rtl'
|
||||
: $settings?.chatDirection === 'LTR'
|
||||
? 'ltr'
|
||||
: 'auto'}
|
||||
>
|
||||
{#if replyToMessage !== null}
|
||||
<div class="px-3 pt-2 text-left w-full flex flex-col z-10">
|
||||
|
|
|
|||
|
|
@ -511,6 +511,7 @@
|
|||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
<slot name="status" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -599,28 +600,30 @@
|
|||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
{#if hasStructuredOutput}
|
||||
<StructuredOutputRenderer
|
||||
id={renderedMessageId}
|
||||
output={messageOutput}
|
||||
done={message?.meta?.done ?? false}
|
||||
allowEmbeds={false}
|
||||
/>
|
||||
{:else if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
|
||||
<Skeleton />
|
||||
{:else}
|
||||
<span class="markdown-prose">
|
||||
<Markdown
|
||||
<slot name="content">
|
||||
{#if hasStructuredOutput}
|
||||
<StructuredOutputRenderer
|
||||
id={renderedMessageId}
|
||||
content={message.content}
|
||||
paragraphTag={message.app_id ? 'p' : 'span'}
|
||||
output={messageOutput}
|
||||
done={message?.meta?.done ?? false}
|
||||
allowEmbeds={false}
|
||||
/>
|
||||
</span>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
|
||||
class="text-gray-500 text-[0.625rem] pl-1 self-center"
|
||||
>({$i18n.t('edited')})</span
|
||||
>{/if}
|
||||
{/if}
|
||||
{:else if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
|
||||
<Skeleton />
|
||||
{:else}
|
||||
<span class="markdown-prose">
|
||||
<Markdown
|
||||
id={renderedMessageId}
|
||||
content={message.content}
|
||||
paragraphTag={message.app_id ? 'p' : 'span'}
|
||||
allowEmbeds={false}
|
||||
/>
|
||||
</span>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
|
||||
class="text-gray-500 text-[0.625rem] pl-1 self-center"
|
||||
>({$i18n.t('edited')})</span
|
||||
>{/if}
|
||||
{/if}
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
{#if message.app_id && (message?.data?.app?.embeds?.length || message?.data?.app?.actions?.length)}
|
||||
|
|
|
|||
|
|
@ -46,13 +46,16 @@
|
|||
};
|
||||
|
||||
export let channel;
|
||||
export let readOnly = false;
|
||||
|
||||
export let onPin = (messageId, pinned) => {};
|
||||
export let onUpdate = () => {};
|
||||
</script>
|
||||
|
||||
<PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} {onPin} />
|
||||
<ChannelInfoModal bind:show={showChannelInfoModal} {channel} {onUpdate} />
|
||||
{#if !readOnly}
|
||||
<PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} {onPin} />
|
||||
<ChannelInfoModal bind:show={showChannelInfoModal} {channel} {onUpdate} />
|
||||
{/if}
|
||||
<nav class="sticky top-0 z-30 w-full px-3 py-1 -mb-8 flex items-center drag-region flex-col">
|
||||
<div
|
||||
id="navbar-bg-gradient-to-b"
|
||||
|
|
@ -60,12 +63,12 @@
|
|||
></div>
|
||||
|
||||
<div class=" flex max-w-full w-full mx-auto pt-0.5 bg-transparent">
|
||||
<div class="flex items-center w-full max-w-full">
|
||||
<div class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full max-w-full">
|
||||
{#if $mobile}
|
||||
<div
|
||||
class="{$showSidebar
|
||||
? 'md:hidden'
|
||||
: ''} mr-1.5 mt-0.5 self-center flex flex-none items-center text-gray-600 dark:text-gray-400"
|
||||
: ''} col-start-1 row-start-1 mr-1.5 mt-0.5 self-center flex flex-none items-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<Tooltip
|
||||
content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
|
||||
|
|
@ -87,7 +90,7 @@
|
|||
{/if}
|
||||
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-hidden max-w-full py-0.5 flex items-center
|
||||
class="col-start-2 row-start-1 min-w-0 overflow-hidden max-w-full py-0.5 flex items-center
|
||||
{$showSidebar ? 'ml-1' : ''}
|
||||
"
|
||||
>
|
||||
|
|
@ -148,50 +151,58 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<slot name="status" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="self-center flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
|
||||
class="col-start-3 row-start-1 self-center flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
|
||||
>
|
||||
{#if channel}
|
||||
<Tooltip content={$i18n.t('Pinned Messages')}>
|
||||
<button
|
||||
class=" flex size-7 items-center justify-center cursor-pointer rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
|
||||
aria-label={$i18n.t('Pinned Messages')}
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showChannelPinnedMessagesModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" flex items-center gap-1 m-auto self-center shrink-0">
|
||||
<Pin className=" size-4" strokeWidth="1.5" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{#if channel?.user_count !== undefined}
|
||||
<Tooltip content={$i18n.t('Users')}>
|
||||
<slot name="actions">
|
||||
{#if channel}
|
||||
<Tooltip content={$i18n.t('Pinned Messages')}>
|
||||
<button
|
||||
class=" flex h-7 items-center cursor-pointer shrink-0 px-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
|
||||
aria-label={$i18n.t('User Count')}
|
||||
class=" flex size-7 items-center justify-center cursor-pointer rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
|
||||
aria-label={$i18n.t('Pinned Messages')}
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showChannelInfoModal = true;
|
||||
showChannelPinnedMessagesModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" flex items-center gap-1 m-auto self-center shrink-0">
|
||||
<UserAlt className=" size-4" strokeWidth="1.5" />
|
||||
|
||||
<div class="text-xs tabular-nums shrink-0">
|
||||
{channel.user_count}
|
||||
</div>
|
||||
<Pin className=" size-4" strokeWidth="1.5" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{#if channel?.user_count !== undefined}
|
||||
<Tooltip content={$i18n.t('Users')}>
|
||||
<button
|
||||
class=" flex h-7 items-center cursor-pointer shrink-0 px-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
|
||||
aria-label={$i18n.t('User Count')}
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showChannelInfoModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" flex items-center gap-1 m-auto self-center shrink-0">
|
||||
<UserAlt className=" size-4" strokeWidth="1.5" />
|
||||
|
||||
<div class="text-xs tabular-nums shrink-0">
|
||||
{channel.user_count}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<ChannelMenu {channel} />
|
||||
{/if}
|
||||
<ChannelMenu {channel} />
|
||||
{/if}
|
||||
</slot>
|
||||
</div>
|
||||
{#if $$slots.subtitle}
|
||||
<div class="col-start-2 row-start-2 min-w-0 {$showSidebar ? 'ml-1' : ''}">
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
const i18n = getContext<Writable<i18nType>>('i18n');
|
||||
const markdown = getContext<{ allowRemoteContent: boolean } | undefined>('markdown');
|
||||
|
||||
export let id = '';
|
||||
export let edit = true;
|
||||
|
|
@ -368,6 +369,7 @@
|
|||
|
||||
const render = async () => {
|
||||
onUpdate(token, id);
|
||||
if (markdown?.allowRemoteContent === false) return;
|
||||
if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) {
|
||||
try {
|
||||
renderHTML = await renderMermaid(code);
|
||||
|
|
@ -440,7 +442,7 @@
|
|||
class="relative {className} flex flex-col rounded-2xl border border-gray-100/30 dark:border-gray-850/30 my-0.5 overflow-clip"
|
||||
dir="ltr"
|
||||
>
|
||||
{#if ['mermaid', 'vega', 'vega-lite'].includes(lang)}
|
||||
{#if markdown?.allowRemoteContent !== false && ['mermaid', 'vega', 'vega-lite'].includes(lang)}
|
||||
{#if renderHTML}
|
||||
<SvgPanZoom
|
||||
className=" rounded-2xl max-h-fit overflow-hidden"
|
||||
|
|
@ -506,7 +508,7 @@
|
|||
>
|
||||
{$i18n.t('Running')}
|
||||
</div>
|
||||
{:else if run}
|
||||
{:else if run && markdown?.allowRemoteContent !== false}
|
||||
<button
|
||||
class="run-code-button flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-normal text-gray-600 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-60 dark:text-gray-400 dark:hover:bg-gray-850 dark:hover:text-white"
|
||||
on:click={async () => {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
</script>
|
||||
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, setContext } from 'svelte';
|
||||
import { replaceTokens, processResponseContent } from '$lib/utils';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
|
|
@ -52,6 +52,13 @@
|
|||
export let topPadding = false;
|
||||
export let allowEmbeds = false;
|
||||
export let allowHtml = true;
|
||||
export let allowRemoteContent = true;
|
||||
|
||||
setContext('markdown', {
|
||||
get allowRemoteContent() {
|
||||
return allowRemoteContent;
|
||||
}
|
||||
});
|
||||
|
||||
export let sourceIds = [];
|
||||
|
||||
|
|
@ -78,9 +85,18 @@
|
|||
lastParsedContent = processed;
|
||||
|
||||
tokens = marked.lexer(processed);
|
||||
if (!allowHtml) {
|
||||
if (!allowHtml || !allowRemoteContent) {
|
||||
marked.walkTokens(tokens, (token) => {
|
||||
if (token.type === 'html') Object.assign(token, { type: 'text' });
|
||||
if (
|
||||
(!allowHtml && token.type === 'html') ||
|
||||
(!allowRemoteContent &&
|
||||
['html', 'image', 'iframe', 'mention', 'details', 'colonFence'].includes(token.type))
|
||||
) {
|
||||
const raw = token.raw;
|
||||
// Drop child tokens too, so blocked content cannot render through a nested renderer.
|
||||
for (const key of Object.keys(token)) Reflect.deleteProperty(token, key);
|
||||
Object.assign(token, { type: 'text', raw, text: raw });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const markdown = getContext<{ allowRemoteContent: boolean } | undefined>('markdown');
|
||||
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { skills, terminalSkills } from '$lib/stores';
|
||||
|
|
@ -90,7 +91,7 @@
|
|||
<HtmlToken {id} {token} {onSourceClick} />
|
||||
{:else if token.type === 'link'}
|
||||
{@const linkToken = getLinkToken(token)}
|
||||
{#if linkToken.noteId}
|
||||
{#if linkToken.noteId && markdown?.allowRemoteContent !== false}
|
||||
<NoteLinkToken noteId={linkToken.noteId} href={linkToken.href} />
|
||||
{:else if token.tokens && !linkToken.isAutolink}
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -137,8 +137,6 @@
|
|||
(displayTokens[0]?.type === 'paragraph' || displayTokens[0]?.type === 'text');
|
||||
|
||||
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
|
||||
console.log('Exporting table to CSV');
|
||||
|
||||
// Extract header row text, decode HTML entities, and escape for CSV.
|
||||
const header = token.header.map(
|
||||
(headerCell) => `"${decode(headerCell.text).replace(/"/g, '""')}"`
|
||||
|
|
@ -160,10 +158,6 @@
|
|||
// Join the rows using commas (,) as the separator and rows using newline (\n).
|
||||
const csvContent = csvData.map((row) => row.join(',')).join('\n');
|
||||
|
||||
// Log rows and CSV content to ensure everything is correct.
|
||||
console.log(csvData);
|
||||
console.log(csvContent);
|
||||
|
||||
// To handle Unicode characters, you need to prefix the data with a BOM:
|
||||
const bom = '\uFEFF'; // BOM for UTF-8
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
||||
|
||||
export let value = '';
|
||||
export let placeholder = '';
|
||||
|
|
@ -8,6 +9,12 @@
|
|||
export let maxSize: number | null = null;
|
||||
export let required = false;
|
||||
export let readonly = false;
|
||||
export let disabled = false;
|
||||
export let id: string | undefined = undefined;
|
||||
export let autocomplete: HTMLTextareaAttributes['autocomplete'] = undefined;
|
||||
export let spellcheck: boolean | undefined = undefined;
|
||||
export let autocapitalize: HTMLTextareaAttributes['autocapitalize'] = undefined;
|
||||
export let onKeydown: (event: KeyboardEvent) => void = () => {};
|
||||
export let className =
|
||||
'w-full rounded-lg px-3.5 py-2 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden h-full';
|
||||
export let ariaLabel: string | null = null;
|
||||
|
|
@ -17,20 +24,11 @@
|
|||
|
||||
let textareaElement: HTMLTextAreaElement | null = null;
|
||||
|
||||
// Adjust height on mount and after setting the element.
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
// The bound element exists on mount. Cancel the layout callback on navigation.
|
||||
onMount(() => {
|
||||
resize();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// setInterveal to cehck until textareaElement is set
|
||||
const interval = setInterval(() => {
|
||||
if (textareaElement) {
|
||||
clearInterval(interval);
|
||||
resize();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
const frame = requestAnimationFrame(resize);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
});
|
||||
|
||||
const resize = () => {
|
||||
|
|
@ -82,6 +80,12 @@
|
|||
{rows}
|
||||
{required}
|
||||
{readonly}
|
||||
{disabled}
|
||||
{id}
|
||||
{autocomplete}
|
||||
{spellcheck}
|
||||
{autocapitalize}
|
||||
on:keydown={onKeydown}
|
||||
on:input={(e) => {
|
||||
resize();
|
||||
|
||||
|
|
|
|||
407
src/lib/encryption/client.ts
Normal file
407
src/lib/encryption/client.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import type { Socket } from 'socket.io-client';
|
||||
import { writable } from 'svelte/store';
|
||||
import CryptoWorker from './worker?worker';
|
||||
|
||||
export type Identity = { channel: string; session: string; user: string; participation: string };
|
||||
type Binding = { identity: Identity; key: string };
|
||||
type Member = { identity: Identity; fingerprint: string; name: string };
|
||||
type Message = {
|
||||
id: string;
|
||||
type?: 'joined';
|
||||
sender: string;
|
||||
user: { id: string; name: string };
|
||||
created_at: number;
|
||||
text: string;
|
||||
expected: string[];
|
||||
received: string[];
|
||||
relayed: boolean;
|
||||
};
|
||||
type View = {
|
||||
channel: string;
|
||||
session: string;
|
||||
host: string;
|
||||
participation?: string;
|
||||
bindings: Binding[];
|
||||
participants: { participation: string; user: string; name: string }[];
|
||||
};
|
||||
export type State = {
|
||||
status: 'joining' | 'active' | 'ended';
|
||||
messages: Message[];
|
||||
members: Member[];
|
||||
self: string;
|
||||
host: string;
|
||||
};
|
||||
type Entry = { action: 'start' } | { action: 'join'; session: string };
|
||||
let entry: { channel: string; intent: Entry } | undefined;
|
||||
export function enter(channel: string, intent: Entry) {
|
||||
entry = { channel, intent };
|
||||
}
|
||||
export function consumeEntry(channel: string) {
|
||||
const result = entry?.channel === channel ? entry.intent : undefined;
|
||||
entry = undefined;
|
||||
return result;
|
||||
}
|
||||
export function connected(socket: Socket | null): socket is Socket {
|
||||
return !!socket?.connected && socket.io.engine?.transport?.name === 'websocket';
|
||||
}
|
||||
export function request(socket: Socket, action: string, data: object): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!connected(socket) || !socket.io.engine.transport.writable)
|
||||
return reject(new Error('Encrypted chat is unavailable.'));
|
||||
// Volatile sends are never put into Socket.IO's disconnected send buffer.
|
||||
socket
|
||||
.timeout(10000)
|
||||
.volatile.emit(`encryption:${action}`, data, (error: unknown, result: any) => {
|
||||
if (error || !result?.ok) reject(new Error('Encrypted chat ended.'));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class Conversation {
|
||||
state = writable<State>({ status: 'joining', messages: [], members: [], self: '', host: '' });
|
||||
private current: State = { status: 'joining', messages: [], members: [], self: '', host: '' };
|
||||
private worker: Worker;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (result: any) => void; reject: () => void; timer: ReturnType<typeof setTimeout> }
|
||||
>();
|
||||
private nextId = 0;
|
||||
private sequence = 0;
|
||||
private stopped = false;
|
||||
private chain: Promise<unknown> = Promise.resolve();
|
||||
private view?: View;
|
||||
private participation = '';
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private joinTimer?: ReturnType<typeof setTimeout>;
|
||||
private messageIds = new Set<string>();
|
||||
private disposers: (() => void)[] = [];
|
||||
|
||||
constructor(
|
||||
private socket: Socket,
|
||||
private channel: string,
|
||||
private user: string,
|
||||
intent: Entry
|
||||
) {
|
||||
this.worker = new CryptoWorker();
|
||||
this.worker.onmessage = ({ data }) => {
|
||||
const pending = this.pending.get(data.id);
|
||||
if (!pending || this.stopped) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(data.id);
|
||||
if (data.error) {
|
||||
pending.reject();
|
||||
this.close();
|
||||
} else pending.resolve(data.result);
|
||||
};
|
||||
this.worker.onerror = (event) => {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
};
|
||||
this.worker.onmessageerror = () => this.close();
|
||||
const receive = (event: any) => {
|
||||
if (
|
||||
event?.channel !== this.channel ||
|
||||
(event.session && event.session !== this.view?.session)
|
||||
)
|
||||
return;
|
||||
if (event.kind === 'ended') {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.enqueue(() => this.receive(event));
|
||||
};
|
||||
const end = () => this.close();
|
||||
socket.on('encryption:event', receive);
|
||||
socket.on('disconnect', end);
|
||||
socket.on('access:updated', end);
|
||||
window.addEventListener('pagehide', end);
|
||||
window.addEventListener('beforeunload', end);
|
||||
document.addEventListener('freeze', end);
|
||||
this.disposers.push(() => {
|
||||
socket.off('encryption:event', receive);
|
||||
socket.off('disconnect', end);
|
||||
socket.off('access:updated', end);
|
||||
window.removeEventListener('pagehide', end);
|
||||
window.removeEventListener('beforeunload', end);
|
||||
document.removeEventListener('freeze', end);
|
||||
});
|
||||
this.joinTimer = setTimeout(end, 30000);
|
||||
this.enqueue(async () => {
|
||||
if (!window.isSecureContext || !connected(socket)) throw new Error();
|
||||
const result = await request(socket, intent.action, { channel, ...intent });
|
||||
if (this.stopped) {
|
||||
void request(socket, 'leave', {
|
||||
channel,
|
||||
session: result.session,
|
||||
participation: result.participation
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
this.view = result;
|
||||
this.participation = result.participation;
|
||||
const identity: Identity = {
|
||||
channel,
|
||||
session: result.session,
|
||||
user,
|
||||
participation: this.participation
|
||||
};
|
||||
await this.crypto('init', JSON.stringify(identity), result.host);
|
||||
if (intent.action === 'start') await this.crypto('create');
|
||||
const key = await this.crypto('public_key');
|
||||
const packet = intent.action === 'join' ? await this.crypto('key_package') : undefined;
|
||||
this.view = await this.send('bind', { key, packet });
|
||||
this.update({ self: this.participation, host: result.host });
|
||||
if (intent.action === 'start') await this.activate();
|
||||
if (this.stopped) return;
|
||||
this.timer = setInterval(
|
||||
() =>
|
||||
this.enqueue(async () => {
|
||||
await this.send('ping', {});
|
||||
}),
|
||||
15000
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private update(patch: Partial<State>) {
|
||||
if (this.stopped) return;
|
||||
if (patch.messages) {
|
||||
let bytes = patch.messages.reduce(
|
||||
(n, m) => n + new TextEncoder().encode(JSON.stringify(m)).length,
|
||||
0
|
||||
);
|
||||
patch.messages = [...patch.messages];
|
||||
while (patch.messages.length > 500 || bytes > 8 * 1024 * 1024) {
|
||||
bytes -= new TextEncoder().encode(JSON.stringify(patch.messages.shift()!)).length;
|
||||
}
|
||||
}
|
||||
this.current = { ...this.current, ...patch };
|
||||
this.state.set(this.current);
|
||||
}
|
||||
private enqueue(operation: () => Promise<void>) {
|
||||
this.chain = this.chain
|
||||
.then(async () => {
|
||||
if (!this.stopped) await operation();
|
||||
})
|
||||
.catch(() => this.close());
|
||||
return this.chain;
|
||||
}
|
||||
private crypto(method: string, ...args: unknown[]): Promise<any> {
|
||||
if (this.stopped) return Promise.reject(new Error());
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++this.nextId;
|
||||
const fail = () => reject(new Error('Encrypted chat ended.'));
|
||||
this.pending.set(id, {
|
||||
resolve,
|
||||
reject: fail,
|
||||
timer: setTimeout(() => {
|
||||
fail();
|
||||
this.close();
|
||||
}, 30000)
|
||||
});
|
||||
this.worker.postMessage({ id, method, args });
|
||||
});
|
||||
}
|
||||
private async send(action: string, data: object) {
|
||||
if (this.stopped || !this.view) throw new Error();
|
||||
const result = await request(this.socket, action, {
|
||||
...data,
|
||||
channel: this.channel,
|
||||
session: this.view.session,
|
||||
participation: this.participation
|
||||
});
|
||||
if (this.stopped) throw new Error();
|
||||
return result as View;
|
||||
}
|
||||
private async members() {
|
||||
const members: Member[] = JSON.parse(await this.crypto('members'));
|
||||
if (this.stopped) return;
|
||||
const previous = new Set(this.current.members.map((m) => m.identity.participation));
|
||||
this.update({
|
||||
members: members.map((m) => ({
|
||||
...m,
|
||||
name:
|
||||
this.view?.participants.find((p) => p.participation === m.identity.participation)?.name ??
|
||||
'Participant'
|
||||
}))
|
||||
});
|
||||
if (this.current.status === 'active') {
|
||||
for (const member of this.current.members) {
|
||||
if (previous.has(member.identity.participation)) continue;
|
||||
this.append({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'joined',
|
||||
sender: member.identity.participation,
|
||||
text: '',
|
||||
expected: [],
|
||||
received: [],
|
||||
relayed: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
private async activate() {
|
||||
clearTimeout(this.joinTimer);
|
||||
await this.members();
|
||||
this.update({ status: 'active' });
|
||||
}
|
||||
private async receive(event: any) {
|
||||
if (!this.view || event.session !== this.view.session || event.sequence !== this.sequence + 1)
|
||||
throw new Error();
|
||||
this.sequence = event.sequence;
|
||||
this.view = event;
|
||||
const bindings = JSON.stringify(event.bindings);
|
||||
if (event.kind === 'candidate') {
|
||||
const added = JSON.parse(await this.crypto('add', event.packet, event.sender, bindings));
|
||||
this.view = await this.send('packet', {
|
||||
kind: 'admit',
|
||||
target: event.sender,
|
||||
packet: added.commit,
|
||||
welcome: added.welcome
|
||||
});
|
||||
await this.members();
|
||||
} else if (event.kind === 'welcome') {
|
||||
if (event.sender !== event.host || this.current.status !== 'joining') throw new Error();
|
||||
await this.crypto('join', event.packet, bindings);
|
||||
await this.activate();
|
||||
} else if (event.kind === 'departed') {
|
||||
if (event.admitted && this.participation === event.host) {
|
||||
const packet = await this.crypto('remove', event.participation, bindings);
|
||||
await this.send('packet', { kind: 'mls', packet });
|
||||
await this.members();
|
||||
}
|
||||
} else if (event.kind === 'mls') {
|
||||
const result = JSON.parse(await this.crypto('process', event.packet, event.sender, bindings));
|
||||
if (result.kind === 'application') await this.application(result.sender, result.text);
|
||||
else if (result.kind === 'proposal' && this.participation === event.host) {
|
||||
const packet = await this.crypto('commit_updates');
|
||||
await this.send('packet', { kind: 'mls', packet });
|
||||
await this.members();
|
||||
} else if (result.kind === 'commit') await this.members();
|
||||
} else throw new Error();
|
||||
}
|
||||
private append(message: Omit<Message, 'user' | 'created_at'>) {
|
||||
const sender = this.current.members.find(
|
||||
(member) => member.identity.participation === message.sender
|
||||
);
|
||||
this.update({
|
||||
messages: [
|
||||
...this.current.messages,
|
||||
{
|
||||
...message,
|
||||
user: { id: sender?.identity.user ?? '', name: sender?.name ?? 'Participant' },
|
||||
created_at: Date.now() * 1000000
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
private async application(sender: Identity, text: string) {
|
||||
const data = JSON.parse(text);
|
||||
if (
|
||||
data.session !== this.view?.session ||
|
||||
data.channel !== this.channel ||
|
||||
typeof data.id !== 'string' ||
|
||||
!/^[a-f0-9-]{36}$/.test(data.id)
|
||||
)
|
||||
throw new Error();
|
||||
if (data.type === 'receipt') {
|
||||
const messages = this.current.messages.map((m) =>
|
||||
m.id === data.id &&
|
||||
m.sender === this.participation &&
|
||||
m.expected.includes(sender.participation)
|
||||
? { ...m, received: [...new Set([...m.received, sender.participation])] }
|
||||
: m
|
||||
);
|
||||
this.update({ messages });
|
||||
} else if (data.type === 'text') {
|
||||
if (
|
||||
typeof data.text !== 'string' ||
|
||||
new TextEncoder().encode(data.text).length > 16384 ||
|
||||
this.messageIds.has(data.id)
|
||||
)
|
||||
throw new Error();
|
||||
this.messageIds.add(data.id);
|
||||
// MLS rejects replay; the bounded ID set additionally rejects repeated app envelopes.
|
||||
if (this.messageIds.size > 1000)
|
||||
this.messageIds.delete(this.messageIds.values().next().value!);
|
||||
this.append({
|
||||
id: data.id,
|
||||
sender: sender.participation,
|
||||
text: data.text,
|
||||
expected: [],
|
||||
received: [],
|
||||
relayed: true
|
||||
});
|
||||
const packet = await this.crypto(
|
||||
'encrypt',
|
||||
JSON.stringify({
|
||||
type: 'receipt',
|
||||
id: data.id,
|
||||
channel: this.channel,
|
||||
session: this.view!.session
|
||||
})
|
||||
);
|
||||
await this.send('packet', { kind: 'mls', packet });
|
||||
} else throw new Error();
|
||||
}
|
||||
sendText(text: string) {
|
||||
if (
|
||||
this.current.status !== 'active' ||
|
||||
!text.trim() ||
|
||||
new TextEncoder().encode(text).length > 16384
|
||||
)
|
||||
return;
|
||||
return this.enqueue(async () => {
|
||||
const id = crypto.randomUUID();
|
||||
const expected = this.current.members
|
||||
.map((m) => m.identity.participation)
|
||||
.filter((p) => p !== this.participation);
|
||||
this.append({ id, sender: this.participation, text, expected, received: [], relayed: false });
|
||||
const packet = await this.crypto(
|
||||
'encrypt',
|
||||
JSON.stringify({
|
||||
type: 'text',
|
||||
id,
|
||||
text,
|
||||
channel: this.channel,
|
||||
session: this.view!.session
|
||||
})
|
||||
);
|
||||
await this.send('packet', { kind: 'mls', packet });
|
||||
this.update({
|
||||
messages: this.current.messages.map((m) => (m.id === id ? { ...m, relayed: true } : m))
|
||||
});
|
||||
});
|
||||
}
|
||||
updateKeys() {
|
||||
return this.enqueue(async () => {
|
||||
const packet = await this.crypto('update');
|
||||
await this.send('packet', { kind: 'mls', packet });
|
||||
});
|
||||
}
|
||||
close() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
clearInterval(this.timer);
|
||||
clearTimeout(this.joinTimer);
|
||||
this.disposers.splice(0).forEach((dispose) => dispose());
|
||||
this.worker.terminate();
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject();
|
||||
}
|
||||
this.pending.clear();
|
||||
this.messageIds.clear();
|
||||
if (this.view && this.participation && connected(this.socket)) {
|
||||
void request(this.socket, 'leave', {
|
||||
channel: this.channel,
|
||||
session: this.view.session,
|
||||
participation: this.participation
|
||||
}).catch(() => {});
|
||||
}
|
||||
this.view = undefined;
|
||||
this.current = { status: 'ended', messages: [], members: [], self: '', host: '' };
|
||||
this.state.set(this.current);
|
||||
}
|
||||
}
|
||||
7011
src/lib/encryption/vendor/THIRD_PARTY_LICENSES.txt
vendored
Normal file
7011
src/lib/encryption/vendor/THIRD_PARTY_LICENSES.txt
vendored
Normal file
File diff suppressed because it is too large
Load diff
68
src/lib/encryption/vendor/encryption.d.ts
vendored
Normal file
68
src/lib/encryption/vendor/encryption.d.ts
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export class Session {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
add(key_package: string, expected: string, bindings: string): string;
|
||||
commit_updates(): string;
|
||||
create(): void;
|
||||
encrypt(text: string): string;
|
||||
join(welcome: string, bindings: string): void;
|
||||
key_package(): string;
|
||||
members(): string;
|
||||
constructor(identity_json: string, host: string);
|
||||
process(packet: string, expected: string, bindings: string): string;
|
||||
public_key(): string;
|
||||
remove(participation: string, bindings: string): string;
|
||||
update(): string;
|
||||
}
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly __wbg_session_free: (a: number, b: number) => void;
|
||||
readonly session_add: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
|
||||
readonly session_commit_updates: (a: number) => [number, number, number, number];
|
||||
readonly session_create: (a: number) => [number, number];
|
||||
readonly session_encrypt: (a: number, b: number, c: number) => [number, number, number, number];
|
||||
readonly session_join: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
||||
readonly session_key_package: (a: number) => [number, number, number, number];
|
||||
readonly session_members: (a: number) => [number, number, number, number];
|
||||
readonly session_new: (a: number, b: number, c: number, d: number) => [number, number, number];
|
||||
readonly session_process: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
|
||||
readonly session_public_key: (a: number) => [number, number];
|
||||
readonly session_remove: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||
readonly session_update: (a: number) => [number, number, number, number];
|
||||
readonly __wbindgen_exn_store: (a: number) => void;
|
||||
readonly __externref_table_alloc: () => number;
|
||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __externref_table_dealloc: (a: number) => void;
|
||||
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
readonly __wbindgen_start: () => void;
|
||||
}
|
||||
|
||||
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||
|
||||
/**
|
||||
* Instantiates the given `module`, which can either be bytes or
|
||||
* a precompiled `WebAssembly.Module`.
|
||||
*
|
||||
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {InitOutput}
|
||||
*/
|
||||
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||
|
||||
/**
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||
587
src/lib/encryption/vendor/encryption.js
vendored
Normal file
587
src/lib/encryption/vendor/encryption.js
vendored
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
/* @ts-self-types="./encryption.d.ts" */
|
||||
|
||||
export class Session {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
SessionFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_session_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* @param {string} key_package
|
||||
* @param {string} expected
|
||||
* @param {string} bindings
|
||||
* @returns {string}
|
||||
*/
|
||||
add(key_package, expected, bindings) {
|
||||
let deferred5_0;
|
||||
let deferred5_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(key_package, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(expected, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ptr2 = passStringToWasm0(bindings, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len2 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.session_add(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
|
||||
var ptr4 = ret[0];
|
||||
var len4 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr4 = 0; len4 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred5_0 = ptr4;
|
||||
deferred5_1 = len4;
|
||||
return getStringFromWasm0(ptr4, len4);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred5_0, deferred5_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
commit_updates() {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ret = wasm.session_commit_updates(this.__wbg_ptr);
|
||||
var ptr1 = ret[0];
|
||||
var len1 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr1 = 0; len1 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred2_0 = ptr1;
|
||||
deferred2_1 = len1;
|
||||
return getStringFromWasm0(ptr1, len1);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
create() {
|
||||
const ret = wasm.session_create(this.__wbg_ptr);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
encrypt(text) {
|
||||
let deferred3_0;
|
||||
let deferred3_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.session_encrypt(this.__wbg_ptr, ptr0, len0);
|
||||
var ptr2 = ret[0];
|
||||
var len2 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr2 = 0; len2 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred3_0 = ptr2;
|
||||
deferred3_1 = len2;
|
||||
return getStringFromWasm0(ptr2, len2);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string} welcome
|
||||
* @param {string} bindings
|
||||
*/
|
||||
join(welcome, bindings) {
|
||||
const ptr0 = passStringToWasm0(welcome, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(bindings, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.session_join(this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
key_package() {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ret = wasm.session_key_package(this.__wbg_ptr);
|
||||
var ptr1 = ret[0];
|
||||
var len1 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr1 = 0; len1 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred2_0 = ptr1;
|
||||
deferred2_1 = len1;
|
||||
return getStringFromWasm0(ptr1, len1);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
members() {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ret = wasm.session_members(this.__wbg_ptr);
|
||||
var ptr1 = ret[0];
|
||||
var len1 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr1 = 0; len1 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred2_0 = ptr1;
|
||||
deferred2_1 = len1;
|
||||
return getStringFromWasm0(ptr1, len1);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string} identity_json
|
||||
* @param {string} host
|
||||
*/
|
||||
constructor(identity_json, host) {
|
||||
const ptr0 = passStringToWasm0(identity_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(host, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.session_new(ptr0, len0, ptr1, len1);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
this.__wbg_ptr = ret[0];
|
||||
SessionFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* @param {string} packet
|
||||
* @param {string} expected
|
||||
* @param {string} bindings
|
||||
* @returns {string}
|
||||
*/
|
||||
process(packet, expected, bindings) {
|
||||
let deferred5_0;
|
||||
let deferred5_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(packet, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(expected, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ptr2 = passStringToWasm0(bindings, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len2 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.session_process(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
|
||||
var ptr4 = ret[0];
|
||||
var len4 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr4 = 0; len4 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred5_0 = ptr4;
|
||||
deferred5_1 = len4;
|
||||
return getStringFromWasm0(ptr4, len4);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred5_0, deferred5_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
public_key() {
|
||||
let deferred1_0;
|
||||
let deferred1_1;
|
||||
try {
|
||||
const ret = wasm.session_public_key(this.__wbg_ptr);
|
||||
deferred1_0 = ret[0];
|
||||
deferred1_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string} participation
|
||||
* @param {string} bindings
|
||||
* @returns {string}
|
||||
*/
|
||||
remove(participation, bindings) {
|
||||
let deferred4_0;
|
||||
let deferred4_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(participation, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(bindings, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.session_remove(this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||
var ptr3 = ret[0];
|
||||
var len3 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr3 = 0; len3 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred4_0 = ptr3;
|
||||
deferred4_1 = len3;
|
||||
return getStringFromWasm0(ptr3, len3);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
update() {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ret = wasm.session_update(this.__wbg_ptr);
|
||||
var ptr1 = ret[0];
|
||||
var len1 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr1 = 0; len1 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred2_0 = ptr1;
|
||||
deferred2_1 = len1;
|
||||
return getStringFromWasm0(ptr1, len1);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) Session.prototype[Symbol.dispose] = Session.prototype.free;
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg___wbindgen_is_function_1ff95bcc5517c252: function(arg0) {
|
||||
const ret = typeof(arg0) === 'function';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_object_a27215656b807791: function(arg0) {
|
||||
const val = arg0;
|
||||
const ret = typeof(val) === 'object' && val !== null;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_string_ea5e6cc2e4141dfe: function(arg0) {
|
||||
const ret = typeof(arg0) === 'string';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_undefined_c05833b95a3cf397: function(arg0) {
|
||||
const ret = arg0 === undefined;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_call_a6e5c5dce5018821: function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.call(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_crypto_38df2bab126b63dc: function(arg0) {
|
||||
const ret = arg0.crypto;
|
||||
return ret;
|
||||
},
|
||||
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
|
||||
arg0.getRandomValues(arg1);
|
||||
}, arguments); },
|
||||
__wbg_getRandomValues_cc7f052a444bb2ce: function() { return handleError(function (arg0, arg1) {
|
||||
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
|
||||
}, arguments); },
|
||||
__wbg_length_1f0964f4a5e2c6d8: function(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
},
|
||||
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
|
||||
const ret = arg0.msCrypto;
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_with_length_e6785c33c8e4cce8: function(arg0) {
|
||||
const ret = new Uint8Array(arg0 >>> 0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_node_84ea875411254db1: function(arg0) {
|
||||
const ret = arg0.node;
|
||||
return ret;
|
||||
},
|
||||
__wbg_now_86c0d4ba3fa605b8: function() {
|
||||
const ret = Date.now();
|
||||
return ret;
|
||||
},
|
||||
__wbg_process_44c7a14e11e9f69e: function(arg0) {
|
||||
const ret = arg0.process;
|
||||
return ret;
|
||||
},
|
||||
__wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) {
|
||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
||||
},
|
||||
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
|
||||
arg0.randomFillSync(arg1);
|
||||
}, arguments); },
|
||||
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
|
||||
const ret = module.require;
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_static_accessor_GLOBAL_4ef717fb391d88b7: function() {
|
||||
const ret = typeof global === 'undefined' ? null : global;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_GLOBAL_THIS_8d1badc68b5a74f4: function() {
|
||||
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_SELF_146583524fe1469b: function() {
|
||||
const ret = typeof self === 'undefined' ? null : self;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_WINDOW_f2829a2234d7819e: function() {
|
||||
const ret = typeof window === 'undefined' ? null : window;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_subarray_3ed232c8a6baee09: function(arg0, arg1, arg2) {
|
||||
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_versions_276b2795b1c6a219: function(arg0) {
|
||||
const ret = arg0.versions;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
|
||||
const ret = getArrayU8FromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./encryption_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
const SessionFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_session_free(ptr, 1));
|
||||
|
||||
function addToExternrefTable0(obj) {
|
||||
const idx = wasm.__externref_table_alloc();
|
||||
wasm.__wbindgen_externrefs.set(idx, obj);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
return decodeText(ptr >>> 0, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
const idx = addToExternrefTable0(e);
|
||||
wasm.__wbindgen_exn_store(idx);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasmInstance, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasmInstance = instance;
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('encryption_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
BIN
src/lib/encryption/vendor/encryption_bg.wasm
vendored
Normal file
BIN
src/lib/encryption/vendor/encryption_bg.wasm
vendored
Normal file
Binary file not shown.
24
src/lib/encryption/vendor/encryption_bg.wasm.d.ts
vendored
Normal file
24
src/lib/encryption/vendor/encryption_bg.wasm.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const __wbg_session_free: (a: number, b: number) => void;
|
||||
export const session_add: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
|
||||
export const session_commit_updates: (a: number) => [number, number, number, number];
|
||||
export const session_create: (a: number) => [number, number];
|
||||
export const session_encrypt: (a: number, b: number, c: number) => [number, number, number, number];
|
||||
export const session_join: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
||||
export const session_key_package: (a: number) => [number, number, number, number];
|
||||
export const session_members: (a: number) => [number, number, number, number];
|
||||
export const session_new: (a: number, b: number, c: number, d: number) => [number, number, number];
|
||||
export const session_process: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
|
||||
export const session_public_key: (a: number) => [number, number];
|
||||
export const session_remove: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||
export const session_update: (a: number) => [number, number, number, number];
|
||||
export const __wbindgen_exn_store: (a: number) => void;
|
||||
export const __externref_table_alloc: () => number;
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __externref_table_dealloc: (a: number) => void;
|
||||
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
export const __wbindgen_start: () => void;
|
||||
741
src/lib/encryption/vendor/manifest.json
vendored
Normal file
741
src/lib/encryption/vendor/manifest.json
vendored
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
{
|
||||
"format": 1,
|
||||
"sourceFingerprint": "0b201e5919b385b1edec4cad2456a573a5aa53f2ac3e265ad4e461d2b317dbf2",
|
||||
"sourceRevision": "613eac51c5c9cc67065998282af16e8016d5a2cb",
|
||||
"toolchain": "1.95.0",
|
||||
"wasmBindgen": "0.2.126",
|
||||
"dependencies": [
|
||||
{
|
||||
"name": "aead",
|
||||
"version": "0.5.2",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "aes",
|
||||
"version": "0.8.4",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "aes-gcm",
|
||||
"version": "0.10.3",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "autocfg",
|
||||
"version": "1.5.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "base16ct",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "base64",
|
||||
"version": "0.22.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "base64ct",
|
||||
"version": "1.8.3",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "block-buffer",
|
||||
"version": "0.10.4",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "block-buffer",
|
||||
"version": "0.12.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "bumpalo",
|
||||
"version": "3.20.3",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "cfg-if",
|
||||
"version": "1.0.5",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "chacha20",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "chacha20poly1305",
|
||||
"version": "0.10.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "cipher",
|
||||
"version": "0.4.4",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "cmov",
|
||||
"version": "0.5.4",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "const-oid",
|
||||
"version": "0.9.6",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "const-oid",
|
||||
"version": "0.10.2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "crossbeam-deque",
|
||||
"version": "0.8.8",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "crossbeam-epoch",
|
||||
"version": "0.9.21",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "crossbeam-utils",
|
||||
"version": "0.8.23",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "crypto-bigint",
|
||||
"version": "0.5.5",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "crypto-common",
|
||||
"version": "0.1.7",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "crypto-common",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "ctr",
|
||||
"version": "0.9.2",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "ctutils",
|
||||
"version": "0.4.3",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "curve25519-dalek",
|
||||
"version": "4.1.3",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "curve25519-dalek",
|
||||
"version": "5.0.0",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "der",
|
||||
"version": "0.7.10",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "der",
|
||||
"version": "0.8.2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "digest",
|
||||
"version": "0.10.7",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "digest",
|
||||
"version": "0.11.3",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "ecdsa",
|
||||
"version": "0.16.9",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "ed25519",
|
||||
"version": "2.2.3",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "ed25519-dalek",
|
||||
"version": "2.2.0",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "either",
|
||||
"version": "1.19.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "elliptic-curve",
|
||||
"version": "0.13.8",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "encryption",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "ff",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT/Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "futures-core",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "futures-task",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "futures-util",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "generic-array",
|
||||
"version": "0.14.7",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "getrandom",
|
||||
"version": "0.2.17",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "getrandom",
|
||||
"version": "0.4.3",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "ghash",
|
||||
"version": "0.5.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "group",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT/Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hax-lib",
|
||||
"version": "0.3.7",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hax-lib-macros",
|
||||
"version": "0.3.7",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hkdf",
|
||||
"version": "0.12.4",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hkdf",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hmac",
|
||||
"version": "0.12.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hmac",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hpke-rs",
|
||||
"version": "0.7.0",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hpke-rs-crypto",
|
||||
"version": "0.7.0",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hpke-rs-rust-crypto",
|
||||
"version": "0.7.0",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
{
|
||||
"name": "hybrid-array",
|
||||
"version": "0.4.15",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "inout",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "itoa",
|
||||
"version": "1.0.18",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "js-sys",
|
||||
"version": "0.3.103",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "k256",
|
||||
"version": "0.13.4",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "keccak",
|
||||
"version": "0.2.2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "kem",
|
||||
"version": "0.3.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "libc",
|
||||
"version": "0.2.190",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "libcrux-intrinsics",
|
||||
"version": "0.0.8",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "libcrux-platform",
|
||||
"version": "0.0.3",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "libcrux-secrets",
|
||||
"version": "0.0.6",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "libcrux-sha3",
|
||||
"version": "0.0.10",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "libcrux-traits",
|
||||
"version": "0.0.8",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "log",
|
||||
"version": "0.4.34",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "memchr",
|
||||
"version": "2.8.3",
|
||||
"license": "Unlicense OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "ml-dsa",
|
||||
"version": "0.1.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "ml-kem",
|
||||
"version": "0.3.2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "module-lattice",
|
||||
"version": "0.2.3",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "num-traits",
|
||||
"version": "0.2.19",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "once_cell",
|
||||
"version": "1.21.4",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "opaque-debug",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "openmls",
|
||||
"version": "0.9.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "openmls_basic_credential",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "openmls_memory_storage",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "openmls_rust_crypto",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "openmls_serialization_helpers",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "openmls_traits",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "p256",
|
||||
"version": "0.13.2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "p384",
|
||||
"version": "0.13.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "pem-rfc7468",
|
||||
"version": "0.7.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "pin-project-lite",
|
||||
"version": "0.2.17",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "pkcs8",
|
||||
"version": "0.10.2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "pkcs8",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "poly1305",
|
||||
"version": "0.8.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "polyval",
|
||||
"version": "0.6.2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "ppv-lite86",
|
||||
"version": "0.2.21",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "primeorder",
|
||||
"version": "0.13.6",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "proc-macro2",
|
||||
"version": "1.0.107",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "quote",
|
||||
"version": "1.0.47",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rand",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rand_chacha",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rand_chacha",
|
||||
"version": "0.10.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rand_core",
|
||||
"version": "0.6.4",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rand_core",
|
||||
"version": "0.10.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rayon",
|
||||
"version": "1.12.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rayon-core",
|
||||
"version": "1.13.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rfc6979",
|
||||
"version": "0.4.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "rustc_version",
|
||||
"version": "0.4.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "rustversion",
|
||||
"version": "1.0.23",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "sec1",
|
||||
"version": "0.7.3",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "semver",
|
||||
"version": "1.0.28",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "serde",
|
||||
"version": "1.0.229",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "serde_bytes",
|
||||
"version": "0.11.19",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "serde_core",
|
||||
"version": "1.0.229",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "serde_derive",
|
||||
"version": "1.0.229",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "serde_json",
|
||||
"version": "1.0.154",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "sha2",
|
||||
"version": "0.10.9",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "sha2",
|
||||
"version": "0.11.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "sha3",
|
||||
"version": "0.11.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "sha3",
|
||||
"version": "0.12.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "shake",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "signature",
|
||||
"version": "2.2.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "signature",
|
||||
"version": "3.0.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "slab",
|
||||
"version": "0.4.12",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "spki",
|
||||
"version": "0.7.3",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "spki",
|
||||
"version": "0.8.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "sponge-cursor",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "subtle",
|
||||
"version": "2.6.1",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "syn",
|
||||
"version": "2.0.119",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "syn",
|
||||
"version": "3.0.9",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "thiserror",
|
||||
"version": "2.0.21",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "thiserror-impl",
|
||||
"version": "2.0.21",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "tls_codec",
|
||||
"version": "0.5.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "tls_codec_derive",
|
||||
"version": "0.5.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "typenum",
|
||||
"version": "1.20.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "unicode-ident",
|
||||
"version": "1.0.27",
|
||||
"license": "(MIT OR Apache-2.0) AND Unicode-3.0"
|
||||
},
|
||||
{
|
||||
"name": "universal-hash",
|
||||
"version": "0.5.1",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "version_check",
|
||||
"version": "0.9.5",
|
||||
"license": "MIT/Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "wasm-bindgen",
|
||||
"version": "0.2.126",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "wasm-bindgen-macro",
|
||||
"version": "0.2.126",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "wasm-bindgen-macro-support",
|
||||
"version": "0.2.126",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "wasm-bindgen-shared",
|
||||
"version": "0.2.126",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "web-time",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "x-wing",
|
||||
"version": "0.1.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "x25519-dalek",
|
||||
"version": "2.0.1",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "x25519-dalek",
|
||||
"version": "3.0.0",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "zerocopy",
|
||||
"version": "0.8.63",
|
||||
"license": "BSD-2-Clause OR Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "zeroize",
|
||||
"version": "1.9.1",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "zeroize_derive",
|
||||
"version": "1.5.0",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "zmij",
|
||||
"version": "1.0.23",
|
||||
"license": "MIT"
|
||||
}
|
||||
],
|
||||
"checksums": {
|
||||
"THIRD_PARTY_LICENSES.txt": "4062b8ec5e519f57e14c7d25ae98499cf5d692cafb83fe09d39661b7069e68a3",
|
||||
"encryption.d.ts": "49f352eb652c1ac5be5b110e689cfdff6dbe43607bb8d1716e1cc005608d214c",
|
||||
"encryption.js": "f6f835a4e715b6eaa647e124e5fdad3534208d0fc60fb599d05647ed13db1526",
|
||||
"encryption_bg.wasm": "778c7c0755e12fe5651d87220b8090d2970ba3c9d6610078d567d1a5bed37d1a",
|
||||
"encryption_bg.wasm.d.ts": "b1e9c3ea7c59b3b447e6cf5e5e778199c322189cb113431d3b4e92b951f4bffa"
|
||||
}
|
||||
}
|
||||
42
src/lib/encryption/worker.ts
Normal file
42
src/lib/encryption/worker.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import init, { Session } from './vendor/encryption';
|
||||
import wasmUrl from './vendor/encryption_bg.wasm?url';
|
||||
|
||||
let session: Session | undefined;
|
||||
let queue = Promise.resolve();
|
||||
const ready = init({ module_or_path: wasmUrl });
|
||||
const allowed = new Set([
|
||||
'public_key',
|
||||
'key_package',
|
||||
'create',
|
||||
'join',
|
||||
'add',
|
||||
'remove',
|
||||
'update',
|
||||
'commit_updates',
|
||||
'encrypt',
|
||||
'process',
|
||||
'members'
|
||||
]);
|
||||
|
||||
self.onmessage = ({ data }) => {
|
||||
queue = queue.then(async () => {
|
||||
try {
|
||||
await ready;
|
||||
let result: unknown;
|
||||
if (data.method === 'init' && !session) {
|
||||
session = new Session(data.args[0], data.args[1]);
|
||||
} else if (session && allowed.has(data.method)) {
|
||||
result = (session[data.method as keyof Session] as Function).apply(session, data.args);
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
self.postMessage({ id: data.id, result });
|
||||
} catch {
|
||||
// Fail closed; do not serialize library exceptions or input into logs.
|
||||
self.postMessage({ id: data.id, error: true });
|
||||
session?.free();
|
||||
session = undefined;
|
||||
self.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -2734,6 +2734,8 @@
|
|||
"settings.admin.general.calendar.label": "Calendar",
|
||||
"settings.admin.general.channels.description": "Allow users to use channels for shared conversations.",
|
||||
"settings.admin.general.channels.label": "Channels",
|
||||
"settings.admin.general.encryptedChannels.description": "Allow temporary end-to-end encrypted chats in direct messages and private groups. Messages are not saved.",
|
||||
"settings.admin.general.encryptedChannels.label": "Encrypted channels",
|
||||
"settings.admin.general.checkForUpdates.label": "Check for updates",
|
||||
"settings.admin.general.communitySharing.description": "Allow users to share chats with the Open WebUI community.",
|
||||
"settings.admin.general.communitySharing.label": "Community Sharing",
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ type Settings = {
|
|||
hapticFeedback?: boolean;
|
||||
responseAutoCopy?: any;
|
||||
richTextInput?: boolean;
|
||||
showFormattingToolbar?: boolean;
|
||||
params?: any;
|
||||
userLocation?: any;
|
||||
webSearch?: any;
|
||||
|
|
@ -367,6 +368,7 @@ type Config = {
|
|||
default_prompt_suggestions: PromptSuggestion[] | null;
|
||||
default_prompt_suggestions_i18n?: Record<string, { suggestion_prompts: PromptSuggestion[] }>;
|
||||
features: {
|
||||
enable_encrypted_channels?: boolean;
|
||||
slim?: boolean;
|
||||
auth: boolean;
|
||||
auth_trusted_header: boolean;
|
||||
|
|
|
|||
354
src/routes/(app)/channels/[id]/encrypted/+page.svelte
Normal file
354
src/routes/(app)/channels/[id]/encrypted/+page.svelte
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { socket, user, showSidebar, channels, settings, mobile } from '$lib/stores';
|
||||
import { consumeEntry, Conversation, type State } from '$lib/encryption/client';
|
||||
|
||||
import Navbar from '$lib/components/channel/Navbar.svelte';
|
||||
import Message from '$lib/components/channel/Messages/Message.svelte';
|
||||
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
|
||||
import UserAlt from '$lib/components/icons/UserAlt.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
const i18n: any = getContext('i18n');
|
||||
$: channel = $page.params.id ?? '';
|
||||
let mounted = false;
|
||||
let stopSession = () => {};
|
||||
// SvelteKit reuses the page when navigating between channel IDs.
|
||||
$: if (mounted) startSession(channel);
|
||||
let showParticipants = false;
|
||||
$: channelInfo = $channels.find((item) => item.id === channel) ?? {
|
||||
id: channel,
|
||||
name: $i18n.t('Conversation'),
|
||||
type: 'group',
|
||||
is_private: true
|
||||
};
|
||||
let conversation: Conversation | undefined;
|
||||
let state: State = { status: 'ended', messages: [], members: [], self: '', host: '' };
|
||||
let draft = '';
|
||||
let inputEditor: Editor | null = null;
|
||||
let verified = new Set<string>();
|
||||
let list: HTMLDivElement;
|
||||
$: oversized = new TextEncoder().encode(draft).length > 16384;
|
||||
const close = () => {
|
||||
inputEditor?.destroy();
|
||||
inputEditor = null;
|
||||
draft = '';
|
||||
showParticipants = false;
|
||||
verified = new Set();
|
||||
conversation?.close();
|
||||
};
|
||||
const leave = () => {
|
||||
close();
|
||||
void goto(`/channels/${encodeURIComponent(channel)}`);
|
||||
};
|
||||
beforeNavigate(close);
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
return () => stopSession();
|
||||
});
|
||||
function startSession(channel: string) {
|
||||
stopSession();
|
||||
state = { status: 'ended', messages: [], members: [], self: '', host: '' };
|
||||
const intent = consumeEntry(channel);
|
||||
const connection = get(socket);
|
||||
const account = get(user);
|
||||
if (!intent || !connection || !account) return;
|
||||
try {
|
||||
conversation = new Conversation(connection, channel, account.id, intent);
|
||||
} catch {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const unsubscribe = conversation.state.subscribe((next) => {
|
||||
state = next;
|
||||
if (next.status === 'ended') {
|
||||
showParticipants = false;
|
||||
draft = '';
|
||||
verified = new Set();
|
||||
}
|
||||
void tick().then(() => {
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
});
|
||||
});
|
||||
const logout = user.subscribe((current) => {
|
||||
if (current?.id !== account.id) close();
|
||||
});
|
||||
stopSession = () => {
|
||||
close();
|
||||
unsubscribe();
|
||||
logout();
|
||||
conversation = undefined;
|
||||
};
|
||||
}
|
||||
function send() {
|
||||
if (oversized || !draft.trim() || state.status !== 'active') return;
|
||||
const text = draft;
|
||||
inputEditor?.commands.clearContent();
|
||||
draft = '';
|
||||
void conversation?.sendText(text);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{$i18n.t('Encrypted chat')} · Open WebUI</title></svelte:head>
|
||||
<div
|
||||
class="h-screen max-h-[100dvh] w-full min-w-0 flex flex-col {$showSidebar
|
||||
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
|
||||
: ''}"
|
||||
>
|
||||
<Navbar channel={channelInfo} readOnly>
|
||||
<span slot="status" class="ml-2 shrink-0 text-[0.6875rem] text-gray-500 dark:text-gray-400"
|
||||
>{$i18n.t('Encrypted')}</span
|
||||
>
|
||||
<svelte:fragment slot="actions">
|
||||
{#if state.status !== 'ended'}
|
||||
<Tooltip content={$i18n.t('Participants')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('{{COUNT}} participants', { COUNT: state.members.length })}
|
||||
class="flex h-7 items-center gap-1 px-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
|
||||
on:click={() => (showParticipants = true)}
|
||||
>
|
||||
<UserAlt className="size-4" strokeWidth="1.5" />
|
||||
<span class="text-xs tabular-nums">{state.members.length}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-lg px-2 text-xs text-gray-500 hover:bg-gray-50/60 dark:text-gray-400 dark:hover:bg-gray-800/60"
|
||||
on:click={leave}>{$i18n.t('Leave')}</button
|
||||
>
|
||||
</svelte:fragment>
|
||||
</Navbar>
|
||||
{#if state.status === 'ended'}
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center"
|
||||
role="status"
|
||||
>
|
||||
<h2 class="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Encrypted chat ended')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-lg bg-gray-100 px-2.5 text-xs font-normal text-gray-800 transition hover:bg-gray-100/70 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-850/60"
|
||||
on:click={leave}>{$i18n.t('Return to conversation')}</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={list}
|
||||
class="flex-1 min-h-0 overflow-y-auto pt-14 pb-2.5"
|
||||
role="log"
|
||||
aria-label={$i18n.t('Encrypted messages')}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="min-h-full flex flex-col justify-end">
|
||||
{#if state.messages.length === 0}
|
||||
<div class="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>
|
||||
{state.status === 'joining'
|
||||
? $i18n.t('Joining encrypted chat…')
|
||||
: $i18n.t('Messages are end-to-end encrypted and aren’t saved.')}
|
||||
</p>
|
||||
<p class="mt-1 text-xs">{$i18n.t('Leaving or reloading clears this device’s chat.')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#each state.messages as message (message.id)}
|
||||
{#if message.type === 'joined'}
|
||||
<p class="px-4 py-1 text-[0.6875rem] font-normal text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('{{NAME}} joined', { NAME: message.user.name })}
|
||||
</p>
|
||||
{:else}
|
||||
<Message
|
||||
channel={channelInfo}
|
||||
disabled
|
||||
message={{
|
||||
id: message.id,
|
||||
content: message.text,
|
||||
user: message.user,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.created_at
|
||||
}}
|
||||
>
|
||||
<span slot="content" class="markdown-prose">
|
||||
<Markdown
|
||||
id={message.id}
|
||||
content={message.text}
|
||||
paragraphTag="span"
|
||||
allowHtml={false}
|
||||
allowEmbeds={false}
|
||||
allowRemoteContent={false}
|
||||
editCodeBlock={false}
|
||||
/>
|
||||
</span>
|
||||
<svelte:fragment slot="status">
|
||||
{#if message.sender === state.self}
|
||||
{#if message.expected.length > 0 && message.received.length === message.expected.length}
|
||||
<span
|
||||
class="hover-reveal shrink-0 translate-y-px text-[0.6875rem] leading-4 font-normal text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{$i18n.t('Delivered')}
|
||||
</span>
|
||||
{:else if !message.relayed}
|
||||
<span
|
||||
class="shrink-0 translate-y-px text-[0.6875rem] leading-4 font-normal text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{$i18n.t('Sending…')}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Message>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<form class="shrink-0 pb-3 px-3" on:submit|preventDefault={send}>
|
||||
<div
|
||||
id="message-input-container"
|
||||
class="flex-1 flex flex-col relative w-full shadow-sm rounded-2xl border border-gray-100/60 dark:border-gray-800/50 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white dark:bg-gray-400/5 dark:text-gray-100"
|
||||
dir={$settings?.chatDirection === 'RTL'
|
||||
? 'rtl'
|
||||
: $settings?.chatDirection === 'LTR'
|
||||
? 'ltr'
|
||||
: 'auto'}
|
||||
>
|
||||
<div class="px-2" spellcheck="false">
|
||||
<div
|
||||
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2 pb-0.5 px-1 resize-none h-fit max-h-96 overflow-auto"
|
||||
>
|
||||
<RichTextInput
|
||||
id="chat-input"
|
||||
className="input-prose min-h-fit h-full text-sm!"
|
||||
bind:editor={inputEditor}
|
||||
json={true}
|
||||
messageInput={true}
|
||||
editable={state.status === 'active'}
|
||||
placeholder={$i18n.t('Type here...')}
|
||||
richText={$settings?.richTextInput ?? true}
|
||||
showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
|
||||
shiftEnter={!($settings?.ctrlEnterToSend ?? false) && !$mobile}
|
||||
floatingMenuPlacement="top-start"
|
||||
onChange={({ md }) => {
|
||||
draft = state.status === 'active' ? md : '';
|
||||
}}
|
||||
on:keydown={({ detail: { event } }) => {
|
||||
if (event.isComposing || event.defaultPrevented || event.key !== 'Enter') return;
|
||||
const control = event.ctrlKey || event.metaKey;
|
||||
if (
|
||||
($settings?.ctrlEnterToSend ?? false)
|
||||
? control
|
||||
: !event.shiftKey && (!$mobile || control)
|
||||
) {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-2 pb-2 pt-1">
|
||||
{#if oversized}<span class="mr-auto text-xs text-gray-500" role="status"
|
||||
>{$i18n.t('Message exceeds the 16 KiB limit.')}</span
|
||||
>{/if}
|
||||
<Tooltip content={$i18n.t('Send message')}>
|
||||
<button
|
||||
id="send-message-button"
|
||||
aria-label={$i18n.t('Send message')}
|
||||
class="{!(state.status !== 'active' || oversized || !draft.trim())
|
||||
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
||||
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full size-7 flex items-center justify-center self-center"
|
||||
type="submit"
|
||||
disabled={state.status !== 'active' || oversized || !draft.trim()}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:show={showParticipants} size="sm" ariaLabel={$i18n.t('Participants')}>
|
||||
<div class="flex items-center justify-between px-4 pt-3 mb-1 dark:text-gray-100">
|
||||
<h2 class="text-base font-normal">{$i18n.t('Participants')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Close')}
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
on:click={() => (showParticipants = false)}><XMark className="size-4" /></button
|
||||
>
|
||||
</div>
|
||||
<div class="max-h-[50dvh] overflow-y-auto px-3 pb-4 dark:text-gray-200">
|
||||
{#each state.members as member (member.identity.participation)}
|
||||
<details class="group text-xs">
|
||||
<summary
|
||||
class="flex cursor-pointer list-none items-center gap-2 px-2 py-1.5 font-normal text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<ProfileImage
|
||||
src={`${WEBUI_API_BASE_URL}/users/${member.identity.user}/profile/image`}
|
||||
className="size-6 shrink-0 rounded-2xl"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-xs font-normal" title={member.name}
|
||||
>{member.name}</span
|
||||
>
|
||||
<span class="shrink-0 text-xs font-normal text-gray-500 dark:text-gray-400"
|
||||
>{verified.has(member.fingerprint) ? $i18n.t('Verified') : $i18n.t('Verify')}</span
|
||||
>
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0 text-gray-400 transition-transform group-open:rotate-90"
|
||||
/>
|
||||
</summary>
|
||||
<div class="mx-2 mb-2 rounded-lg bg-gray-50/60 p-2.5 dark:bg-gray-850/60">
|
||||
<p class="text-[0.6875rem] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t(
|
||||
'Compare this code with this participant through another trusted channel. It changes when they rejoin.'
|
||||
)}
|
||||
</p>
|
||||
<code
|
||||
class="my-2 block break-all text-[0.6875rem] leading-4 text-gray-700 dark:text-gray-300"
|
||||
>{member.fingerprint.match(/.{1,4}/g)?.join(' ')}</code
|
||||
>
|
||||
{#if member.identity.participation !== state.self}
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-lg bg-gray-100 px-2.5 text-xs font-normal text-gray-800 transition hover:bg-gray-100/70 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-850/60"
|
||||
on:click={() => (verified = new Set([...verified, member.fingerprint]))}
|
||||
>{$i18n.t('Codes match')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
<div class="flex justify-end px-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-lg bg-gray-100 px-2.5 text-xs font-normal text-gray-800 transition hover:bg-gray-100/70 disabled:opacity-60 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-850/60"
|
||||
disabled={state.status !== 'active'}
|
||||
on:click={() => conversation?.updateKeys()}>{$i18n.t('Refresh encryption keys')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
@ -15,6 +15,10 @@ export default defineConfig({
|
|||
sveltekit(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: 'src/lib/encryption/vendor/THIRD_PARTY_LICENSES.txt',
|
||||
dest: 'licenses/encryption'
|
||||
},
|
||||
{
|
||||
src: 'node_modules/onnxruntime-web/dist/*.jsep.*',
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue