This commit is contained in:
Cogent Apps
2023-03-14 11:00:40 +00:00
parent 4a5e8c9e16
commit 645b66b988
104 changed files with 11064 additions and 1565 deletions

40
server/src/auth0.ts Normal file
View File

@@ -0,0 +1,40 @@
import { auth, ConfigParams } from 'express-openid-connect';
import ChatServer from './index';
const config: ConfigParams = {
authRequired: false,
auth0Logout: false,
secret: process.env.AUTH_SECRET || 'keyboard cat',
baseURL: process.env.PUBLIC_URL,
clientID: process.env.AUTH0_CLIENT_ID,
issuerBaseURL: process.env.AUTH0_ISSUER,
routes: {
login: false,
logout: false,
},
};
export function configureAuth0(context: ChatServer) {
context.app.use(auth(config));
context.app.get('/chatapi/login', (req, res) => {
res.oidc.login({
returnTo: process.env.PUBLIC_URL,
authorizationParams: {
redirect_uri: process.env.PUBLIC_URL + '/chatapi/login-callback',
},
});
});
context.app.get('/chatapi/logout', (req, res) => {
res.oidc.logout({
returnTo: process.env.PUBLIC_URL,
});
});
context.app.all('/chatapi/login-callback', (req, res) => {
res.oidc.callback({
redirectUri: process.env.PUBLIC_URL!,
})
});
}

View File

@@ -0,0 +1,15 @@
export default abstract class Database {
public async initialize() {}
public abstract createUser(email: string, passwordHash: Buffer, salt: Buffer): Promise<void>;
public abstract getUser(email: string): Promise<{
id: string;
email: string;
passwordHash: Buffer;
salt: Buffer;
}>;
public abstract getChats(userID: string): Promise<any[]>;
public abstract getMessages(userID: string): Promise<any[]>;
public abstract insertMessages(userID: string, messages: any[]): Promise<void>;
public abstract createShare(userID: string|null, id: string): Promise<boolean>;
public abstract setTitle(userID: string, chatID: string, title: string): Promise<void>;
}

View File

@@ -0,0 +1,168 @@
import { verbose } from "sqlite3";
import { validate as validateEmailAddress } from 'email-validator';
import Database from "./index";
const sqlite3 = verbose();
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 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, salt: 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, salt) VALUES (?, ?, ?, ?)`, [email, email, passwordHash, salt], (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, row) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to get user ${email}`);
} else {
resolve({
...row,
passwordHash: Buffer.from(row.password_hash),
salt: Buffer.from(row.salt),
});
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, rows) => {
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, rows) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to get messages for user ${userID}`);
} else {
resolve(rows.map((row) => {
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}`)
}
});
});
}
}

View File

@@ -0,0 +1,45 @@
import express from 'express';
import ChatServer from '../index';
export default abstract class RequestHandler {
constructor(public context: ChatServer, private req: express.Request, private res: express.Response) {
this.callback().then(() => {});
}
public async callback() {
if (!this.userID && this.isProtected()) {
this.res.sendStatus(401);
return;
}
try {
return await this.handler(this.req, this.res);
} catch (e) {
console.error(e);
this.res.sendStatus(500);
}
}
public abstract handler(req: express.Request, res: express.Response): any;
public isProtected() {
return false;
}
public get userID(): string | null {
const request = this.req as any;
if (request.oidc) {
const user = request.oidc.user;
if (user) {
return user.sub;
}
}
const userID = request.session?.passport?.user?.id;
if (userID) {
return userID;
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,57 @@
// @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

@@ -0,0 +1,14 @@
import express from 'express';
import RequestHandler from "./base";
export default class GetShareRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const id = req.params.id;
const data = await this.context.objectStore.get('chats/' + id + '.json');
if (data) {
res.json(JSON.parse(data));
} else {
res.sendStatus(404);
}
}
}

View File

@@ -0,0 +1,8 @@
import express from 'express';
import RequestHandler from "./base";
export default class HealthRequestHandler extends RequestHandler {
handler(req: express.Request, res: express.Response): any {
res.json({ status: 'ok' });
}
}

View File

@@ -0,0 +1,18 @@
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,37 @@
import express from 'express';
import RequestHandler from "./base";
export default class SessionRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const request = req as any;
if (request.oidc) {
const user = request.oidc.user;
console.log(user);
if (user) {
res.json({
authenticated: true,
userID: user.sub,
name: user.name,
email: user.email,
picture: user.picture,
});
return;
}
}
const userID = request.session?.passport?.user?.id;
if (userID) {
res.json({
authenticated: true,
userID,
email: userID,
});
return;
}
res.json({
authenticated: false,
});
}
}

