feat: add one-click channel approval via email

Replace manual Threadfin lookup instructions with a signed approve
button in admin notification emails. Clicking it activates channels
in Threadfin via WebSocket API and sends a confirmation email to
the requester.

- Add /api/approve-channels endpoint with HMAC-signed tokens
- Add lib/threadfin.ts for WebSocket-based channel activation
- Add lib/token.ts for signed token creation/verification
- Add ws dependency for Threadfin WebSocket communication
- Update docker-compose with TOKEN_SECRET and THREADFIN_* env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 12:15:10 -07:00
parent cee87aef10
commit 8c79b97ef7
8 changed files with 410 additions and 43 deletions

View File

@ -6,3 +6,10 @@ SMTP_PASS=your-mailbox-password
# Admin email to receive access request notifications # Admin email to receive access request notifications
ADMIN_EMAIL=jeff@jeffemmett.com ADMIN_EMAIL=jeff@jeffemmett.com
# Channel approval token signing secret (generate with: openssl rand -hex 32)
TOKEN_SECRET=your-random-secret-here
# Threadfin credentials for one-click channel activation
THREADFIN_USER=your-threadfin-username
THREADFIN_PASS=your-threadfin-password

View File

@ -0,0 +1,163 @@
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])
}

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { createApproveToken } from '@/lib/token'
interface ChannelSelection { interface ChannelSelection {
id: string id: string
@ -75,6 +76,14 @@ export async function POST(request: NextRequest) {
? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}` ? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}`
: `[Jefflix] Channel Request: ${channels.length} channels` : `[Jefflix] Channel Request: ${channels.length} channels`
// Generate approve token for one-click activation
const approveToken = createApproveToken(
channels.map((ch) => ({ id: ch.id, name: ch.name })),
email,
)
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://jefflix.lol'
const approveUrl = `${baseUrl}/api/approve-channels?token=${approveToken}`
await transporter.sendMail({ await transporter.sendMail({
from: `Jefflix <${smtpUser}>`, from: `Jefflix <${smtpUser}>`,
to: adminEmail, to: adminEmail,
@ -82,15 +91,17 @@ export async function POST(request: NextRequest) {
html: ` html: `
<h2>New Channel Request</h2> <h2>New Channel Request</h2>
<p><strong>${escapeHtml(email)}</strong> requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:</p> <p><strong>${escapeHtml(email)}</strong> requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:</p>
<div style="text-align: center; margin: 24px 0;">
<a href="${approveUrl}" style="display: inline-block; background: #22c55e; color: #fff; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-size: 16px; font-weight: 600;">
&#9989; Approve &amp; Add Channels
</a>
</div>
<p style="text-align: center; color: #888; font-size: 12px;">One click activates these channels in Threadfin and notifies the requester. Link expires in 7 days.</p>
<ul style="line-height: 1.8;"> <ul style="line-height: 1.8;">
${channelListHtml} ${channelListHtml}
</ul> </ul>
<p><strong>To add these channels:</strong></p>
<ol>
<li>Search each channel ID in Threadfin / iptv-org</li>
<li>Map the streams in Threadfin</li>
<li>Reply to <a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a> to confirm</li>
</ol>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" /> <hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
<p style="color: #666; font-size: 12px;">Automated message from Jefflix · ${new Date().toLocaleString()}</p> <p style="color: #666; font-size: 12px;">Automated message from Jefflix · ${new Date().toLocaleString()}</p>
`, `,

View File

@ -15,6 +15,10 @@ services:
- SMTP_USER=${SMTP_USER} - SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS} - SMTP_PASS=${SMTP_PASS}
- ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com} - ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com}
- TOKEN_SECRET=${TOKEN_SECRET}
- THREADFIN_URL=https://threadfin.jefflix.lol
- THREADFIN_USER=${THREADFIN_USER}
- THREADFIN_PASS=${THREADFIN_PASS}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)" - "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"

128
lib/threadfin.ts Normal file
View File

@ -0,0 +1,128 @@
import WebSocket from 'ws'
interface ThreadfinConfig {
url: string
user: string
pass: string
}
interface XepgEntry {
'tvg-id': string
'x-active': boolean
[key: string]: unknown
}
type EpgMapping = Record<string, XepgEntry>
interface ActivateResult {
activated: string[]
notFound: string[]
}
function getConfig(): ThreadfinConfig {
const url = process.env.THREADFIN_URL
const user = process.env.THREADFIN_USER
const pass = process.env.THREADFIN_PASS
if (!url || !user || !pass) {
throw new Error('THREADFIN_URL, THREADFIN_USER, THREADFIN_PASS must be set')
}
return { url: url.replace(/\/$/, ''), user, pass }
}
async function login(): Promise<string> {
const { url, user, pass } = getConfig()
const res = await fetch(`${url}/api/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd: 'login', username: user, password: pass }),
})
if (!res.ok) throw new Error(`Threadfin login failed: ${res.status}`)
const data = await res.json()
if (!data.token) throw new Error('Threadfin login returned no token')
return data.token
}
export async function activateChannels(
channelIds: string[],
): Promise<ActivateResult> {
const { url } = getConfig()
const token = await login()
const wsUrl = url.replace(/^http/, 'ws') + `/data/?Token=${token}`
return new Promise<ActivateResult>((resolve, reject) => {
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Threadfin WebSocket timed out after 30s'))
}, 30_000)
let currentToken = token
ws.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString())
// Update token if rotated
if (msg.token) currentToken = msg.token
// Initial data response contains the xepg map
if (msg.xepg) {
const epgMapping: EpgMapping = msg.xepg
const activated: string[] = []
const idSet = new Set(channelIds)
const foundIds = new Set<string>()
// Find and activate matching channels
for (const [key, entry] of Object.entries(epgMapping)) {
const tvgId = entry['tvg-id']
if (tvgId && idSet.has(tvgId)) {
entry['x-active'] = true
activated.push(tvgId)
foundIds.add(tvgId)
}
}
const notFound = channelIds.filter((id) => !foundIds.has(id))
if (activated.length === 0) {
clearTimeout(timeout)
ws.close()
resolve({ activated, notFound })
return
}
// Must send the ENTIRE map back
const saveMsg = JSON.stringify({
cmd: 'saveEpgMapping',
epgMapping,
token: currentToken,
})
ws.send(saveMsg, (err) => {
clearTimeout(timeout)
if (err) {
ws.close()
reject(err)
return
}
// Give Threadfin a moment to process, then close
setTimeout(() => {
ws.close()
resolve({ activated, notFound })
}, 1000)
})
}
} catch (err) {
clearTimeout(timeout)
ws.close()
reject(err)
}
})
})
}

58
lib/token.ts Normal file
View File

@ -0,0 +1,58 @@
import { createHmac } from 'crypto'
interface ApprovePayload {
channels: { id: string; name: string }[]
email: string
exp: number
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
function getSecret(): string {
const secret = process.env.TOKEN_SECRET
if (!secret) throw new Error('TOKEN_SECRET env var is not set')
return secret
}
export function createApproveToken(
channels: { id: string; name: string }[],
email: string,
): string {
const payload: ApprovePayload = {
channels,
email,
exp: Date.now() + SEVEN_DAYS_MS,
}
const data = Buffer.from(JSON.stringify(payload)).toString('base64url')
const sig = createHmac('sha256', getSecret()).update(data).digest('hex')
return `${data}.${sig}`
}
export function verifyApproveToken(token: string): ApprovePayload | null {
const parts = token.split('.')
if (parts.length !== 2) return null
const [data, sig] = parts
const expected = createHmac('sha256', getSecret()).update(data).digest('hex')
// Constant-time comparison
if (sig.length !== expected.length) return null
let mismatch = 0
for (let i = 0; i < sig.length; i++) {
mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i)
}
if (mismatch !== 0) return null
try {
const payload: ApprovePayload = JSON.parse(
Buffer.from(data, 'base64url').toString('utf-8'),
)
if (!payload.exp || !payload.channels || !payload.email) return null
if (Date.now() > payload.exp) return null
return payload
} catch {
return null
}
}

68
package-lock.json generated
View File

@ -36,7 +36,6 @@
"@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6", "@radix-ui/react-tooltip": "1.1.6",
"@vercel/analytics": "latest",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -58,6 +57,7 @@
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.20.0",
"zod": "3.25.76" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
@ -66,6 +66,7 @@
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"postcss": "^8.5", "postcss": "^8.5",
"tailwindcss": "^4.1.9", "tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3", "tw-animate-css": "1.3.3",
@ -2506,42 +2507,14 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@vercel/analytics": { "node_modules/@types/ws": {
"version": "1.6.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MPL-2.0", "dev": true,
"peerDependencies": { "license": "MIT",
"@remix-run/react": "^2", "dependencies": {
"@sveltejs/kit": "^1 || ^2", "@types/node": "*"
"next": ">= 13",
"react": "^18 || ^19 || ^19.0.0-rc",
"svelte": ">= 4",
"vue": "^3",
"vue-router": "^4"
},
"peerDependenciesMeta": {
"@remix-run/react": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"next": {
"optional": true
},
"react": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
},
"vue-router": {
"optional": true
}
} }
}, },
"node_modules/aria-hidden": { "node_modules/aria-hidden": {
@ -4006,6 +3979,27 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@ -58,6 +58,7 @@
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.20.0",
"zod": "3.25.76" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
@ -66,6 +67,7 @@
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"postcss": "^8.5", "postcss": "^8.5",
"tailwindcss": "^4.1.9", "tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3", "tw-animate-css": "1.3.3",