feat: grant system for obs chat overlay

This commit is contained in:
2025-08-09 16:57:04 +02:00
parent 54e3d120f6
commit 4237b1b41b
6 changed files with 90 additions and 38 deletions

View File

@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises';
import { lucia } from '@hctv/auth';
import { getCookie } from 'hono/cookie';
import { getPersonalChannel } from './utils/personalChannel.js';
import { getRedisConnection, prisma } from '@hctv/db';
import { getRedisConnection, prisma, type User } from '@hctv/db';
import uFuzzy from '@leeoniya/ufuzzy';
const redis = getRedisConnection();
@@ -31,32 +31,53 @@ app.get(
// https://hono.dev/helpers/websocket
async onOpen(evt, ws) {
const token = getCookie(c, 'auth_session');
if (!token) {
const grant = c.req.query('grant');
console.log({
token,
grant,
})
if (!token && !grant) {
console.log('closing a')
ws.close();
return;
}
// but if there is actually a token with no grant, we let it pass through
if (!token && grant === 'null') {
console.log('closing b')
ws.close();
return;
}
const { user } = await lucia.validateSession(token);
if (!user) {
let user: User | null = null
const dbGrant = await prisma.channel.findFirst({
where: {
obsChatGrantToken: grant,
}
});
if (token) {
user = (await lucia.validateSession(token)).user;
const personalChannel = await getPersonalChannel(user!.id);
if (!personalChannel) {
ws.close();
return;
}
ws.personalChannel = personalChannel;
}
if (!user && !dbGrant) {
ws.close();
return;
}
const personalChannel = await getPersonalChannel(user.id);
if (!personalChannel) {
ws.close();
return;
}
// ignoring user here which might be undefined so
const { username } = c.req.param();
ws.targetUsername = username;
ws.user = user;
ws.personalChannel = personalChannel;
if (ws.raw) {
ws.raw.targetUsername = username;
// @ts-ignore
ws.raw.user = user;
ws.raw.personalChannel = personalChannel;
ws.raw.personalChannel = ws.personalChannel;
}
const redis = getRedisConnection();
@@ -71,20 +92,24 @@ app.get(
})
);
}
await prisma.streamInfo.update({
where: {
username,
},
data: {
viewers: {
increment: 1,
if (token && grant === 'null') {
await prisma.streamInfo.update({
where: {
username,
},
},
});
data: {
viewers: {
increment: 1,
},
},
});
}
},
async onClose(evt, ws) {
// if prematurely exiting due to authentication issues
console.log('client disconnected');
if (!ws.targetUsername) return;
const streamInfo = await prisma.streamInfo.findUnique({
where: {
username: ws.targetUsername,
@@ -116,6 +141,7 @@ app.get(
return;
}
if (msg.type === 'message') {
if (!ws.personalChannel) return;
const message = (msg.message as string).trim();
const msgObj = {
user: {

View File

@@ -1,5 +1,4 @@
import ChatPanel from "@/components/app/ChatPanel/ChatPanel";
import LiveStream from "@/components/app/Livestream/Livestream";
import { prisma } from '@hctv/db';
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
@@ -12,6 +11,8 @@ export default async function Page({ params }: { params: Promise<{ username: str
return <div>Stream not found</div>;
}
return (
<ChatPanel isObsPanel />
<div className="bg-green-500 h-screen">
<ChatPanel isObsPanel />
</div>
);
}

View File

@@ -1,15 +1,14 @@
import './globals.css'
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import './globals.css';
export default function Layout({
children,
}: {
children: React.ReactNode
}) {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<main>{children}</main>
</body>
<NuqsAdapter>
<body>
<main>{children}</main>
</body>
</NuqsAdapter>
</html>
)
}
);
}

View File

@@ -8,9 +8,11 @@ import { useParams } from 'next/navigation';
import { Message } from './message';
import { useMap } from '@uidotdev/usehooks';
import { EmojiSearch } from './EmojiSearch';
import { useQueryState } from 'nuqs';
export default function ChatPanel(props: Props) {
const { username } = useParams();
const [grant, setGrant] = useQueryState('grant');
const [message, setMessage] = useState('');
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
@@ -20,12 +22,15 @@ export default function ChatPanel(props: Props) {
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
console.log(grant)
}, [grant]);
useEffect(() => {
console.log('Initializing WebSocket connection for user:', username);
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}`
}/api/stream/chat/ws/${username}?grant=${grant}`
);
socketRef.current = socket;
@@ -91,7 +96,7 @@ export default function ChatPanel(props: Props) {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}`
}/api/stream/chat/ws/${username}?grant=${grant}`
);
socket.onopen = () => {
socket.send(JSON.stringify({ type: 'message', message }));
@@ -145,7 +150,7 @@ export default function ChatPanel(props: Props) {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}`
}/api/stream/chat/ws/${username}?grant=${grant}`
);
socket.onopen = () => {
@@ -244,7 +249,7 @@ export default function ChatPanel(props: Props) {
};
return (
<div className={`${props.isObsPanel ? '' : 'md:border bg-mantle'} flex flex-col w-[350px] max-w-[350px] h-full`}>
<div className={`${props.isObsPanel ? 'w-full text-white' : 'md:border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}>
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
<div className="space-y-4 flex-1">
{chatMessages.map((msg, i) => (

View File

@@ -0,0 +1,20 @@
/*
Warnings:
- A unique constraint covering the columns `[obsChatGrantToken]` on the table `Channel` will be added. If there are existing duplicate values, this will fail.
- The required column `obsChatGrantToken` was added to the `Channel` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable: add column as nullable first
ALTER TABLE "Channel" ADD COLUMN "obsChatGrantToken" TEXT;
-- Update: set random string for existing rows
UPDATE "Channel"
SET "obsChatGrantToken" = substr(md5(random()::text || clock_timestamp()::text), 1, 32)
WHERE "obsChatGrantToken" IS NULL;
-- AlterTable: make column NOT NULL
ALTER TABLE "Channel" ALTER COLUMN "obsChatGrantToken" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Channel_obsChatGrantToken_key" ON "Channel"("obsChatGrantToken");

View File

@@ -50,6 +50,7 @@ model Channel {
streamInfo StreamInfo[]
followers Follow[] @relation("ChannelFollowers")
streamKey StreamKey?
obsChatGrantToken String @unique @default(cuid())
@@index([ownerId])
}