Back to blog

Xây Dựng URL Shortener: URL Moderation & Abuse Detection

typescriptnodejsadminsecuritybackend
Xây Dựng URL Shortener: URL Moderation & Abuse Detection

Một URL shortener không có moderation chẳng khác gì vũ khí. Không phóng đại đâu — các chiến dịch phishing, phát tán malware, và spam rings đều dựa vào URL shortener để che giấu destination độc hại phía sau những short link trông vô hại. User click vào short.ly/invoice tưởng là file PDF, ai ngờ lại vào trang đánh cắp credentials.

Với admin roles và RBAC đã có từ bài trước, giờ ta có hạ tầng để xây dựng moderation tools đàng hoàng. Trong bài này, ta sẽ tạo một hệ thống URL moderation hoàn chỉnh: status workflows, bulk operations, domain blocklists, automated abuse detection, và community reporting. Mục tiêu là chặn những link xấu trước khi chúng gây thiệt hại — và cung cấp cho admin công cụ hiệu quả để xử lý những cái lọt lưới.

Đây không phải tính năng "có cũng được". Nếu bạn chạy một public URL shortener, moderation chính là ranh giới giữa một công cụ hữu ích và một liability.

Thời gian: 2-3 giờ
Yêu cầu: Phase 10: Admin RBAC & User Management

Những gì ta sẽ xây dựng:
✅ URL moderation system với status workflow (active → flagged → disabled)
✅ Admin URL listing với filters (status, user, date range, search)
✅ Bulk operations API (bulk disable, delete, export)
✅ Domain blocklist management (block các domain độc hại)
✅ Automated abuse detection (pattern matching, rate-based flagging)
✅ URL report/flag system cho community reporting


Kiến Trúc URL Moderation

Trước khi viết code, hãy vạch ra URL lifecycle và moderation workflow. Mỗi URL trong hệ thống sẽ có một status quyết định nó có được redirect hay không:

Ba status phục vụ các mục đích khác nhau:

StatusRedirect BehaviorAi Có Thể Chuyển ĐổiUse Case
ACTIVERedirect bình thườngSystem (khi tạo)Trạng thái mặc định cho mọi URL
FLAGGEDHiện warning pageAdmin, system, reportsĐang review — nghi ngờ nhưng chưa xác nhận
DISABLEDBlock hoàn toànChỉ adminĐã xác nhận là malicious hoặc vi phạm policy

Đây là full moderation workflow từ detection đến resolution:


Cập Nhật Prisma Schema

Ta cần thêm moderation fields vào URL model và tạo model mới cho blocklist và reports. Thêm vào file prisma/schema.prisma:

// prisma/schema.prisma
 
enum UrlStatus {
  ACTIVE
  FLAGGED
  DISABLED
}
 
model Url {
  id            String    @id @default(uuid())
  originalUrl   String    @map("original_url")
  shortCode     String    @unique @map("short_code")
  customAlias   String?   @unique @map("custom_alias")
  userId        String?   @map("user_id")
  user          User?     @relation(fields: [userId], references: [id])
  clicks        Int       @default(0)
  expiresAt     DateTime? @map("expires_at")
  createdAt     DateTime  @default(now()) @map("created_at")
  updatedAt     DateTime  @updatedAt @map("updated_at")
 
  // Moderation fields
  status        UrlStatus @default(ACTIVE)
  flaggedAt     DateTime? @map("flagged_at")
  flaggedBy     String?   @map("flagged_by")
  flagReason    String?   @map("flag_reason")
  disabledAt    DateTime? @map("disabled_at")
  disabledBy    String?   @map("disabled_by")
  reviewedAt    DateTime? @map("reviewed_at")
  reviewedBy    String?   @map("reviewed_by")
 
  // Relations
  clickEvents   ClickEvent[]
  reports       UrlReport[]
 
  @@map("urls")
}
 
model BlockedDomain {
  id        String   @id @default(uuid())
  domain    String   @unique
  reason    String?
  addedBy   String   @map("added_by")
  addedByUser User   @relation(fields: [addedBy], references: [id])
  createdAt DateTime @default(now()) @map("created_at")
 
  @@map("blocked_domains")
}
 
model UrlReport {
  id         String   @id @default(uuid())
  urlId      String   @map("url_id")
  url        Url      @relation(fields: [urlId], references: [id], onDelete: Cascade)
  reporterIp String   @map("reporter_ip")
  reason     String
  details    String?
  status     String   @default("pending") // pending, reviewed, dismissed
  reviewedBy String?  @map("reviewed_by")
  reviewedAt DateTime? @map("reviewed_at")
  createdAt  DateTime @default(now()) @map("created_at")
 
  @@index([urlId])
  @@index([status])
  @@map("url_reports")
}

Chạy migration:

npx prisma migrate dev --name add-url-moderation

Schema này thêm:

  • UrlStatus enum với ba trạng thái cho mỗi URL
  • Moderation tracking fields trên Url — ai flag, khi nào, tại sao
  • BlockedDomain table để quản lý domain blocklist
  • UrlReport table cho community reports với review tracking

Moderation Validation Schemas

Trước khi xây endpoint, define các validation schema với Zod:

// src/validators/moderation.validator.ts
import { z } from 'zod';
 
export const updateUrlStatusSchema = z.object({
  status: z.enum(['ACTIVE', 'FLAGGED', 'DISABLED']),
  reason: z.string().max(500).optional(),
});
 
export const bulkActionSchema = z.object({
  action: z.enum(['disable', 'flag', 'delete', 'activate']),
  urlIds: z.array(z.string().uuid()).min(1).max(100),
  reason: z.string().max(500).optional(),
});
 
export const adminUrlQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  status: z.enum(['ACTIVE', 'FLAGGED', 'DISABLED']).optional(),
  userId: z.string().uuid().optional(),
  search: z.string().max(200).optional(),
  dateFrom: z.string().datetime().optional(),
  dateTo: z.string().datetime().optional(),
  sortBy: z.enum(['createdAt', 'clicks', 'flaggedAt']).default('createdAt'),
  sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
 
export const blockedDomainSchema = z.object({
  domain: z
    .string()
    .min(3)
    .max(255)
    .transform((d) => d.toLowerCase().replace(/^www\./, '')),
  reason: z.string().max(500).optional(),
});
 
export const urlReportSchema = z.object({
  reason: z.enum([
    'phishing',
    'malware',
    'spam',
    'inappropriate',
    'copyright',
    'other',
  ]),
  details: z.string().max(1000).optional(),
});
 
export type UpdateUrlStatusInput = z.infer<typeof updateUrlStatusSchema>;
export type BulkActionInput = z.infer<typeof bulkActionSchema>;
export type AdminUrlQuery = z.infer<typeof adminUrlQuerySchema>;
export type BlockedDomainInput = z.infer<typeof blockedDomainSchema>;
export type UrlReportInput = z.infer<typeof urlReportSchema>;

URL Moderation Service

Moderation service đóng gói toàn bộ business logic cho việc quản lý URL status, giữ controller gọn nhẹ:

// src/services/moderation.service.ts
import { prisma } from '../lib/prisma';
import { redisClient } from '../lib/redis';
import { AppError } from '../utils/errors';
import type {
  UpdateUrlStatusInput,
  BulkActionInput,
  AdminUrlQuery,
} from '../validators/moderation.validator';
 
