v0.2.0
This commit is contained in:
40
server/src/auth0.ts
Normal file
40
server/src/auth0.ts
Normal 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!,
|
||||
})
|
||||
});
|
||||
}
|
15
server/src/database/index.ts
Normal file
15
server/src/database/index.ts
Normal 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>;
|
||||
}
|
168
server/src/database/sqlite.ts
Normal file
168
server/src/database/sqlite.ts
Normal 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}`)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
45
server/src/endpoints/base.ts
Normal file
45
server/src/endpoints/base.ts
Normal 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;
|
||||
}
|
||||
}
|
20
server/src/endpoints/completion/basic.ts
Normal file
20
server/src/endpoints/completion/basic.ts
Normal 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;
|
||||
}
|
||||
}
|
57
server/src/endpoints/completion/streaming.ts
Normal file
57
server/src/endpoints/completion/streaming.ts
Normal 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;
|
||||
}
|
||||
}
|
14
server/src/endpoints/get-share.ts
Normal file
14
server/src/endpoints/get-share.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
8
server/src/endpoints/health.ts
Normal file
8
server/src/endpoints/health.ts
Normal 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' });
|
||||
}
|
||||
}
|
18
server/src/endpoints/messages.ts
Normal file
18
server/src/endpoints/messages.ts
Normal 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;
|
||||
}
|
||||
}
|
37
server/src/endpoints/session.ts
Normal file
37
server/src/endpoints/session.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
31
server/src/endpoints/share.ts
Normal file
31
server/src/endpoints/share.ts
Normal 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);
|
||||
}
|
||||
}
|
35
server/src/endpoints/sync.ts
Normal file
35
server/src/endpoints/sync.ts
Normal 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;
|
||||
}
|
||||
}
|
13
server/src/endpoints/title.ts
Normal file
13
server/src/endpoints/title.ts
Normal 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
107
server/src/index.ts
Normal 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();
|
5
server/src/object-store/index.ts
Normal file
5
server/src/object-store/index.ts
Normal 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>;
|
||||
}
|
43
server/src/object-store/s3.ts
Normal file
43
server/src/object-store/s3.ts
Normal 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');
|
||||
}
|
48
server/src/object-store/sqlite.ts
Normal file
48
server/src/object-store/sqlite.ts
Normal 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
84
server/src/passport.ts
Normal 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
5
server/src/utils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function randomID() {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
Reference in New Issue
Block a user