Code clean up. Separation of concerns.

This commit is contained in:
Tom
2025-04-04 16:45:07 +00:00
parent 13fb846277
commit 41ef684461
19 changed files with 743 additions and 391 deletions

34
data/oauth.ts Normal file
View File

@ -0,0 +1,34 @@
export const OAuthData: { [service: string]: { type: string, endpoint: string, grantType: string, scopes: string[], redirect: string } } = {
'nightbot': {
type: 'nightbot',
endpoint: 'https://api.nightbot.tv/oauth2/authorize',
grantType: 'token',
scopes: ['song_requests', 'song_requests_queue', 'song_requests_playlist'],
redirect: process.env.WEB_HOST + '/connections/callback'
},
'twitch': {
type: 'twitch',
endpoint: 'https://id.twitch.tv/oauth2/authorize',
grantType: 'token',
scopes: [
'chat:read',
'bits:read',
'channel:read:polls',
'channel:read:predictions',
'channel:read:subscriptions',
'channel:read:vips',
'moderator:read:blocked_terms',
'chat:read',
'channel:moderate',
'channel:read:redemptions',
'channel:manage:redemptions',
'channel:manage:predictions',
'user:read:chat',
'channel:bot',
'moderator:read:followers',
'channel:read:ads',
'moderator:read:chatters',
],
redirect: process.env.WEB_HOST + '/connections/callback'
},
};

10
package-lock.json generated
View File

@ -15,6 +15,7 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
@ -1336,6 +1337,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",

View File

@ -4,7 +4,7 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"build": "npx tsc", "build": "npx tsc",
"start": "node dist/index.js", "start": "node dist/src/index.js",
"dev": "nodemon src/index.ts" "dev": "nodemon src/index.ts"
}, },
"keywords": [], "keywords": [],
@ -18,6 +18,7 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",

View File

@ -0,0 +1,35 @@
import { database } from "../services/database";
export async function getUsers(req: any, res: any, next: any) {
const users = await database.manyOrNone('SELECT id, name FROM "User"');
res.send(users);
};
export async function updateImpersonation(req: any, res: any, next: any) {
if (!req.body.impersonation) {
res.status(400).send('Invalid user.');
return;
}
const data = await database.oneOrNone('SELECT "targetId" FROM "Impersonation" where "sourceId" = $1', req.user.id);
if (!data?.targetId) {
await database.none('INSERT INTO "Impersonation" ("sourceId", "targetId") VALUES ($1, $2)', [req.user.id, req.body.impersonation]);
} else {
await database.none('UPDATE "Impersonation" SET "targetId" = $2 WHERE "sourceId" = $1', [req.user.id, req.body.impersonation]);
}
res.send({
data: {
impersonation: true
}
});
};
export async function deleteImpersonation(req: any, res: any, next: any) {
if (req.user.role != 'ADMIN') {
res.status(403).send('You do not have the permissions for this.');
return;
}
const data = await database.oneOrNone('DELETE FROM "Impersonation" where "sourceId" = $1', req.user.id);
res.send({ data });
};

View File

@ -0,0 +1,41 @@
import { database } from "../services/database";
import { v4 as uuidv4 } from 'uuid';
export async function getApiKeys(req: any, res: any, next: any) {
const userId = req.user.impersonation?.id ?? req.user.id;
const data = await database.manyOrNone('SELECT id, label FROM "ApiKey" WHERE "userId" = $1', userId);
res.send(data);
};
export async function createApiKey(req: any, res: any, next: any) {
const userId = req.user.impersonation?.id ?? req.user.id;
const keys = await database.one('SELECT count(*) FROM "ApiKey" WHERE "userId" = $1', userId);
if (keys.count > 10) {
res.status(403).send('Too many keys');
return;
}
const label = req.body.label;
if (!label) {
res.status(400).send('No label is attached.');
return;
}
const key = uuidv4();
await database.none('INSERT INTO "ApiKey" (id, label, "userId") VALUES ($1, $2, $3);', [key, label, userId]);
res.send({ label, key });
};
export async function deleteApiKey(req: any, res: any, next: any) {
if (!req.body.key) {
res.status(400).send('key has not been provided.');
return;
}
const key = await database.one('SELECT EXISTS(SELECT 1 FROM "ApiKey" WHERE id = $1)', req.body.key);
if (!key.exists) {
res.status(400).send('key does not exist.');
return;
}
await database.none('DELETE FROM "ApiKey" WHERE id = $1', req.body.key);
res.send({ key: req.body.key });
};

View File

