Code clean up. Separation of concerns.
This commit is contained in:
34
data/oauth.ts
Normal file
34
data/oauth.ts
Normal 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
10
package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
35
src/controllers/admin.controller.ts
Normal file
35
src/controllers/admin.controller.ts
Normal 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 });
|
||||||
|
};
|
41
src/controllers/api-keys.controller.ts
Normal file
41
src/controllers/api-keys.controller.ts
Normal 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 });
|
||||||
|
};
|
121
src/controllers/auth.controller.ts
Normal file
121
src/controllers/auth.controller.ts
Normal 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 });
|
||||||
|
}
|
48
src/controllers/twitch.controller.ts
Normal file
48
src/controllers/twitch.controller.ts
Normal 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
244
src/database.postgres.sql
Normal 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
|
||||||
|
);
|
411
src/index.ts
411
src/index.ts
@ -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}`);
|
||||||
|
25
src/middlewares/api-key.middleware.ts
Normal file
25
src/middlewares/api-key.middleware.ts
Normal 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);
|
||||||
|
}
|
32
src/middlewares/authentication.middleware.ts
Normal file
32
src/middlewares/authentication.middleware.ts
Normal 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.' });
|
||||||
|
}
|
8
src/middlewares/common.middleware.ts
Normal file
8
src/middlewares/common.middleware.ts
Normal 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]
|
14
src/middlewares/impersonation.middleware.ts
Normal file
14
src/middlewares/impersonation.middleware.ts
Normal 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
12
src/routes/admin.route.ts
Normal 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;
|
12
src/routes/api-keys.route.ts
Normal file
12
src/routes/api-keys.route.ts
Normal 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
13
src/routes/auth.route.ts
Normal 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;
|
11
src/routes/twitch.route.ts
Normal file
11
src/routes/twitch.route.ts
Normal 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
4
src/services/database.ts
Normal 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
56
src/services/passport.ts
Normal 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);
|
||||||
|
});
|
Reference in New Issue
Block a user