jefflix-website/app/api/approve-channels/route.ts

164 lines
6.0 KiB
TypeScript

import { NextRequest } from 'next/server'
import nodemailer from 'nodemailer'
import { verifyApproveToken } from '@/lib/token'
import { activateChannels } from '@/lib/threadfin'
function htmlPage(title: string, body: string): Response {
return new Response(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title} — Jefflix</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 60px auto; padding: 0 20px; color: #1a1a1a; background: #0a0a0a; color: #e5e5e5; }
.card { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 32px; }
.icon { font-size: 48px; margin-bottom: 16px; }
h1 { margin: 0 0 16px; font-size: 24px; }
ul { padding-left: 20px; line-height: 1.8; }
code { background: #2a2a2a; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.success { color: #4ade80; }
.warning { color: #facc15; }
.error { color: #f87171; }
.muted { color: #888; font-size: 13px; margin-top: 24px; }
</style>
</head>
<body>
<div class="card">
${body}
</div>
</body>
</html>`,
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
)
}
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')
if (!token) {
return htmlPage('Invalid Link', `
<div class="icon">&#10060;</div>
<h1 class="error">Invalid Link</h1>
<p>No approval token provided.</p>
`)
}
const payload = verifyApproveToken(token)
if (!payload) {
return htmlPage('Expired or Invalid', `
<div class="icon">&#9203;</div>
<h1 class="error">Link Expired or Invalid</h1>
<p>This approval link has expired or is invalid. Channel requests expire after 7 days.</p>
<p>Ask the user to submit a new request.</p>
`)
}
try {
const result = await activateChannels(
payload.channels.map((ch) => ch.id),
)
// Send confirmation email to the requester
await sendConfirmationEmail(payload.email, result.activated, result.notFound, payload.channels)
const activatedHtml = result.activated.length > 0
? `<h2 class="success">Activated (${result.activated.length})</h2>
<ul>${result.activated.map((id) => {
const ch = payload.channels.find((c) => c.id === id)
return `<li><strong>${ch?.name ?? id}</strong> — <code>${id}</code></li>`
}).join('')}</ul>`
: ''
const notFoundHtml = result.notFound.length > 0
? `<h2 class="warning">Not Found in Playlists (${result.notFound.length})</h2>
<ul>${result.notFound.map((id) => {
const ch = payload.channels.find((c) => c.id === id)
return `<li><strong>${ch?.name ?? id}</strong> — <code>${id}</code></li>`
}).join('')}</ul>
<p class="muted">These channels aren't in the current M3U playlists (english/news/sports). Add them to Threadfin's playlist sources first.</p>`
: ''
return htmlPage('Channels Approved', `
<div class="icon">&#9989;</div>
<h1 class="success">Channels Approved</h1>
<p>Request from <strong>${escapeHtml(payload.email)}</strong> has been processed.</p>
${activatedHtml}
${notFoundHtml}
<p class="muted">A confirmation email has been sent to the requester.</p>
`)
} catch (err) {
console.error('Channel activation error:', err)
return htmlPage('Activation Failed', `
<div class="icon">&#10060;</div>
<h1 class="error">Activation Failed</h1>
<p>Could not connect to Threadfin or activate channels.</p>
<p><code>${escapeHtml(err instanceof Error ? err.message : String(err))}</code></p>
<p>Check Threadfin is running and credentials are correct.</p>
`)
}
}
async function sendConfirmationEmail(
to: string,
activated: string[],
notFound: string[],
channels: { id: string; name: string }[],
) {
const smtpHost = process.env.SMTP_HOST
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (!smtpHost || !smtpUser || !smtpPass) return
const transporter = nodemailer.createTransport({
host: smtpHost,
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: { user: smtpUser, pass: smtpPass },
tls: { rejectUnauthorized: false },
})
const activatedList = activated.map((id) => {
const ch = channels.find((c) => c.id === id)
return `<li>&#9989; <strong>${escapeHtml(ch?.name ?? id)}</strong></li>`
}).join('')
const notFoundList = notFound.map((id) => {
const ch = channels.find((c) => c.id === id)
return `<li>&#10060; <strong>${escapeHtml(ch?.name ?? id)}</strong> — not available in current playlists</li>`
}).join('')
await transporter.sendMail({
from: `Jefflix <${smtpUser}>`,
to,
subject: `[Jefflix] Your channel request has been processed`,
html: `
<h2>Your Channel Request Update</h2>
${activated.length > 0 ? `
<p>The following channels have been activated and should appear in your guide shortly:</p>
<ul style="line-height: 1.8;">${activatedList}</ul>
` : ''}
${notFound.length > 0 ? `
<p>The following channels were not found in the current playlists:</p>
<ul style="line-height: 1.8;">${notFoundList}</ul>
<p style="color: #666; font-size: 13px;">These may become available when new playlist sources are added.</p>
` : ''}
${activated.length > 0 ? '<p>It may take a few minutes for the channels to appear in your TV guide. If you don\'t see them after 15 minutes, try refreshing your IPTV app.</p>' : ''}
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
<p style="color: #666; font-size: 12px;">Jefflix · ${new Date().toLocaleString()}</p>
`,
})
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}
return text.replace(/[&<>"']/g, (char) => map[char])
}