View File

@@ -0,0 +1,31 @@
import express from 'express';
import RequestHandler from "./base";
export default class ShareRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const { nanoid } = await import('nanoid'); // esm
if (!req.body.messages?.length) {
res.sendStatus(400);
return;
}
for (let length = 5; length < 24; length += 2) {
const id = nanoid(length);
if (await this.context.database.createShare(null, id)) {
await this.context.objectStore.put(
'chats/' + id + '.json',
JSON.stringify({
title: req.body.title,
messages: req.body.messages,
}),
'application/json',
);
res.json({ id });
return;
}
}
res.sendStatus(500);
}
}

View File

@@ -0,0 +1,35 @@
import express from 'express';
import RequestHandler from "./base";
export default class SyncRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const [chats, messages] = await Promise.all([
this.context.database.getChats(this.userID!),
this.context.database.getMessages(this.userID!),
]);
const output: Record<string, any> = {};
for (const m of messages) {
const chat = output[m.chat_id] || {
messages: [],
};
chat.messages.push(m.data);
output[m.chat_id] = chat;
}
for (const c of chats) {
const chat = output[c.id] || {
messages: [],
};
chat.title = c.title;
output[c.id] = chat;
}
res.json(output);
}
public isProtected() {
return true;
}
}

View File

@@ -0,0 +1,13 @@
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;
}
}

107
server/src/index.ts Normal file
View File

@@ -0,0 +1,107 @@
require('dotenv').config()
import express from 'express';
import compression from 'compression';
import fs from 'fs';
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 { configurePassport } from './passport';
import { configureAuth0 } from './auth0';
process.on('unhandledRejection', (reason, p) => {
console.error('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
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 {
app: express.Application;
objectStore: ObjectStore = process.env.S3_BUCKET ? new S3ObjectStore() : new SQLiteObjectStore();
database: Database = new SQLiteAdapter();
constructor() {
this.app = express();
this.app.use(express.urlencoded({ extended: false }));
if (process.env.AUTH0_CLIENT_ID && process.env.AUTH0_ISSUER && process.env.PUBLIC_URL) {
console.log('Configuring Auth0.');
configureAuth0(this);
} else {
console.log('Configuring Passport.');
configurePassport(this);
}
this.app.use(express.json({ limit: '1mb' }));
this.app.use(compression());
this.app.use((req, res, next) => {
res.set({
'Access-Control-Allow-Origin': origins.includes(req.headers.origin!) ? req.headers.origin : origins[0],
'Access-Control-Allow-Credentials': true.toString(),
'Access-Control-Allow-Methods': 'GET,POST,PUT,OPTIONS',
'Access-Control-Max-Age': 2592000,
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
});
next();
});
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.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));
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 (fs.existsSync('public')) {
this.app.use(express.static('public'));
// serve index.html for all other routes
this.app.get('*', (req, res) => {
res.sendFile('public/index.html', { root: path.resolve(__dirname, '..') });
});
}
}
async initialize() {
await this.objectStore.initialize();;
await this.database.initialize();;
try {
this.app.listen(port, () => {
console.log(`Listening on port ${port}.`);
});
} catch (e) {
console.log(e);
}
}
}
new ChatServer().initialize();

View File

@@ -0,0 +1,5 @@
export default abstract class ObjectStore {
public async initialize() {}
public abstract get(key: string): Promise<string | null>;
public abstract put(key: string, value: string, contentType: string): Promise<void>;
}

View File

@@ -0,0 +1,43 @@
import {
S3,
PutObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import type {Readable} from 'stream';
import ObjectStore from "./index";
const bucket = process.env.S3_BUCKET;
const s3 = new S3({
region: process.env.DEFAULT_S3_REGION,
});
export default class S3ObjectStore extends ObjectStore {
public async get(key: string) {
const params = {
Bucket: bucket,
Key: key,
};
const data = await s3.send(new GetObjectCommand(params));
return await readStream(data.Body as Readable);
}
public async put(key: string, value: string, contentType: string) {
const params = {
Bucket: bucket,
Key: key,
Body: value,
ContentType: contentType,
StorageClass: "INTELLIGENT_TIERING",
};
await s3.send(new PutObjectCommand(params));
}
}
async function readStream(stream: Readable) {
const chunks: any[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
}

View File

@@ -0,0 +1,48 @@
import { verbose } from "sqlite3";
import ObjectStore from "./index";
const sqlite3 = verbose();
const db = new sqlite3.Database('./data/object-store.sqlite');
export interface StoredObject {
key: string;
value: string;
}
export default class SQLiteObjectStore extends ObjectStore {
public async initialize() {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS objects (
key TEXT PRIMARY KEY,
value TEXT
)`);
});
}
public async get(key: string): Promise<string | null> {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM objects WHERE key = ?`, [key], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row?.value ?? null);
console.log(`[object-store:sqlite] retrieved object ${key}`)
}
});
});
}
public async put(key: string, value: string, contentType: string): Promise<void> {
return new Promise((resolve, reject) => {
db.run(`INSERT OR REPLACE INTO objects (key, value) VALUES (?, ?)`, [key, value], (err) => {
if (err) {
reject(err);
} else {
console.log(`[object-store:sqlite] stored object ${key}`)
resolve();
}
});
});
}
}