@ -0,0 +1,121 @@
import { Request, Response } from "express";
import { database } from "../services/database";
import * as http from 'typed-rest-client/HttpClient';
import { jwt } from "../services/passport";
import { OAuthData } from "../../data/oauth";
import { v4 as uuidv4 } from 'uuid';
import moment from "moment";
const pino = require('pino-http')();
export async function connectionPreparation(req: Request, res: Response) {
const state = req.query['state'];
const access_token = req.query['token'];
let expires_in = req.query['expires_in'];
if (!state || !access_token) {
res.status(400).send({ error: 'Missing fields in the body.' });
return;
}
const connection = await database.oneOrNone('SELECT "name", "type", "clientId", "grantType", "userId" FROM "ConnectionState" WHERE "state" = $1', [state]);
if (!connection) {
res.status(400).send({ error: 'Invalid combination.' });
return;
}
if (connection.type == 'twitch') {
const rest = new http.HttpClient(null);
const response = await rest.get('https://id.twitch.tv/oauth2/validate', {
'Authorization': 'OAuth ' + access_token,
});
const body = await response.readBody();
const json = JSON.parse(body);
expires_in = json.expires_in;
if (!expires_in) {
res.status(400).send({ error: 'Twitch API is not working correctly.' });
return;
}
}
const expiration = expires_in ? parseInt(expires_in.toString()) : null;
if (!expiration || isNaN(expiration) || !isFinite(expiration) || expiration < 0) {
res.status(400).send({ error: 'Could not determine the expiration of the token.' });
return;
}
const expires_at = moment().add(expiration - 300, 's');
res.send({
data: { connection, expires_at },
});
};
export async function connectionCallback(req: any, res: Response) {
const name = req.body.name;
const type = req.body.type?.toLowerCase();
const client_id = req.body.client_id;
const grant_type = req.body.grant_type?.toLowerCase();
if (!name || !type || !client_id || !grant_type) {
res.status(400).send({ error: 'Missing fields in the body.' });
return;
}
const url = OAuthData[type].endpoint;
const redirect = OAuthData[type].redirect;
const scopes = OAuthData[type].scopes.join(' ');
const nounce = uuidv4();
await database.none('INSERT INTO "ConnectionState" ("name", "type", "clientId", "grantType", "state", "userId") VALUES ($1, $2, $3, $4, $5, $6)'
+ ' ON CONFLICT ("userId", "name") DO UPDATE SET "type" = $2, "clientId" = $3, "grantType" = $4, "state" = $5;',
[name, type, client_id, grant_type, nounce, req.user.id]);
const redirect_uri = url + '?client_id=' + client_id + '&force_verify=true&redirect_uri=' + redirect + '&response_type=token&scope=' + scopes + '&state=' + nounce;
res.send({ success: true, error: null, data: redirect_uri });
};
// login/register
export async function twitchCallback(req: any, res: any) {
const query = `client_id=${process.env.AUTH_CLIENT_ID}&client_secret=${process.env.AUTH_CLIENT_SECRET}&code=${req.body.code}&grant_type=authorization_code&redirect_uri=${process.env.AUTH_REDIRECT_URI}`
const rest = new http.HttpClient(null);
const response = await rest.post('https://id.twitch.tv/oauth2/token', query, {
'Content-Type': 'application/x-www-form-urlencoded'
});
const body = await response.readBody();
const codeData = JSON.parse(body);
if (!codeData || codeData.message) {
console.log('Failed to validate Twitch code authentication:', codeData);
res.send({ authenticated: false });
return;
}
console.log('Validated Twitch code authentication:', codeData);
const resp = await rest.get('https://api.twitch.tv/helix/users', {
'Authorization': 'Bearer ' + codeData.access_token,
'Client-Id': process.env.AUTH_CLIENT_ID
});
const b = await resp.readBody();
const userData = JSON.parse(b);
if (!userData?.data) {
console.log('Failed to fetch twitch data:', codeData, userData?.data);
res.send({ authenticated: false, error: 'Twitch API is not working correctly.' });
return;
}
console.log('Fetched Twitch user data:', userData);
const account: any = await database.oneOrNone('SELECT "userId" FROM "Account" WHERE "providerAccountId" = $1', userData.data[0].id);
if (account != null) {
const user: any = await database.one('SELECT id FROM "User" WHERE id = $1', account.userId);
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '30d' });
res.send({ authenticated: true, token: token });
var now = Date.now();
const expires_at = ((now / 1000) | 0) + codeData.expires_in;
await database.none('UPDATE "Account" SET refresh_token = COALESCE($1, refresh_token), access_token = $2, id_token = COALESCE($3, id_token), expires_at = $4, scope = $5 WHERE "userId" = $6', [codeData.refresh_token, codeData.access_token, codeData.id_token, expires_at, codeData.scope.join(' '), account.userId]);
return;
}
res.send({ authenticated: false });
};
export async function validate(req: any, res: Response, next: () => void) {
res.send({ authenticated: req?.user != null, user: req?.user });
}

View File

@ -0,0 +1,48 @@
import { database } from "../services/database";
import * as http from 'typed-rest-client/HttpClient';
export async function getTwitchRedemptions(req: any, res: any, next: any) {
const userId = req.user.impersonation?.id ?? req.user.id;
const account: any = await database.one('SELECT "providerAccountId" FROM "Account" WHERE "userId" = $1', userId);
const connection: any = await database.oneOrNone('SELECT "clientId", "accessToken" FROM "Connection" WHERE "userId" = $1 AND "default" = true AND "type" = \'twitch\'', userId);
const rest = new http.HttpClient(null);
const resp = await rest.get('https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=' + account.providerAccountId, {
'Authorization': 'Bearer ' + connection.accessToken,
'Client-Id': connection.clientId,
});
const twitch = JSON.parse(await resp.readBody());
if (!twitch?.data) {
console.log('Failed to fetch twitch data:', account, twitch.data);
res.status(401).send({ error: 'Could not fetch Twitch channel redemption data.' });
return;
}
res.send(twitch.data);
};
export async function getTwitchUsers(req: any, res: any) {
const username = req.query.login?.toLowerCase();
if (!username) {
res.status(400).send({ user: null });
return;
}
const rest = new http.HttpClient(null);
const userId = req.user.impersonation?.id ?? req.user.id;
const connection: any = await database.oneOrNone('SELECT "clientId", "accessToken" FROM "Connection" WHERE "userId" = $1 AND "default" = true AND "type" = \'twitch\'', userId);
const resp = await rest.get('https://api.twitch.tv/helix/users?login=' + username, {
'Authorization': 'Bearer ' + connection.accessToken,
'Client-Id': connection.clientId,
});
const twitch = JSON.parse(await resp.readBody());
if (!twitch?.data) {
res.status(403).send({ user: null });
return;
}
const user = twitch.data.find((u: any) => u.login == username);
res.send({ user });
};

