v0.2.3
This commit is contained in:
13
server/generate-self-signed-certificate.sh
Executable file
13
server/generate-self-signed-certificate.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
if [ -z "$DOMAIN" ]; then
|
||||
DOMAIN=localhost
|
||||
fi
|
||||
|
||||
mkdir -p data && \
|
||||
cd data && \
|
||||
openssl genrsa -out key.pem 2048 && \
|
||||
openssl req -new -key key.pem -out csr.pem -subj "/C=US/ST=California/L=San Francisco/O=ChatWithGPT/OU=ChatWithGPT/CN=localhost" && \
|
||||
openssl x509 -req -days 365 -in csr.pem -signkey key.pem -out cert.pem && \
|
||||
rm csr.pem && \
|
||||
echo "Generated self-signed certificate."
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-with-gpt",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"description": "An open-source ChatGPT app with a voice",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.282.0",
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/connect-sqlite3": "^0.9.2",
|
||||
@@ -42,7 +43,9 @@
|
||||
"idb-keyval": "^6.2.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwks-rsa": "^3.0.1",
|
||||
"knex": "^2.4.2",
|
||||
"launchdarkly-eventsource": "^1.4.4",
|
||||
"lib0": "^0.2.73",
|
||||
"localforage": "^1.10.0",
|
||||
"match-sorter": "^6.3.1",
|
||||
"nanoid": "^4.0.1",
|
||||
@@ -50,10 +53,12 @@
|
||||
"passport": "^0.6.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.9.0",
|
||||
"react-router-dom": "^6.8.2",
|
||||
"sort-by": "^0.0.2",
|
||||
"sqlite3": "^5.1.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"xhr2": "^0.2.1"
|
||||
"xhr2": "^0.2.1",
|
||||
"y-protocols": "^1.0.5",
|
||||
"yaml": "^2.2.1",
|
||||
"yjs": "^13.5.51"
|
||||
}
|
||||
}
|
||||
|
@@ -1,16 +1,14 @@
|
||||
import crypto from 'crypto';
|
||||
import { auth, ConfigParams } from 'express-openid-connect';
|
||||
import ChatServer from './index';
|
||||
import { config } from './config';
|
||||
|
||||
const secret = process.env.AUTH_SECRET || crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const config: ConfigParams = {
|
||||
const auth0Config: ConfigParams = {
|
||||
authRequired: false,
|
||||
auth0Logout: false,
|
||||
secret,
|
||||
baseURL: process.env.PUBLIC_URL,
|
||||
clientID: process.env.AUTH0_CLIENT_ID,
|
||||
issuerBaseURL: process.env.AUTH0_ISSUER,
|
||||
secret: config.authSecret,
|
||||
baseURL: config.publicSiteURL,
|
||||
clientID: config.auth0?.clientID,
|
||||
issuerBaseURL: config.auth0?.issuer,
|
||||
routes: {
|
||||
login: false,
|
||||
logout: false,
|
||||
@@ -18,26 +16,36 @@ const config: ConfigParams = {
|
||||
};
|
||||
|
||||
export function configureAuth0(context: ChatServer) {
|
||||
context.app.use(auth(config));
|
||||
if (!config.publicSiteURL) {
|
||||
throw new Error('Missing public site URL in config, required for Auth0');
|
||||
}
|
||||
if (!config.auth0?.clientID) {
|
||||
throw new Error('Missing Auth0 client ID in config');
|
||||
}
|
||||
if (!config.auth0?.issuer) {
|
||||
throw new Error('Missing Auth0 issuer in config');
|
||||
}
|
||||
|
||||
context.app.use(auth(auth0Config));
|
||||
|
||||
context.app.get('/chatapi/login', (req, res) => {
|
||||
res.oidc.login({
|
||||
returnTo: process.env.PUBLIC_URL,
|
||||
returnTo: config.publicSiteURL,
|
||||
authorizationParams: {
|
||||
redirect_uri: process.env.PUBLIC_URL + '/chatapi/login-callback',
|
||||
redirect_uri: config.publicSiteURL + '/chatapi/login-callback',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
context.app.get('/chatapi/logout', (req, res) => {
|
||||
res.oidc.logout({
|
||||
returnTo: process.env.PUBLIC_URL,
|
||||
returnTo: config.publicSiteURL,
|
||||
});
|
||||
});
|
||||
|
||||
context.app.all('/chatapi/login-callback', (req, res) => {
|
||||
res.oidc.callback({
|
||||
redirectUri: process.env.PUBLIC_URL!,
|
||||
})
|
||||
redirectUri: config.publicSiteURL!,
|
||||
});
|
||||
});
|
||||
}
|
140
server/src/config.ts
Normal file
140
server/src/config.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parse } from 'yaml';
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
/**
|
||||
* The Config interface represents the configuration settings for various components
|
||||
* of the application, such as the server, database and external services.
|
||||
*
|
||||
* You may provide a `config.yaml` file in the `data` directory to override the default values.
|
||||
* (Or you can set the `CHATWITHGPT_CONFIG_FILENAME` environment variable to point to a different file.)
|
||||
*/
|
||||
export interface Config {
|
||||
services?: {
|
||||
openai?: {
|
||||
// The API key required to authenticate with the OpenAI service.
|
||||
// When provided, signed in users will be able to access OpenAI through the server
|
||||
// without needing their own API key.
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
elevenlabs?: {
|
||||
// The API key required to authenticate with the ElevenLabs service.
|
||||
// When provided, signed in users will be able to access ElevenLabs through the server
|
||||
// without needing their own API key.
|
||||
apiKey?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
Optional configuration for enabling Transport Layer Security (TLS) in the server.
|
||||
Requires specifying the file paths for the key and cert files. Includes:
|
||||
- key: The file path to the TLS private key file.
|
||||
- cert: The file path to the TLS certificate file.
|
||||
*/
|
||||
tls?: {
|
||||
selfSigned?: boolean;
|
||||
key?: string;
|
||||
cert?: string;
|
||||
};
|
||||
|
||||
/*
|
||||
The configuration object for the Knex.js database client.
|
||||
Detailed configuration options can be found in the Knex.js documentation:
|
||||
https://knexjs.org/guide/#configuration-options
|
||||
*/
|
||||
database: Knex.Config;
|
||||
|
||||
/*
|
||||
The secret session key used to encrypt the session cookie.
|
||||
If not provided, a random key will be generated.
|
||||
Changing this value will invalidate all existing sessions.
|
||||
*/
|
||||
authSecret: string;
|
||||
|
||||
/*
|
||||
Optional configuration object for the Auth0 authentication service.
|
||||
If provided, the server will use Auth0 for authentication.
|
||||
Otherwise, it will use a local authentication system.
|
||||
*/
|
||||
auth0?: {
|
||||
clientID?: string;
|
||||
issuer?: string;
|
||||
};
|
||||
|
||||
/*
|
||||
The URL of the public-facing server.
|
||||
*/
|
||||
publicSiteURL?: string;
|
||||
|
||||
/*
|
||||
The configuration object for the rate-limiting middleware.
|
||||
Each IP address is limited to a certain number of requests (max) per time window (windowMs).
|
||||
Detailed configuration options can be found in the Express Rate Limit documentation:
|
||||
https://www.npmjs.com/package/express-rate-limit
|
||||
*/
|
||||
rateLimit: {
|
||||
windowMs?: number;
|
||||
max?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// default config:
|
||||
let config: Config = {
|
||||
authSecret: crypto.randomBytes(32).toString('hex'),
|
||||
database: {
|
||||
client: 'sqlite3',
|
||||
connection: {
|
||||
filename: './data/chat.sqlite',
|
||||
},
|
||||
useNullAsDefault: true,
|
||||
},
|
||||
rateLimit: {
|
||||
// limit each IP to 100 requests per minute:
|
||||
max: 100,
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
}
|
||||
};
|
||||
|
||||
if (!fs.existsSync('./data')) {
|
||||
fs.mkdirSync('./data');
|
||||
}
|
||||
|
||||
const filename = process.env.CHATWITHGPT_CONFIG_FILENAME
|
||||
? path.resolve(process.env.CHATWITHGPT_CONFIG_FILENAME)
|
||||
: path.resolve(__dirname, '../data/config.yaml');
|
||||
|
||||
if (fs.existsSync(filename)) {
|
||||
config = {
|
||||
...config,
|
||||
...parse(fs.readFileSync(filename).toString()),
|
||||
};
|
||||
console.log("Loaded config from:", filename);
|
||||
}
|
||||
|
||||
if (process.env.AUTH_SECRET) {
|
||||
config.authSecret = process.env.AUTH_SECRET;
|
||||
}
|
||||
|
||||
if (process.env.RATE_LIMIT_WINDOW_MS) {
|
||||
config.rateLimit.windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10);
|
||||
}
|
||||
if (process.env.RATE_LIMIT_MAX) {
|
||||
config.rateLimit.max = parseInt(process.env.RATE_LIMIT_MAX, 10);
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-signed')) {
|
||||
config.tls = {
|
||||
selfSigned: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (config.publicSiteURL) {
|
||||
config.publicSiteURL = config.publicSiteURL.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export {
|
||||
config
|
||||
};
|
@@ -1,3 +1,11 @@
|
||||
import ExpiryMap from "expiry-map";
|
||||
|
||||
// @ts-ignore
|
||||
import type { Doc } from "yjs";
|
||||
|
||||
// const documents = new ExpiryMap<string, Doc>(60 * 60 * 1000);
|
||||
const documents = new ExpiryMap<string, Doc>(48 * 60 * 60 * 1000);
|
||||
|
||||
export default abstract class Database {
|
||||
public async initialize() {}
|
||||
public abstract createUser(email: string, passwordHash: Buffer): Promise<void>;
|
||||
@@ -14,4 +22,17 @@ export default abstract class Database {
|
||||
public abstract setTitle(userID: string, chatID: string, title: string): Promise<void>;
|
||||
public abstract deleteChat(userID: string, chatID: string): Promise<any>;
|
||||
public abstract getDeletedChatIDs(userID: string): Promise<string[]>;
|
||||
|
||||
protected abstract loadYDoc(userID: string): Promise<Doc>;
|
||||
public abstract saveYUpdate(userID: string, update: Uint8Array): Promise<void>;
|
||||
|
||||
public async getYDoc(userID: string): Promise<Doc> {
|
||||
const doc = documents.get(userID);
|
||||
if (doc) {
|
||||
return doc;
|
||||
}
|
||||
const newDoc = await this.loadYDoc(userID);
|
||||
documents.set(userID, newDoc);
|
||||
return newDoc;
|
||||
}
|
||||
}
|
204
server/src/database/knex.ts
Normal file
204
server/src/database/knex.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { validate as validateEmailAddress } from 'email-validator';
|
||||
import { Knex, knex as KnexClient } from 'knex';
|
||||
import Database from "./index";
|
||||
import { config } from '../config';
|
||||
|
||||
const tableNames = {
|
||||
authentication: 'authentication',
|
||||
chats: 'chats',
|
||||
deletedChats: 'deleted_chats',
|
||||
messages: 'messages',
|
||||
shares: 'shares',
|
||||
yjsUpdates: 'updates',
|
||||
};
|
||||
|
||||
export default class KnexDatabaseAdapter extends Database {
|
||||
private knex = KnexClient(this.knexConfig);
|
||||
|
||||
constructor(private knexConfig: Knex.Config = config.database) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
await this.createTables();
|
||||
}
|
||||
|
||||
private async createTables() {
|
||||
await this.createTableIfNotExists(tableNames.authentication, (table) => {
|
||||
table.text('id').primary();
|
||||
table.text('email');
|
||||
table.binary('password_hash');
|
||||
table.binary('salt');
|
||||
});
|
||||
|
||||
await this.createTableIfNotExists(tableNames.chats, (table) => {
|
||||
table.text('id').primary();
|
||||
table.text('user_id');
|
||||
table.text('title');
|
||||
});
|
||||
|
||||
await this.createTableIfNotExists(tableNames.deletedChats, (table) => {
|
||||
table.text('id').primary();
|
||||
table.text('user_id');
|
||||
table.dateTime('deleted_at');
|
||||
});
|
||||
|
||||
await this.createTableIfNotExists(tableNames.messages, (table) => {
|
||||
table.text('id').primary();
|
||||
table.text('user_id');
|
||||
table.text('chat_id');
|
||||
table.text('data');
|
||||
});
|
||||
|
||||
await this.createTableIfNotExists(tableNames.shares, (table) => {
|
||||
table.text('id').primary();
|
||||
table.text('user_id');
|
||||
table.dateTime('created_at');
|
||||
});
|
||||
|
||||
await this.createTableIfNotExists(tableNames.yjsUpdates, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.text('user_id');
|
||||
table.binary('update');
|
||||
table.index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
private async createTableIfNotExists(tableName: string, tableBuilderCallback: (tableBuilder: Knex.CreateTableBuilder) => any) {
|
||||
const exists = await this.knex.schema.hasTable(tableName);
|
||||
if (!exists) {
|
||||
await this.knex.schema.createTable(tableName, tableBuilderCallback);
|
||||
}
|
||||
}
|
||||
|
||||
public async createUser(email: string, passwordHash: Buffer): Promise<void> {
|
||||
if (!validateEmailAddress(email)) {
|
||||
throw new Error('invalid email address');
|
||||
}
|
||||
|
||||
await this.knex(tableNames.authentication).insert({
|
||||
id: email,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
});
|
||||
}
|
||||
|
||||
public async getUser(email: string): Promise<any> {
|
||||
const row = await this.knex(tableNames.authentication)
|
||||
.where('email', email)
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
passwordHash: Buffer.from(row.password_hash),
|
||||
salt: row.salt ? Buffer.from(row.salt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
public async getChats(userID: string): Promise<any[]> {
|
||||
return await this.knex(tableNames.chats)
|
||||
.where('user_id', userID).select();
|
||||
}
|
||||
|
||||
public async getMessages(userID: string): Promise<any[]> {
|
||||
const rows = await this.knex(tableNames.messages)
|
||||
.where('user_id', userID).select();
|
||||
|
||||
return rows.map((row: any) => {
|
||||
// row.data = JSON.parse(row.data);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
public async insertMessages(userID: string, messages: any[]): Promise<void> {
|
||||
// deprecated
|
||||
}
|
||||
|
||||
public async createShare(userID: string | null, id: string): Promise<boolean> {
|
||||
await this.knex(tableNames.shares)
|
||||
.insert({
|
||||
id,
|
||||
user_id: userID,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async setTitle(userID: string, chatID: string, title: string): Promise<void> {
|
||||
// deprecated
|
||||
}
|
||||
|
||||
public async deleteChat(userID: string, chatID: string): Promise<any> {
|
||||
await this.knex.transaction(async (trx) => {
|
||||
await trx(tableNames.chats).where({ id: chatID, user_id: userID }).delete();
|
||||
await trx(tableNames.messages).where({ chat_id: chatID, user_id: userID }).delete();
|
||||
await trx(tableNames.deletedChats)
|
||||
.insert({ id: chatID, user_id: userID, deleted_at: new Date() });
|
||||
});
|
||||
}
|
||||
|
||||
public async getDeletedChatIDs(userID: string): Promise<string[]> {
|
||||
const rows = await this.knex(tableNames.deletedChats)
|
||||
.where('user_id', userID)
|
||||
.select();
|
||||
return rows.map((row: any) => row.id);
|
||||
}
|
||||
|
||||
protected async loadYDoc(userID: string) {
|
||||
const Y = await import('yjs');
|
||||
|
||||
const ydoc = new Y.Doc();
|
||||
|
||||
const updates = await this.knex(tableNames.yjsUpdates)
|
||||
.where('user_id', userID)
|
||||
.select();
|
||||
|
||||
updates.forEach((updateRow: any) => {
|
||||
try {
|
||||
const update = new Uint8Array(updateRow.update);
|
||||
if (update.byteLength > 4) {
|
||||
Y.applyUpdate(ydoc, update);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to apply update', updateRow, e);
|
||||
}
|
||||
});
|
||||
|
||||
const merged = Y.encodeStateAsUpdate(ydoc);
|
||||
|
||||
if (updates.length) {
|
||||
// In a transaction, insert the merged update, then delete all previous updates (lower ID).
|
||||
// This needs to be done together in a transaction to avoid consistency errors or data loss!
|
||||
await this.knex.transaction(async (trx) => {
|
||||
await trx(tableNames.yjsUpdates)
|
||||
.insert({
|
||||
user_id: userID,
|
||||
update: Buffer.from(merged),
|
||||
});
|
||||
|
||||
await trx(tableNames.yjsUpdates)
|
||||
.where('user_id', userID)
|
||||
.where('id', '<', updates[updates.length - 1].id)
|
||||
.delete();
|
||||
});
|
||||
}
|
||||
|
||||
return ydoc;
|
||||
}
|
||||
|
||||
public async saveYUpdate(userID: string, update: Uint8Array): Promise<void> {
|
||||
if (update.byteLength <= 4) {
|
||||
return;
|
||||
}
|
||||
await this.knex(tableNames.yjsUpdates)
|
||||
.insert({
|
||||
user_id: userID,
|
||||
update: Buffer.from(update),
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,202 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import { verbose } from "sqlite3";
|
||||
import { validate as validateEmailAddress } from 'email-validator';
|
||||
import Database from "./index";
|
||||
|
||||
const sqlite3 = verbose();
|
||||
|
||||
if (!fs.existsSync('./data')) {
|
||||
fs.mkdirSync('./data');
|
||||
}
|
||||
|
||||
const db = new sqlite3.Database('./data/chat.sqlite');
|
||||
|
||||
// interface ChatRow {
|
||||
// id: string;
|
||||
// user_id: string;
|
||||
// title: string;
|
||||
// }
|
||||
|
||||
// interface MessageRow {
|
||||
// id: string;
|
||||
// user_id: string;
|
||||
// chat_id: string;
|
||||
// data: any;
|
||||
// }
|
||||
|
||||
// interface ShareRow {
|
||||
// id: string;
|
||||
// user_id: string;
|
||||
// created_at: Date;
|
||||
// }
|
||||
|
||||
export class SQLiteAdapter extends Database {
|
||||
public async initialize() {
|
||||
db.serialize(() => {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS authentication (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
password_hash BLOB,
|
||||
salt BLOB
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS chats (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
title TEXT
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS deleted_chats (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
deleted_at DATETIME
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
chat_id TEXT,
|
||||
data TEXT
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
created_at DATETIME
|
||||
)`);
|
||||
});
|
||||
}
|
||||
|
||||
public createUser(email: string, passwordHash: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!validateEmailAddress(email)) {
|
||||
reject(new Error('invalid email address'));
|
||||
return;
|
||||
}
|
||||
|
||||
db.run(`INSERT INTO authentication (id, email, password_hash) VALUES (?, ?, ?)`, [email, email, passwordHash], (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
console.log(`[database:sqlite] failed to create user ${email}`);
|
||||
} else {
|
||||
resolve();
|
||||
console.log(`[database:sqlite] created user ${email}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async getUser(email: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(`SELECT * FROM authentication WHERE email = ?`, [email], (err: any, row: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
console.log(`[database:sqlite] failed to get user ${email}`);
|
||||
} else if (!row) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve({
|
||||
...row,
|
||||
passwordHash: Buffer.from(row.password_hash),
|
||||
salt: row.salt ? Buffer.from(row.salt) : null,
|
||||
});
|
||||
console.log(`[database:sqlite] retrieved user ${email}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async getChats(userID: string): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(`SELECT * FROM chats WHERE user_id = ?`, [userID], (err: any, rows: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
console.log(`[database:sqlite] failed to get chats for user ${userID}`);
|
||||
} else {
|
||||
resolve(rows);
|
||||
console.log(`[database:sqlite] retrieved ${rows.length} chats for user ${userID}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async getMessages(userID: string): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(`SELECT * FROM messages WHERE user_id = ?`, [userID], (err: any, rows: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
console.log(`[database:sqlite] failed to get messages for user ${userID}`);
|
||||
} else {
|
||||
resolve(rows.map((row: any) => {
|
||||
row.data = JSON.parse(row.data);
|
||||
return row;
|
||||
}));
|
||||
console.log(`[database:sqlite] retrieved ${rows.length} messages for user ${userID}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async insertMessages(userID: string, messages: any[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
const stmt = db.prepare(`INSERT OR IGNORE INTO messages (id, user_id, chat_id, data) VALUES (?, ?, ?, ?)`);
|
||||
messages.forEach((message) => {
|
||||
stmt.run(message.id, userID, message.chatID, JSON.stringify(message));
|
||||
});
|
||||
stmt.finalize();
|
||||
console.log(`[database:sqlite] inserted ${messages.length} messages`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async createShare(userID: string|null, id: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(`INSERT INTO shares (id, user_id, created_at) VALUES (?, ?, ?)`, [id, userID, new Date()], (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
console.log(`[database:sqlite] failed to create share ${id}`);
|
||||
} else {
|
||||
resolve(true);
|
||||
console.log(`[database:sqlite] created share ${id}`)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async setTitle(userID: string, chatID: string, title: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(`INSERT OR IGNORE INTO chats (id, user_id, title) VALUES (?, ?, ?)`, [chatID, userID, title], (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
console.log(`[database:sqlite] failed to set title for chat ${chatID}`);
|
||||
} else {
|
||||
resolve();
|
||||
console.log(`[database:sqlite] set title for chat ${chatID}`)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteChat(userID: string, chatID: string): Promise<any> {
|
||||
db.serialize(() => {
|
||||
db.run(`DELETE FROM chats WHERE id = ? AND user_id = ?`, [chatID, userID]);
|
||||
db.run(`DELETE FROM messages WHERE chat_id = ? AND user_id = ?`, [chatID, userID]);
|
||||
db.run(`INSERT INTO deleted_chats (id, user_id, deleted_at) VALUES (?, ?, ?)`, [chatID, userID, new Date()]);
|
||||
console.log(`[database:sqlite] deleted chat ${chatID}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async getDeletedChatIDs(userID: string): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(`SELECT * FROM deleted_chats WHERE user_id = ?`, [userID], (err: any, rows: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows.map((row: any) => row.id));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,5 +1,11 @@
|
||||
import express from 'express';
|
||||
import ChatServer from '../index';
|
||||
import ExpirySet from 'expiry-set';
|
||||
|
||||
const recentUsers = new ExpirySet<string>(1000 * 60 * 5);
|
||||
export function getActiveUsersInLast5Minutes() {
|
||||
return Array.from(recentUsers.values());
|
||||
}
|
||||
|
||||
export default abstract class RequestHandler {
|
||||
constructor(public context: ChatServer, private req: express.Request, private res: express.Response) {
|
||||
@@ -12,6 +18,10 @@ export default abstract class RequestHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.userID) {
|
||||
recentUsers.add(this.userID);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.handler(this.req, this.res);
|
||||
} catch (e) {
|
||||
|
@@ -1,20 +0,0 @@
|
||||
import express from 'express';
|
||||
import { Configuration, OpenAIApi } from "openai";
|
||||
import RequestHandler from "../base";
|
||||
|
||||
const configuration = new Configuration({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
const openai = new OpenAIApi(configuration);
|
||||
|
||||
export default class BasicCompletionRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
const response = await openai.createChatCompletion(req.body);
|
||||
res.json(response);
|
||||
}
|
||||
|
||||
public isProtected() {
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -1,57 +0,0 @@
|
||||
// @ts-ignore
|
||||
import { EventSource } from "launchdarkly-eventsource";
|
||||
import express from 'express';
|
||||
import RequestHandler from "../base";
|
||||
|
||||
export default class StreamingCompletionRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const eventSource = new EventSource('https://api.openai.com/v1/chat/completions', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...req.body,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
eventSource.addEventListener('message', async (event: any) => {
|
||||
res.write(`data: ${event.data}\n\n`);
|
||||
res.flush();
|
||||
|
||||
if (event.data === '[DONE]') {
|
||||
res.end();
|
||||
eventSource.close();
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (event: any) => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
eventSource.addEventListener('abort', (event: any) => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
eventSource.close();
|
||||
});
|
||||
|
||||
res.on('error', () => {
|
||||
eventSource.close();
|
||||
});
|
||||
}
|
||||
|
||||
public isProtected() {
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -1,18 +0,0 @@
|
||||
import express from 'express';
|
||||
import RequestHandler from "./base";
|
||||
|
||||
export default class MessagesRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
if (!req.body.messages?.length) {
|
||||
console.log("Invalid request")
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
await this.context.database.insertMessages(this.userID!, req.body.messages);
|
||||
res.json({ status: 'ok' });
|
||||
}
|
||||
|
||||
public isProtected() {
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -0,0 +1,28 @@
|
||||
import express from 'express';
|
||||
import RequestHandler from "../../base";
|
||||
import axios from 'axios';
|
||||
import { config } from '../../../config';
|
||||
|
||||
export const endpoint = 'https://api.elevenlabs.io';
|
||||
export const apiKey = config.services?.elevenlabs?.apiKey || process.env.ELEVENLABS_API_KEY;
|
||||
|
||||
export default class ElevenLabsTTSProxyRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
const voiceID = req.params.voiceID;
|
||||
const response = await axios.post(endpoint + '/v1/text-to-speech/' + voiceID,
|
||||
JSON.stringify(req.body),
|
||||
{
|
||||
headers: {
|
||||
'xi-api-key': apiKey || '',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
res.setHeader('Content-Type', response.headers['content-type'] || 'audio/mpeg');
|
||||
res.send(response.data);
|
||||
}
|
||||
|
||||
public isProtected() {
|
||||
return true;
|
||||
}
|
||||
}
|
21
server/src/endpoints/service-proxies/elevenlabs/voices.ts
Normal file
21
server/src/endpoints/service-proxies/elevenlabs/voices.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import express from 'express';
|
||||
import RequestHandler from "../../base";
|
||||
import axios from 'axios';
|
||||
import { endpoint, apiKey } from './text-to-speech';
|
||||
|
||||
export default class ElevenLabsVoicesProxyRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
const response = await axios.get(endpoint + '/v1/voices',
|
||||
{
|
||||
headers: {
|
||||
'xi-api-key': apiKey || '',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
res.json(response.data);
|
||||
}
|
||||
|
||||
public isProtected() {
|
||||
return true;
|
||||
}
|
||||
}
|
15
server/src/endpoints/service-proxies/openai/basic.ts
Normal file
15
server/src/endpoints/service-proxies/openai/basic.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from 'express';
|
||||
import axios from 'axios';
|
||||
import { apiKey, endpoint } from '.';
|
||||
|
||||
export async function basicHandler(req: express.Request, res: express.Response) {
|
||||
const response = await axios.post(endpoint, JSON.stringify(req.body), {
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
res.json(response.data);
|
||||
}
|
22
server/src/endpoints/service-proxies/openai/index.ts
Normal file
22
server/src/endpoints/service-proxies/openai/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import express from 'express';
|
||||
import RequestHandler from "../../base";
|
||||
import { streamingHandler } from './streaming';
|
||||
import { basicHandler } from './basic';
|
||||
import { config } from '../../../config';
|
||||
|
||||
export const endpoint = 'https://api.openai.com/v1/chat/completions';
|
||||
export const apiKey = config.services?.openai?.apiKey || process.env.OPENAI_API_KEY;
|
||||
|
||||
export default class OpenAIProxyRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
if (req.body?.stream) {
|
||||
await streamingHandler(req, res);
|
||||
} else {
|
||||
await basicHandler(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
public isProtected() {
|
||||
return true;
|
||||
}
|
||||
}
|
51
server/src/endpoints/service-proxies/openai/streaming.ts
Normal file
51
server/src/endpoints/service-proxies/openai/streaming.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// @ts-ignore
|
||||
import { EventSource } from "launchdarkly-eventsource";
|
||||
import express from 'express';
|
||||
import { apiKey } from ".";
|
||||
|
||||
export async function streamingHandler(req: express.Request, res: express.Response) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const eventSource = new EventSource('https://api.openai.com/v1/chat/completions', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...req.body,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
eventSource.addEventListener('message', async (event: any) => {
|
||||
res.write(`data: ${event.data}\n\n`);
|
||||
res.flush();
|
||||
|
||||
if (event.data === '[DONE]') {
|
||||
res.end();
|
||||
eventSource.close();
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (event: any) => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
eventSource.addEventListener('abort', (event: any) => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
eventSource.close();
|
||||
});
|
||||
|
||||
res.on('error', e => {
|
||||
eventSource.close();
|
||||
});
|
||||
}
|
@@ -1,20 +1,25 @@
|
||||
import express from 'express';
|
||||
import RequestHandler from "./base";
|
||||
import { config } from '../config';
|
||||
|
||||
export default class SessionRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
const request = req as any;
|
||||
|
||||
const availableServiceNames = Object.keys(config.services || {})
|
||||
.filter(key => (config.services as any)?.[key]?.apiKey);
|
||||
|
||||
if (request.oidc) {
|
||||
const user = request.oidc.user;
|
||||
console.log(user);
|
||||
if (user) {
|
||||
res.json({
|
||||
authProvider: this.context.authProvider,
|
||||
authenticated: true,
|
||||
userID: user.sub,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.picture,
|
||||
services: availableServiceNames,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -23,14 +28,17 @@ export default class SessionRequestHandler extends RequestHandler {
|
||||
const userID = request.session?.passport?.user?.id;
|
||||
if (userID) {
|
||||
res.json({
|
||||
authProvider: this.context.authProvider,
|
||||
authenticated: true,
|
||||
userID,
|
||||
email: userID,
|
||||
services: availableServiceNames,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
authProvider: this.context.authProvider,
|
||||
authenticated: false,
|
||||
});
|
||||
}
|
||||
|
46
server/src/endpoints/sync-legacy.ts
Normal file
46
server/src/endpoints/sync-legacy.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
|
||||
import RequestHandler from "./base";
|
||||
import ExpiryMap from 'expiry-map';
|
||||
|
||||
const cache = new ExpiryMap<string, any>(1000 * 60 * 60);
|
||||
|
||||
interface Chat {
|
||||
id: string;
|
||||
messages: any[];
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export default class LegacySyncRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
if (cache.has(this.userID!)) {
|
||||
res.json(cache.get(this.userID!));
|
||||
return;
|
||||
}
|
||||
|
||||
const [chats, messages, deletedChatIDs] = await Promise.all([
|
||||
this.context.database.getChats(this.userID!),
|
||||
this.context.database.getMessages(this.userID!),
|
||||
this.context.database.getDeletedChatIDs(this.userID!),
|
||||
]);
|
||||
|
||||
|
||||
const response: Chat[] = [];
|
||||
|
||||
for (const chat of chats) {
|
||||
if (!deletedChatIDs.includes(chat.id)) {
|
||||
const chatMessages = messages.filter((message) => message.chat_id === chat.id).map(m => m.data);
|
||||
|
||||
response.push({
|
||||
id: chat.id,
|
||||
messages: chatMessages,
|
||||
title: chat.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(this.userID!, response);
|
||||
|
||||
res.json(response);
|
||||
}
|
||||
}
|
@@ -1,39 +1,59 @@
|
||||
import express from 'express';
|
||||
import { encode } from '@msgpack/msgpack';
|
||||
import ExpirySet from 'expiry-set';
|
||||
|
||||
import RequestHandler from "./base";
|
||||
|
||||
let totalUpdatesProcessed = 0;
|
||||
const recentUpdates = new ExpirySet<number>(1000 * 60 * 5);
|
||||
|
||||
export function getNumUpdatesProcessedIn5Minutes() {
|
||||
return recentUpdates.size;
|
||||
}
|
||||
|
||||
export default class SyncRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
const [chats, messages, deletedChatIDs] = await Promise.all([
|
||||
this.context.database.getChats(this.userID!),
|
||||
this.context.database.getMessages(this.userID!),
|
||||
this.context.database.getDeletedChatIDs(this.userID!),
|
||||
]);
|
||||
const encoding = await import('lib0/encoding');
|
||||
const decoding = await import('lib0/decoding');
|
||||
const syncProtocol = await import('y-protocols/sync');
|
||||
|
||||
const output: Record<string, any> = {};
|
||||
const doc = await this.context.database.getYDoc(this.userID!);
|
||||
|
||||
const Y = await import('yjs');
|
||||
|
||||
for (const m of messages) {
|
||||
const chat = output[m.chat_id] || {
|
||||
messages: [],
|
||||
};
|
||||
chat.messages.push(m.data);
|
||||
output[m.chat_id] = chat;
|
||||
const encoder = encoding.createEncoder();
|
||||
const decoder = decoding.createDecoder(req.body);
|
||||
|
||||
const messageType = decoding.readVarUint(decoder);
|
||||
|
||||
if (messageType === syncProtocol.messageYjsSyncStep2 || messageType === syncProtocol.messageYjsUpdate) {
|
||||
await this.context.database.saveYUpdate(this.userID!,
|
||||
decoding.readVarUint8Array(decoder));
|
||||
}
|
||||
|
||||
decoder.pos = 0;
|
||||
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, 'server');
|
||||
|
||||
const responseBuffers = [
|
||||
encoding.toUint8Array(encoder),
|
||||
];
|
||||
|
||||
if (messageType === syncProtocol.messageYjsSyncStep1) {
|
||||
const encoder = encoding.createEncoder();
|
||||
syncProtocol.writeSyncStep1(encoder, doc);
|
||||
responseBuffers.push(encoding.toUint8Array(encoder));
|
||||
} else if (messageType === syncProtocol.messageYjsUpdate) {
|
||||
totalUpdatesProcessed += 1;
|
||||
recentUpdates.add(totalUpdatesProcessed);
|
||||
}
|
||||
|
||||
for (const c of chats) {
|
||||
const chat = output[c.id] || {
|
||||
messages: [],
|
||||
};
|
||||
chat.title = c.title;
|
||||
output[c.id] = chat;
|
||||
}
|
||||
const buffer = Buffer.from(encode(responseBuffers));
|
||||
|
||||
for (const chatID of deletedChatIDs) {
|
||||
output[chatID] = {
|
||||
deleted: true
|
||||
};
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
|
||||
res.json(output);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
public isProtected() {
|
||||
|
@@ -1,13 +0,0 @@
|
||||
import express from 'express';
|
||||
import RequestHandler from "./base";
|
||||
|
||||
export default class TitleRequestHandler extends RequestHandler {
|
||||
async handler(req: express.Request, res: express.Response) {
|
||||
await this.context.database.setTitle(this.userID!, req.body.id, req.body.title);
|
||||
res.json({ status: 'ok' });
|
||||
}
|
||||
|
||||
public isProtected() {
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -1,8 +0,0 @@
|
||||
import express from 'express';
|
||||
import RequestHandler from "./base";
|
||||
|
||||
export default class WhisperRequestHandler extends RequestHandler {
|
||||
handler(req: express.Request, res: express.Response): any {
|
||||
res.json({ status: 'ok' });
|
||||
}
|
||||
}
|
@@ -1,27 +1,30 @@
|
||||
require('dotenv').config()
|
||||
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import express from 'express';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import S3ObjectStore from './object-store/s3';
|
||||
import { SQLiteAdapter } from './database/sqlite';
|
||||
import SQLiteObjectStore from './object-store/sqlite';
|
||||
import ObjectStore from './object-store/index';
|
||||
import Database from './database/index';
|
||||
import HealthRequestHandler from './endpoints/health';
|
||||
import TitleRequestHandler from './endpoints/title';
|
||||
import MessagesRequestHandler from './endpoints/messages';
|
||||
import SyncRequestHandler from './endpoints/sync';
|
||||
import ShareRequestHandler from './endpoints/share';
|
||||
import BasicCompletionRequestHandler from './endpoints/completion/basic';
|
||||
import StreamingCompletionRequestHandler from './endpoints/completion/streaming';
|
||||
import SessionRequestHandler from './endpoints/session';
|
||||
import GetShareRequestHandler from './endpoints/get-share';
|
||||
import WhisperRequestHandler from './endpoints/whisper';
|
||||
import { configurePassport } from './passport';
|
||||
import { configureAuth0 } from './auth0';
|
||||
import { config } from './config';
|
||||
import Database from './database/index';
|
||||
import KnexDatabaseAdapter from './database/knex';
|
||||
import GetShareRequestHandler from './endpoints/get-share';
|
||||
import HealthRequestHandler from './endpoints/health';
|
||||
import DeleteChatRequestHandler from './endpoints/delete-chat';
|
||||
import ElevenLabsTTSProxyRequestHandler from './endpoints/service-proxies/elevenlabs/text-to-speech';
|
||||
import ElevenLabsVoicesProxyRequestHandler from './endpoints/service-proxies/elevenlabs/voices';
|
||||
import OpenAIProxyRequestHandler from './endpoints/service-proxies/openai';
|
||||
import SessionRequestHandler from './endpoints/session';
|
||||
import ShareRequestHandler from './endpoints/share';
|
||||
import ObjectStore from './object-store/index';
|
||||
import S3ObjectStore from './object-store/s3';
|
||||
import SQLiteObjectStore from './object-store/sqlite';
|
||||
import { configurePassport } from './passport';
|
||||
import SyncRequestHandler, { getNumUpdatesProcessedIn5Minutes } from './endpoints/sync';
|
||||
import LegacySyncRequestHandler from './endpoints/sync-legacy';
|
||||
import { getActiveUsersInLast5Minutes } from './endpoints/base';
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.error('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
@@ -32,19 +35,12 @@ if (process.env.CI) {
|
||||
}
|
||||
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
|
||||
const webappPort = process.env.WEBAPP_PORT ? parseInt(process.env.WEBAPP_PORT, 10) : 3000;
|
||||
const origins = (process.env.ALLOWED_ORIGINS || '').split(',');
|
||||
|
||||
if (process.env['GITPOD_WORKSPACE_URL']) {
|
||||
origins.push(
|
||||
process.env['GITPOD_WORKSPACE_URL']?.replace('https://', `https://${webappPort}-`)
|
||||
);
|
||||
}
|
||||
|
||||
export default class ChatServer {
|
||||
authProvider = 'local';
|
||||
app: express.Application;
|
||||
objectStore: ObjectStore = process.env.S3_BUCKET ? new S3ObjectStore() : new SQLiteObjectStore();
|
||||
database: Database = new SQLiteAdapter();
|
||||
database: Database = new KnexDatabaseAdapter();
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
@@ -56,44 +52,65 @@ export default class ChatServer {
|
||||
|
||||
this.app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
if (process.env.AUTH0_CLIENT_ID && process.env.AUTH0_ISSUER && process.env.PUBLIC_URL) {
|
||||
if (config.auth0?.clientID && config.auth0?.issuer && config.publicSiteURL) {
|
||||
console.log('Configuring Auth0.');
|
||||
this.authProvider = 'auth0';
|
||||
configureAuth0(this);
|
||||
} else {
|
||||
console.log('Configuring Passport.');
|
||||
this.authProvider = 'local';
|
||||
configurePassport(this);
|
||||
}
|
||||
|
||||
this.app.use(express.json({ limit: '1mb' }));
|
||||
this.app.use(compression());
|
||||
|
||||
|
||||
const rateLimitWindowMs = process.env.RATE_LIMIT_WINDOW_MS ? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) : 15 * 60 * 1000; // 15 minutes
|
||||
const rateLimitMax = process.env.RATE_LIMIT_MAX ? parseInt(process.env.RATE_LIMIT_MAX, 10) : 100; // limit each IP to 100 requests per windowMs
|
||||
this.app.use(compression({
|
||||
filter: (req, res) => !req.path.includes("proxies"),
|
||||
}));
|
||||
|
||||
const { default: rateLimit } = await import('express-rate-limit'); // esm
|
||||
const limiter = rateLimit({
|
||||
windowMs: rateLimitWindowMs,
|
||||
max: rateLimitMax,
|
||||
});
|
||||
this.app.use(limiter);
|
||||
|
||||
this.app.get('/chatapi/health', (req, res) => new HealthRequestHandler(this, req, res));
|
||||
this.app.get('/chatapi/session', (req, res) => new SessionRequestHandler(this, req, res));
|
||||
this.app.post('/chatapi/messages', (req, res) => new MessagesRequestHandler(this, req, res));
|
||||
this.app.post('/chatapi/title', (req, res) => new TitleRequestHandler(this, req, res));
|
||||
|
||||
this.app.get('/chatapi/session',
|
||||
rateLimit({ windowMs: 60 * 1000, max: 100 }),
|
||||
(req, res) => new SessionRequestHandler(this, req, res));
|
||||
|
||||
this.app.post('/chatapi/y-sync',
|
||||
rateLimit({ windowMs: 60 * 1000, max: 100 }),
|
||||
express.raw({ type: 'application/octet-stream', limit: '10mb' }),
|
||||
(req, res) => new SyncRequestHandler(this, req, res));
|
||||
|
||||
this.app.get('/chatapi/legacy-sync',
|
||||
rateLimit({ windowMs: 60 * 1000, max: 100 }),
|
||||
(req, res) => new LegacySyncRequestHandler(this, req, res));
|
||||
|
||||
this.app.use(rateLimit({
|
||||
windowMs: config.rateLimit.windowMs,
|
||||
max: config.rateLimit.max,
|
||||
}));
|
||||
|
||||
this.app.post('/chatapi/delete', (req, res) => new DeleteChatRequestHandler(this, req, res));
|
||||
this.app.post('/chatapi/sync', (req, res) => new SyncRequestHandler(this, req, res));
|
||||
this.app.get('/chatapi/share/:id', (req, res) => new GetShareRequestHandler(this, req, res));
|
||||
this.app.post('/chatapi/share', (req, res) => new ShareRequestHandler(this, req, res));
|
||||
this.app.post('/chatapi/whisper', (req, res) => new WhisperRequestHandler(this, req, res));
|
||||
|
||||
if (process.env.ENABLE_SERVER_COMPLETION) {
|
||||
this.app.post('/chatapi/completion', (req, res) => new BasicCompletionRequestHandler(this, req, res));
|
||||
this.app.post('/chatapi/completion/streaming', (req, res) => new StreamingCompletionRequestHandler(this, req, res));
|
||||
if (config.services?.openai?.apiKey) {
|
||||
this.app.post('/chatapi/proxies/openai/v1/chat/completions', (req, res) => new OpenAIProxyRequestHandler(this, req, res));
|
||||
}
|
||||
|
||||
if (config.services?.elevenlabs?.apiKey) {
|
||||
this.app.post('/chatapi/proxies/elevenlabs/v1/text-to-speech/:voiceID', (req, res) => new ElevenLabsTTSProxyRequestHandler(this, req, res));
|
||||
this.app.get('/chatapi/proxies/elevenlabs/v1/voices', (req, res) => new ElevenLabsVoicesProxyRequestHandler(this, req, res));
|
||||
}
|
||||
|
||||
if (fs.existsSync('public')) {
|
||||
const match = `<script> window.AUTH_PROVIDER = "local"; </script>`;
|
||||
const replace = `<script> window.AUTH_PROVIDER = "${this.authProvider}"; </script>`;
|
||||
|
||||
const indexFilename = "public/index.html";
|
||||
const indexSource = fs.readFileSync(indexFilename, 'utf8');
|
||||
|
||||
fs.writeFileSync(indexFilename, indexSource.replace(match, replace));
|
||||
|
||||
this.app.use(express.static('public'));
|
||||
|
||||
// serve index.html for all other routes
|
||||
@@ -106,12 +123,57 @@ export default class ChatServer {
|
||||
await this.database.initialize();
|
||||
|
||||
try {
|
||||
this.app.listen(port, () => {
|
||||
const callback = () => {
|
||||
console.log(`Listening on port ${port}.`);
|
||||
});
|
||||
};
|
||||
|
||||
if (config.tls?.key && config.tls?.cert) {
|
||||
console.log('Configuring TLS.');
|
||||
|
||||
const server = https.createServer({
|
||||
key: fs.readFileSync(config.tls.key),
|
||||
cert: fs.readFileSync(config.tls.cert),
|
||||
}, this.app);
|
||||
|
||||
server.listen(port, callback);
|
||||
} else if (config.tls?.selfSigned) {
|
||||
console.log('Configuring self-signed TLS.');
|
||||
|
||||
if (!fs.existsSync('./data/key.pem') || !fs.existsSync('./data/cert.pem')) {
|
||||
execSync('sh generate-self-signed-certificate.sh');
|
||||
}
|
||||
|
||||
const server = https.createServer({
|
||||
key: fs.readFileSync('./data/key.pem'),
|
||||
cert: fs.readFileSync('./data/cert.pem'),
|
||||
}, this.app);
|
||||
|
||||
server.listen(port, callback);
|
||||
} else {
|
||||
this.app.listen(port, callback);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const activeUsers = getActiveUsersInLast5Minutes();
|
||||
|
||||
const activeUsersToDisplay = activeUsers.slice(0, 10);
|
||||
const extraActiveUsers = activeUsers.slice(10);
|
||||
|
||||
const numRecentUpdates = getNumUpdatesProcessedIn5Minutes();
|
||||
|
||||
console.log(`Statistics (last 5m):`);
|
||||
|
||||
if (extraActiveUsers.length) {
|
||||
console.log(` - ${activeUsers.length} active users: ${activeUsersToDisplay.join(', ')} and ${extraActiveUsers.length} more`);
|
||||
} else {
|
||||
console.log(` - ${activeUsers.length} active users: ${activeUsersToDisplay.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(` - ${numRecentUpdates} updates processed`);
|
||||
}, 1000 * 60);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -32,7 +32,6 @@ export default class SQLiteObjectStore extends ObjectStore {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row?.value ?? null);
|
||||
console.log(`[object-store:sqlite] retrieved object ${key}`)
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -44,7 +43,6 @@ export default class SQLiteObjectStore extends ObjectStore {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log(`[object-store:sqlite] stored object ${key}`)
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
@@ -5,8 +5,9 @@ import session from 'express-session';
|
||||
import createSQLiteSessionStore from 'connect-sqlite3';
|
||||
import { Strategy as LocalStrategy } from 'passport-local';
|
||||
import ChatServer from './index';
|
||||
import { config } from './config';
|
||||
|
||||
const secret = process.env.AUTH_SECRET || crypto.randomBytes(32).toString('hex');
|
||||
const secret = config.authSecret;
|
||||
|
||||
export function configurePassport(context: ChatServer) {
|
||||
const SQLiteStore = createSQLiteSessionStore(session);
|
||||
@@ -20,7 +21,6 @@ export function configurePassport(context: ChatServer) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(user.salt ? 'Using pbkdf2' : 'Using bcrypt');
|
||||
const isPasswordCorrect = user.salt
|
||||
? crypto.timingSafeEqual(user.passwordHash, crypto.pbkdf2Sync(password, user.salt, 310000, 32, 'sha256'))
|
||||
: await bcrypt.compare(password, user.passwordHash.toString());
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function randomID() {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
export function randomID(bytes = 16) {
|
||||
return crypto.randomBytes(bytes).toString('hex');
|
||||
}
|
Reference in New Issue
Block a user