84
server/src/passport.ts Normal file
View File

@@ -0,0 +1,84 @@
import crypto from 'crypto';
import passport from 'passport';
import session from 'express-session';
import createSQLiteSessionStore from 'connect-sqlite3';
import { Strategy as LocalStrategy } from 'passport-local';
import ChatServer from './index';
export function configurePassport(context: ChatServer) {
const SQLiteStore = createSQLiteSessionStore(session);
const sessionStore = new SQLiteStore({ db: 'sessions.db' });
passport.use(new LocalStrategy(async (email: string, password: string, cb: any) => {
const user = await context.database.getUser(email);
if (!user) {
return cb(null, false, { message: 'Incorrect username or password.' });
}
crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', (err, hashedPassword) => {
if (err) {
return cb(err);
}
if (!crypto.timingSafeEqual(user.passwordHash, hashedPassword)) {
return cb(null, false, { message: 'Incorrect username or password.' });
}
return cb(null, user);
});
}));
passport.serializeUser((user: any, cb: any) => {
process.nextTick(() => {
cb(null, { id: user.id, username: user.username });
});
});
passport.deserializeUser((user: any, cb: any) => {
process.nextTick(() => {
return cb(null, user);
});
});
context.app.use(session({
secret: process.env.AUTH_SECRET || 'keyboard cat',
resave: false,
saveUninitialized: false,
store: sessionStore as any,
}));
context.app.use(passport.authenticate('session'));
context.app.post('/chatapi/login', passport.authenticate('local', {
successRedirect: '/',
failureRedirect: '/?error=login'
}));
context.app.post('/chatapi/register', async (req, res, next) => {
const { username, password } = req.body;
const salt = crypto.randomBytes(32);
crypto.pbkdf2(password, salt, 310000, 32, 'sha256', async (err, hashedPassword) => {
if (err) {
return next(err);
}
try {
await context.database.createUser(username, hashedPassword, salt);
passport.authenticate('local')(req, res, () => {
res.redirect('/');
});
} catch (err) {
res.redirect('/?error=register');
}
});
});
context.app.all('/chatapi/logout', (req, res, next) => {
req.logout((err) => {
if (err) {
return next(err);
}
res.redirect('/');
});
});
}

5
server/src/utils.ts Normal file
View File

@@ -0,0 +1,5 @@
import crypto from 'crypto';
export function randomID() {
return crypto.randomBytes(16).toString('hex');
}