NathMen12's picture
Upload 55 files
cc27fd7 verified
Raw
History Blame Contribute Delete
13.9 kB
// @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);
}