← back to Jill Website
src/routes/linkValidation.ts
275 lines
import { Router, Request, Response } from 'express';
import { LinkCheckModel } from '../models/LinkCheck';
import { PropertyModel } from '../models/Property';
import { ActivityModel } from '../models/Activity';
import { linkValidationService } from '../services/linkValidationService';
import { requireAuth } from '../middleware/auth';
export function createLinkValidationRouter(
linkCheckModel?: LinkCheckModel,
propertyModel?: PropertyModel,
activityModel?: ActivityModel
): Router {
const router = Router();
// P0 fix 2026-05-04: gate the entire link-validation router with admin auth.
// /check-url was an unauth SSRF (POST any URL → server fetched it). Even with
// the new IP block in linkValidationService.checkLink, this route should be
// admin-only — link validation is an internal operations tool.
router.use(requireAuth);
const linkChecks = linkCheckModel || new LinkCheckModel();
const properties = propertyModel || new PropertyModel();
const activities = activityModel || new ActivityModel();
/**
* GET /api/link-validation/stats
* Get link validation statistics
*/
router.get('/stats', async (req: Request, res: Response) => {
try {
const stats = await linkChecks.getStats();
res.json({
success: true,
data: stats,
});
} catch (error) {
console.error('Error getting link validation stats:', error);
res.status(500).json({
success: false,
error: 'Failed to get stats',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* GET /api/link-validation/broken
* Get all broken links
*/
router.get('/broken', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 100;
const offset = parseInt(req.query.offset as string) || 0;
const brokenLinks = await linkChecks.findBrokenLinks(limit, offset);
res.json({
success: true,
data: brokenLinks,
count: brokenLinks.length,
});
} catch (error) {
console.error('Error getting broken links:', error);
res.status(500).json({
success: false,
error: 'Failed to get broken links',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* GET /api/link-validation/all
* Get all link check records
*/
router.get('/all', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 100;
const offset = parseInt(req.query.offset as string) || 0;
const allLinks = await linkChecks.findAll(limit, offset);
res.json({
success: true,
data: allLinks,
count: allLinks.length,
});
} catch (error) {
console.error('Error getting all link checks:', error);
res.status(500).json({
success: false,
error: 'Failed to get link checks',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* POST /api/link-validation/check-all
* Manually trigger a full link validation check
*/
router.post('/check-all', async (req: Request, res: Response) => {
try {
const results: any[] = [];
// Check all property links
const allProperties = await properties.findAll(1000, 0);
for (const property of allProperties) {
// Check Airbnb URL
if (property.airbnb_url) {
const checkResult = await linkValidationService.checkLink(property.airbnb_url);
await linkChecks.upsert({
url: property.airbnb_url,
source_type: 'property',
source_id: property.id!,
http_status: checkResult.httpStatus,
is_valid: checkResult.isValid,
error_message: checkResult.errorMessage,
response_time_ms: checkResult.responseTimeMs,
});
results.push({
source: 'property',
source_id: property.id,
...checkResult,
});
}
// Check image URLs
if (property.images && property.images.length > 0) {
for (const imageUrl of property.images) {
const checkResult = await linkValidationService.checkLink(imageUrl);
await linkChecks.upsert({
url: imageUrl,
source_type: 'property_image',
source_id: property.id!,
http_status: checkResult.httpStatus,
is_valid: checkResult.isValid,
error_message: checkResult.errorMessage,
response_time_ms: checkResult.responseTimeMs,
});
results.push({
source: 'property_image',
source_id: property.id,
...checkResult,
});
}
}
}
// Check all activity links
const allActivities = await activities.findAll(1000, 0);
for (const activity of allActivities) {
// Check website URL
if (activity.website) {
const checkResult = await linkValidationService.checkLink(activity.website);
await linkChecks.upsert({
url: activity.website,
source_type: 'activity_website',
source_id: activity.id!,
http_status: checkResult.httpStatus,
is_valid: checkResult.isValid,
error_message: checkResult.errorMessage,
response_time_ms: checkResult.responseTimeMs,
});
results.push({
source: 'activity_website',
source_id: activity.id,
...checkResult,
});
}
// Check maps link
if (activity.mapsLink) {
const checkResult = await linkValidationService.checkLink(activity.mapsLink);
await linkChecks.upsert({
url: activity.mapsLink,
source_type: 'activity_maps',
source_id: activity.id!,
http_status: checkResult.httpStatus,
is_valid: checkResult.isValid,
error_message: checkResult.errorMessage,
response_time_ms: checkResult.responseTimeMs,
});
results.push({
source: 'activity_maps',
source_id: activity.id,
...checkResult,
});
}
// Check review link
if (activity.reviewLink) {
const checkResult = await linkValidationService.checkLink(activity.reviewLink);
await linkChecks.upsert({
url: activity.reviewLink,
source_type: 'activity_review',
source_id: activity.id!,
http_status: checkResult.httpStatus,
is_valid: checkResult.isValid,
error_message: checkResult.errorMessage,
response_time_ms: checkResult.responseTimeMs,
});
results.push({
source: 'activity_review',
source_id: activity.id,
...checkResult,
});
}
}
const validCount = results.filter((r) => r.isValid).length;
const invalidCount = results.filter((r) => !r.isValid).length;
res.json({
success: true,
message: 'Link validation completed',
data: {
total: results.length,
valid: validCount,
invalid: invalidCount,
results,
},
});
} catch (error) {
console.error('Error checking all links:', error);
res.status(500).json({
success: false,
error: 'Failed to check links',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* POST /api/link-validation/check-url
* Manually check a single URL
*/
router.post('/check-url', async (req: Request, res: Response) => {
try {
if (!req.body) {
return res.status(400).json({
success: false,
error: 'Request body is required',
});
}
const { url } = req.body;
if (!url) {
return res.status(400).json({
success: false,
error: 'URL is required',
});
}
const checkResult = await linkValidationService.checkLink(url);
res.json({
success: true,
data: checkResult,
});
} catch (error) {
console.error('Error checking URL:', error);
res.status(500).json({
success: false,
error: 'Failed to check URL',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
return router;
}
// Export default instance
export default createLinkValidationRouter();