From 8c79b97ef7f440012c2dea1d80d718e778caa25c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 12:15:10 -0700 Subject: [PATCH] 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 --- .env.example | 7 ++ app/api/approve-channels/route.ts | 163 ++++++++++++++++++++++++++++++ app/api/request-channel/route.ts | 23 +++-- docker-compose.yml | 4 + lib/threadfin.ts | 128 +++++++++++++++++++++++ lib/token.ts | 58 +++++++++++ package-lock.json | 68 ++++++------- package.json | 2 + 8 files changed, 410 insertions(+), 43 deletions(-) create mode 100644 app/api/approve-channels/route.ts create mode 100644 lib/threadfin.ts create mode 100644 lib/token.ts diff --git a/.env.example b/.env.example index 24aa0cc..7aea2eb 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,10 @@ SMTP_PASS=your-mailbox-password # Admin email to receive access request notifications 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 diff --git a/app/api/approve-channels/route.ts b/app/api/approve-channels/route.ts new file mode 100644 index 0000000..3b34cd8 --- /dev/null +++ b/app/api/approve-channels/route.ts @@ -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( + ` + + + + + ${title} — Jefflix + + + +
+ ${body} +
+ +`, + { 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', ` +
+

Invalid Link

+

No approval token provided.

+ `) + } + + const payload = verifyApproveToken(token) + if (!payload) { + return htmlPage('Expired or Invalid', ` +
+

Link Expired or Invalid

+

This approval link has expired or is invalid. Channel requests expire after 7 days.

+

Ask the user to submit a new request.

+ `) + } + + 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 + ? `

Activated (${result.activated.length})

+ ` + : '' + + const notFoundHtml = result.notFound.length > 0 + ? `

Not Found in Playlists (${result.notFound.length})

+ +

These channels aren't in the current M3U playlists (english/news/sports). Add them to Threadfin's playlist sources first.

` + : '' + + return htmlPage('Channels Approved', ` +
+

Channels Approved

+

Request from ${escapeHtml(payload.email)} has been processed.

+ ${activatedHtml} + ${notFoundHtml} +

A confirmation email has been sent to the requester.

+ `) + } catch (err) { + console.error('Channel activation error:', err) + return htmlPage('Activation Failed', ` +
+

Activation Failed

+

Could not connect to Threadfin or activate channels.

+

${escapeHtml(err instanceof Error ? err.message : String(err))}

+

Check Threadfin is running and credentials are correct.

+ `) + } +} + +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 `
  • ${escapeHtml(ch?.name ?? id)}
  • ` + }).join('') + + const notFoundList = notFound.map((id) => { + const ch = channels.find((c) => c.id === id) + return `
  • ${escapeHtml(ch?.name ?? id)} — not available in current playlists
  • ` + }).join('') + + await transporter.sendMail({ + from: `Jefflix <${smtpUser}>`, + to, + subject: `[Jefflix] Your channel request has been processed`, + html: ` +

    Your Channel Request Update

    + ${activated.length > 0 ? ` +

    The following channels have been activated and should appear in your guide shortly:

    + + ` : ''} + ${notFound.length > 0 ? ` +

    The following channels were not found in the current playlists:

    + +

    These may become available when new playlist sources are added.

    + ` : ''} + ${activated.length > 0 ? '

    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.

    ' : ''} +
    +

    Jefflix · ${new Date().toLocaleString()}

    + `, + }) +} + +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + } + return text.replace(/[&<>"']/g, (char) => map[char]) +} diff --git a/app/api/request-channel/route.ts b/app/api/request-channel/route.ts index 32c38e1..2a86974 100644 --- a/app/api/request-channel/route.ts +++ b/app/api/request-channel/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import nodemailer from 'nodemailer' +import { createApproveToken } from '@/lib/token' interface ChannelSelection { id: string @@ -75,6 +76,14 @@ export async function POST(request: NextRequest) { ? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}` : `[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({ from: `Jefflix <${smtpUser}>`, to: adminEmail, @@ -82,15 +91,17 @@ export async function POST(request: NextRequest) { html: `

    New Channel Request

    ${escapeHtml(email)} requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:

    + +
    + + ✅ Approve & Add Channels + +
    +

    One click activates these channels in Threadfin and notifies the requester. Link expires in 7 days.

    +
      ${channelListHtml}
    -

    To add these channels:

    -
      -
    1. Search each channel ID in Threadfin / iptv-org
    2. -
    3. Map the streams in Threadfin
    4. -
    5. Reply to ${escapeHtml(email)} to confirm
    6. -

    Automated message from Jefflix · ${new Date().toLocaleString()}

    `, diff --git a/docker-compose.yml b/docker-compose.yml index 299a1b3..cd17ec4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,10 @@ services: - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - 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: - "traefik.enable=true" - "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)" diff --git a/lib/threadfin.ts b/lib/threadfin.ts new file mode 100644 index 0000000..7236256 --- /dev/null +++ b/lib/threadfin.ts @@ -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 + +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 { + 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 { + const { url } = getConfig() + const token = await login() + + const wsUrl = url.replace(/^http/, 'ws') + `/data/?Token=${token}` + + return new Promise((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() + + // 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) + } + }) + }) +} diff --git a/lib/token.ts b/lib/token.ts new file mode 100644 index 0000000..4e58da6 --- /dev/null +++ b/lib/token.ts @@ -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 + } +} diff --git a/package-lock.json b/package-lock.json index 2c80526..22b986a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", - "@vercel/analytics": "latest", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -58,6 +57,7 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", + "ws": "^8.20.0", "zod": "3.25.76" }, "devDependencies": { @@ -66,6 +66,7 @@ "@types/nodemailer": "^7.0.9", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "postcss": "^8.5", "tailwindcss": "^4.1.9", "tw-animate-css": "1.3.3", @@ -2506,42 +2507,14 @@ "@types/react": "^19.2.0" } }, - "node_modules/@vercel/analytics": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", - "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", - "license": "MPL-2.0", - "peerDependencies": { - "@remix-run/react": "^2", - "@sveltejs/kit": "^1 || ^2", - "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/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" } }, "node_modules/aria-hidden": { @@ -4006,6 +3979,27 @@ "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": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 2347872..c4701a6 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", + "ws": "^8.20.0", "zod": "3.25.76" }, "devDependencies": { @@ -66,6 +67,7 @@ "@types/nodemailer": "^7.0.9", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "postcss": "^8.5", "tailwindcss": "^4.1.9", "tw-animate-css": "1.3.3",