Spaces:
Paused
Paused
| // @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<string, { label: string; class: string }> = { | |
| 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<string[]> { | |
| const channels = await channelRepository.findPublished({ limit: 10000 }); | |
| const tags = new Set<string>(); | |
| 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); | |
| } |