This commit is contained in:
Cogent Apps
2023-04-15 10:30:02 +00:00
parent 943bca2f4d
commit eb58d900b5
118 changed files with 5785 additions and 2471 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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