244
src/database.postgres.sql Normal file
View File

@ -0,0 +1,244 @@
DELETE TYPE IF EXISTS "UserRole";
DELETE TYPE IF EXISTS "ActionType";
DROP TABLE IF EXISTS "actions";
DROP TABLE IF EXISTS "chatters";
DROP TABLE IF EXISTS "chatter_groups";
DROP TABLE IF EXISTS "redemptions";
DROP TABLE IF EXISTS "permissions";
DROP TABLE IF EXISTS "policies";
DROP TABLE IF EXISTS "groups";
DROP TABLE IF EXISTS "connections";
DROP TABLE IF EXISTS "accounts";
DROP TABLE IF EXISTS "api_keys";
DROP TABLE IF EXISTS "users";
CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN');
CREATE TYPE "ActionType" AS ENUM (
'WRITE_TO_FILE',
'APPEND_TO_FILE',
'AUDIO_FILE',
'OBS_TRANSFORM',
'RANDOM_TTS_VOICE',
'SPECIFIC_TTS_VOICE',
'TTS_MESSAGE',
'TOGGLE_OBS_VISIBILITY',
'SPECIFIC_OBS_VISIBILITY',
'SPECIFIC_OBS_INDEX',
'SLEEP',
'OAUTH',
'NIGHTBOT_PLAY',
'NIGHTBOT_PAUSE',
'NIGHTBOT_SKIP',
'TWITCH_OAUTH',
'NIGHTBOT_CLEAR_PLAYLIST',
'NIGHTBOT_CLEAR_QUEUE',
'VEADOTUBE_SET_STATE',
'VEADOTUBE_PUSH_STATE',
'VEADOTUBE_POP_STATE'
);
CREATE TABLE
users (
user_id uuid DEFAULT gen_random_uuid (),
name text NOT NULL,
email text,
role "UserRole" NOT NULL DEFAULT USER,
image text,
tts_default_voice text NOT NULL,
created_at timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("user_id")
);
CREATE TABLE
accounts (
account_id uuid DEFAULT gen_random_uuid (),
user_id uuid NOT NULL,
type text NOT NULL,
provider text NOT NULL,
providerAccountId text NOT NULL,
access_token text NOT NULL,
refresh_token text NOT NULL,
expires_at integer NOT NULL,
token_type text NOT NULL,
scope text NOT NULL,
created_at timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("account_id"),
CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id")
);
CREATE TABLE
actions (
user_id uuid DEFAULT gen_random_uuid (),
name text NOT NULL,
type "ActionType" NOT NULL,
data jsonb NOT NULL,
has_message boolean NOT NULL DEFAULT false,
CONSTRAINT "Action_pkey" PRIMARY KEY ("user_id", "name"),
CONSTRAINT "s_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
api_keys (
user_id uuid NOT NULL,
key text NOT NULL,
label text NOT NULL,
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("key") CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
chatters (chatter_id text NOT NULL, name text NOT NULL);
CREATE TABLE
chatter_groups (
chatter_id text DEFAULT gen_random_uuid (),
group_id uuid DEFAULT gen_random_uuid (),
user_id uuid NOT NULL,
chatter_label text NOT NULL,
CONSTRAINT "chatter_groups_pkey" PRIMARY KEY ("user_id", "group_id", "chatter_id") CONSTRAINT "chatter_groups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
connections (
user_id uuid NOT NULL,
name text NOT NULL,
provider text NOT NULL,
grant_type text NOT NULL,
client_id text NOT NULL,
access_token NOT NULL,
scope text NOT NULL,
expires_at timestamp(3) NOT NULL,
default boolean NOT NULL DEFAULT false,
CONSTRAINT "connections_pkey" PRIMARY KEY ("user_id", "name") CONSTRAINT "connections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
connection_previews (
user_id uuid NOT NULL,
name text NOT NULL,
provider text NOT NULL,
grant_type text NOT NULL,
client_id text NOT NULL,
state text NOT NULL,
CONSTRAINT "connection_previews_pkey" PRIMARY KEY ("user_id", "name") CONSTRAINT "connection_previews_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
emotes (
emote_id text NOT NULL,
label text NOT NULL,
CONSTRAINT "emotes_pkey" PRIMARY KEY ("emote_id")
);
CREATE TABLE
emote_usages (
emote_id text NOT NULL,
chatter_id text NOT NULL,
broadcaster_id text NOT NULL,
timestamp timestamp(3) NOT NULL
);
CREATE TABLE
groups (
group_id uuid DEFAULT gen_random_uuid (),
user_id uuid NOT NULL,
name text NOT NULL,
priority integer NOT NULL,
CONSTRAINT "groups_pkey" PRIMARY KEY ("group_id"),
CONSTRAINT "groups_user_id_name_unique" UNIQUE ("user_id", "name") CONSTRAINT "groups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE INDEX "groups_userId_idx" ON "groups" USING btree ("user_id");
CREATE TABLE
permissions (
permission_id uuid DEFAULT gen_random_uuid (),
group_id uuid NOT NULL,
user_id uuid NOT NULL,
path text NOT NULL,
allow boolean,
CONSTRAINT "permissions" PRIMARY KEY ("permission_id") CONSTRAINT "permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE INDEX "permissions_user_id_idx" ON "permissions" USING btree ("user_id");
CREATE TABLE
policies (
policy_id uuid DEFAULT gen_random_uuid (),
group_id uuid NOT NULL,
user_id uuid NOT NULL,
path text NOT NULL,
count integer NOT NULL,
span integer NOT NULL,
CONSTRAINT "policies_pkey" PRIMARY KEY ("policy_id") CONSTRAINT "policies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE INDEX "policies_user_id_idx" ON "policies" USING btree ("user_id");
CREATE TABLE
impersonations (
source_id uuid NOT NULL,
target_id uuid NOT NULL,
CONSTRAINT "impersonations_pkey" PRIMARY KEY ("source_id") CONSTRAINT "impersonations_source_id_fkey" FOREIGN KEY ("source_id") REFERENCES users ("user_id") ON DELETE CASCADE CONSTRAINT "impersonations_target_id_fkey" FOREIGN KEY ("target_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
redemption_actions (
redemption_actions_id uuid DEFAULT gen_random_uuid (),
user_id uuid NOT NULL,
provider_redemption_id text,
action_name text NOT NULL,
order integer NOT NULL,
state boolean NOT NULL,
CONSTRAINT "redemption_actions_pkey" PRIMARY KEY ("redemption_actions_id"),
CONSTRAINT "redemptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
tts_chatter_voices (
chatter_id text NOT NULL,
user_id uuid NOT NULL,
tts_voice_id uuid NOT NULL,
CONSTRAINT "tts_chatter_voices" PRIMARY KEY ("user_id", "chatter_id") CONSTRAINT "tts_chatter_voices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
tts_voices (
tts_voice_id uuid DEFAULT gen_random_uuid (),
name uuid NOT NULL,
gender text,
base_language text CONSTRAINT "tts_voices_pkey" PRIMARY KEY ("tts_voice_id"),
);
CREATE TABLE
tts_voice_states (
tts_voice_id uuid NOT NULL,
user_id NOT NULL,
state boolean NOT NULL DEFAULT true,
CONSTRAINT "tts_voice_states_pkey" PRIMARY KEY ("tts_voice_id", "user_id"),
CONSTRAINT "tts_voice_states_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);
CREATE TABLE
tts_word_filters (
tts_word_filter_id uuid DEFAULT gen_random_uuid (),
user_id NOT NULL,
search text NOT NULL,
replace text NOT NULL,
flag integer NOT NULL,
CONSTRAINT "tts_word_filters_pkey" PRIMARY KEY ("tts_word_filter_id"),
CONSTRAINT "tts_word_filters_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES users ("user_id") ON DELETE CASCADE
);

View File

@ -1,25 +1,19 @@
import express, { Express, Request, Response } from "express"; import express, { Express, Request } from "express";
import pgPromise from "pg-promise";
import rateLimit from "express-rate-limit"; import rateLimit from "express-rate-limit";
import helmet from "helmet"; import helmet from "helmet";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { v4 as uuidv4 } from 'uuid';
import * as httpm from 'typed-rest-client/HttpClient';
dotenv.config(); dotenv.config();
if (!process.env.CONNECTION_STRING) { if (!process.env.CONNECTION_STRING) {
throw new Error("Cannot find connection string."); throw new Error("Cannot find connection string.");
} }
import { passport } from "./services/passport";
const pgp = pgPromise({});
const db = pgp(process.env.CONNECTION_STRING as string);
const limiter = rateLimit({ const limiter = rateLimit({
legacyHeaders: true, legacyHeaders: true,
standardHeaders: true, standardHeaders: true,
windowMs: 15 * 60 * 1000, windowMs: 15 * 1000,
limit: 200, limit: 8,
max: 2, max: 2,
message: "Too many requests, please try again later.", message: "Too many requests, please try again later.",
keyGenerator: (req: Request) => req.ip as string, keyGenerator: (req: Request) => req.ip as string,
@ -28,395 +22,32 @@ const limiter = rateLimit({
const app: Express = express(); const app: Express = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
app.use(express.json());
app.use(express.urlencoded());
var jwt = require('jsonwebtoken');
const passport = require('passport');
const JwtStrat = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
passport.use(new JwtStrat({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
}, async (jwt_payload: any, done: any) => {
const user = await db.oneOrNone('SELECT id, name, role, "ttsDefaultVoice" FROM "User" WHERE id = $1', jwt_payload.id);
if (user) {
done(null, user);
} else {
done(null, false);
}
}));
const session = require('express-session'); const session = require('express-session');
const OpenIDConnectStrategy = require('passport-openidconnect');
app.use(session({ app.use(session({
key: 'passport', secret: process.env.SESSION_SECRET,
secret: process.env.AUTH_SECRET,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: {
maxAge: 7 * 24 * 60 * 60 * 1000,
secure: true,
}
})); }));
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()); app.use(passport.session());
app.set('trust proxy', true);
passport.use(new OpenIDConnectStrategy({
issuer: 'https://id.twitch.tv/oauth2',
authorizationURL: 'https://id.twitch.tv/oauth2/authorize',
tokenURL: 'https://id.twitch.tv/oauth2/token',
clientID: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET,
callbackURL: process.env.AUTH_REDIRECT_URI,
scope: 'user_read'
}, async (url: any, profile: any, something: any, done: any) => {
console.log('login', 'pus:', profile, url, something);
const account: any = await db.oneOrNone('SELECT "userId" FROM "Account" WHERE "providerAccountId" = $1', profile.id);
if (account != null) {
const user: any = await db.oneOrNone('SELECT id, name, role, "ttsDefaultVoice" FROM "User" WHERE id = $1', account.userId);
if (user.name != profile.username) {
db.none('UPDATE "User" SET name = $1 WHERE id = $2', [profile.username, profile.id]);
user.name = profile.username;
}
return done(null, user);
}
return done(new Error('Account does not exist.'), null);
}
));
passport.serializeUser((user: any, done: any) => {
if (!user)
return done(new Error('user is null'), null);
return done(null, user);
});
passport.deserializeUser((user: any, done: any) => {
done(null, user);
});
app.get('/api/auth', passport.authenticate("openidconnect", { failureRedirect: '/login' }), (req: Request, res: Response) => {
res.send('');
});
app.get('/api/auth/validate', [isApiKeyAuthenticated, isJWTAuthenticated, updateImpersonation], (req: any, res: Response, next: () => void) => {
const user = req?.user;
res.send({ authenticated: user != null, user: user });
});
async function isApiKeyAuthenticated(req: any, res: any, next: any) {
if (!req.user) {
const key = req.get('x-api-key');
if (key) {
const data = await db.oneOrNone('SELECT "userId" from "ApiKey" WHERE id = $1', key);
if (data) {
req.user = await db.oneOrNone('SELECT id, name, role, "ttsDefaultVoice" FROM "User" WHERE id = $1', data.userId);
}
}
}
next();
}
function isNotAuthenticated(req: any, res: any, next: () => void) {
if (req.user) {
next();
return;
}
console.log('user is not authenticated.');
res.status(401).send({ message: 'User is not authenticated.' });
}
function isJWTAuthenticated(req: any, res: any, next: () => void) {
if (req.user) {
next();
return;
}
const check = passport.authenticate('jwt', { session: false });
check(req, res, next);
}
async function updateImpersonation(req: any, res: any, next: () => void) {
if (req.user && req.user.role == 'ADMIN') {
const impersonationId = await db.oneOrNone('SELECT "targetId" FROM "Impersonation" WHERE "sourceId" = $1', req.user.id);
if (impersonationId) {
const impersonation = await db.oneOrNone('SELECT id, name, role, "ttsDefaultVoice" FROM "User" WHERE id = $1', impersonationId.targetId);
if (impersonation) {
req.user.impersonation = impersonation;
}
}
}
next();
}
const apiMiddlewares = [isApiKeyAuthenticated, isJWTAuthenticated, updateImpersonation, isNotAuthenticated]
app.get('/api/admin/users', apiMiddlewares, async (req: any, res: any, next: any) => {
if (req.user.role != 'ADMIN') {
res.status(403).send('You do not have the permissions for this.');
return;
}
const data = await db.manyOrNone('SELECT id, name FROM "User"');
res.send(data);
});
app.put('/api/admin/impersonate', apiMiddlewares, async (req: any, res: any, next: any) => {
if (req.user.role != 'ADMIN') {
res.status(403).send('You do not have the permissions for this.');
return;
}
if (!req.body.impersonation) {
res.status(400).send('Invalid user.');
return;
}
const impersonation = await db.one('SELECT EXISTS (SELECT 1 FROM "User" WHERE id = $1)', req.body.impersonation);
if (!impersonation) {
res.status(400).send('Invalid user.');
return;
}
const data = await db.oneOrNone('SELECT "targetId" FROM "Impersonation" where "sourceId" = $1', req.user.id);
if (!data?.targetId) {
await db.none('INSERT INTO "Impersonation" ("sourceId", "targetId") VALUES ($1, $2)', [req.user.id, req.body.impersonation]);
} else {
await db.none('UPDATE "Impersonation" SET "targetId" = $2 WHERE "sourceId" = $1', [req.user.id, req.body.impersonation]);
}
res.send();
});
app.delete('/api/admin/impersonate', apiMiddlewares, async (req: any, res: any, next: any) => {
if (req.user.role != 'ADMIN') {
res.status(403).send('You do not have the permissions for this.');
return;
}
const data = await db.oneOrNone('DELETE FROM "Impersonation" where "sourceId" = $1', req.user.id);
res.send(data);
});
app.get('/api/keys', apiMiddlewares, async (req: any, res: any, next: any) => {
const userId = req.user.impersonation?.id ?? req.user.id;
const data = await db.manyOrNone('SELECT id, label FROM "ApiKey" WHERE "userId" = $1', userId);
res.send(data);
});
app.post('/api/keys', apiMiddlewares, async (req: any, res: any, next: any) => {
const userId = req.user.impersonation?.id ?? req.user.id;
const keys = await db.one('SELECT count(*) FROM "ApiKey" WHERE "userId" = $1', userId);
if (keys.count > 10) {
res.status(403).send('Too many keys');
return;
}
const label = req.body.label;
if (!label) {
res.status(400).send('No label is attached.');
return;
}
const key = uuidv4();
await db.none('INSERT INTO "ApiKey" (id, label, "userId") VALUES ($1, $2, $3);', [key, label, userId]);
res.send({ label, key });
});
app.delete('/api/keys', apiMiddlewares, async (req: any, res: any, next: any) => {
if (!req.body.key) {
res.status(400).send('key has not been provided.');
return;
}
const key = await db.one('SELECT EXISTS(SELECT 1 FROM "ApiKey" WHERE id = $1)', req.body.key);
if (!key.exists) {
res.status(400).send('key does not exist.');
return;
}
await db.none('DELETE FROM "ApiKey" WHERE id = $1', req.body.key);
res.send({ key: req.body.key });
});
app.get('/api/twitch/redemptions', apiMiddlewares, async (req: any, res: any, next: any) => {
const userId = req.user.impersonation?.id ?? req.user.id;
const account: any = await db.one('SELECT "providerAccountId" FROM "Account" WHERE "userId" = $1', userId);
const connection: any = await db.oneOrNone('SELECT "clientId", "accessToken" FROM "Connection" WHERE "userId" = $1 AND "default" = true AND "type" = \'twitch\'', userId);
const rest = new httpm.HttpClient(null);
const resp = await rest.get('https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=' + account.providerAccountId, {
'Authorization': 'Bearer ' + connection.accessToken,
'Client-Id': connection.clientId,
});
const twitch = JSON.parse(await resp.readBody());
if (!twitch?.data) {
console.log('Failed to fetch twitch data:', account, twitch.data);
res.status(401).send({ error: 'Could not fetch Twitch channel redemption data.' });
return;
}
res.send(twitch.data);
});
app.get("/api/auth/twitch/users", apiMiddlewares, async (req: any, res: any) => {
const username = req.query.login?.toLowerCase();
if (!username) {
res.status(400).send({ user: null });
return;
}
const rest = new httpm.HttpClient(null);
const userId = req.user.impersonation?.id ?? req.user.id;
const connection: any = await db.oneOrNone('SELECT "clientId", "accessToken" FROM "Connection" WHERE "userId" = $1 AND "default" = true AND "type" = \'twitch\'', userId);
const resp = await rest.get('https://api.twitch.tv/helix/users?login=' + username, {
'Authorization': 'Bearer ' + connection.accessToken,
'Client-Id': connection.clientId,
});
const twitch = JSON.parse(await resp.readBody());
if (!twitch?.data) {
res.status(403).send({ user: null });
return;
}
const user = twitch.data.find((u: any) => u.login == username);
res.send({ user });
});
app.post("/api/auth/connections", apiMiddlewares, async (req: any, res: any) => {
const name = req.body.name;
const type = req.body.type?.toLowerCase();
const client_id = req.body.client_id;
const grant_type = req.body.grant_type?.toLowerCase();
if (!name || !type || !client_id || !grant_type) {
const missing = [name, type, client_id, grant_type]
res.status(400).send({ error: 'Missing fields in the body.' });
return;
}
const AuthData: { [service: string]: { type: string, endpoint: string, grantType: string, scopes: string[], redirect: string } } = {
'nightbot': {
type: 'nightbot',
endpoint: 'https://api.nightbot.tv/oauth2/authorize',
grantType: 'token',
scopes: ['song_requests', 'song_requests_queue', 'song_requests_playlist'],
redirect: 'https://beta.tomtospeech.com/connections/callback'
},
'twitch': {
type: 'twitch',
endpoint: 'https://id.twitch.tv/oauth2/authorize',
grantType: 'token',
scopes: [
'chat:read',
'bits:read',
'channel:read:polls',
'channel:read:predictions',
'channel:read:subscriptions',
'channel:read:vips',
'moderator:read:blocked_terms',
'chat:read',
'channel:moderate',
'channel:read:redemptions',
'channel:manage:redemptions',
'channel:manage:predictions',
'user:read:chat',
'channel:bot',
'moderator:read:followers',
'channel:read:ads',
'moderator:read:chatters',
],
redirect: 'https://beta.tomtospeech.com/connections/callback'
},
};
const url = AuthData[type].endpoint;
const redirect = AuthData[type].redirect;
const scopes = AuthData[type].scopes.join(' ');
const nounce = uuidv4();
await db.none('INSERT INTO "ConnectionState" ("name", "type", "clientId", "grantType", "state", "userId") VALUES ($1, $2, $3, $4, $5, $6)'
+ ' ON CONFLICT ("userId", "name") DO UPDATE SET "type" = $2, "clientId" = $3, "grantType" = $4, "state" = $5;',
[name, type, client_id, grant_type, nounce, req.user.id]);
const redirect_uri = url + '?client_id=' + client_id + '&force_verify=true&redirect_uri=' + redirect + '&response_type=token&scope=' + scopes + '&state=' + nounce;
res.send({ success: true, error: null, data: redirect_uri });
});
app.get("/api/auth/connections", async (req: Request, res: Response) => {
const state = req.query['state'];
const access_token = req.query['token'];
let expires_in = req.query['expires_in'];
if (!state || !access_token) {
res.status(400).send({ error: 'Missing fields in the body.' });
return;
}
const connection = await db.oneOrNone('SELECT "name", "type", "clientId", "grantType", "userId" FROM "ConnectionState" WHERE "state" = $1', [state]);
if (!connection) {
res.status(400).send({ error: 'Failed to link the account.' });
return;
}
if (connection.type == 'twitch') {
const rest = new httpm.HttpClient(null);
const response = await rest.get('https://id.twitch.tv/oauth2/validate', {
'Authorization': 'OAuth ' + access_token,
});
const json = JSON.parse(await response.readBody());
expires_in = json.expires_in;
}
if (!expires_in) {
res.status(400).send({ error: 'Could not determine the expiration of the token.' });
return;
}
const expires_at = new Date();
expires_at.setSeconds(expires_at.getSeconds() + parseInt(expires_in.toString()) - 300);
res.send({
data: { connection, expires_at },
});
});
app.post("/api/auth/twitch/callback", async (req: any, res: any) => {
const query = `client_id=${process.env.AUTH_CLIENT_ID}&client_secret=${process.env.AUTH_CLIENT_SECRET}&code=${req.body.code}&grant_type=authorization_code&redirect_uri=${process.env.AUTH_REDIRECT_URI}`
const rest = new httpm.HttpClient(null);
const response = await rest.post('https://id.twitch.tv/oauth2/token', query, {
'Content-Type': 'application/x-www-form-urlencoded'
});
const body = await response.readBody();
const data = JSON.parse(body);
if (!data || data.message) {
console.log('Failed to validate Twitch code authentication:', data);
res.send({ authenticated: false });
return;
}
console.log('Successfully validated Twitch code authentication. Attempting to read user data from Twitch.');
const resp = await rest.get('https://api.twitch.tv/helix/users', {
'Authorization': 'Bearer ' + data.access_token,
'Client-Id': process.env.AUTH_CLIENT_ID
});
const b = await resp.readBody();
const twitch = JSON.parse(b);
if (!twitch?.data) {
console.log('Failed to fetch twitch data:', data, twitch?.data);
res.send({ authenticated: false });
return;
}
const account: any = await db.oneOrNone('SELECT "userId" FROM "Account" WHERE "providerAccountId" = $1', twitch.data[0].id);
if (account != null) {
const user: any = await db.one('SELECT id FROM "User" WHERE id = $1', account.userId);
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '30d' });
res.send({ authenticated: true, token: token });
var now = Date.now();
const expires_at = ((now / 1000) | 0) + data.expires_in;
await db.none('UPDATE "Account" SET refresh_token = COALESCE($1, refresh_token), access_token = $2, id_token = COALESCE($3, id_token), expires_at = $4, scope = $5 WHERE "userId" = $6', [data.refresh_token, data.access_token, data.id_token, expires_at, data.scope.join(' '), account.userId]);
return;
}
res.send({ authenticated: false });
});
const cors = require("cors");
app.use(cors({ credentials: true, origin: true }));
app.use(express.json());
app.use(express.urlencoded());
app.use(helmet()); app.use(helmet());
app.use(limiter); app.use(limiter);
app.set('trust proxy', true);
app.get('/api/auth', passport.authenticate("openidconnect", { failureRedirect: '/login' }));
app.use(require('./routes/admin.route'));
app.use(require('./routes/api-keys.route'));
app.use(require('./routes/auth.route'));
app.use(require('./routes/twitch.route'));
app.listen(port, () => { app.listen(port, () => {
console.log(`[server]: Server is running at http://localhost:${port}`); console.log(`[server]: Server is running at http://localhost:${port}`);

View File

@ -0,0 +1,25 @@
import { database } from "../services/database";
import { passport } from "../services/passport";
export async function isApiKeyAuthenticated(req: any, res: any, next: any) {
if (!req.user) {
const key = req.get('x-api-key');
if (key) {
const data = await database.oneOrNone('SELECT "userId" from "ApiKey" WHERE id = $1', key);
if (data) {
req.user = await database.oneOrNone('SELECT id, name, role, "ttsDefaultVoice" FROM "User" WHERE id = $1', data.userId);
}
}
}
next();
}
export function isJWTAuthenticated(req: any, res: any, next: () => void) {
if (req.user) {
next();
return;
}
const check = passport.authenticate('jwt', { session: false });
check(req, res, next);
}

View File

@ -0,0 +1,32 @@
export function isAuthenticated(req: any, res: any, next: () => void) {
if (req.user) {
next();
return;
}
res.status(401).send({ error: 'User is not authenticated.' });
}
export function isAdminAuthenticated(req: any, res: any, next: () => void) {
if (req.user) {
if (req.user.role == 'ADMIN') {
next();
return;
}
res.status(403).send('You do not have the permissions for this.');
return;
}
res.status(401).send({ error: 'User is not authenticated.' });
}
export function isNotAuthenticated(req: any, res: any, next: () => void) {
if (!req.user) {
next();
return;
}
res.status(403).send({ error: 'User is authenticated.' });
}

View File

@ -0,0 +1,8 @@
import { isApiKeyAuthenticated, isJWTAuthenticated } from "./api-key.middleware";
import { isAdminAuthenticated, isAuthenticated, isNotAuthenticated } from "./authentication.middleware";
import { checkImpersonation } from "./impersonation.middleware";
export const AUTH_MIDDLEWARES = [isJWTAuthenticated, checkImpersonation];
export const PUBLIC_API_MIDDLEWARES = [];
export const PROTECTED_API_MIDDLEWARES = [isApiKeyAuthenticated, isJWTAuthenticated, checkImpersonation, isAuthenticated];
export const ADMIN_API_MIDDLEWARES = [isApiKeyAuthenticated, isJWTAuthenticated, isAdminAuthenticated]

View File

@ -0,0 +1,14 @@
import { database } from "../services/database";
export async function checkImpersonation(req: any, res: any, next: () => void) {
if (req.user && req.user.role == 'ADMIN') {
const impersonationId = await database.oneOrNone('SELECT "targetId" FROM "Impersonation" WHERE "sourceId" = $1', req.user.id);
if (impersonationId) {
const impersonation = await database.oneOrNone('SELECT id, name, role, "ttsDefaultVoice" FROM "User" WHERE id = $1', impersonationId.targetId);
if (impersonation) {
req.user.impersonation = impersonation;
}
}
}
next();
}

12
src/routes/admin.route.ts Normal file
View File

@ -0,0 +1,12 @@
import { ADMIN_API_MIDDLEWARES } from "../middlewares/common.middleware";
export { };
const express = require('express');
const router = express.Router();
const adminController = require('../controllers/admin.controller');
router.get('/api/admin/users', ADMIN_API_MIDDLEWARES, adminController.getUsers);
router.put('/api/admin/impersonate', ADMIN_API_MIDDLEWARES, adminController.updateImpersonation);
router.delete('/api/admin/impersonate', ADMIN_API_MIDDLEWARES, adminController.deleteImpersonation);
module.exports = router;

View File

@ -0,0 +1,12 @@
import { PROTECTED_API_MIDDLEWARES } from "../middlewares/common.middleware";
export { };
const express = require('express');
const router = express.Router();
const keyController = require('../controllers/api-keys.controller');
router.get('/api/keys', PROTECTED_API_MIDDLEWARES, keyController.getApiKeys);
router.post('/api/keys', PROTECTED_API_MIDDLEWARES, keyController.createApiKey);
router.delete('/api/keys', PROTECTED_API_MIDDLEWARES, keyController.deleteApiKey);
module.exports = router;

13
src/routes/auth.route.ts Normal file
View File

@ -0,0 +1,13 @@
import { AUTH_MIDDLEWARES, PROTECTED_API_MIDDLEWARES, PUBLIC_API_MIDDLEWARES } from "../middlewares/common.middleware";
export { };
const express = require('express');
const router = express.Router();
const authController = require('../controllers/auth.controller');
router.get('/api/auth/connections', authController.connectionCallback);
router.post('/api/auth/connections', PROTECTED_API_MIDDLEWARES, authController.connectionPreparation);
router.post('/api/auth/twitch/callback', authController.twitchCallback);
router.get('/api/auth/validate', AUTH_MIDDLEWARES, authController.validate);
module.exports = router;

View File

@ -0,0 +1,11 @@
import { PROTECTED_API_MIDDLEWARES } from "../middlewares/common.middleware";
export { };
const express = require('express');
const router = express.Router();
const twitchController = require('../controllers/twitch.controller');
router.get('/api/twitch/redemptions', PROTECTED_API_MIDDLEWARES, twitchController.getTwitchRedemptions);
router.get('/api/twitch/users', PROTECTED_API_MIDDLEWARES, twitchController.getTwitchUsers);
module.exports = router;

4
src/services/database.ts Normal file
View File

@ -0,0 +1,4 @@
import pgPromise from "pg-promise";
const pgp = pgPromise();
export const database = pgp(process.env.CONNECTION_STRING!);

56
src/services/passport.ts Normal file
View File

@ -0,0 +1,56 @@
import { OAuthData } from "../../data/oauth";
import { database } from "./database";
export const jwt = require('jsonwebtoken');
export const passport = require('passport');
const JwtStrat = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
passport.use(new JwtStrat({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
}, async (jwt_payload: any, done: any) => {
const user = await database.oneOrNone('SELECT id, name, role, "ttsDefaultVoice" FROM "User" WHERE id = $1', jwt_payload.id);
if (user) {
done(null, user);
} else {
done(null, false);
}
}));
const OpenIDConnectStrategy = require('passport-openidconnect');
passport.use(new OpenIDConnectStrategy({
issuer: 'https://id.twitch.tv/oauth2',
authorizationURL: 'https://id.twitch.tv/oauth2/authorize',
tokenURL: 'https://id.twitch.tv/oauth2/token',
clientID: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET,
callbackURL: process.env.AUTH_REDIRECT_URI,
scope: 'user_read ' + OAuthData['twitch'].scopes.join(' '),
},
async (url: any, profile: any, something: any, done: any) => {
console.log('login', 'profile:', profile, 'url', url, 'something', something);
const account: any = await database.oneOrNone('SELECT "userId" FROM "Account" WHERE "providerAccountId" = $1', profile.id);
if (account != null) {
const user: any = await database.oneOrNone('SELECT id, name, role, "ttsDefaultVoice" FROM "User" WHERE id = $1', account.userId);
if (user.name != profile.username) {
await database.none('UPDATE "User" SET name = $1 WHERE id = $2', [profile.username, profile.id]);
user.name = profile.username;
}
return done(null, user);
}
return done(new Error('Account does not exist.'), null);
}
));
passport.serializeUser((user: any, done: any) => {
if (!user)
return done(new Error('user is null'), null);
return done(null, user);
});
passport.deserializeUser((user: any, done: any) => {
done(null, user);
});