mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-20 13:38:04 +02:00
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>
337 lines
11 KiB
TypeScript
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 });
|
|
}
|
|
};
|