index.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
  2. import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
  3. import { isValidExternalUrl, verifySuperUser, validateCronSecret, isInternalCall } from '../_shared/security.ts'
  4. const corsHeaders = {
  5. 'Access-Control-Allow-Origin': '*',
  6. 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-cron-secret',
  7. }
  8. serve(async (req) => {
  9. if (req.method === 'OPTIONS') {
  10. return new Response(null, { headers: corsHeaders })
  11. }
  12. try {
  13. // Authentication: Allow cron jobs, internal calls, or super users
  14. const isCronJob = await validateCronSecret(req);
  15. const isInternal = isInternalCall(req);
  16. const isSuperUser = await verifySuperUser(req);
  17. if (!isCronJob && !isInternal && !isSuperUser) {
  18. console.log('Unauthorized access attempt to update-feed');
  19. return new Response(
  20. JSON.stringify({ error: 'Unauthorized - Super user access, cron secret, or service role required' }),
  21. { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  22. )
  23. }
  24. const { feedId, url } = await req.json()
  25. // Input validation
  26. if (!feedId || typeof feedId !== 'string') {
  27. return new Response(
  28. JSON.stringify({ error: 'Valid Feed ID is required' }),
  29. { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  30. )
  31. }
  32. if (!url || typeof url !== 'string') {
  33. return new Response(
  34. JSON.stringify({ error: 'Valid URL is required' }),
  35. { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  36. )
  37. }
  38. // SSRF Protection: Validate URL
  39. const urlValidation = isValidExternalUrl(url);
  40. if (!urlValidation.valid) {
  41. console.log(`SSRF blocked in update-feed: ${url} - ${urlValidation.error}`);
  42. return new Response(
  43. JSON.stringify({ error: urlValidation.error }),
  44. { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  45. )
  46. }
  47. // Initialize Supabase client
  48. const supabase = createClient(
  49. Deno.env.get('SUPABASE_URL') ?? '',
  50. Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  51. )
  52. // Verify the feed exists
  53. const { data: existingFeed, error: feedCheckError } = await supabase
  54. .from('feeds')
  55. .select('id')
  56. .eq('id', feedId)
  57. .single()
  58. if (feedCheckError || !existingFeed) {
  59. return new Response(
  60. JSON.stringify({ error: 'Feed not found' }),
  61. { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  62. )
  63. }
  64. console.log(`Fetching RSS feed from: ${url}`)
  65. // Fetch RSS feed with timeout
  66. const controller = new AbortController();
  67. const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
  68. let response;
  69. try {
  70. response = await fetch(url, {
  71. signal: controller.signal,
  72. headers: {
  73. 'User-Agent': 'Mozilla/5.0 (compatible; RSS Feed Updater/1.0)'
  74. }
  75. })
  76. } finally {
  77. clearTimeout(timeoutId);
  78. }
  79. if (!response.ok) {
  80. throw new Error(`HTTP error! status: ${response.status}`)
  81. }
  82. const rssText = await response.text()
  83. // Limit content size
  84. if (rssText.length > 5 * 1024 * 1024) { // 5MB limit
  85. throw new Error('RSS feed content too large');
  86. }
  87. console.log('RSS content fetched successfully')
  88. // Parse RSS content to extract metadata
  89. const titleMatch = rssText.match(/<title[^>]*>(.*?)<\/title>/i)
  90. const descriptionMatch = rssText.match(/<description[^>]*>(.*?)<\/description>/i) ||
  91. rssText.match(/<subtitle[^>]*>(.*?)<\/subtitle>/i)
  92. // Count items in the feed
  93. const itemMatches = rssText.match(/<item[^>]*>/gi) || rssText.match(/<entry[^>]*>/gi) || []
  94. const articleCount = itemMatches.length
  95. // Clean up extracted text (remove HTML tags and decode entities)
  96. const cleanText = (text: string) => {
  97. if (!text) return ''
  98. return text
  99. .replace(/<!\[CDATA\[(.*?)\]\]>/g, '$1')
  100. .replace(/<[^>]+>/g, '')
  101. .replace(/&lt;/g, '<')
  102. .replace(/&gt;/g, '>')
  103. .replace(/&amp;/g, '&')
  104. .replace(/&quot;/g, '"')
  105. .replace(/&#39;/g, "'")
  106. .trim()
  107. }
  108. const extractedTitle = titleMatch ? cleanText(titleMatch[1]) : null
  109. const extractedDescription = descriptionMatch ? cleanText(descriptionMatch[1]) : null
  110. console.log(`Extracted data: title="${extractedTitle}", description="${extractedDescription}", articles=${articleCount}`)
  111. // Update feed in database
  112. const updateData: any = {
  113. last_updated: new Date().toISOString(),
  114. article_count: articleCount,
  115. status: 'active'
  116. }
  117. // Only update title and description if we found them and they're different
  118. if (extractedTitle) {
  119. updateData.name = extractedTitle
  120. }
  121. if (extractedDescription) {
  122. updateData.description = extractedDescription
  123. }
  124. const { data, error } = await supabase
  125. .from('feeds')
  126. .update(updateData)
  127. .eq('id', feedId)
  128. .select()
  129. if (error) {
  130. console.error('Database update error:', error)
  131. return new Response(
  132. JSON.stringify({ error: 'Failed to update feed in database' }),
  133. { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  134. )
  135. }
  136. console.log('Feed updated successfully')
  137. return new Response(
  138. JSON.stringify({
  139. success: true,
  140. data: data[0],
  141. extracted: {
  142. title: extractedTitle,
  143. description: extractedDescription,
  144. articleCount
  145. }
  146. }),
  147. { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  148. )
  149. } catch (error: any) {
  150. console.error('Error updating feed:', error)
  151. return new Response(
  152. JSON.stringify({ error: 'An error occurred while updating the feed' }),
  153. { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
  154. )
  155. }
  156. })