export class ModerationService {
  /**
   * Lấy URLs với admin filters, pagination, và sorting
   */
  async getUrls(query: AdminUrlQuery) {
    const {
      page,
      limit,
      status,
      userId,
      search,
      dateFrom,
      dateTo,
      sortBy,
      sortOrder,
    } = query;
 
    const where: Record<string, unknown> = {};
 
    if (status) {
      where.status = status;
    }
 
    if (userId) {
      where.userId = userId;
    }
 
    if (search) {
      where.OR = [
        { originalUrl: { contains: search, mode: 'insensitive' } },
        { shortCode: { contains: search, mode: 'insensitive' } },
        { customAlias: { contains: search, mode: 'insensitive' } },
      ];
    }
 
    if (dateFrom || dateTo) {
      where.createdAt = {};
      if (dateFrom) {
        (where.createdAt as Record<string, unknown>).gte = new Date(dateFrom);
      }
      if (dateTo) {
        (where.createdAt as Record<string, unknown>).lte = new Date(dateTo);
      }
    }
 
    const [urls, total] = await Promise.all([
      prisma.url.findMany({
        where,
        include: {
          user: { select: { id: true, email: true, name: true } },
          _count: { select: { reports: true } },
        },
        orderBy: { [sortBy]: sortOrder },
        skip: (page - 1) * limit,
        take: limit,
      }),
      prisma.url.count({ where }),
    ]);
 
    return {
      urls,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }
 
  /**
   * Lấy chi tiết URL bao gồm reports và click stats
   */
  async getUrlDetails(urlId: string) {
    const url = await prisma.url.findUnique({
      where: { id: urlId },
      include: {
        user: { select: { id: true, email: true, name: true } },
        reports: {
          orderBy: { createdAt: 'desc' },
          take: 20,
        },
        _count: {
          select: { reports: true, clickEvents: true },
        },
      },
    });
 
    if (!url) {
      throw new AppError('URL not found', 404);
    }
 
    // Lấy click stats gần đây
    const recentClicks = await prisma.clickEvent.groupBy({
      by: ['createdAt'],
      where: {
        urlId,
        createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
      },
      _count: true,
    });
 
    return { url, recentClicks };
  }
 
  /**
   * Update URL status kèm audit trail
   */
  async updateUrlStatus(
    urlId: string,
    input: UpdateUrlStatusInput,
    adminId: string
  ) {
    const url = await prisma.url.findUnique({ where: { id: urlId } });
 
    if (!url) {
      throw new AppError('URL not found', 404);
    }
 
    const updateData: Record<string, unknown> = {
      status: input.status,
      reviewedAt: new Date(),
      reviewedBy: adminId,
    };
 
    // Set status-specific fields
    if (input.status === 'FLAGGED') {
      updateData.flaggedAt = new Date();
      updateData.flaggedBy = adminId;
      updateData.flagReason = input.reason || 'Flagged by admin';
    } else if (input.status === 'DISABLED') {
      updateData.disabledAt = new Date();
      updateData.disabledBy = adminId;
    } else if (input.status === 'ACTIVE') {
      // Xóa flags khi reactivate
      updateData.flaggedAt = null;
      updateData.flaggedBy = null;
      updateData.flagReason = null;
      updateData.disabledAt = null;
      updateData.disabledBy = null;
    }
 
    const updated = await prisma.url.update({
      where: { id: urlId },
      data: updateData,
    });
 
    // Invalidate Redis cache để redirect engine nhận status mới
    await this.invalidateUrlCache(url.shortCode, url.customAlias);
 
    return updated;
  }
 
  /**
   * Thực thi bulk operations trên nhiều URLs trong một transaction
   */
  async bulkAction(input: BulkActionInput, adminId: string) {
    const { action, urlIds, reason } = input;
 
    // Verify tất cả URLs tồn tại trước khi xử lý
    const existingUrls = await prisma.url.findMany({
      where: { id: { in: urlIds } },
      select: { id: true, shortCode: true, customAlias: true },
    });
 
    if (existingUrls.length !== urlIds.length) {
      const foundIds = new Set(existingUrls.map((u) => u.id));
      const missing = urlIds.filter((id) => !foundIds.has(id));
      throw new AppError(
        `URLs not found: ${missing.join(', ')}`,
        404
      );
    }
 
    const result = await prisma.$transaction(async (tx) => {
      switch (action) {
        case 'disable':
          return tx.url.updateMany({
            where: { id: { in: urlIds } },
            data: {
              status: 'DISABLED',
              disabledAt: new Date(),
              disabledBy: adminId,
            },
          });
 
        case 'flag':
          return tx.url.updateMany({
            where: { id: { in: urlIds } },
            data: {
              status: 'FLAGGED',
              flaggedAt: new Date(),
              flaggedBy: adminId,
              flagReason: reason || 'Bulk flagged by admin',
            },
          });
 
        case 'activate':
          return tx.url.updateMany({
            where: { id: { in: urlIds } },
            data: {
              status: 'ACTIVE',
              flaggedAt: null,
              flaggedBy: null,
              flagReason: null,
              disabledAt: null,
              disabledBy: null,
              reviewedAt: new Date(),
              reviewedBy: adminId,
            },
          });
 
        case 'delete':
          return tx.url.deleteMany({
            where: { id: { in: urlIds } },
          });
 
        default:
          throw new AppError(`Unknown action: ${action}`, 400);
      }
    });
 
    // Invalidate cache cho tất cả affected URLs
    await Promise.all(
      existingUrls.map((url) =>
        this.invalidateUrlCache(url.shortCode, url.customAlias)
      )
    );
 
    return { action, affected: result.count };
  }
 
  /**
   * Lấy tất cả flagged URLs để admin review nhanh
   */
  async getFlaggedUrls(page = 1, limit = 20) {
    const [urls, total] = await Promise.all([
      prisma.url.findMany({
        where: { status: 'FLAGGED' },
        include: {
          user: { select: { id: true, email: true, name: true } },
          _count: { select: { reports: true } },
        },
        orderBy: { flaggedAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      prisma.url.count({ where: { status: 'FLAGGED' } }),
    ]);
 
    return {
      urls,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }
 
  /**
   * Invalidate Redis cache khi URL status thay đổi
   */
  private async invalidateUrlCache(
    shortCode: string,
    customAlias: string | null
  ) {
    const keysToDelete = [`url:${shortCode}`];
    if (customAlias) {
      keysToDelete.push(`url:${customAlias}`);
    }
 
    try {
      await redisClient.del(...keysToDelete);
    } catch (error) {
      // Log nhưng không throw — cache invalidation failure không nên
      // block moderation action
      console.error('Failed to invalidate cache:', error);
    }
  }
}
 
export const moderationService = new ModerationService();

Moderation Controller & Routes

Với service layer sẵn sàng, controller chỉ lo validation và delegation:

// src/controllers/moderation.controller.ts
import { Request, Response, NextFunction } from 'express';
import { moderationService } from '../services/moderation.service';
import {
  updateUrlStatusSchema,
  bulkActionSchema,
  adminUrlQuerySchema,
} from '../validators/moderation.validator';
 
export class ModerationController {
  async getUrls(req: Request, res: Response, next: NextFunction) {
    try {
      const query = adminUrlQuerySchema.parse(req.query);
      const result = await moderationService.getUrls(query);
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
 
  async getUrlDetails(req: Request, res: Response, next: NextFunction) {
    try {
      const { id } = req.params;
      const result = await moderationService.getUrlDetails(id);
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
 
  async updateUrlStatus(req: Request, res: Response, next: NextFunction) {
    try {
      const { id } = req.params;
      const input = updateUrlStatusSchema.parse(req.body);
      const result = await moderationService.updateUrlStatus(
        id,
        input,
        req.user!.id
      );
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
 
  async bulkAction(req: Request, res: Response, next: NextFunction) {
    try {
      const input = bulkActionSchema.parse(req.body);
      const result = await moderationService.bulkAction(input, req.user!.id);
      res.json({ success: true, ...result });
    } catch (error) {
      next(error);
    }
  }
 
  async getFlaggedUrls(req: Request, res: Response, next: NextFunction) {
    try {
      const page = parseInt(req.query.page as string) || 1;
      const limit = parseInt(req.query.limit as string) || 20;
      const result = await moderationService.getFlaggedUrls(page, limit);
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
}
 
export const moderationController = new ModerationController();

Giờ nối routes:

// src/routes/admin/moderation.routes.ts
import { Router } from 'express';
import { moderationController } from '../../controllers/moderation.controller';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
 
const router = Router();
 
// Tất cả moderation routes yêu cầu role admin hoặc moderator
router.use(authenticate, requireRole('ADMIN', 'MODERATOR'));
 
// URL management
router.get('/urls', moderationController.getUrls);
router.get('/urls/flagged', moderationController.getFlaggedUrls);
router.get('/urls/:id', moderationController.getUrlDetails);
router.patch('/urls/:id/status', moderationController.updateUrlStatus);
 
// Bulk operations — chỉ admin
router.post(
  '/urls/bulk',
  requireRole('ADMIN'),
  moderationController.bulkAction
);
 
export default router;

Register vào admin router chính:

// src/routes/admin/index.ts
import { Router } from 'express';
import moderationRoutes from './moderation.routes';
import blocklistRoutes from './blocklist.routes';
 
const router = Router();
 
router.use('/admin', moderationRoutes);
router.use('/admin/blocklist', blocklistRoutes);
 
export default router;

Domain Blocklist

Domain blocklist ngăn URLs từ các domain độc hại được rút gọn. Đây là tuyến phòng thủ đầu tiên — block nguồn trước khi short link kịp tồn tại.

Blocklist Service

// src/services/blocklist.service.ts
import { prisma } from '../lib/prisma';
import { redisClient } from '../lib/redis';
import { AppError } from '../utils/errors';
 
const BLOCKLIST_CACHE_KEY = 'blocklist:domains';
const BLOCKLIST_CACHE_TTL = 300; // 5 phút
 
export class BlocklistService {
  /**
   * Kiểm tra domain của URL có bị block không
   * Dùng Redis cache cho performance vì check này chạy mỗi lần tạo URL
   */
  async isBlocked(url: string): Promise<boolean> {
    let hostname: string;
    try {
      hostname = new URL(url).hostname.replace(/^www\./, '');
    } catch {
      throw new AppError('Invalid URL', 400);
    }
 
    // Kiểm cache trước
    const cached = await redisClient.sismember(BLOCKLIST_CACHE_KEY, hostname);
    if (cached) return true;
 
    // Kiểm parent domains (vd: evil.example.com phải match example.com)
    const parts = hostname.split('.');
    const domainsToCheck = [hostname];
    for (let i = 1; i < parts.length - 1; i++) {
      domainsToCheck.push(parts.slice(i).join('.'));
    }
 
    const blocked = await prisma.blockedDomain.findFirst({
      where: { domain: { in: domainsToCheck } },
    });
 
    if (blocked) {
      // Cache kết quả cho các lần lookup sau
      await redisClient.sadd(BLOCKLIST_CACHE_KEY, hostname);
      return true;
    }
 
    return false;
  }
 
  /**
   * Lấy tất cả blocked domains với pagination
   */
  async getBlockedDomains(page = 1, limit = 50) {
    const [domains, total] = await Promise.all([
      prisma.blockedDomain.findMany({
        include: {
          addedByUser: { select: { id: true, email: true, name: true } },
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      prisma.blockedDomain.count(),
    ]);
 
    return {
      domains,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }
 
  /**
   * Thêm domain vào blocklist
   */
  async addDomain(domain: string, reason: string | undefined, adminId: string) {
    const normalized = domain.toLowerCase().replace(/^www\./, '');
 
    // Kiểm tra đã bị block chưa
    const existing = await prisma.blockedDomain.findUnique({
      where: { domain: normalized },
    });
 
    if (existing) {
      throw new AppError(`Domain '${normalized}' is already blocked`, 409);
    }
 
    const blocked = await prisma.blockedDomain.create({
      data: {
        domain: normalized,
        reason,
        addedBy: adminId,
      },
    });
 
    // Update cache
    await this.refreshCache();
 
    // Auto-flag các URLs hiện tại từ domain này
    await this.flagExistingUrls(normalized, adminId);
 
    return blocked;
  }
 
  /**
   * Xóa domain khỏi blocklist
   */
  async removeDomain(id: string) {
    const domain = await prisma.blockedDomain.findUnique({
      where: { id },
    });
 
    if (!domain) {
      throw new AppError('Blocked domain not found', 404);
    }
 
    await prisma.blockedDomain.delete({ where: { id } });
 
    // Refresh cache sau khi xóa
    await this.refreshCache();
 
    return domain;
  }
 
  /**
   * Khi domain bị block, auto-flag tất cả URLs hiện tại từ domain đó
   */
  private async flagExistingUrls(domain: string, adminId: string) {
    const result = await prisma.url.updateMany({
      where: {
        originalUrl: { contains: domain },
        status: 'ACTIVE',
      },
      data: {
        status: 'FLAGGED',
        flaggedAt: new Date(),
        flaggedBy: 'system',
        flagReason: `Domain '${domain}' added to blocklist by admin ${adminId}`,
      },
    });
 
    if (result.count > 0) {
      console.log(
        `Auto-flagged ${result.count} URLs from blocked domain: ${domain}`
      );
    }
  }
 
  /**
   * Rebuild Redis blocklist cache từ database
   */
  private async refreshCache() {
    const domains = await prisma.blockedDomain.findMany({
      select: { domain: true },
    });
 
    const pipeline = redisClient.pipeline();
    pipeline.del(BLOCKLIST_CACHE_KEY);
 
    if (domains.length > 0) {
      pipeline.sadd(
        BLOCKLIST_CACHE_KEY,
        ...domains.map((d) => d.domain)
      );
      pipeline.expire(BLOCKLIST_CACHE_KEY, BLOCKLIST_CACHE_TTL);
    }
 
    await pipeline.exec();
  }
}
 
export const blocklistService = new BlocklistService();

Blocklist Controller & Routes

// src/controllers/blocklist.controller.ts
import { Request, Response, NextFunction } from 'express';
import { blocklistService } from '../services/blocklist.service';
import { blockedDomainSchema } from '../validators/moderation.validator';
 
export class BlocklistController {
  async getDomains(req: Request, res: Response, next: NextFunction) {
    try {
      const page = parseInt(req.query.page as string) || 1;
      const limit = parseInt(req.query.limit as string) || 50;
      const result = await blocklistService.getBlockedDomains(page, limit);
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
 
  async addDomain(req: Request, res: Response, next: NextFunction) {
    try {
      const { domain, reason } = blockedDomainSchema.parse(req.body);
      const result = await blocklistService.addDomain(
        domain,
        reason,
        req.user!.id
      );
      res.status(201).json(result);
    } catch (error) {
      next(error);
    }
  }
 
  async removeDomain(req: Request, res: Response, next: NextFunction) {
    try {
      const { id } = req.params;
      const result = await blocklistService.removeDomain(id);
      res.json({ success: true, removed: result });
    } catch (error) {
      next(error);
    }
  }
}
 
export const blocklistController = new BlocklistController();
// src/routes/admin/blocklist.routes.ts
import { Router } from 'express';
import { blocklistController } from '../../controllers/blocklist.controller';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
 
const router = Router();
 
router.use(authenticate, requireRole('ADMIN'));
 
router.get('/', blocklistController.getDomains);
router.post('/', blocklistController.addDomain);
router.delete('/:id', blocklistController.removeDomain);
 
export default router;

Tích Hợp Blocklist Vào URL Creation

Giờ update flow tạo URL để check blocklist trước khi tạo short link:

// src/middleware/blocklist.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { blocklistService } from '../services/blocklist.service';
import { AppError } from '../utils/errors';
 
/**
 * Middleware kiểm tra URL đến với domain blocklist.
 * Gắn vào URL creation route TRƯỚC controller.
 */
export async function checkBlocklist(
  req: Request,
  _res: Response,
  next: NextFunction
) {
  try {
    const { url, originalUrl } = req.body;
    const targetUrl = url || originalUrl;
 
    if (!targetUrl) {
      return next();
    }
 
    const isBlocked = await blocklistService.isBlocked(targetUrl);
 
    if (isBlocked) {
      throw new AppError(
        'This URL cannot be shortened — the domain has been blocked',
        403
      );
    }
 
    next();
  } catch (error) {
    next(error);
  }
}

Thêm middleware này vào URL creation route:

// src/routes/url.routes.ts
import { Router } from 'express';
import { urlController } from '../controllers/url.controller';
import { checkBlocklist } from '../middleware/blocklist.middleware';
import { optionalAuth } from '../middleware/auth.middleware';
 
const router = Router();
 
// Blocklist check chạy trước URL creation
router.post('/', optionalAuth, checkBlocklist, urlController.createUrl);
 
export default router;

Automated Abuse Detection

Moderation thủ công không scale được. Bạn cần automated detection để flag URL đáng ngờ trước khi admin kịp nhìn thấy. Đây là abuse detection service toàn diện:

// src/services/abuse-detection.service.ts
import { prisma } from '../lib/prisma';
import { redisClient } from '../lib/redis';
 
// Các pattern phishing phổ biến — mở rộng danh sách này dựa trên threat thực tế
const SUSPICIOUS_PATTERNS = [
  /login.*\.html$/i,
  /signin.*\.(php|html)$/i,
  /verify.*account/i,
  /secure.*update/i,
  /paypal.*confirm/i,
  /apple.*id.*verify/i,
  /microsoft.*reset/i,
  /bank.*confirm/i,
  /\.tk\//i,              // Free TLD phổ biến dùng trong phishing
  /\.ml\//i,              // Free TLD phổ biến dùng trong phishing
  /bit\.ly\/[a-z0-9]+$/i, // Short URL trỏ tới shortener khác
  /tinyurl\.com/i,        // Redirect chain
  /goo\.gl/i,             // Redirect chain
];
 
// Rate limits cho abuse detection
const RATE_LIMIT_WINDOW = 3600; // 1 giờ tính bằng giây
const RATE_LIMIT_MAX_URLS = 50; // Max URLs mỗi user mỗi giờ
const REPORT_AUTO_FLAG_THRESHOLD = 3; // Auto-flag sau N reports
 
interface AbuseCheckResult {
  flagged: boolean;
  reasons: string[];
}
 
export class AbuseDetectionService {
  /**
   * Chạy tất cả abuse checks trên một URL trước hoặc sau khi tạo
   */
  async checkUrl(
    originalUrl: string,
    userId?: string
  ): Promise<AbuseCheckResult> {
    const reasons: string[] = [];
 
    // Check 1: Suspicious URL patterns
    const patternMatch = this.checkSuspiciousPatterns(originalUrl);
    if (patternMatch) {
      reasons.push(`Suspicious pattern: ${patternMatch}`);
    }
 
    // Check 2: Redirect chain detection (short URL → short URL)
    if (this.isRedirectChain(originalUrl)) {
      reasons.push('Redirect chain detected: URL points to another URL shortener');
    }
 
    // Check 3: Rate-based detection (nếu đã authenticate)
    if (userId) {
      const rateAbuse = await this.checkRateAbuse(userId);
      if (rateAbuse) {
        reasons.push(
          `Rate abuse: user created ${rateAbuse} URLs in the last hour (limit: ${RATE_LIMIT_MAX_URLS})`
        );
      }
    }
 
    return {
      flagged: reasons.length > 0,
      reasons,
    };
  }
 
  /**
   * Kiểm URL với các pattern phishing/malicious phổ biến
   */
  private checkSuspiciousPatterns(url: string): string | null {
    for (const pattern of SUSPICIOUS_PATTERNS) {
      if (pattern.test(url)) {
        return pattern.toString();
      }
    }
    return null;
  }
 
  /**
   * Phát hiện redirect chain — short URL trỏ tới short URL service khác
   * Đây là kỹ thuật phổ biến để che giấu destination malicious cuối cùng
   */
  private isRedirectChain(url: string): boolean {
    const shortenerDomains = [
      'bit.ly',
      'tinyurl.com',
      'goo.gl',
      't.co',
      'ow.ly',
      'is.gd',
      'buff.ly',
      'rebrand.ly',
      'cutt.ly',
    ];
 
    try {
      const hostname = new URL(url).hostname.replace(/^www\./, '');
      return shortenerDomains.includes(hostname);
    } catch {
      return false;
    }
  }
 
  /**
   * Kiểm xem user có đang tạo URL ở tốc độ đáng ngờ không
   */
  private async checkRateAbuse(userId: string): Promise<number | null> {
    const key = `abuse:rate:${userId}`;
    const count = await redisClient.incr(key);
 
    // Set expiry ở lần increment đầu tiên
    if (count === 1) {
      await redisClient.expire(key, RATE_LIMIT_WINDOW);
    }
 
    return count > RATE_LIMIT_MAX_URLS ? count : null;
  }
 
  /**
   * Auto-flag URL nếu nhận đủ community reports
   */
  async checkReportThreshold(urlId: string): Promise<boolean> {
    const pendingReports = await prisma.urlReport.count({
      where: {
        urlId,
        status: 'pending',
      },
    });
 
    if (pendingReports >= REPORT_AUTO_FLAG_THRESHOLD) {
      const url = await prisma.url.findUnique({ where: { id: urlId } });
 
      if (url && url.status === 'ACTIVE') {
        await prisma.url.update({
          where: { id: urlId },
          data: {
            status: 'FLAGGED',
            flaggedAt: new Date(),
            flaggedBy: 'system',
            flagReason: `Auto-flagged: received ${pendingReports} community reports`,
          },
        });
 
        // Invalidate cache
        const keysToDelete = [`url:${url.shortCode}`];
        if (url.customAlias) {
          keysToDelete.push(`url:${url.customAlias}`);
        }
        await redisClient.del(...keysToDelete);
 
        console.log(
          `Auto-flagged URL ${urlId}: ${pendingReports} reports exceeded threshold`
        );
        return true;
      }
    }
 
    return false;
  }
 
  /**
   * Chạy periodic scan trên các URL mới tạo gần đây
   * Gọi từ scheduled job (vd: mỗi 5 phút)
   */
  async scanRecentUrls() {
    const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
 
    const recentUrls = await prisma.url.findMany({
      where: {
        createdAt: { gte: fiveMinutesAgo },
        status: 'ACTIVE',
      },
      select: {
        id: true,
        originalUrl: true,
        userId: true,
        shortCode: true,
        customAlias: true,
      },
    });
 
    let flaggedCount = 0;
 
    for (const url of recentUrls) {
      const result = await this.checkUrl(url.originalUrl, url.userId ?? undefined);
 
      if (result.flagged) {
        await prisma.url.update({
          where: { id: url.id },
          data: {
            status: 'FLAGGED',
            flaggedAt: new Date(),
            flaggedBy: 'system',
            flagReason: result.reasons.join('; '),
          },
        });
 
        // Invalidate cache
        const keysToDelete = [`url:${url.shortCode}`];
        if (url.customAlias) {
          keysToDelete.push(`url:${url.customAlias}`);
        }
        await redisClient.del(...keysToDelete);
 
        flaggedCount++;
      }
    }
 
    if (flaggedCount > 0) {
      console.log(
        `Abuse scan: flagged ${flaggedCount} of ${recentUrls.length} recent URLs`
      );
    }
 
    return { scanned: recentUrls.length, flagged: flaggedCount };
  }
}
 
export const abuseDetectionService = new AbuseDetectionService();

Tích Hợp Abuse Detection Vào URL Creation

Thêm abuse checking vào flow tạo URL. Khác với blocklist (block hẳn việc tạo), abuse detection flag URL sau khi tạo:

// src/middleware/abuse-check.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { abuseDetectionService } from '../services/abuse-detection.service';
 
/**
 * Middleware chạy abuse detection sau khi URL được tạo.
 * Không block việc tạo — chỉ flag URL đáng ngờ để review.
 * Gắn SAU controller như một post-creation hook.
 */
export async function postCreationAbuseCheck(
  req: Request,
  res: Response,
  _next: NextFunction
) {
  // Chỉ chạy nếu URL đã được tạo thành công
  if (!res.locals.createdUrl) return;
 
  const { id, originalUrl } = res.locals.createdUrl;
  const userId = req.user?.id;
 
  try {
    const result = await abuseDetectionService.checkUrl(originalUrl, userId);
 
    if (result.flagged) {
      // Flag URL bất đồng bộ — không block response
      const { prisma } = await import('../lib/prisma');
      await prisma.url.update({
        where: { id },
        data: {
          status: 'FLAGGED',
          flaggedAt: new Date(),
          flaggedBy: 'system',
          flagReason: result.reasons.join('; '),
        },
      });
 
      console.warn(`URL ${id} auto-flagged: ${result.reasons.join('; ')}`);
    }
  } catch (error) {
    // Log nhưng không fail — abuse detection là best-effort
    console.error('Abuse detection error:', error);
  }
}

Cách gọn hơn là chạy abuse checks trực tiếp trong URL creation service:

// Trong src/services/url.service.ts — update method createUrl
import { abuseDetectionService } from './abuse-detection.service';
 
async createUrl(data: CreateUrlInput, userId?: string) {
  // ... logic tạo URL hiện tại ...
 
  const url = await prisma.url.create({
    data: {
      originalUrl: data.url,
      shortCode,
      customAlias: data.customAlias,
      userId,
      expiresAt: data.expiresAt,
    },
  });
 
  // Chạy abuse check sau khi tạo (non-blocking)
  this.runAbuseCheck(url.id, data.url, userId).catch((err) =>
    console.error('Abuse check failed:', err)
  );
 
  return url;
}
 
private async runAbuseCheck(urlId: string, originalUrl: string, userId?: string) {
  const result = await abuseDetectionService.checkUrl(originalUrl, userId);
 
  if (result.flagged) {
    await prisma.url.update({
      where: { id: urlId },
      data: {
        status: 'FLAGGED',
        flaggedAt: new Date(),
        flaggedBy: 'system',
        flagReason: result.reasons.join('; '),
      },
    });
  }
}

Lên Lịch Periodic Abuse Scans

Set up cron job để định kỳ scan các URL mới:

// src/jobs/abuse-scan.job.ts
import cron from 'node-cron';
import { abuseDetectionService } from '../services/abuse-detection.service';
 
/**
 * Chạy abuse detection scan mỗi 5 phút
 * Bắt các URL lọt qua real-time checks
 */
export function startAbuseScanJob() {
  cron.schedule('*/5 * * * *', async () => {
    try {
      const result = await abuseDetectionService.scanRecentUrls();
      if (result.flagged > 0) {
        console.log(
          `[Abuse Scan] Scanned ${result.scanned} URLs, flagged ${result.flagged}`
        );
      }
    } catch (error) {
      console.error('[Abuse Scan] Failed:', error);
    }
  });
 
  console.log('Abuse scan job scheduled: every 5 minutes');
}

Khởi động job trong application bootstrap:

// src/app.ts
import { startAbuseScanJob } from './jobs/abuse-scan.job';
 
// ... existing app setup ...
 
// Start background jobs
if (process.env.NODE_ENV !== 'test') {
  startAbuseScanJob();
}

Community Reporting

Cho phép bất kỳ ai report một URL đáng ngờ. Đây là public endpoint — rate-limited nhưng không cần authentication:

Report Service

// src/services/report.service.ts
import { prisma } from '../lib/prisma';
import { AppError } from '../utils/errors';
import { abuseDetectionService } from './abuse-detection.service';
 
export class ReportService {
  /**
   * Gửi report cho một URL
   */
  async createReport(
    shortCode: string,
    reason: string,
    details: string | undefined,
    reporterIp: string
  ) {
    // Tìm URL bằng short code
    const url = await prisma.url.findFirst({
      where: {
        OR: [{ shortCode }, { customAlias: shortCode }],
      },
    });
 
    if (!url) {
      throw new AppError('URL not found', 404);
    }
 
    // Kiểm tra report trùng từ cùng IP trong 24 giờ
    const existingReport = await prisma.urlReport.findFirst({
      where: {
        urlId: url.id,
        reporterIp,
        createdAt: {
          gte: new Date(Date.now() - 24 * 60 * 60 * 1000),
        },
      },
    });
 
    if (existingReport) {
      throw new AppError(
        'You have already reported this URL in the last 24 hours',
        429
      );
    }
 
    // Tạo report
    const report = await prisma.urlReport.create({
      data: {
        urlId: url.id,
        reporterIp,
        reason,
        details,
      },
    });
 
    // Kiểm tra report threshold đã đạt chưa để auto-flag
    await abuseDetectionService.checkReportThreshold(url.id);
 
    return {
      id: report.id,
      message: 'Report submitted successfully. Our team will review it.',
    };
  }
 
  /**
   * Lấy reports cho admin review
   */
  async getReports(
    status: string = 'pending',
    page: number = 1,
    limit: number = 20
  ) {
    const [reports, total] = await Promise.all([
      prisma.urlReport.findMany({
        where: { status },
        include: {
          url: {
            select: {
              id: true,
              shortCode: true,
              originalUrl: true,
              status: true,
              clicks: true,
            },
          },
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      prisma.urlReport.count({ where: { status } }),
    ]);
 
    return {
      reports,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }
 
  /**
   * Admin review một report (đánh dấu reviewed hoặc dismissed)
   */
  async reviewReport(
    reportId: string,
    status: 'reviewed' | 'dismissed',
    adminId: string
  ) {
    const report = await prisma.urlReport.findUnique({
      where: { id: reportId },
    });
 
    if (!report) {
      throw new AppError('Report not found', 404);
    }
 
    return prisma.urlReport.update({
      where: { id: reportId },
      data: {
        status,
        reviewedBy: adminId,
        reviewedAt: new Date(),
      },
    });
  }
}
 
export const reportService = new ReportService();

Report Controller & Routes

// src/controllers/report.controller.ts
import { Request, Response, NextFunction } from 'express';
import { reportService } from '../services/report.service';
import { urlReportSchema } from '../validators/moderation.validator';
 
export class ReportController {
  /**
   * Public endpoint — ai cũng có thể report URL
   */
  async reportUrl(req: Request, res: Response, next: NextFunction) {
    try {
      const { shortCode } = req.params;
      const input = urlReportSchema.parse(req.body);
      const reporterIp =
        (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
        req.ip ||
        'unknown';
 
      const result = await reportService.createReport(
        shortCode,
        input.reason,
        input.details,
        reporterIp
      );
 
      res.status(201).json(result);
    } catch (error) {
      next(error);
    }
  }
 
  /**
   * Admin: lấy tất cả reports để review
   */
  async getReports(req: Request, res: Response, next: NextFunction) {
    try {
      const status = (req.query.status as string) || 'pending';
      const page = parseInt(req.query.page as string) || 1;
      const limit = parseInt(req.query.limit as string) || 20;
 
      const result = await reportService.getReports(status, page, limit);
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
 
  /**
   * Admin: review một report cụ thể
   */
  async reviewReport(req: Request, res: Response, next: NextFunction) {
    try {
      const { id } = req.params;
      const { status } = req.body;
 
      if (!['reviewed', 'dismissed'].includes(status)) {
        return res
          .status(400)
          .json({ error: 'Status must be "reviewed" or "dismissed"' });
      }
 
      const result = await reportService.reviewReport(
        id,
        status,
        req.user!.id
      );
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
}
 
export const reportController = new ReportController();
// src/routes/report.routes.ts
import { Router } from 'express';
import { reportController } from '../controllers/report.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requireRole } from '../middleware/rbac.middleware';
import { rateLimit } from 'express-rate-limit';
 
const router = Router();
 
// Public report endpoint với rate limiting nghiêm ngặt
const reportLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 phút
  max: 10, // 10 reports mỗi 15 phút mỗi IP
  message: { error: 'Too many reports. Please try again later.' },
  standardHeaders: true,
  legacyHeaders: false,
});
 
router.post(
  '/urls/:shortCode/report',
  reportLimiter,
  reportController.reportUrl
);
 
// Admin report management
router.get(
  '/admin/reports',
  authenticate,
  requireRole('ADMIN', 'MODERATOR'),
  reportController.getReports
);
 
router.patch(
  '/admin/reports/:id',
  authenticate,
  requireRole('ADMIN', 'MODERATOR'),
  reportController.reviewReport
);
 
export default router;

Cập Nhật Redirect Engine

Thay đổi quan trọng nhất: update redirect handler để tôn trọng URL status. URL bị disabled phải không bao giờ redirect. URL bị flagged phải hiện warning:

// src/controllers/redirect.controller.ts
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../lib/prisma';
import { redisClient } from '../lib/redis';
import { AppError } from '../utils/errors';
 
interface CachedUrl {
  originalUrl: string;
  status: string;
}
 
export async function handleRedirect(
  req: Request,
  res: Response,
  next: NextFunction
) {
  try {
    const { shortCode } = req.params;
    const cacheKey = `url:${shortCode}`;
 
    // Check Redis cache trước
    let urlData: CachedUrl | null = null;
    const cached = await redisClient.get(cacheKey);
 
    if (cached) {
      urlData = JSON.parse(cached);
    } else {
      // Fetch từ database
      const url = await prisma.url.findFirst({
        where: {
          OR: [{ shortCode }, { customAlias: shortCode }],
        },
        select: {
          id: true,
          originalUrl: true,
          status: true,
          expiresAt: true,
        },
      });
 
      if (!url) {
        throw new AppError('Short URL not found', 404);
      }
 
      // Kiểm tra hết hạn
      if (url.expiresAt && url.expiresAt < new Date()) {
        throw new AppError('This short URL has expired', 410);
      }
 
      urlData = {
        originalUrl: url.originalUrl,
        status: url.status,
      };
 
      // Cache kết quả (kèm status cho các lần check sau)
      await redisClient.setex(cacheKey, 3600, JSON.stringify(urlData));
    }
 
    // Xử lý URL status
    switch (urlData.status) {
      case 'DISABLED':
        return res.status(403).send(renderBlockedPage(shortCode));
 
      case 'FLAGGED':
        return res.status(200).send(
          renderWarningPage(shortCode, urlData.originalUrl)
        );
 
      case 'ACTIVE':
      default:
        // Track click bất đồng bộ
        trackClick(shortCode, req).catch((err) =>
          console.error('Click tracking failed:', err)
        );
 
        return res.redirect(302, urlData.originalUrl);
    }
  } catch (error) {
    next(error);
  }
}
 
/**
 * Render warning page cho flagged URLs
 * User có thể chọn tiếp tục hoặc quay lại
 */
function renderWarningPage(shortCode: string, destination: string): string {
  return `
    <!DOCTYPE html>
    <html>
    <head><title>Warning - Flagged URL</title></head>
    <body style="font-family: sans-serif; max-width: 600px; margin: 100px auto; text-align: center;">
      <h1>⚠️ Warning</h1>
      <p>This short link has been flagged for review.</p>
      <p>Destination: <code>${escapeHtml(destination)}</code></p>
      <p>Proceed at your own risk.</p>
      <a href="${escapeHtml(destination)}"
         style="display: inline-block; padding: 10px 20px; background: #f59e0b; color: white; text-decoration: none; border-radius: 5px; margin: 10px;">
        Continue Anyway
      </a>
      <a href="/"
         style="display: inline-block; padding: 10px 20px; background: #6b7280; color: white; text-decoration: none; border-radius: 5px; margin: 10px;">
        Go Back
      </a>
    </body>
    </html>
  `;
}
 
/**
 * Render blocked page cho disabled URLs
 */
function renderBlockedPage(shortCode: string): string {
  return `
    <!DOCTYPE html>
    <html>
    <head><title>URL Blocked</title></head>
    <body style="font-family: sans-serif; max-width: 600px; margin: 100px auto; text-align: center;">
      <h1>🚫 URL Blocked</h1>
      <p>This short link has been disabled for violating our terms of service.</p>
      <p>If you believe this is a mistake, please contact support.</p>
      <a href="/"
         style="display: inline-block; padding: 10px 20px; background: #3b82f6; color: white; text-decoration: none; border-radius: 5px;">
        Go to Homepage
      </a>
    </body>
    </html>
  `;
}
 
/**
 * Escape HTML để ngăn XSS trong rendered pages
 */
function escapeHtml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}
 
/**
 * Track click event bất đồng bộ
 */
async function trackClick(shortCode: string, req: Request) {
  // ... logic click tracking hiện tại ...
}

Thay đổi quan trọng ở đây là cached URL data giờ bao gồm status. Khi URL bị flagged hoặc disabled, redirect handler hiển thị warning hoặc blocked page thay vì redirect. Và khi admin thay đổi status URL, Redis cache bị invalidate nên status mới có hiệu lực ngay.

Đây là tóm tắt trực quan của redirect flow đã cập nhật:


Những Lỗi Hay Gặp

1. Không Check Blocklist Khi Tạo URL

Nếu bạn chỉ check blocklist khi redirect (khi ai đó click short link), thiệt hại đã xảy ra rồi. Short link tồn tại, đã được share, và user đang click vào nó. Luôn check blocklist khi tạo URL và reject blocked domains trước khi short code được generate.

2. Bulk Operations Không Có Transaction

// SAI — partial failure để database ở trạng thái không nhất quán
for (const urlId of urlIds) {
  await prisma.url.update({ where: { id: urlId }, data: { status: 'DISABLED' } });
}
 
// ĐÚNG — tất cả hoặc không gì cả
await prisma.$transaction(async (tx) => {
  await tx.url.updateMany({
    where: { id: { in: urlIds } },
    data: { status: 'DISABLED' },
  });
});

Nếu bạn update 100 URLs từng cái một và cái thứ 50 fail, bạn có 49 URL bị disabled và 51 vẫn active mà không biết cái nào ra cái nào. Dùng $transaction để đảm bảo atomicity.

3. Quên Invalidate Redis Cache

Khi status URL đổi từ ACTIVE sang DISABLED, cached entry cũ vẫn ghi là ACTIVE. Redirect engine đọc cache và vui vẻ redirect tới URL độc hại. Mỗi lần thay đổi status phải invalidate Redis cache cho short code đó.

// Sau mỗi status update
await redisClient.del(`url:${shortCode}`);
if (customAlias) {
  await redisClient.del(`url:${customAlias}`);
}

4. Không Rate-Limit Report Endpoint

Report endpoint là public — không cần authentication. Không có rate limiting, attacker có thể flood database bằng fake reports, trigger auto-flagging trên các URL hợp lệ, và làm ngập moderation queue. Luôn rate-limit các public endpoints một cách nghiêm ngặt.


Testing

Test URL Status Transitions

// src/__tests__/moderation.service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ModerationService } from '../services/moderation.service';
import { prisma } from '../lib/prisma';
import { redisClient } from '../lib/redis';
 
vi.mock('../lib/prisma');
vi.mock('../lib/redis');
 
describe('ModerationService', () => {
  const service = new ModerationService();
 
  beforeEach(() => {
    vi.clearAllMocks();
  });
 
  describe('updateUrlStatus', () => {
    it('should flag a URL with reason', async () => {
      const mockUrl = {
        id: 'url-1',
        shortCode: 'abc123',
        customAlias: null,
        status: 'ACTIVE',
      };
 
      vi.mocked(prisma.url.findUnique).mockResolvedValue(mockUrl as never);
      vi.mocked(prisma.url.update).mockResolvedValue({
        ...mockUrl,
        status: 'FLAGGED',
      } as never);
      vi.mocked(redisClient.del).mockResolvedValue(1);
 
      const result = await service.updateUrlStatus(
        'url-1',
        { status: 'FLAGGED', reason: 'Suspected phishing' },
        'admin-1'
      );
 
      expect(result.status).toBe('FLAGGED');
      expect(prisma.url.update).toHaveBeenCalledWith(
        expect.objectContaining({
          data: expect.objectContaining({
            status: 'FLAGGED',
            flaggedBy: 'admin-1',
            flagReason: 'Suspected phishing',
          }),
        })
      );
      expect(redisClient.del).toHaveBeenCalledWith('url:abc123');
    });
 
    it('should clear flags when reactivating', async () => {
      const mockUrl = {
        id: 'url-1',
        shortCode: 'abc123',
        customAlias: 'myalias',
        status: 'FLAGGED',
      };
 
      vi.mocked(prisma.url.findUnique).mockResolvedValue(mockUrl as never);
      vi.mocked(prisma.url.update).mockResolvedValue({
        ...mockUrl,
        status: 'ACTIVE',
      } as never);
      vi.mocked(redisClient.del).mockResolvedValue(1);
 
      await service.updateUrlStatus(
        'url-1',
        { status: 'ACTIVE' },
        'admin-1'
      );
 
      expect(prisma.url.update).toHaveBeenCalledWith(
        expect.objectContaining({
          data: expect.objectContaining({
            status: 'ACTIVE',
            flaggedAt: null,
            flaggedBy: null,
            flagReason: null,
          }),
        })
      );
      // Phải invalidate cả short code và alias cache
      expect(redisClient.del).toHaveBeenCalledWith('url:abc123', 'url:myalias');
    });
 
    it('should throw 404 for non-existent URL', async () => {
      vi.mocked(prisma.url.findUnique).mockResolvedValue(null);
 
      await expect(
        service.updateUrlStatus('fake-id', { status: 'FLAGGED' }, 'admin-1')
      ).rejects.toThrow('URL not found');
    });
  });
});

Test Bulk Operations

// src/__tests__/bulk-operations.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ModerationService } from '../services/moderation.service';
import { prisma } from '../lib/prisma';
import { redisClient } from '../lib/redis';
 
vi.mock('../lib/prisma');
vi.mock('../lib/redis');
 
describe('Bulk Operations', () => {
  const service = new ModerationService();
 
  beforeEach(() => {
    vi.clearAllMocks();
  });
 
  it('should disable multiple URLs in a transaction', async () => {
    const urlIds = ['url-1', 'url-2', 'url-3'];
    const mockUrls = urlIds.map((id) => ({
      id,
      shortCode: `code-${id}`,
      customAlias: null,
    }));
 
    vi.mocked(prisma.url.findMany).mockResolvedValue(mockUrls as never);
    vi.mocked(prisma.$transaction).mockImplementation(async (fn) => {
      return fn({
        url: {
          updateMany: vi.fn().mockResolvedValue({ count: 3 }),
        },
      } as never);
    });
    vi.mocked(redisClient.del).mockResolvedValue(1);
 
    const result = await service.bulkAction(
      { action: 'disable', urlIds },
      'admin-1'
    );
 
    expect(result.affected).toBe(3);
    expect(prisma.$transaction).toHaveBeenCalled();
  });
 
  it('should reject batches larger than 100', async () => {
    const urlIds = Array.from({ length: 101 }, (_, i) => `url-${i}`);
 
    // Validation xảy ra ở schema level, nhưng nếu gọi trực tiếp:
    await expect(
      service.bulkAction({ action: 'disable', urlIds, reason: undefined }, 'admin-1')
    ).rejects.toThrow();
  });
 
  it('should throw if any URL in batch is missing', async () => {
    vi.mocked(prisma.url.findMany).mockResolvedValue([
      { id: 'url-1', shortCode: 'abc', customAlias: null },
    ] as never);
 
    await expect(
      service.bulkAction(
        { action: 'disable', urlIds: ['url-1', 'url-2'] },
        'admin-1'
      )
    ).rejects.toThrow('URLs not found');
  });
});

Test Blocklist

// src/__tests__/blocklist.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BlocklistService } from '../services/blocklist.service';
import { prisma } from '../lib/prisma';
import { redisClient } from '../lib/redis';
 
vi.mock('../lib/prisma');
vi.mock('../lib/redis');
 
describe('BlocklistService', () => {
  const service = new BlocklistService();
 
  beforeEach(() => {
    vi.clearAllMocks();
  });
 
  it('should detect blocked domain from cache', async () => {
    vi.mocked(redisClient.sismember).mockResolvedValue(1);
 
    const result = await service.isBlocked('https://evil-phishing.com/login');
    expect(result).toBe(true);
  });
 
  it('should check parent domains', async () => {
    vi.mocked(redisClient.sismember).mockResolvedValue(0);
    vi.mocked(prisma.blockedDomain.findFirst).mockResolvedValue({
      id: '1',
      domain: 'evil.com',
      reason: 'Phishing',
      addedBy: 'admin-1',
      createdAt: new Date(),
    } as never);
    vi.mocked(redisClient.sadd).mockResolvedValue(1);
 
    // subdomain.evil.com phải match evil.com
    const result = await service.isBlocked('https://subdomain.evil.com/page');
    expect(result).toBe(true);
  });
 
  it('should allow non-blocked domains', async () => {
    vi.mocked(redisClient.sismember).mockResolvedValue(0);
    vi.mocked(prisma.blockedDomain.findFirst).mockResolvedValue(null);
 
    const result = await service.isBlocked('https://legitimate-site.com');
    expect(result).toBe(false);
  });
 
  it('should auto-flag existing URLs when domain is blocked', async () => {
    vi.mocked(prisma.blockedDomain.findUnique).mockResolvedValue(null);
    vi.mocked(prisma.blockedDomain.create).mockResolvedValue({
      id: '1',
      domain: 'spam.com',
      reason: 'Spam',
      addedBy: 'admin-1',
      createdAt: new Date(),
    } as never);
    vi.mocked(prisma.url.updateMany).mockResolvedValue({ count: 5 } as never);
    vi.mocked(prisma.blockedDomain.findMany).mockResolvedValue([]);
 
    const pipeline = { del: vi.fn(), sadd: vi.fn(), expire: vi.fn(), exec: vi.fn() };
    vi.mocked(redisClient.pipeline).mockReturnValue(pipeline as never);
 
    await service.addDomain('spam.com', 'Known spam domain', 'admin-1');
 
    expect(prisma.url.updateMany).toHaveBeenCalledWith(
      expect.objectContaining({
        where: expect.objectContaining({
          originalUrl: { contains: 'spam.com' },
        }),
      })
    );
  });
});

Test Abuse Detection

// src/__tests__/abuse-detection.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AbuseDetectionService } from '../services/abuse-detection.service';
import { redisClient } from '../lib/redis';
 
vi.mock('../lib/prisma');
vi.mock('../lib/redis');
 
describe('AbuseDetectionService', () => {
  const service = new AbuseDetectionService();
 
  beforeEach(() => {
    vi.clearAllMocks();
  });
 
  it('should flag URLs with phishing patterns', async () => {
    vi.mocked(redisClient.incr).mockResolvedValue(1);
    vi.mocked(redisClient.expire).mockResolvedValue(1);
 
    const result = await service.checkUrl(
      'https://evil.com/paypal-confirm-account.html'
    );
 
    expect(result.flagged).toBe(true);
    expect(result.reasons[0]).toContain('Suspicious pattern');
  });
 
  it('should flag redirect chains', async () => {
    vi.mocked(redisClient.incr).mockResolvedValue(1);
    vi.mocked(redisClient.expire).mockResolvedValue(1);
 
    const result = await service.checkUrl('https://bit.ly/abc123');
 
    expect(result.flagged).toBe(true);
    expect(result.reasons[0]).toContain('Redirect chain');
  });
 
  it('should flag rate abuse', async () => {
    vi.mocked(redisClient.incr).mockResolvedValue(51); // Vượt limit
 
    const result = await service.checkUrl(
      'https://legitimate-site.com',
      'user-1'
    );
 
    expect(result.flagged).toBe(true);
    expect(result.reasons[0]).toContain('Rate abuse');
  });
 
  it('should pass clean URLs', async () => {
    vi.mocked(redisClient.incr).mockResolvedValue(1);
    vi.mocked(redisClient.expire).mockResolvedValue(1);
 
    const result = await service.checkUrl('https://github.com/my-repo');
 
    expect(result.flagged).toBe(false);
    expect(result.reasons).toHaveLength(0);
  });
});

Integration Test — Full Moderation Flow

// src/__tests__/integration/moderation.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../app';
import { prisma } from '../../lib/prisma';
import { createTestUser, getAuthToken } from '../helpers';
 
describe('Moderation API Integration', () => {
  let adminToken: string;
  let testUrl: { id: string; shortCode: string };
 
  beforeAll(async () => {
    // Tạo admin user và lấy token
    const admin = await createTestUser({ role: 'ADMIN' });
    adminToken = await getAuthToken(admin);
 
    // Tạo test URL
    testUrl = await prisma.url.create({
      data: {
        originalUrl: 'https://example.com',
        shortCode: 'test123',
        status: 'ACTIVE',
      },
    });
  });
 
  afterAll(async () => {
    await prisma.url.deleteMany({});
    await prisma.user.deleteMany({});
  });
 
  it('should list URLs with filters', async () => {
    const res = await request(app)
      .get('/api/admin/urls?status=ACTIVE&limit=10')
      .set('Authorization', `Bearer ${adminToken}`);
 
    expect(res.status).toBe(200);
    expect(res.body.urls).toBeInstanceOf(Array);
    expect(res.body.pagination).toBeDefined();
  });
 
  it('should flag a URL', async () => {
    const res = await request(app)
      .patch(`/api/admin/urls/${testUrl.id}/status`)
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ status: 'FLAGGED', reason: 'Suspected phishing' });
 
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('FLAGGED');
    expect(res.body.flagReason).toBe('Suspected phishing');
  });
 
  it('should show flagged URL in flagged list', async () => {
    const res = await request(app)
      .get('/api/admin/urls/flagged')
      .set('Authorization', `Bearer ${adminToken}`);
 
    expect(res.status).toBe(200);
    expect(res.body.urls.some((u: { id: string }) => u.id === testUrl.id)).toBe(
      true
    );
  });
 
  it('should bulk disable URLs', async () => {
    const urls = await Promise.all([
      prisma.url.create({
        data: { originalUrl: 'https://a.com', shortCode: 'bulk1' },
      }),
      prisma.url.create({
        data: { originalUrl: 'https://b.com', shortCode: 'bulk2' },
      }),
    ]);
 
    const res = await request(app)
      .post('/api/admin/urls/bulk')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({
        action: 'disable',
        urlIds: urls.map((u) => u.id),
      });
 
    expect(res.status).toBe(200);
    expect(res.body.affected).toBe(2);
  });
 
  it('should accept community reports', async () => {
    const res = await request(app)
      .post(`/api/urls/${testUrl.shortCode}/report`)
      .send({ reason: 'phishing', details: 'This looks like a fake login page' });
 
    expect(res.status).toBe(201);
    expect(res.body.message).toContain('submitted successfully');
  });
});

Tiếp Theo

Giờ ta đã có một hệ thống moderation hoàn chỉnh — admin URL management, bulk operations, domain blocklists, automated abuse detection, và community reporting. URL shortener của bạn có thể tự bảo vệ trước các abuse pattern phổ biến nhất.

Nhưng moderation là reactive. Trong Phase 12, ta sẽ xây analytics và audit cho admin panel: system-wide analytics dashboards hiển thị URL creation trends, redirect volumes, và abuse patterns theo thời gian, cùng comprehensive audit log tracking mọi admin action cho accountability và compliance.


Series: Xây Dựng URL Shortener
Trước đó: Phase 10: Admin RBAC & User Management
Tiếp theo: Phase 12: System Analytics & Audit Logs

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.