Build a URL Shortener: URL Moderation & Abuse Detection

A URL shortener without moderation is a weapon. That's not hyperbole — phishing campaigns, malware distribution, and spam rings rely on URL shorteners to mask their malicious destinations behind innocent-looking short links. Your users click short.ly/invoice expecting a PDF and land on a credential-harvesting page instead.
With admin roles and RBAC in place from the previous post, we now have the infrastructure to build proper moderation tools. In this post, we'll create a complete URL moderation system: status workflows, bulk operations, domain blocklists, automated abuse detection, and community reporting. The goal is to catch bad links before they cause damage — and give admins efficient tools to handle the ones that slip through.
This isn't optional polish. If you're running a public URL shortener, moderation is the difference between a useful tool and a liability.
Time commitment: 2-3 hours
Prerequisites: Phase 10: Admin RBAC & User Management
What we'll build in this post:
✅ URL moderation system with status workflow (active → flagged → disabled)
✅ Admin URL listing with filters (status, user, date range, search)
✅ Bulk operations API (bulk disable, delete, export)
✅ Domain blocklist management (block known malicious domains)
✅ Automated abuse detection (pattern matching, rate-based flagging)
✅ URL report/flag system for community reporting
URL Moderation Architecture
Before writing code, let's map out the URL lifecycle and moderation workflow. Every URL in our system will have a status that determines whether it can redirect:
The three states serve distinct purposes:
| Status | Redirect Behavior | Who Can Transition | Use Case |
|---|---|---|---|
| ACTIVE | Normal redirect | System (on creation) | Default state for all URLs |
| FLAGGED | Warning page shown | Admin, system, reports | Under review — suspicious but unconfirmed |
| DISABLED | Blocked entirely | Admin only | Confirmed malicious or policy violation |
Here's the full moderation workflow from detection to resolution:
Prisma Schema Updates
We need to add moderation fields to our existing URL model and create new models for the blocklist and reports. Add these to your 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")
}Run the migration:
npx prisma migrate dev --name add-url-moderationThis adds:
- UrlStatus enum with three states for every URL
- Moderation tracking fields on
Url— who flagged it, when, why - BlockedDomain table for maintaining a domain blocklist
- UrlReport table for community-submitted reports with review tracking
Moderation Validation Schemas
Before building endpoints, define the validation schemas with 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
The moderation service encapsulates all business logic for managing URL statuses, keeping our controllers thin:
// 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 {
/**
* Get URLs with admin filters, pagination, and 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),
},
};
}
/**
* Get detailed URL info including reports and 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);
}
// Get recent click stats
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 with 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') {
// Clearing flags when reactivating
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 so redirect engine picks up new status
await this.invalidateUrlCache(url.shortCode, url.customAlias);
return updated;
}
/**
* Execute bulk operations on multiple URLs in a transaction
*/
async bulkAction(input: BulkActionInput, adminId: string) {
const { action, urlIds, reason } = input;
// Verify all URLs exist before proceeding
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 for all affected URLs
await Promise.all(
existingUrls.map((url) =>
this.invalidateUrlCache(url.shortCode, url.customAlias)
)
);
return { action, affected: result.count };
}
/**
* Get all flagged URLs for quick admin review
*/
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 when URL status changes
*/
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 but don't throw — cache invalidation failure shouldn't
// block the moderation action
console.error('Failed to invalidate cache:', error);
}
}
}
export const moderationService = new ModerationService();Moderation Controller & Routes
With the service layer in place, the controller stays thin — just validation and 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();Now wire up the 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();
// All moderation routes require admin or moderator role
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 — admin only
router.post(
'/urls/bulk',
requireRole('ADMIN'),
moderationController.bulkAction
);
export default router;Register these in your main admin router:
// 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
The domain blocklist prevents URLs from known malicious domains from ever being shortened. This is your first line of defense — block the source before a short link even exists.
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 minutes
export class BlocklistService {
/**
* Check if a URL's domain is blocked
* Uses Redis cache for performance since this runs on every URL creation
*/
async isBlocked(url: string): Promise<boolean> {
let hostname: string;
try {
hostname = new URL(url).hostname.replace(/^www\./, '');
} catch {
throw new AppError('Invalid URL', 400);
}
// Check cache first
const cached = await redisClient.sismember(BLOCKLIST_CACHE_KEY, hostname);
if (cached) return true;
// Check parent domains (e.g., evil.example.com should 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 the result for future lookups
await redisClient.sadd(BLOCKLIST_CACHE_KEY, hostname);
return true;
}
return false;
}
/**
* Get all blocked domains with 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),
},
};
}
/**
* Add a domain to the blocklist
*/
async addDomain(domain: string, reason: string | undefined, adminId: string) {
const normalized = domain.toLowerCase().replace(/^www\./, '');
// Check if already blocked
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 existing URLs from this domain
await this.flagExistingUrls(normalized, adminId);
return blocked;
}
/**
* Remove a domain from the 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 after removal
await this.refreshCache();
return domain;
}
/**
* When a domain is blocked, auto-flag all existing URLs from that 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 the Redis blocklist cache from 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;Integrating Blocklist into URL Creation
Now update the URL creation flow to check the blocklist before creating a 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 that checks incoming URLs against the domain blocklist.
* Attach this to the URL creation route BEFORE the 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);
}
}Add this middleware to your 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 runs before URL creation
router.post('/', optionalAuth, checkBlocklist, urlController.createUrl);
export default router;Automated Abuse Detection
Manual moderation doesn't scale. You need automated detection to flag suspicious URLs before an admin even sees them. Here's a comprehensive abuse detection service:
// src/services/abuse-detection.service.ts
import { prisma } from '../lib/prisma';
import { redisClient } from '../lib/redis';
// Known phishing patterns — extend this list based on real threats
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, // Common free TLD used in phishing
/\.ml\//i, // Common free TLD used in phishing
/bit\.ly\/[a-z0-9]+$/i, // Short URL pointing to another shortener
/tinyurl\.com/i, // Redirect chain
/goo\.gl/i, // Redirect chain
];
// Rate limits for abuse detection
const RATE_LIMIT_WINDOW = 3600; // 1 hour in seconds
const RATE_LIMIT_MAX_URLS = 50; // Max URLs per user per hour
const REPORT_AUTO_FLAG_THRESHOLD = 3; // Auto-flag after N reports
interface AbuseCheckResult {
flagged: boolean;
reasons: string[];
}
export class AbuseDetectionService {
/**
* Run all abuse checks on a URL before or after creation
*/
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 (if authenticated)
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,
};
}
/**
* Check URL against known phishing/malicious patterns
*/
private checkSuspiciousPatterns(url: string): string | null {
for (const pattern of SUSPICIOUS_PATTERNS) {
if (pattern.test(url)) {
return pattern.toString();
}
}
return null;
}
/**
* Detect redirect chains — a short URL pointing to another short URL service
* This is a common technique to hide the final malicious destination
*/
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;
}
}
/**
* Check if a user is creating URLs at a suspicious rate
*/
private async checkRateAbuse(userId: string): Promise<number | null> {
const key = `abuse:rate:${userId}`;
const count = await redisClient.incr(key);
// Set expiry on first increment
if (count === 1) {
await redisClient.expire(key, RATE_LIMIT_WINDOW);
}
return count > RATE_LIMIT_MAX_URLS ? count : null;
}
/**
* Auto-flag URL if it has received enough 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;
}
/**
* Run periodic scan on recently created URLs
* Call this from a scheduled job (e.g., every 5 minutes)
*/
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();Integrating Abuse Detection into URL Creation
Add abuse checking to the URL creation flow. Unlike the blocklist (which blocks creation entirely), abuse detection flags the URL after creation:
// src/middleware/abuse-check.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { abuseDetectionService } from '../services/abuse-detection.service';
/**
* Middleware that runs abuse detection after URL creation.
* This doesn't block creation — it flags suspicious URLs for review.
* Attach this AFTER the controller as a post-creation hook.
*/
export async function postCreationAbuseCheck(
req: Request,
res: Response,
_next: NextFunction
) {
// Only run if URL was successfully created
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 the URL asynchronously — don't block the 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 but don't fail — abuse detection is best-effort
console.error('Abuse detection error:', error);
}
}A cleaner approach is to run abuse checks directly in the URL creation service:
// In src/services/url.service.ts — update the createUrl method
import { abuseDetectionService } from './abuse-detection.service';
async createUrl(data: CreateUrlInput, userId?: string) {
// ... existing URL creation logic ...
const url = await prisma.url.create({
data: {
originalUrl: data.url,
shortCode,
customAlias: data.customAlias,
userId,
expiresAt: data.expiresAt,
},
});
// Run abuse check after creation (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('; '),
},
});
}
}Scheduling Periodic Abuse Scans
Set up a cron job to periodically scan recent URLs:
// src/jobs/abuse-scan.job.ts
import cron from 'node-cron';
import { abuseDetectionService } from '../services/abuse-detection.service';
/**
* Run abuse detection scan every 5 minutes
* Catches URLs that slipped through 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');
}Start the job in your 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
Let anyone report a suspicious URL. This is a public endpoint — rate-limited but no authentication required:
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 {
/**
* Submit a report for a URL
*/
async createReport(
shortCode: string,
reason: string,
details: string | undefined,
reporterIp: string
) {
// Find the URL by short code
const url = await prisma.url.findFirst({
where: {
OR: [{ shortCode }, { customAlias: shortCode }],
},
});
if (!url) {
throw new AppError('URL not found', 404);
}
// Check for duplicate report from same IP within 24 hours
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
);
}
// Create the report
const report = await prisma.urlReport.create({
data: {
urlId: url.id,
reporterIp,
reason,
details,
},
});
// Check if report threshold has been reached for auto-flagging
await abuseDetectionService.checkReportThreshold(url.id);
return {
id: report.id,
message: 'Report submitted successfully. Our team will review it.',
};
}
/**
* Get reports for 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 reviews a report (mark as reviewed or 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 — anyone can report a 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: get all reports for 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 a specific report
*/
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 with strict rate limiting
const reportLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 reports per 15 minutes per 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;Redirect Engine Updates
The most critical change: update the redirect handler to respect URL status. Disabled URLs should never redirect. Flagged URLs should show a 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 first
let urlData: CachedUrl | null = null;
const cached = await redisClient.get(cacheKey);
if (cached) {
urlData = JSON.parse(cached);
} else {
// Fetch from 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);
}
// Check expiration
if (url.expiresAt && url.expiresAt < new Date()) {
throw new AppError('This short URL has expired', 410);
}
urlData = {
originalUrl: url.originalUrl,
status: url.status,
};
// Cache the result (include status for future checks)
await redisClient.setex(cacheKey, 3600, JSON.stringify(urlData));
}
// Handle 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 asynchronously
trackClick(shortCode, req).catch((err) =>
console.error('Click tracking failed:', err)
);
return res.redirect(302, urlData.originalUrl);
}
} catch (error) {
next(error);
}
}
/**
* Render a warning page for flagged URLs
* User can choose to proceed or go back
*/
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 a blocked page for 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 to prevent XSS in rendered pages
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Track click event asynchronously
*/
async function trackClick(shortCode: string, req: Request) {
// ... existing click tracking logic ...
}The key change is that the cached URL data now includes status. When a URL is flagged or disabled, the redirect handler serves a warning or blocked page instead of redirecting. And when an admin changes a URL's status, the Redis cache is invalidated so the new status takes effect immediately.
Here's a visual summary of the updated redirect flow:
Common Pitfalls
1. Not Checking Blocklist During URL Creation
If you only check the blocklist during redirect (when someone clicks the short link), the damage is already done. The short link exists, has been shared, and users are clicking it. Always check the blocklist during URL creation and reject blocked domains before the short code is generated.
2. Bulk Operations Without Transactions
// WRONG — partial failure leaves database in inconsistent state
for (const urlId of urlIds) {
await prisma.url.update({ where: { id: urlId }, data: { status: 'DISABLED' } });
}
// RIGHT — all or nothing
await prisma.$transaction(async (tx) => {
await tx.url.updateMany({
where: { id: { in: urlIds } },
data: { status: 'DISABLED' },
});
});If you update 100 URLs one by one and the 50th fails, you have 49 disabled URLs and 51 active ones with no easy way to know which is which. Use $transaction to ensure atomicity.
3. Forgetting to Invalidate Redis Cache
When a URL's status changes from ACTIVE to DISABLED, the old cached entry still says ACTIVE. The redirect engine reads the cache and happily redirects to a malicious URL. Every status change must invalidate the Redis cache for that short code.
// After any status update
await redisClient.del(`url:${shortCode}`);
if (customAlias) {
await redisClient.del(`url:${customAlias}`);
}4. Not Rate-Limiting the Report Endpoint
The report endpoint is public — no authentication required. Without rate limiting, an attacker can flood your database with fake reports, trigger auto-flagging on legitimate URLs, and overwhelm your moderation queue. Always rate-limit public endpoints aggressively.
Testing
Testing 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,
}),
})
);
// Should invalidate both short code and 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');
});
});
});Testing 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 happens at the schema level, but if called directly:
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');
});
});Testing 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 should 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' },
}),
})
);
});
});Testing 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); // Over 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 () => {
// Create admin user and get token
const admin = await createTestUser({ role: 'ADMIN' });
adminToken = await getAuthToken(admin);
// Create a 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');
});
});What's Next
We now have a complete moderation system — admin URL management, bulk operations, domain blocklists, automated abuse detection, and community reporting. Your URL shortener can defend itself against the most common abuse patterns.
But moderation is reactive. In Phase 12, we'll build the analytics and audit side of the admin panel: system-wide analytics dashboards showing URL creation trends, redirect volumes, and abuse patterns over time, plus a comprehensive audit log tracking every admin action for accountability and compliance.
Series: Build a URL Shortener
Previous: Phase 10: Admin RBAC & User Management
Next: Phase 12: System Analytics & Audit Logs
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.