Files
podcastic/backend/src/controllers/podcastController.ts
T
MichaelandClaude Haiku 4.5 5497c89a99 security: SSRF protection on user-supplied RSS URLs
Validate every RSS URL submitted via /api/podcasts/subscribe before
fetching. Rejects non-http(s), localhost, private IPv4/IPv6 ranges,
link-local, and cloud metadata endpoints (169.254.169.254). Resolves
hostnames via DNS and re-checks every returned address.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-18 07:25:33 +02:00

337 lines
11 KiB
TypeScript

import { Request, Response } from 'express';
import mongoose from 'mongoose';
import { Podcast } from '../models/Podcast';
import { UserSubscription } from '../models/UserSubscription';
import { Episode } from '../models/Episode';
import { podcastIndexService } from '../services/podcastIndexService';
import { rssParserService } from '../services/rssParserService';
import { validateExternalUrl } from '../utils/urlValidator';
import { z } from 'zod';
const subscribeSchema = z.object({
rssUrl: z.string().url('Invalid RSS URL'),
});
const searchSchema = z.object({
q: z.string().min(2, 'Query must be at least 2 characters'),
limit: z.coerce.number().int().min(1).max(50).optional().default(20),
offset: z.coerce.number().int().min(0).optional().default(0),
});
export const getUserSubscriptions = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const subscriptions = await UserSubscription.find({
userId: req.user.id,
})
.populate('podcastId')
.sort({ subscribedAt: -1 });
const podcasts = subscriptions.map((sub) => {
const podcastObj = (sub.podcastId as any).toJSON();
return {
...podcastObj,
subscriptionId: sub._id.toString(),
};
});
res.json({ podcasts, count: podcasts.length });
} catch (error) {
console.error('Get subscriptions error:', error);
res.status(500).json({ message: 'Failed to fetch subscriptions' });
}
};
export const subscribe = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { rssUrl } = subscribeSchema.parse(req.body);
// SSRF protection — block internal/private hosts
try {
await validateExternalUrl(rssUrl);
} catch (err: any) {
return res.status(400).json({ message: `URL refusée : ${err.message}` });
}
// Check if podcast exists
let podcast = await Podcast.findOne({ rssUrl });
if (!podcast) {
// Parse RSS feed
const feedData = await rssParserService.parseFeed(rssUrl);
const episodes = rssParserService.parseEpisodes(feedData.episodes, feedData.imageUrl, rssUrl);
try {
const result = await rssParserService.createPodcastFromRss(rssUrl);
podcast = await Podcast.findById(result.podcast._id);
} catch (error: any) {
if (error.message.includes('already in database')) {
podcast = await Podcast.findOne({ rssUrl });
} else {
return res.status(400).json({
message: `Impossible de récupérer le podcast : ${error.message}`,
});
}
}
}
if (!podcast) {
return res.status(400).json({ message: "Impossible d'ajouter le podcast" });
}
// Check if already subscribed
const existingSubscription = await UserSubscription.findOne({
userId: req.user.id,
podcastId: podcast._id,
});
if (!existingSubscription) {
const subscription = new UserSubscription({
userId: req.user.id,
podcastId: podcast._id,
});
await subscription.save();
}
res.status(200).json({
message: existingSubscription ? 'Déjà abonné' : 'Abonnement réussi',
podcast: podcast.toJSON(),
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: 'Validation error',
errors: error.errors,
});
}
console.error('Subscribe error:', error);
res.status(500).json({ message: "Impossible de s'abonner" });
}
};
export const unsubscribe = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { podcastId } = req.params;
// Log the request
console.log(`[Unsubscribe] User ${req.user.id} attempting to remove: ${podcastId}`);
// Method 1: Try deletion by Subscription ID (New robust method)
let subscription = await UserSubscription.findOneAndDelete({
_id: new mongoose.Types.ObjectId(podcastId),
userId: new mongoose.Types.ObjectId(req.user.id),
});
// Method 2: Fallback to Podcast ID if Method 1 found nothing (Old compatibility method)
if (!subscription) {
console.log(`[Unsubscribe] Not found by Subscription ID, trying by Podcast ID: ${podcastId}`);
subscription = await UserSubscription.findOneAndDelete({
userId: new mongoose.Types.ObjectId(req.user.id),
podcastId: new mongoose.Types.ObjectId(podcastId),
});
}
if (!subscription) {
console.warn(`[Unsubscribe] No subscription record found for deletion.`);
return res.status(404).json({ message: 'Subscription not found' });
}
// Cast to any to avoid TypeScript error with ModifyResult in logs
const result = subscription as any;
console.log(`[Unsubscribe] Successfully removed subscription ${result._id}`);
res.json({ message: 'Unsubscribed successfully', deletedId: result._id });
} catch (error) {
console.error('Unsubscribe error:', error);
res.status(500).json({ message: 'Failed to unsubscribe' });
}
};
export const syncPodcast = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { podcastId } = req.params;
const podcast = await Podcast.findById(podcastId);
if (!podcast) {
return res.status(404).json({ message: 'Podcast not found' });
}
console.log(`[Sync] Triggering manual sync for podcast: ${podcast.title} (${podcastId})`);
const result = await rssParserService.syncPodcastEpisodes(podcastId, podcast.rssUrl, true);
res.json(result);
} catch (error: any) {
console.error('Manual sync error:', error);
res.status(500).json({ message: error.message || 'Failed to sync podcast' });
}
};
export const searchPodcasts = async (req: Request, res: Response) => {
try {
const { q, limit = 20, skip = 0 } = req.query;
if (!q) {
return res.status(400).json({ message: 'Search query required' });
}
const podcasts = await Podcast.find(
{ $text: { $search: q as string } },
{ score: { $meta: 'textScore' } }
)
.sort({ score: { $meta: 'textScore' } })
.limit(Number(limit))
.skip(Number(skip));
res.json({ podcasts, count: podcasts.length });
} catch (error) {
console.error('Search error:', error);
res.status(500).json({ message: 'Search failed' });
}
};
export const getPodcast = async (req: Request, res: Response) => {
try {
const { podcastId } = req.params;
const podcast = await Podcast.findById(podcastId);
if (!podcast) {
return res.status(404).json({ message: 'Podcast not found' });
}
res.json({ podcast });
} catch (error) {
console.error('Get podcast error:', error);
res.status(500).json({ message: 'Failed to fetch podcast' });
}
};
export const discoverPodcasts = async (req: Request, res: Response) => {
try {
const { q, limit = 20 } = searchSchema.parse(req.query);
const query = q as string;
const lim = limit as number;
// 1. Try iTunes Search API
try {
const results = await podcastIndexService.searchPodcasts(query, lim);
if (results.length > 0) {
return res.json({ source: 'itunes', podcasts: results, count: results.length });
}
console.log(`[Discover] iTunes returned 0 results for "${query}", trying local DB`);
} catch (itunesError: any) {
console.error(`[Discover] iTunes failed for "${query}":`, itunesError.message);
}
// 2. Fallback: search local DB podcasts already in the system
const localPodcasts = await Podcast.find({
$or: [
{ title: { $regex: query, $options: 'i' } },
{ author: { $regex: query, $options: 'i' } },
{ description: { $regex: query, $options: 'i' } },
],
}).limit(lim);
const mapped = localPodcasts.map((p: any) => ({
id: p._id.toString(),
title: p.title,
rssUrl: p.rssUrl,
description: p.description || '',
author: p.author || '',
imageUrl: p.imageUrl,
episodeCount: p.episodeCount,
}));
return res.json({ source: 'local', podcasts: mapped, count: mapped.length });
} catch (error: any) {
console.error('Discover podcasts error:', error.message);
res.json({ source: 'itunes', podcasts: [], count: 0 });
}
};
export const subscribeFromDiscovery = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { rssUrl, title, author, imageUrl } = req.body;
if (!rssUrl) {
return res.status(400).json({ message: 'RSS URL required' });
}
// Check if podcast already in database
let podcast = await Podcast.findOne({ rssUrl });
// If not in database, create it from RSS (parse feed for real episodes)
if (!podcast) {
try {
const result = await rssParserService.createPodcastFromRss(rssUrl);
// Refetch as a proper Mongoose document (result.podcast is a plain object)
podcast = await Podcast.findById(result.podcast._id);
} catch (error: any) {
if (error.message.includes('already in database')) {
podcast = await Podcast.findOne({ rssUrl });
} else {
return res.status(400).json({
message: `Impossible de récupérer le podcast : ${error.message}`,
});
}
}
}
if (!podcast) {
return res.status(400).json({ message: "Impossible d'ajouter le podcast" });
}
// Check if already subscribed
const existingSubscription = await UserSubscription.findOne({
userId: req.user.id,
podcastId: podcast._id,
});
if (!existingSubscription) {
const subscription = new UserSubscription({
userId: req.user.id,
podcastId: podcast._id,
});
await subscription.save();
}
res.status(200).json({
message: existingSubscription ? 'Déjà abonné' : 'Abonnement réussi',
podcast: podcast.toJSON(),
});
} catch (error: any) {
console.error('Subscribe from discovery error:', error);
res.status(500).json({ message: "Impossible de s'abonner" });
}
};
export const getTrendingPodcasts = async (req: Request, res: Response) => {
try {
const limit = Number(req.query.limit) || 20;
const genre = req.query.genre as string;
const trending = await podcastIndexService.getTrendingPodcasts(limit, genre);
res.json({ source: 'itunes', podcasts: trending, count: trending.length });
} catch (error: any) {
console.error('Trending podcasts error:', error.message);
// Return empty rather than crashing the page
res.json({ source: 'itunes', podcasts: [], count: 0 });
}
};