// @ts-nocheck import type { FastifyInstance } from 'fastify'; import { channelRepository } from '@core/storage/repositories/channels.js'; import { scrapeQueueRepository } from '@core/storage/repositories/scrapeQueue.js'; import { userRepository } from '@core/storage/repositories/users.js'; import { logRepository } from '@core/storage/repositories/logs.js'; import { validateInput, channelAddSchema, channelTagsSchema, searchSchema } from '@core/security/validation.js'; import { requireAuth, checkBanned } from '@core/auth/middleware.js'; import { config } from '@config/index.js'; import { getClientIP, hashIP } from '@core/security/vpnDetect.js'; import { generateVerificationCode } from '@core/utils/id.js'; import { formatNumber, formatRelativeTime, isValidWhatsAppChannelUrl } from '@core/utils/helpers.js'; export async function channelRoutes(app: FastifyInstance) { // Add channel page app.get('/channels/add', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { return reply.view('pages/channels/add.njk', { csrfToken: request.csrfToken(), user: request.user, maxChannels: config.maxChannelsPerUser || 10, }); }); // Add channel handler app.post('/channels/add', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const ip = getClientIP(request); const validation = validateInput(channelAddSchema, request.body); if (!validation.success) { return reply.status(400).view('pages/channels/add.njk', { csrfToken: request.csrfToken(), user: request.user, errors: validation.errors.map(e => e.message), formData: request.body, }); } const { inviteLink } = validation.data; // Check user's channel count const user = await userRepository.findById(request.user!.id); if (!user) { return reply.status(404).send({ error: 'User not found' }); } const maxChannels = config.maxChannelsPerUser || 10; if (user.channelCount >= maxChannels) { return reply.status(400).view('pages/channels/add.njk', { csrfToken: request.csrfToken(), user: request.user, errors: [`Maximum ${maxChannels} channels allowed per user`], formData: request.body, }); } // Check if already in queue or published const existing = await channelRepository.findOne({ inviteLink, ownerId: request.user!.id }); if (existing) { return reply.status(400).view('pages/channels/add.njk', { csrfToken: request.csrfToken(), user: request.user, errors: ['This channel is already in your list'], formData: request.body, }); } // Generate verification code const verificationCode = generateVerificationCode(); // Create pending channel in scrape queue const queueItem = await scrapeQueueRepository.addToQueue({ type: 'new', channelUrl: inviteLink, userId: request.user!.id, }); // Update queue item with verification code await scrapeQueueRepository.setValidationCode(queueItem.id, verificationCode); // Increment user's channel count await userRepository.incrementChannelCount(request.user!.id); // Log await logRepository.log({ level: 'info', message: 'Channel submitted for scraping', context: { inviteLink, queueId: queueItem.id, verificationCode }, userId: request.user!.id, ip, userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.view('pages/channels/verify.njk', { csrfToken: request.csrfToken(), user: request.user, verificationCode, inviteLink, queueId: queueItem.id, }); }); // My channels dashboard app.get('/channels/my', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const page = parseInt(request.query.page as string) || 1; const limit = 10; const offset = (page - 1) * limit; const [channels, total] = await Promise.all([ channelRepository.findByOwner(request.user!.id, { limit, offset }), channelRepository.count({ ownerId: request.user!.id }), ]); return reply.view('pages/channels/my.njk', { csrfToken: request.csrfToken(), user: request.user, channels: channels.map(c => ({ ...c, formattedFollowers: formatNumber(c.followerCount), formattedVotes: formatNumber(c.voteCount), relativeTime: formatRelativeTime(c.createdAt), statusLabel: getStatusLabel(c.status), })), pagination: { page, totalPages: Math.ceil(total / limit), total, hasNext: page * limit < total, hasPrev: page > 1, }, }); }); // Channel details (owner view) app.get('/channels/my/:id', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const channel = await channelRepository.findById(request.params.id); if (!channel || channel.ownerId !== request.user!.id) { return reply.status(404).view('pages/404.njk', { user: request.user }); } return reply.view('pages/channels/detail-owner.njk', { csrfToken: request.csrfToken(), user: request.user, channel: { ...channel, formattedFollowers: formatNumber(channel.followerCount), formattedVotes: formatNumber(channel.voteCount), relativeTime: formatRelativeTime(channel.createdAt), statusLabel: getStatusLabel(channel.status), }, }); }); // Update channel tags app.post('/channels/my/:id/tags', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const channel = await channelRepository.findById(request.params.id); if (!channel || channel.ownerId !== request.user!.id) { return reply.status(404).send({ error: 'Channel not found' }); } const validation = validateInput(channelTagsSchema, request.body); if (!validation.success) { return reply.status(400).send({ errors: validation.errors.map(e => e.message) }); } const updated = await channelRepository.addTags(channel.id, validation.data.tags); return reply.send({ success: true, tags: updated?.tags }); }); // Remove channel tag app.delete('/channels/my/:id/tags/:tag', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const channel = await channelRepository.findById(request.params.id); if (!channel || channel.ownerId !== request.user!.id) { return reply.status(404).send({ error: 'Channel not found' }); } await channelRepository.removeTag(channel.id, request.params.tag); return reply.send({ success: true }); }); // Delete channel app.delete('/channels/my/:id', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const channel = await channelRepository.findById(request.params.id); if (!channel || channel.ownerId !== request.user!.id) { return reply.status(404).send({ error: 'Channel not found' }); } await channelRepository.delete(channel.id); await userRepository.decrementChannelCount(request.user!.id); await logRepository.log({ level: 'info', message: 'Channel deleted by owner', context: { channelId: channel.id, channelName: channel.name }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); // Public channel listing app.get('/channels', async (request, reply) => { const validation = validateInput(searchSchema, request.query); const params = validation.success ? validation.data : { q: '', sort: 'newest', page: 1, limit: 20 }; const tag = request.query.tag as string | undefined; const search = params.q; const sort = params.sort; const page = params.page; const limit = params.limit; const offset = (page - 1) * limit; // Get sort field and order const { field, order } = getSortField(sort); const [channels, total, allTags] = await Promise.all([ channelRepository.findPublished({ tag, search, sortBy: field, sortOrder: order, limit, offset, }), channelRepository.countPublished({ ...(tag && { tags: tag }), ...(search && { $or: [] }) }), getAllTags(), ]); return reply.view('pages/channels/list.njk', { csrfToken: request.csrfToken(), user: request.user, channels: channels.map(c => ({ ...c, formattedFollowers: formatNumber(c.followerCount), formattedVotes: formatNumber(c.voteCount), relativeTime: formatRelativeTime(c.createdAt), })), tags: allTags, currentTag: tag, search: search, sort: sort, pagination: { page, totalPages: Math.ceil(total / limit), total, hasNext: page * limit < total, hasPrev: page > 1, }, sortOptions: [ { value: 'trending', label: 'Trending' }, { value: 'followers', label: 'Most Followers' }, { value: 'votes', label: 'Most Votes' }, { value: 'newest', label: 'Newest' }, { value: 'oldest', label: 'Oldest' }, { value: 'alphabetical', label: 'Alphabetical' }, { value: 'updated', label: 'Recently Updated' }, ], }); }); // Public channel detail app.get('/channels/:id', async (request, reply) => { const channel = await channelRepository.findById(request.params.id); if (!channel || channel.status !== 'published' || channel.isBanned) { return reply.status(404).view('pages/404.njk', { user: request.user }); } // Increment view count await channelRepository.incrementViewCount(channel.id); return reply.view('pages/channels/detail.njk', { csrfToken: request.csrfToken(), user: request.user, channel: { ...channel, formattedFollowers: formatNumber(channel.followerCount), formattedVotes: formatNumber(channel.voteCount), relativeTime: formatRelativeTime(channel.createdAt), }, ipHash: hashIP(getClientIP(request)), }); }); // Vote on channel app.post('/channels/:id/vote', async (request, reply) => { const ip = getClientIP(request); const ipHash = hashIP(ip); const channelId = request.params.id; const channel = await channelRepository.findById(channelId); if (!channel || channel.status !== 'published' || channel.isBanned) { return reply.status(404).send({ error: 'Channel not found' }); } // Check VPN/Proxy const vpnCheck = await checkVPN(ip); if (vpnCheck.isVPN || vpnCheck.isProxy || vpnCheck.isDatacenter) { await logRepository.log({ level: 'warn', message: 'Vote blocked - VPN/Proxy detected', context: { channelId, ip, ...vpnCheck }, ip, userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.status(403).send({ error: 'Votes from VPN, Proxy, or Datacenter IPs are not allowed', details: vpnCheck.details, }); } // Check vote cooldown (6 hours) const { voteRepository } = await import('@core/storage/repositories/votes.js'); const recentVotes = await voteRepository.getRecentVotesByIp(ipHash, 6); if (recentVotes.length > 0) { return reply.status(429).send({ error: 'You can only vote once every 6 hours', retryAfter: '6 hours', }); } // Check if already voted const hasVoted = await voteRepository.hasVoted(channelId, ipHash); if (hasVoted) { return reply.status(400).send({ error: 'Already voted' }); } // Record vote await voteRepository.recordVote({ channelId, ipHash, userId: request.user?.id }); await channelRepository.incrementVoteCount(channelId); await logRepository.log({ level: 'info', message: 'Vote recorded', context: { channelId }, userId: request.user?.id, ip, userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true, voteCount: channel.voteCount + 1 }); }); } function getStatusLabel(status: string): { label: string; class: string } { const labels: Record = { pending: { label: 'Pending', class: 'badge-warning' }, scraping: { label: 'Scraping', class: 'badge-info' }, validating: { label: 'Verifying', class: 'badge-info' }, published: { label: 'Published', class: 'badge-success' }, rejected: { label: 'Rejected', class: 'badge-danger' }, banned: { label: 'Banned', class: 'badge-dark' }, }; return labels[status] || { label: status, class: 'badge-secondary' }; } function getSortField(sort: string): { field: string; order: 'asc' | 'desc' } { switch (sort) { case 'trending': return { field: 'voteCount', order: 'desc' }; case 'followers': return { field: 'followerCount', order: 'desc' }; case 'votes': return { field: 'voteCount', order: 'desc' }; case 'newest': return { field: 'createdAt', order: 'desc' }; case 'oldest': return { field: 'createdAt', order: 'asc' }; case 'alphabetical': return { field: 'name', order: 'asc' }; case 'updated': return { field: 'lastScrapedAt', order: 'desc' }; default: return { field: 'createdAt', order: 'desc' }; } } async function getAllTags(): Promise { const channels = await channelRepository.findPublished({ limit: 10000 }); const tags = new Set(); channels.forEach(c => c.tags.forEach(t => tags.add(t))); return Array.from(tags).sort(); } async function checkVPN(ip: string) { const { checkVPN } = await import('@core/security/vpnDetect.js'); return checkVPN(ip); }