This commit is contained in:
Timothy Jaeryang Baek 2026-10-15 17:08:00 +04:00
parent 613eac51c5
commit 7dc91de86c
39 changed files with 12438 additions and 84 deletions

View file

@ -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/

View file

@ -311,4 +311,7 @@ cypress/screenshots
/static/*
/static/*
# Generated and checksum-verified crypto distribution
src/lib/encryption/vendor/
crypto/encryption/target/

View file

@ -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,

View file

@ -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'),

View file

@ -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:

View file

@ -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(

View 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)

View file

@ -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
View file

@ -0,0 +1 @@
/target/

2026
crypto/encryption/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View 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"

View file

@ -0,0 +1,4 @@
[toolchain]
channel = "1.95.0"
targets = ["wasm32-unknown-unknown"]
profile = "minimal"

View 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());
}
}

View file

@ -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",

View 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
View 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
)

View file

@ -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')}

View file

@ -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">

View file

@ -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>

View file

@ -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">

View file

@ -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)}

View file

@ -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>

View file

@ -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 () => {

View file

@ -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 });
}
});
}
};

View file

@ -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

View file

@ -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

View file

@ -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();

View 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);
}
}

File diff suppressed because it is too large Load diff

View 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
View 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 };

Binary file not shown.

View 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
View 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"
}
}

View 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();
}
});
};

View file

@ -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",

View file

@ -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;

View 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>

View file

@ -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.*',