diff --git a/src/events/ban.ts b/src/events/ban.ts index b14e00f..ea6289e 100644 --- a/src/events/ban.ts +++ b/src/events/ban.ts @@ -1,70 +1,67 @@ import type { Server, Socket } from 'socket.io'; -import { get, set } from '@utils/cache'; import canDoModerationOperationOnTarget from '@utils/canDoModerationOperationOnTarget'; import sendSystemMessage from '@utils/systemMessage'; +import useSocket from '@utils/useSocket'; -import { CoreParticipant, Participant } from './login'; +import type { CoreParticipant } from './create'; export default class BanOrUnbanParticipant { async handle({ socket, io, data }: { socket: Socket; io: Server; data: any }) { const targetUserId = data?.target; if (!targetUserId) return; - const rooms = Array.from(socket.rooms); - const room = rooms[1]; + const hook = useSocket(socket); + if (hook?.error) return; - if (!room) return; + const room = hook.getRoomPtr(); + const mod = await hook.getCurrentUser(); + const targetUser = await hook.getUserFromId(targetUserId); + const bannedParticipants = await hook.getBannedParticipants(); - const socketId = socket.id; - const socketRoomParticipants = (await get(`room:${room}:users`)) as Participant[]; + let newBannedParticipants: CoreParticipant[] = bannedParticipants; + let shouldBroadcast = false; - if (socketRoomParticipants) { - const mod = socketRoomParticipants.find((user) => user.sid == socketId); - const targetUser = socketRoomParticipants.find((user) => user.id == targetUserId); + const isBanned = bannedParticipants.find((x) => x.id == targetUserId); - const bannedParticipants = ((await get(`room:${room}:bannedParticipants`)) ?? - []) as CoreParticipant[]; + if (isBanned) { + // unban + if (mod?.moderator) { + newBannedParticipants = bannedParticipants.filter((x) => x.id != targetUserId); + shouldBroadcast = true; - let newBannedParticipants = []; - - const isBanned = bannedParticipants.find((x) => x.id == targetUserId); - - if (isBanned) { - // unban - if (mod?.moderator) { - newBannedParticipants = bannedParticipants.filter((x) => x.id != targetUserId); - - sendSystemMessage( - room, - `${mod.username}, ${isBanned.username} kullanıcısının yasağını kaldırdı.`, - ); - } - } else if (mod && targetUser && canDoModerationOperationOnTarget(mod, targetUser)) { - //ban + sendSystemMessage( + room, + `${mod.username}, ${isBanned.username} kullanıcısının yasağını kaldırdı.`, + ); + } + } else if (mod && targetUser && canDoModerationOperationOnTarget(mod, targetUser)) { + //ban - const targetUserCpy = { ...targetUser }; + const targetUserCpy = { ...targetUser }; - delete targetUserCpy.sid; - delete targetUserCpy.owner; - delete targetUserCpy.moderator; + delete targetUserCpy.sid; + delete targetUserCpy.owner; + delete targetUserCpy.moderator; - newBannedParticipants = [...bannedParticipants, targetUserCpy]; + newBannedParticipants = [...bannedParticipants, targetUserCpy]; + shouldBroadcast = true; - const getTargetSocket = io.sockets.sockets.get(targetUser.sid); - getTargetSocket.disconnect(); + const getTargetSocket = io.sockets.sockets.get(targetUser.sid); + getTargetSocket?.disconnect(); - sendSystemMessage( - room, - `${mod.username}, ${targetUser.username} kullanıcısını yasakladı.`, - ); - } + sendSystemMessage( + room, + `${mod.username}, ${targetUser.username} kullanıcısını yasakladı.`, + ); + } - io.in(room).emit('ban', { + if (shouldBroadcast) { + hook.broadcastToEveryone('ban', { bannedParticipants: newBannedParticipants, }); - await set(`room:${room}:bannedParticipants`, newBannedParticipants); + await hook.setRoomKey('bannedParticipants', newBannedParticipants); } } } diff --git a/src/events/create.ts b/src/events/create.ts new file mode 100644 index 0000000..5d4b5f6 --- /dev/null +++ b/src/events/create.ts @@ -0,0 +1,161 @@ +import crypto from 'node:crypto'; + +import { Socket } from 'socket.io'; +import { z } from 'zod'; + +import { chatBotProps, io } from '@index'; +import { get, multipleSet, set } from '@utils/cache'; +import sendSystemMessage from '@utils/systemMessage'; +import { addTurkishPossessiveSuffix } from '@utils/turkishPossessiveSuffix'; +import useSocket from '@utils/useSocket'; + +export type Participant = { + id: string; + username: string; + avatar: string; + avatarDecoration: number; + owner: boolean; + moderator: boolean; + sid: string; +}; + +export type CoreParticipant = Omit; + +export type SocketSession = { + room: string; + id: string; + participant?: Participant; +}; + +export async function getParticipantsFromSocketRoom(room: string) { + const roomSockets = io.sockets.adapter.rooms.get(room); + if (!roomSockets) return []; + + const participants = await Promise.all( + [...roomSockets].map(async (sid) => { + const session = (await get(`sid:${sid}`)) as SocketSession | null; + + if (session?.room != room) return null; + return session.participant ?? null; + }), + ); + + return participants.filter((participant): participant is Participant => !!participant); +} + +const validation = z.object({ + token: z.string().max(1000), + anime: z.object({ + fansub: z.string().min(1).max(500), + slug: z.string().min(1).max(500), + season: z.number().int(), + episode: z.number().int(), + }), + timestamp: z.number().nonnegative().optional(), +}); + +export default class CreateRoom { + async handle({ socket, callback, data }: { socket: Socket; callback: any; data: any }) { + const token = data?.token || socket.handshake.headers.authorization; + + const val = validation.safeParse({ + ...data, + token, + }); + + if ('error' in val) { + let err = val.error.issues[0].message; + + if (err == 'Required') err = 'Invalid body'; + return callback({ error: err }); + } + + const { anime, timestamp } = data; + + const user = (await fetch(`${process.env.API_URL}/user`, { + headers: { + Authorization: token, + 'Client-Protocol-Model': process.env.CLIENT_PROTOCOL_MODEL_VALUE, + }, + })) as any; + + // allocate 4 bytes. in hex, a byte is represented by 2 chars so its n * 2 - so 8 chars. + const roomId = 'room:' + crypto.randomBytes(4).toString('hex'); + + const json = await user.json(); + if (!json?.id) return callback({ error: 'Kullanıcı verisi alınamadı' }); + + let roomParticipants = ((await get(`${roomId}:users`)) ?? []) as Participant[]; + const participantsDefinedBySocketIO = io.sockets.adapter.rooms.get(roomId); + + if (participantsDefinedBySocketIO) { + roomParticipants = roomParticipants.filter((participant) => + participantsDefinedBySocketIO.has(participant.sid), + ); + + if (roomParticipants.length == 0) { + roomParticipants = await getParticipantsFromSocketRoom(roomId); + } + } + + if (roomParticipants && roomParticipants.find((user) => user.id == json.id)) { + return callback({ error: 'Zaten bu odadasın' }); + } + + const roomName = `${addTurkishPossessiveSuffix(json.username)} odası`; + + const currentParticipant: Participant = { + id: json.id, + username: json.username, + avatar: json.avatar, + avatarDecoration: json?.avatarDecoration ?? 0, + owner: true, + moderator: true, + sid: socket.id, + }; + + await multipleSet({ + [`${roomId}:users`]: [currentParticipant], + [`${roomId}:timestamp`]: timestamp ?? 0, + [`${roomId}:anime`]: anime, + [`${roomId}:owner`]: json.id, + [`${roomId}:password`]: null, + [`${roomId}:bannedParticipants`]: [], + [`${roomId}:mutedParticipants`]: [], + [`${roomId}:controlledByMods`]: false, + [`${roomId}:name`]: roomName, + }); + + await set(`sid:${socket.id}`, { + room: roomId, + id: json.id, + participant: currentParticipant, + }); + + socket.join(roomId); + + sendSystemMessage(roomId, `${json.username} odaya katıldı 👋`); + + setTimeout(async () => { + const hook = useSocket(socket); + if (hook?.error) return; + + hook.broadcastToEveryone('participants', { + participants: await hook.getParticipants(), + }); + }, 1000); + + return callback({ + message: 'OK', + details: { + bannedParticipants: [], + mutedParticipants: [], + timestamp: timestamp ?? 0, + roomId: roomId, + roomName: roomName, + controlledByMods: false, + }, + system: chatBotProps, + }); + } +} diff --git a/src/events/disconnecting.ts b/src/events/disconnecting.ts index 404d8a1..3cb6cf1 100644 --- a/src/events/disconnecting.ts +++ b/src/events/disconnecting.ts @@ -1,31 +1,77 @@ import type { Server, Socket } from 'socket.io'; -import { del, delWithPattern, get, set } from '@utils/cache'; +import type { Participant } from '@events/login'; + +import { del, delWithPattern, get } from '@utils/cache'; +import useSocket from '@utils/useSocket'; + +type SocketSession = { + room: string; + id: string; + participant?: Participant; +}; + +async function getParticipantsFromSocketIds(room: string, socketIds: string[]) { + const participants = await Promise.all( + socketIds.map(async (sid) => { + const session = (await get(`sid:${sid}`)) as SocketSession | null; + + if (session?.room != room) return null; + return session.participant ?? null; + }), + ); + + return participants.filter((participant): participant is Participant => !!participant); +} export default class Disconnect { async handle({ socket, io }: { socket: Socket; io: Server }) { - const rooms = Array.from(socket.rooms); - const room = rooms[1]; + const hook = useSocket(socket); + if (hook?.error) return; - if (!room) return; + const room = hook.getRoomPtr(); + const socketRoomParticipants = await hook.getParticipants(); + const remainingSocketIds = [...(io.sockets.adapter.rooms.get(room) ?? [])].filter( + (sid) => sid != socket.id, + ); - const socketId = socket.id; - const socketRoomParticipants = await get(`room:${room}:users`); + await del(`sid:${socket.id}`); if (socketRoomParticipants) { - const newParticipants = socketRoomParticipants.filter((user) => user.sid != socketId); + const newParticipants = socketRoomParticipants.filter((user) => user.sid != socket.id); + + if (newParticipants.length == 0) { + const rebuiltParticipants = await getParticipantsFromSocketIds( + room, + remainingSocketIds, + ); - await set(`room:${room}:users`, newParticipants); + if (rebuiltParticipants.length > 0) { + await hook.setRoomKey('users', rebuiltParticipants); - io.in(room).emit('participants', { + hook.broadcastToEveryone('participants', { + participants: rebuiltParticipants, + }); + + return; + } + + if (remainingSocketIds.length == 0) { + await delWithPattern(`room:${room}:*`); + } + + return; + } + + await hook.setRoomKey('users', newParticipants); + + hook.broadcastToEveryone('participants', { participants: newParticipants, }); - } - const remainingParticipants = io.sockets.adapter.rooms.get(room); - if (!remainingParticipants) { - await delWithPattern(`room:${room}:*`); - await del(`sid:${socketId}`); + return; } + + if (remainingSocketIds.length == 0) await delWithPattern(`room:${room}:*`); } } diff --git a/src/events/kick.ts b/src/events/kick.ts index ee1a1c7..217abdf 100644 --- a/src/events/kick.ts +++ b/src/events/kick.ts @@ -1,37 +1,26 @@ import type { Server, Socket } from 'socket.io'; -import { get } from '@utils/cache'; import canDoModerationOperationOnTarget from '@utils/canDoModerationOperationOnTarget'; import sendSystemMessage from '@utils/systemMessage'; - -import { Participant } from './login'; +import useSocket from '@utils/useSocket'; export default class KickParticipant { async handle({ socket, io, data }: { socket: Socket; io: Server; data: any }) { const targetUserId = data?.target; if (!targetUserId) return; - const rooms = Array.from(socket.rooms); - const room = rooms[1]; - - if (!room) return; - - const socketId = socket.id; - const socketRoomParticipants = (await get(`room:${room}:users`)) as Participant[]; + const hook = useSocket(socket); + if (hook?.error) return; - if (socketRoomParticipants) { - const mod = socketRoomParticipants.find((user) => user.sid == socketId); - const targetUser = socketRoomParticipants.find((user) => user.id == targetUserId); + const room = hook.getRoomPtr(); + const mod = await hook.getCurrentUser(); + const targetUser = await hook.getUserFromId(targetUserId); - if (canDoModerationOperationOnTarget(mod, targetUser)) { - const getTargetSocket = io.sockets.sockets.get(targetUser.sid); - getTargetSocket.disconnect(); + if (canDoModerationOperationOnTarget(mod, targetUser)) { + const getTargetSocket = io.sockets.sockets.get(targetUser.sid); + getTargetSocket?.disconnect(); - sendSystemMessage( - room, - `${mod.username}, ${targetUser.username} kullanıcısını attı..`, - ); - } + sendSystemMessage(room, `${mod.username}, ${targetUser.username} kullanıcısını attı..`); } } } diff --git a/src/events/login.ts b/src/events/login.ts index 3ab3504..910eb12 100644 --- a/src/events/login.ts +++ b/src/events/login.ts @@ -2,36 +2,16 @@ import { Socket } from 'socket.io'; import { z } from 'zod'; import { io } from '@index'; -import { get, multipleSet, set } from '@utils/cache'; +import { get, set } from '@utils/cache'; import sendSystemMessage from '@utils/systemMessage'; +import useSocket from '@utils/useSocket'; -type Participant = { - id: string; - username: string; - avatar: string; - owner: boolean; - moderator: boolean; - sid: string; -}; - -type CoreParticipant = Omit; +import { type CoreParticipant, getParticipantsFromSocketRoom, type Participant } from './create'; const validation = z.object({ token: z.string().max(1000), - room: z - .string() - .trim() - .min(2, { message: 'Oda adı en az 2 karakter olabilir' }) - .max(32, { message: 'Oda adı en fazla 32 karakter olabilir' }), - password: z - .string() - .trim() - .min(2, { - message: 'Oda şifresi en az 2 karakter olabilir', - }) - .max(32, { - message: 'Oda şifresi en fazla 32 karakter olabilir', - }), + roomId: z.string().trim().max(32), // starts with the "room:" prefix + password: z.string().trim().max(32).optional(), anime: z.object({ fansub: z.string().min(1).max(500), slug: z.string().min(1).max(500), @@ -40,7 +20,11 @@ const validation = z.object({ }), }); -export default class Login { +function isEqual(a, b) { + return JSON.stringify(a, Object.keys(a).sort()) === JSON.stringify(b, Object.keys(b).sort()); +} + +export default class LoginIntoRoom { async handle({ socket, callback, data }: { socket: Socket; callback: any; data: any }) { const token = data?.token || socket.handshake.headers.authorization; @@ -56,13 +40,11 @@ export default class Login { return callback({ error: err }); } - let { password, room } = data; - const { anime } = data; - - const prefix = 'room:' + room; + const { roomId, password, anime } = val.data; - password = password.trim(); - room = room.trim(); + if (!roomId.startsWith('room:')) { + return callback({ error: 'Geçersiz oda kodu' }); + } const user = (await fetch(`${process.env.API_URL}/user`, { headers: { @@ -74,17 +56,44 @@ export default class Login { const json = await user.json(); if (!json?.id) return callback({ error: 'Kullanıcı verisi alınamadı' }); - const roomParticipants = await get(`${prefix}:users`); + const getRoomAnimeInformation = await get(`${roomId}:anime`); + + if (!getRoomAnimeInformation) { + return callback({ + error: 'Böyle bir oda yok', + }); + } + + if (getRoomAnimeInformation && !isEqual(getRoomAnimeInformation, anime)) { + return callback({ + error: 'content_differ', + anime: getRoomAnimeInformation, + }); + } + + let roomParticipants = ((await get(`${roomId}:users`)) ?? []) as Participant[]; + const participantsDefinedBySocketIO = io.sockets.adapter.rooms.get(roomId); + + if (participantsDefinedBySocketIO) { + roomParticipants = roomParticipants.filter((participant) => + participantsDefinedBySocketIO.has(participant.sid), + ); + + if (roomParticipants.length == 0) { + roomParticipants = await getParticipantsFromSocketRoom(roomId); + } + } + if (roomParticipants && roomParticipants.find((user) => user.id == json.id)) { return callback({ error: 'Zaten bu odadasın' }); } - const getPass = await get(`${prefix}:password`); + const getPass = await get(`${roomId}:password`); if (getPass && getPass != password) { - return callback({ error: 'Yanlış şifre' }); + return callback({ error: 'wrong_password' }); } - const bannedParticipants = ((await get(`${prefix}:bannedParticipants`)) ?? + const bannedParticipants = ((await get(`${roomId}:bannedParticipants`)) ?? []) as CoreParticipant[]; const banned = bannedParticipants.find((x) => x.id == json.id); @@ -92,77 +101,62 @@ export default class Login { return callback({ error: 'Bu odadan yasaklandınız' }); } - const getRoomAnimeInformation = await get(`${prefix}:anime`); - const mutedParticipants = (await get(`${prefix}:mutedParticipants`)) ?? []; + const mutedParticipants = (await get(`${roomId}:mutedParticipants`)) ?? []; + const controlledByMods = (await get(`${roomId}:controlledByMods`)) ?? false; - if ( - getRoomAnimeInformation && - JSON.stringify(getRoomAnimeInformation) != JSON.stringify(anime) - ) { + const roomOwner = await get(`${roomId}:owner`); + + if (roomParticipants.length == 0) { return callback({ - error: 'Bu oda başka bir anime izliyor', - anime: getRoomAnimeInformation, + error: 'Katılımcı listesi alınamadı, lütfen tekrar deneyin', }); } - const participantsDefinedBySocketIO = io.sockets.adapter.rooms.get(room); - - // If there is no clients inside the room, we should create a new room and make the user the owner of the room - if (!participantsDefinedBySocketIO) { - await multipleSet({ - [`${prefix}:users`]: [ - { - id: json.id, - username: json.username, - avatar: json.avatar, - owner: true, - moderator: true, - sid: socket.id, - }, - ], - [`${prefix}:timestamp`]: 0, - [`${prefix}:anime`]: anime, - [`${prefix}:owner`]: json.id, - [`${prefix}:password`]: password, - [`${prefix}:bannedParticipants`]: [], - [`${prefix}:mutedParticipants`]: [], - [`${prefix}:controlledByMods`]: false, - }); - } else { - const roomOwner = await get(`${prefix}:owner`); - - await set(`${prefix}:users`, [ - ...roomParticipants, - { - id: json.id, - username: json.username, - avatar: json.avatar, - owner: roomOwner == json.id, - moderator: roomOwner == json.id, - sid: socket.id, - }, - ]); - } + const currentParticipant: Participant = { + id: json.id, + username: json.username, + avatar: json.avatar, + avatarDecoration: json?.avatarDecoration ?? 0, + owner: roomOwner == json.id, + moderator: roomOwner == json.id, + sid: socket.id, + }; + + await set(`${roomId}:users`, [...roomParticipants, currentParticipant]); await set(`sid:${socket.id}`, { - room, + room: roomId, id: json.id, + participant: currentParticipant, }); - socket.join(room); + const lastTimestamp = (await get(`${roomId}:timestamp`)) ?? 0; + + const roomName = await get(`${roomId}:name`); - const lastTimestamp = (await get(`${prefix}:timestamp`)) ?? 0; - sendSystemMessage(room, `${json.username} odaya katıldı 👋`); + socket.join(roomId); + + sendSystemMessage(roomId, `${json.username} odaya katıldı 👋`); setTimeout(async () => { - io.in(room).emit('participants', { - participants: await get(`${prefix}:users`), + const hook = useSocket(socket); + if (hook?.error) return; + + hook.broadcastToEveryone('participants', { + participants: await hook.getParticipants(), }); }, 1000); return callback({ message: 'OK', - details: { bannedParticipants, mutedParticipants, timestamp: lastTimestamp, room }, + details: { + bannedParticipants, + mutedParticipants, + timestamp: lastTimestamp, + roomId: roomId, + roomName: roomName, + controlledByMods, + }, }); } } diff --git a/src/events/message.ts b/src/events/message.ts index 1689bfb..50d0a6a 100644 --- a/src/events/message.ts +++ b/src/events/message.ts @@ -1,29 +1,28 @@ -import type { Server, Socket } from 'socket.io'; +import type { Socket } from 'socket.io'; -import { get } from '@utils/cache'; +import useSocket from '@utils/useSocket'; export default class CreateMessage { - async handle({ socket, io, data }: { socket: Socket; io: Server; data: any }) { + async handle({ socket, data }: { socket: Socket; data: any }) { if ( 'message' in data && typeof data.message == 'string' && data.message.trim().length > 0 && data.message.trim().length <= 250 ) { - const rooms = Array.from(socket.rooms); - const room = rooms[1]; + const hook = useSocket(socket); + if (hook?.error) return; - if (!room) return; + const currentUser = await hook.getCurrentUser(); + if (!currentUser) return; - const socketRoomParticipants = await get(`room:${room}:users`); - const author = socketRoomParticipants.find((user) => user.sid == socket.id); + const muted = await hook.isMuted(); + if (muted) return; - if (!author) return; + delete currentUser.sid; - delete author.sid; - - io.in(room).emit('message', { - author, + hook.broadcastToEveryone('message', { + author: currentUser, content: data.message.trim(), }); } diff --git a/src/events/mod.ts b/src/events/mod.ts index d87390a..77e11db 100644 --- a/src/events/mod.ts +++ b/src/events/mod.ts @@ -1,74 +1,68 @@ -import type { Server, Socket } from 'socket.io'; +import type { Socket } from 'socket.io'; -import { get, set } from '@utils/cache'; import sendSystemMessage from '@utils/systemMessage'; +import useSocket from '@utils/useSocket'; -import { Participant } from './login'; +import { Participant } from './create'; export default class MakeModeratorOrTakeModerator { - async handle({ socket, io, data }: { socket: Socket; io: Server; data: any }) { + async handle({ socket, data }: { socket: Socket; data: any }) { const targetUserId = data?.target; if (!targetUserId) return; - const rooms = Array.from(socket.rooms); - const room = rooms[1]; + const hook = useSocket(socket); + if (hook?.error) return; - if (!room) return; + const currentUser = await hook.getCurrentUser(); + if (!currentUser.owner) return; - const socketId = socket.id; - const socketRoomParticipants = (await get(`room:${room}:users`)) as Participant[]; + const targetUser = await hook.getUserFromId(targetUserId); + if (!targetUser) return; - if (socketRoomParticipants) { - const requestedBy = socketRoomParticipants.find((user) => user.sid == socketId); - const targetUser = socketRoomParticipants.find((user) => user.id == targetUserId); + const participants = await hook.getParticipants(); - if (requestedBy.owner) { - const isTargetAlreadyMod = socketRoomParticipants.find( - (user) => user.id == targetUserId, - )?.moderator; + let newParticipants: Participant[] = []; - let newParticipants = []; + if (targetUser.moderator) { + // take mod - if (isTargetAlreadyMod) { - //take mod - newParticipants = socketRoomParticipants.map((participant) => { - if (participant.id == targetUserId) { - return { - ...participant, - moderator: false, - }; - } - return participant; - }); + newParticipants = participants.map((participant) => { + if (participant.id == targetUserId) { + return { + ...participant, + moderator: false, + }; + } + return participant; + }); - sendSystemMessage( - room, - `${requestedBy.username}, ${targetUser.username} kullanıcısının moderatör yetkisini aldı.`, - ); - } else { - //make mod - newParticipants = socketRoomParticipants.map((participant) => { - if (participant.id == targetUserId) { - return { - ...participant, - moderator: true, - }; - } - return participant; - }); + sendSystemMessage( + hook.getRoomPtr(), + `${currentUser.username}, ${targetUser.username} kullanıcısının moderatör yetkisini aldı.`, + ); + } else { + // make mod - sendSystemMessage( - room, - `${requestedBy.username}, ${targetUser.username} kullanıcısını moderatör olarak atadı.`, - ); + newParticipants = participants.map((participant) => { + if (participant.id == targetUserId) { + return { + ...participant, + moderator: true, + }; } + return participant; + }); - io.in(room).emit('participants', { - participants: newParticipants, - }); - - await set(`room:${room}:users`, newParticipants); - } + sendSystemMessage( + hook.getRoomPtr(), + `${currentUser.username}, ${targetUser.username} kullanıcısını moderatör olarak atadı.`, + ); } + + hook.broadcastToEveryone('participants', { + participants: newParticipants, + }); + + await hook.setRoomKey('users', newParticipants); } } diff --git a/src/events/modControl.ts b/src/events/modControl.ts deleted file mode 100644 index 3ef9e6d..0000000 --- a/src/events/modControl.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Server, Socket } from 'socket.io'; - -import { get, set } from '@utils/cache'; - -import { Participant } from './login'; - -export default class ChangeModeratorControl { - async handle({ socket, io, data }: { socket: Socket; io: Server; data: any }) { - const controlledByMods = data?.controlledByMods; - if (typeof controlledByMods !== 'boolean') return; - - const rooms = Array.from(socket.rooms); - const room = rooms[1]; - - if (!room) return; - - const socketId = socket.id; - const socketRoomParticipants = (await get(`room:${room}:users`)) as Participant[]; - const user = socketRoomParticipants.find((user) => user.sid == socketId); - - if (user.owner) { - await set(`room:${room}:controlledByMods`, controlledByMods); - - io.in(room).emit('modControl', { - controlledByMods, - }); - } - } -} diff --git a/src/events/mute.ts b/src/events/mute.ts index 4306738..d5e8f77 100644 --- a/src/events/mute.ts +++ b/src/events/mute.ts @@ -1,58 +1,52 @@ -import type { Server, Socket } from 'socket.io'; +import type { Socket } from 'socket.io'; -import { get, set } from '@utils/cache'; import canDoModerationOperationOnTarget from '@utils/canDoModerationOperationOnTarget'; import sendSystemMessage from '@utils/systemMessage'; - -import { Participant } from './login'; +import useSocket from '@utils/useSocket'; export default class MuteOrUnmuteParticipant { - async handle({ socket, io, data }: { socket: Socket; io: Server; data: any }) { + async handle({ socket, data }: { socket: Socket; data: any }) { const targetUserId = data?.target; if (!targetUserId) return; - const rooms = Array.from(socket.rooms); - const room = rooms[1]; + const hook = useSocket(socket); - if (!room) return; + if (hook?.error) return; - const socketId = socket.id; - const socketRoomParticipants = (await get(`room:${room}:users`)) as Participant[]; + const participants = await hook.getParticipants(); - if (socketRoomParticipants) { - const mod = socketRoomParticipants.find((user) => user.sid == socketId); - const targetUser = socketRoomParticipants.find((user) => user.id == targetUserId); + if (participants) { + const currentUser = await hook.getCurrentUser(); + const targetUser = await hook.getUserFromId(targetUserId); - if (canDoModerationOperationOnTarget(mod, targetUser)) { - const alreadyMutedParticipants = await get(`room:${room}:mutedParticipants`); + if (canDoModerationOperationOnTarget(currentUser, targetUser)) { + const mutedParticipants = await hook.getMutedParticipants(); let newMutedParticipants = []; - if (alreadyMutedParticipants.includes(targetUserId)) { + if (mutedParticipants.includes(targetUserId)) { //unmute - newMutedParticipants = alreadyMutedParticipants.filter( - (x) => x != targetUserId, - ); + newMutedParticipants = mutedParticipants.filter((x) => x != targetUserId); sendSystemMessage( - room, - `${mod.username}, ${targetUser.username} kullanıcısının susturmasını kaldırdı.`, + hook.getRoomPtr(), + `${currentUser.username}, ${targetUser.username} kullanıcısının susturmasını kaldırdı.`, ); } else { //mute - newMutedParticipants = [...alreadyMutedParticipants, targetUserId]; + newMutedParticipants = [...mutedParticipants, targetUserId]; sendSystemMessage( - room, - `${mod.username}, ${targetUser.username} kullanıcısını susturdu.`, + hook.getRoomPtr(), + `${currentUser.username}, ${targetUser.username} kullanıcısını susturdu.`, ); } - io.in(room).emit('mute', { + hook.broadcastToEveryone('mute', { mutedParticipants: newMutedParticipants, }); - await set(`room:${room}:mutedParticipants`, newMutedParticipants); + await hook.setRoomKey('mutedParticipants', newMutedParticipants); } } } diff --git a/src/events/navigate.ts b/src/events/navigate.ts new file mode 100644 index 0000000..3294dd8 --- /dev/null +++ b/src/events/navigate.ts @@ -0,0 +1,70 @@ +import type { Socket } from 'socket.io'; +import { z } from 'zod'; + +import useSocket from '@utils/useSocket'; + +const validate = z + .object({ + slug: z.string().min(1).max(500), + fansub: z.string().min(1).max(500).optional(), + season: z.number().int(), + episode: z.number().int(), + type: z.string().min(1).max(100).optional(), + timestamp: z.number().optional(), + }) + .strict(); + +export default class PerformNavigate { + async handle({ socket, data, callback }: { socket: Socket; data: any; callback?: any }) { + const val = validate.safeParse(data); + if (val?.error) return callback?.({ error: 'Invalid body' }); + + const hook = useSocket(socket); + if (hook?.error) return callback?.({ error: hook.error }); + + let pass = false; + + const controlledByMods = await hook.roomControlledByMods(); + + if (controlledByMods) { + pass = await hook.isPriviliged(); + } else { + pass = true; + } + + if (!pass) { + return callback?.({ error: 'Unauthorized' }); + } + + const currentAnime = (await hook.getRoomKey('anime')) ?? {}; + const fansub = val.data.fansub ?? currentAnime.fansub; + const timestamp = val.data.timestamp ?? 0; + + if (!fansub) return callback?.({ error: 'Invalid navigation target' }); + + const navigationId = crypto.randomUUID(); + + const payload = { + navigationId, + slug: val.data.slug, + fansub, + season: val.data.season, + episode: val.data.episode, + type: val.data.type, + timestamp, + }; + + hook.broadcastToEveryoneExceptAuthor('performNavigate', payload); + + await hook.setRoomKey('anime', { + fansub, + slug: val.data.slug, + season: val.data.season, + episode: val.data.episode, + }); + + await hook.setRoomKey('timestamp', timestamp); + + return callback?.({ message: 'OK', navigationId }); + } +} diff --git a/src/events/playerState.ts b/src/events/playerState.ts index aacbc9f..b52a87a 100644 --- a/src/events/playerState.ts +++ b/src/events/playerState.ts @@ -1,36 +1,29 @@ import type { Socket } from 'socket.io'; -import { get } from '@utils/cache'; +import useSocket from '@utils/useSocket'; -import { Participant } from './login'; - -export default class MuteOrUnmuteParticipant { +export default class UpdatePlayerState { async handle({ socket, data }: { socket: Socket; data: any }) { const playing = data?.playing; if (typeof data?.playing !== 'boolean') return; - const rooms = Array.from(socket.rooms); - const room = rooms[1]; + const hook = useSocket(socket); - if (!room) return; + if (hook?.error) return; - const socketId = socket.id; let pass = false; - const modRequired = (await get(`room:${room}:controlledByMods`)) ?? false; - if (modRequired) { - const socketRoomParticipants = (await get(`room:${room}:users`)) as Participant[]; + const controlledByMods = await hook.roomControlledByMods(); - if (socketRoomParticipants) { - const mod = socketRoomParticipants.find((user) => user.sid == socketId); - if (mod?.moderator) pass = true; - } + if (controlledByMods) { + const isMod = await hook.isModerator(); + pass = isMod; } else { pass = true; } if (pass) { - socket.broadcast.to(room).emit('playerState', { playing }); + hook.broadcastToEveryoneExceptAuthor('playerState', { playing }); } } } diff --git a/src/events/playerTimestamp.ts b/src/events/playerTimestamp.ts index f37caf6..9f297cf 100644 --- a/src/events/playerTimestamp.ts +++ b/src/events/playerTimestamp.ts @@ -1,37 +1,30 @@ import type { Socket } from 'socket.io'; -import { get, set } from '@utils/cache'; +import useSocket from '@utils/useSocket'; -import { Participant } from './login'; - -export default class MuteOrUnmuteParticipant { +export default class UpdatePlayerTimestamp { async handle({ socket, data }: { socket: Socket; data: any }) { const videoTimestamp = data?.timestamp; if (isNaN(videoTimestamp)) return; - const rooms = Array.from(socket.rooms); - const room = rooms[1]; + const hook = useSocket(socket); - if (!room) return; + if (hook?.error) return; - const socketId = socket.id; let pass = false; - const modRequired = (await get(`room:${room}:controlledByMods`)) ?? false; - if (modRequired) { - const socketRoomParticipants = (await get(`room:${room}:users`)) as Participant[]; + const controlledByMods = await hook.roomControlledByMods(); - if (socketRoomParticipants) { - const mod = socketRoomParticipants.find((user) => user.sid == socketId); - if (mod?.moderator) pass = true; - } + if (controlledByMods) { + const isMod = await hook.isModerator(); + pass = isMod; } else { pass = true; } if (pass) { - socket.broadcast.to(room).emit('playerTimestamp', { timestamp: videoTimestamp }); - await set(`room:${room}:timestamp`, videoTimestamp); + hook.broadcastToEveryoneExceptAuthor('playerTimestamp', { timestamp: videoTimestamp }); + hook.setRoomKey('timestamp', videoTimestamp); } } } diff --git a/src/events/roomData.ts b/src/events/roomData.ts new file mode 100644 index 0000000..874d262 --- /dev/null +++ b/src/events/roomData.ts @@ -0,0 +1,82 @@ +import { Socket } from 'socket.io'; +import { z } from 'zod'; + +import { addTurkishPossessiveSuffix } from '@utils/turkishPossessiveSuffix'; +import useSocket from '@utils/useSocket'; + +const validation = z.object({ + name: z + .string() + .trim() + .max(32, { message: 'Oda adı en fazla 32 karakter olabilir' }) + .optional(), + password: z + .string() + .trim() + .max(32, { + message: 'Oda şifresi en fazla 32 karakter olabilir', + }) + .optional(), + controlledByMods: z.boolean().optional(), + resend: z.boolean().optional(), +}); + +export default class UpdateRoomData { + async handle({ socket, callback, data }: { socket: Socket; callback: any; data: any }) { + const val = validation.safeParse(data); + + if ('error' in val) { + let err = val.error.issues[0].message; + + if (err == 'Required') err = 'Invalid body'; + return callback({ error: err }); + } + + const { name, password, controlledByMods, resend } = val.data; + + const hook = useSocket(socket); + + const isOwner = await hook?.isOwner(); + + if (!isOwner) { + return callback({ error: 'Unauthorized' }); + } + + if (typeof controlledByMods === 'boolean') { + hook.setRoomKey('controlledByMods', controlledByMods); + + hook.broadcastToEveryone('modControl', { + controlledByMods, + }); + } + + if (typeof name === 'string' && !name.length) { + const currentUser = await hook.getCurrentUser(); + if (currentUser) { + const newName = `${addTurkishPossessiveSuffix(currentUser.username)} odası`; + hook.setRoomKey('name', newName); + + // we use resend when the menu flyout is closing to not interfere with the reactivity of the room name input on frontend + if (resend) { + hook?.broadcastToEveryone('roomNameUpdated', { + name: newName, + }); + } else { + hook?.broadcastToEveryoneExceptAuthor('roomNameUpdated', { + name: newName, + }); + } + } + } else if (name) { + hook?.setRoomKey('name', name); + + hook?.broadcastToEveryoneExceptAuthor('roomNameUpdated', { + name, + }); + } + + if (typeof password === 'string') { + hook?.setRoomKey('password', !password.length ? null : password); + } + } +} diff --git a/src/utils/turkishPossessiveSuffix.ts b/src/utils/turkishPossessiveSuffix.ts new file mode 100644 index 0000000..22cda13 --- /dev/null +++ b/src/utils/turkishPossessiveSuffix.ts @@ -0,0 +1,66 @@ +const TURKISH_VOWELS = ['a', 'e', 'ı', 'i', 'o', 'ö', 'u', 'ü'] as const; + +type TurkishVowel = (typeof TURKISH_VOWELS)[number]; +type PossessiveSuffix = 'ın' | 'in' | 'un' | 'ün'; + +const POSSESSIVE_SUFFIX_BY_LAST_VOWEL: Record = { + a: 'ın', + ı: 'ın', + e: 'in', + i: 'in', + o: 'un', + u: 'un', + ö: 'ün', + ü: 'ün', +}; + +const TURKISH_DIGIT_PRONUNCIATIONS = { + '0': 'sıfır', + '1': 'bir', + '2': 'iki', + '3': 'üç', + '4': 'dört', + '5': 'beş', + '6': 'altı', + '7': 'yedi', + '8': 'sekiz', + '9': 'dokuz', +}; + +function isTurkishVowel(char: string) { + return TURKISH_VOWELS.includes(char as TurkishVowel); +} + +function getLastTurkishVowel(value: string) { + return [...value].reverse().find(isTurkishVowel); +} + +function getSuffixSource(value: string): string { + const lastChar = [...value].at(-1); + + if (lastChar && TURKISH_DIGIT_PRONUNCIATIONS[lastChar]) { + return TURKISH_DIGIT_PRONUNCIATIONS[lastChar]; + } + + return value; +} + +export function addTurkishPossessiveSuffix(name: string) { + const trimmedName = name.trim(); + + if (!trimmedName) { + return ''; + } + + const normalizedName = trimmedName.toLocaleLowerCase('tr-TR'); + const suffixSource = getSuffixSource(normalizedName); + + const lastVowel = getLastTurkishVowel(suffixSource); + const suffix = lastVowel ? POSSESSIVE_SUFFIX_BY_LAST_VOWEL[lastVowel] : 'ın'; + + const lastCharOfSuffixSource = [...suffixSource].at(-1); + const bufferLetter = + lastCharOfSuffixSource && isTurkishVowel(lastCharOfSuffixSource) ? 'n' : ''; + + return `${trimmedName}'${bufferLetter}${suffix}`; +} diff --git a/src/utils/useSocket.ts b/src/utils/useSocket.ts new file mode 100644 index 0000000..21027ab --- /dev/null +++ b/src/utils/useSocket.ts @@ -0,0 +1,127 @@ +import type { Socket } from 'socket.io'; + +import type { CoreParticipant, Participant } from '@events/login'; + +import { io } from '@index'; + +import { get, set } from './cache'; + +export default function useSocket(socket: Socket) { + const rooms = Array.from(socket.rooms); + const room = rooms[1]; + + if (!room) + return { + error: 'Not in a room', + }; + + const socketId = socket.id; + + const prefix = `${room}`; + + let cachedParticipants: Participant[] | null = null; + + // creating a new function helps here since we actually want to use the cached value once per request + async function getParticipants() { + if (cachedParticipants !== null) return cachedParticipants; + + const socketRoomParticipants = ((await get(`${prefix}:users`)) ?? []) as Participant[]; + + cachedParticipants = socketRoomParticipants; + + return socketRoomParticipants; + } + + return { + getRoomPtr: () => { + return room; + }, + isOwner: async () => { + const socketRoomParticipants = await getParticipants(); + + if (socketRoomParticipants) { + const owner = socketRoomParticipants.find((user) => user.owner); + if (owner?.sid == socketId) return true; + } + + return false; + }, + isModerator: async () => { + const socketRoomParticipants = await getParticipants(); + + if (socketRoomParticipants) { + const mod = socketRoomParticipants.find((user) => user.sid == socketId); + if (mod?.moderator) return true; + } + + return false; + }, + isPriviliged: async function () { + return (await this.isOwner()) || (await this.isModerator()); + }, + isMuted: async () => { + const mutedParticipants = ((await get(`${prefix}:mutedParticipants`)) ?? + []) as string[]; + const socketRoomParticipants = await getParticipants(); + + if (socketRoomParticipants) { + const currentUser = socketRoomParticipants.find((user) => user.sid == socketId); + if (currentUser && mutedParticipants.includes(currentUser.id)) return true; + } + + return false; + }, + getParticipants: async () => { + const socketRoomParticipants = await getParticipants(); + + return socketRoomParticipants; + }, + getCurrentUser: async (): Promise => { + const socketRoomParticipants = await getParticipants(); + + if (socketRoomParticipants) { + const currentUser = socketRoomParticipants.find((user) => user.sid == socketId); + if (currentUser) return currentUser; + else return null; + } + + return null; + }, + getUserFromId: async (id: string) => { + const socketRoomParticipants = await getParticipants(); + + if (socketRoomParticipants) { + const user = socketRoomParticipants.find((user) => user.id == id); + return user; + } + + return null; + }, + getMutedParticipants: async () => { + const mutedParticipants = ((await get(`${prefix}:mutedParticipants`)) ?? + []) as string[]; + return mutedParticipants; + }, + getBannedParticipants: async () => { + const bannedParticipants = ((await get(`${prefix}:bannedParticipants`)) ?? + []) as CoreParticipant[]; + return bannedParticipants; + }, + roomControlledByMods: async () => { + const modRequired = (await get(`${prefix}:controlledByMods`)) ?? false; + return modRequired; + }, + getRoomKey: async (key: string) => { + return get(`${prefix}:${key}`); + }, + broadcastToEveryoneExceptAuthor: (event: string, data: any) => { + socket.broadcast.to(room).emit(event, data); + }, + broadcastToEveryone: (event: string, data: any) => { + io.in(room).emit(event, data); + }, + setRoomKey: async (key: string, value: any) => { + await set(`${prefix}:${key}`, value); + }, + }; +}