From 333cb30e314d8cf9a2af587dd9e081384ce81500 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 5 May 2025 18:51:17 -0500 Subject: [PATCH 1/5] refactor for recalling /decrypt on lesson change --- .../content/courses/CombinedLesson.js | 27 ++-- .../content/courses/CourseSidebar.js | 5 +- .../content/courses/DocumentLesson.js | 15 +- src/components/content/courses/VideoLesson.js | 26 +-- src/config/appConfig.js | 3 +- src/hooks/encryption/useDecryptContent.js | 73 ++++++--- src/pages/course/[slug]/index.js | 149 +++++++++++++----- 7 files changed, 206 insertions(+), 92 deletions(-) diff --git a/src/components/content/courses/CombinedLesson.js b/src/components/content/courses/CombinedLesson.js index ba4eeab..d837973 100644 --- a/src/components/content/courses/CombinedLesson.js +++ b/src/components/content/courses/CombinedLesson.js @@ -167,10 +167,17 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple }, [videoCompleted, videoTracking, lesson.id, setCompleted, isVideo]); const renderContent = () => { - if (isPaid && decryptionPerformed) { + if (!lesson?.content) { + if (isVideo) { + return ( +
+

No content available for this lesson.

+
+ ); + } return ( -
- +
+

No content available for this lesson.

); } @@ -209,15 +216,11 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple ); } - if (lesson?.content) { - return ( -
- -
- ); - } - - return null; + return ( +
+ +
+ ); }; return ( diff --git a/src/components/content/courses/CourseSidebar.js b/src/components/content/courses/CourseSidebar.js index a757233..189e7f1 100644 --- a/src/components/content/courses/CourseSidebar.js +++ b/src/components/content/courses/CourseSidebar.js @@ -36,7 +36,10 @@ const CourseSidebar = ({ } ${isMobileView ? 'mb-3' : 'mb-2'} `} - onClick={() => onLessonSelect(index)} + onClick={() => { + // Force full page refresh to trigger proper decryption + window.location.href = `/course/${window.location.pathname.split('/').pop()}?active=${index}`; + }} >
{lesson.image && ( diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index 8af1ba7..d13cb49 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.js @@ -113,9 +113,14 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple }, [isCompleted, lesson.id, setCompleted, isTracking]); const renderContent = () => { - if (isPaid && decryptionPerformed) { - return ; + if (!lesson?.content) { + return ( +
+

No content available for this lesson.

+
+ ); } + if (isPaid && !decryptionPerformed) { return (
@@ -128,10 +133,8 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
); } - if (lesson?.content) { - return ; - } - return null; + + return ; }; return ( diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index 326ff53..1a704e0 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -163,13 +163,17 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }, [decryptionPerformed, isPaid, checkDuration]); const renderContent = () => { - if (isPaid && decryptionPerformed) { + // Content not available + if (!lesson?.content) { return ( -
- +
+

No content available for this lesson.

); - } else if (isPaid && !decryptionPerformed) { + } + + // Paid content that needs to be purchased + if (isPaid && !decryptionPerformed) { return (
); - } else if (lesson?.content) { - return ( -
- -
- ); } - return null; + + // Content is available and decrypted (or free) + return ( +
+ +
+ ); }; return ( diff --git a/src/config/appConfig.js b/src/config/appConfig.js index a8325ba..4363377 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,7 +11,8 @@ const appConfig = { ], authorPubkeys: [ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', - 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' + 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345', + '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4' ], customLightningAddresses: [ { diff --git a/src/hooks/encryption/useDecryptContent.js b/src/hooks/encryption/useDecryptContent.js index 5584f02..ae12ec6 100644 --- a/src/hooks/encryption/useDecryptContent.js +++ b/src/hooks/encryption/useDecryptContent.js @@ -1,30 +1,63 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import axios from 'axios'; export const useDecryptContent = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - - const decryptContent = async encryptedContent => { - setIsLoading(true); - setError(null); - - try { - const response = await axios.post('/api/decrypt', { encryptedContent }); - - if (response.status !== 200) { - throw new Error('Failed to decrypt content'); - } - - const decryptedContent = response.data.decryptedContent; - setIsLoading(false); - return decryptedContent; - } catch (err) { - setError(err.message); - setIsLoading(false); + const inProgressRef = useRef(false); + const cachedResults = useRef({}); + + const decryptContent = async (encryptedContent) => { + // Validate input + if (!encryptedContent) { return null; } + + // Prevent multiple simultaneous calls + if (inProgressRef.current) { + // Wait for a small delay to prevent tight loop + await new Promise(resolve => setTimeout(resolve, 100)); + + // Return a cached result if we have one + const firstChars = encryptedContent.substring(0, 20); + if (cachedResults.current[firstChars]) { + return cachedResults.current[firstChars]; + } + + return null; + } + + // Check if we've already decrypted this content + const firstChars = encryptedContent.substring(0, 20); + if (cachedResults.current[firstChars]) { + return cachedResults.current[firstChars]; + } + + try { + inProgressRef.current = true; + setIsLoading(true); + setError(null); + + const response = await axios.post('/api/decrypt', { encryptedContent }); + + if (response.status !== 200) { + throw new Error(`Failed to decrypt: ${response.statusText}`); + } + + const decryptedContent = response.data.decryptedContent; + + // Cache the result + cachedResults.current[firstChars] = decryptedContent; + + return decryptedContent; + } catch (error) { + setError(error.message || 'Decryption failed'); + return null; + } finally { + setIsLoading(false); + inProgressRef.current = false; + } }; - + return { decryptContent, isLoading, error }; }; diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 22de673..49c384d 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useRouter } from 'next/router'; import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr'; import CourseDetails from '@/components/content/courses/CourseDetails'; @@ -135,42 +135,102 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { return { lessons, uniqueLessons, setLessons }; }; -const useDecryption = (session, paidCourse, course, lessons, setLessons) => { - const [decryptionPerformed, setDecryptionPerformed] = useState(false); - const [loading, setLoading] = useState(true); +const useDecryption = (session, paidCourse, course, lessons, setLessons, router) => { + const [decryptedLessonIds, setDecryptedLessonIds] = useState({}); + const [loading, setLoading] = useState(false); const { decryptContent } = useDecryptContent(); - - useEffect(() => { - const decrypt = async () => { - if (session?.user && paidCourse && !decryptionPerformed) { - setLoading(true); - const canAccess = - session.user.purchased?.some(purchase => purchase.courseId === course?.d) || - session.user?.role?.subscribed || - session.user?.pubkey === course?.pubkey; - - if (canAccess && lessons.length > 0) { - try { - const decryptedLessons = await Promise.all( - lessons.map(async lesson => { - const decryptedContent = await decryptContent(lesson.content); - return { ...lesson, content: decryptedContent }; - }) - ); - setLessons(decryptedLessons); - setDecryptionPerformed(true); - } catch (error) { - console.error('Error decrypting lessons:', error); - } - } - setLoading(false); + const processingRef = useRef(false); + const lastLessonIdRef = useRef(null); + + // Get the current active lesson + const currentLessonIndex = router.query.active ? parseInt(router.query.active, 10) : 0; + const currentLesson = lessons.length > 0 ? lessons[currentLessonIndex] : null; + const currentLessonId = currentLesson?.id; + + // Check if the current lesson has been decrypted + const isCurrentLessonDecrypted = + !paidCourse || + (currentLessonId && decryptedLessonIds[currentLessonId]); + + // Check user access + const hasAccess = useMemo(() => { + if (!session?.user || !paidCourse || !course) return false; + + return ( + session.user.purchased?.some(purchase => purchase.courseId === course?.d) || + session.user?.role?.subscribed || + session.user?.pubkey === course?.pubkey + ); + }, [session, paidCourse, course]); + + // Simplified decrypt function + const decryptCurrentLesson = useCallback(async () => { + if (!currentLesson || !hasAccess || !paidCourse) return; + if (processingRef.current) return; + if (decryptedLessonIds[currentLesson.id]) return; + if (!currentLesson.content) return; + + try { + processingRef.current = true; + setLoading(true); + + // Add safety timeout to prevent infinite processing + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Decryption timeout')), 10000) + ); + + // Race between decryption and timeout + const decryptedContent = await Promise.race([ + decryptContent(currentLesson.content), + timeoutPromise + ]); + + if (!decryptedContent) { + return; } + + // Update the lessons array with decrypted content + const updatedLessons = lessons.map(lesson => + lesson.id === currentLesson.id + ? { ...lesson, content: decryptedContent } + : lesson + ); + + setLessons(updatedLessons); + + // Mark this lesson as decrypted + setDecryptedLessonIds(prev => ({ + ...prev, + [currentLesson.id]: true + })); + } catch (error) { + // Silent error handling to prevent UI disruption + } finally { setLoading(false); - }; - decrypt(); - }, [session, paidCourse, course, lessons, decryptionPerformed, setLessons]); - - return { decryptionPerformed, loading }; + processingRef.current = false; + } + }, [currentLesson, hasAccess, paidCourse, decryptContent, lessons, setLessons, decryptedLessonIds]); + + // Run decryption when lesson changes + useEffect(() => { + if (!currentLessonId) return; + + // Skip if the lesson hasn't changed + if (lastLessonIdRef.current === currentLessonId) return; + + // Update the last processed lesson id + lastLessonIdRef.current = currentLessonId; + + if (hasAccess && paidCourse && !decryptedLessonIds[currentLessonId]) { + decryptCurrentLesson(); + } + }, [currentLessonId, hasAccess, paidCourse, decryptedLessonIds, decryptCurrentLesson]); + + return { + decryptionPerformed: isCurrentLessonDecrypted, + loading, + decryptedLessonIds + }; }; const Course = () => { @@ -262,12 +322,13 @@ const Course = () => { course?.pubkey ); - const { decryptionPerformed, loading: decryptionLoading } = useDecryption( + const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption( session, paidCourse, course, lessons, - setLessons + setLessons, + router ); useEffect(() => { @@ -478,23 +539,27 @@ const Course = () => { } const renderLesson = lesson => { + if (!lesson) return null; + + // Check if this specific lesson is decrypted + const lessonDecrypted = !paidCourse || decryptedLessonIds[lesson.id] || false; + if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) { return ( - ); } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { return ( @@ -504,7 +569,7 @@ const Course = () => { @@ -545,7 +610,9 @@ const Course = () => {
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
- {renderLesson(uniqueLessons[activeIndex])} +
+ {renderLesson(uniqueLessons[activeIndex])} +
) : (
From c54785353e41422e23c4f8a8ee09c4fdfbad4e83 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sat, 10 May 2025 16:56:40 -0500 Subject: [PATCH 2/5] add retry logic for lesson decryption with timeout handling --- src/pages/course/[slug]/index.js | 50 +++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 49c384d..4a92a1e 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -141,6 +141,8 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons, router) const { decryptContent } = useDecryptContent(); const processingRef = useRef(false); const lastLessonIdRef = useRef(null); + const retryCountRef = useRef({}); + const MAX_RETRIES = 3; // Get the current active lesson const currentLessonIndex = router.query.active ? parseInt(router.query.active, 10) : 0; @@ -163,6 +165,13 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons, router) ); }, [session, paidCourse, course]); + // Reset retry count when lesson changes + useEffect(() => { + if (currentLessonId && lastLessonIdRef.current !== currentLessonId) { + retryCountRef.current[currentLessonId] = 0; + } + }, [currentLessonId]); + // Simplified decrypt function const decryptCurrentLesson = useCallback(async () => { if (!currentLesson || !hasAccess || !paidCourse) return; @@ -170,6 +179,19 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons, router) if (decryptedLessonIds[currentLesson.id]) return; if (!currentLesson.content) return; + // Check retry count + if (!retryCountRef.current[currentLesson.id]) { + retryCountRef.current[currentLesson.id] = 0; + } + + // Limit maximum retries + if (retryCountRef.current[currentLesson.id] >= MAX_RETRIES) { + return; + } + + // Increment retry count + retryCountRef.current[currentLesson.id]++; + try { processingRef.current = true; setLoading(true); @@ -179,11 +201,22 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons, router) setTimeout(() => reject(new Error('Decryption timeout')), 10000) ); - // Race between decryption and timeout - const decryptedContent = await Promise.race([ - decryptContent(currentLesson.content), - timeoutPromise - ]); + // Use a separate try-catch for the race + let decryptedContent; + try { + // Race between decryption and timeout + decryptedContent = await Promise.race([ + decryptContent(currentLesson.content), + timeoutPromise + ]); + } catch (error) { + // If timeout or network error, schedule a retry + setTimeout(() => { + processingRef.current = false; + decryptCurrentLesson(); + }, 5000); + throw error; + } if (!decryptedContent) { return; @@ -203,6 +236,9 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons, router) ...prev, [currentLesson.id]: true })); + + // Reset retry counter on success + retryCountRef.current[currentLesson.id] = 0; } catch (error) { // Silent error handling to prevent UI disruption } finally { @@ -215,8 +251,8 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons, router) useEffect(() => { if (!currentLessonId) return; - // Skip if the lesson hasn't changed - if (lastLessonIdRef.current === currentLessonId) return; + // Skip if the lesson hasn't changed, unless it failed decryption previously + if (lastLessonIdRef.current === currentLessonId && decryptedLessonIds[currentLessonId]) return; // Update the last processed lesson id lastLessonIdRef.current = currentLessonId; From f0f5b5476879db03b90554881e775c960cbfce8b Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sat, 10 May 2025 17:00:10 -0500 Subject: [PATCH 3/5] use per-content promise map for concurrent decryption --- src/hooks/encryption/useDecryptContent.js | 89 +++++++++++++---------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/src/hooks/encryption/useDecryptContent.js b/src/hooks/encryption/useDecryptContent.js index ae12ec6..2eae6f9 100644 --- a/src/hooks/encryption/useDecryptContent.js +++ b/src/hooks/encryption/useDecryptContent.js @@ -4,7 +4,8 @@ import axios from 'axios'; export const useDecryptContent = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const inProgressRef = useRef(false); + // Map of in-progress decryption promises, keyed by content hash + const inProgressMap = useRef(new Map()); const cachedResults = useRef({}); const decryptContent = async (encryptedContent) => { @@ -13,49 +14,63 @@ export const useDecryptContent = () => { return null; } - // Prevent multiple simultaneous calls - if (inProgressRef.current) { - // Wait for a small delay to prevent tight loop - await new Promise(resolve => setTimeout(resolve, 100)); - - // Return a cached result if we have one - const firstChars = encryptedContent.substring(0, 20); - if (cachedResults.current[firstChars]) { - return cachedResults.current[firstChars]; - } - - return null; - } + // Use first 20 chars as our cache/lock key + const cacheKey = encryptedContent.substring(0, 20); // Check if we've already decrypted this content - const firstChars = encryptedContent.substring(0, 20); - if (cachedResults.current[firstChars]) { - return cachedResults.current[firstChars]; + if (cachedResults.current[cacheKey]) { + return cachedResults.current[cacheKey]; } - try { - inProgressRef.current = true; - setIsLoading(true); - setError(null); - - const response = await axios.post('/api/decrypt', { encryptedContent }); - - if (response.status !== 200) { - throw new Error(`Failed to decrypt: ${response.statusText}`); + // Check if this specific content is already being decrypted + if (inProgressMap.current.has(cacheKey)) { + // Return the existing promise for this content + try { + return await inProgressMap.current.get(cacheKey); + } catch (error) { + // If the existing promise rejects, we'll try again below + console.warn('Previous decryption attempt failed, retrying'); } - - const decryptedContent = response.data.decryptedContent; - - // Cache the result - cachedResults.current[firstChars] = decryptedContent; - - return decryptedContent; + } + + // Create a new decryption promise for this content + const decryptPromise = (async () => { + try { + setIsLoading(true); + setError(null); + + const response = await axios.post('/api/decrypt', { encryptedContent }); + + if (response.status !== 200) { + throw new Error(`Failed to decrypt: ${response.statusText}`); + } + + const decryptedContent = response.data.decryptedContent; + + // Cache the successful result + cachedResults.current[cacheKey] = decryptedContent; + + return decryptedContent; + } catch (error) { + setError(error.message || 'Decryption failed'); + // Re-throw to signal failure to awaiter + throw error; + } finally { + setIsLoading(false); + // Remove this promise from the in-progress map + inProgressMap.current.delete(cacheKey); + } + })(); + + // Store the promise in our map + inProgressMap.current.set(cacheKey, decryptPromise); + + // Return the promise + try { + return await decryptPromise; } catch (error) { - setError(error.message || 'Decryption failed'); + // We've already set the error state in the promise return null; - } finally { - setIsLoading(false); - inProgressRef.current = false; } }; From 1e9e9471b7c6ed3a016cf72882b3dd44f1fb4b23 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sat, 10 May 2025 17:04:39 -0500 Subject: [PATCH 4/5] cancel in-flight decryption requests on timeout --- src/hooks/encryption/useDecryptContent.js | 29 ++++++++++++++++++++--- src/pages/course/[slug]/index.js | 21 ++++++++++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/hooks/encryption/useDecryptContent.js b/src/hooks/encryption/useDecryptContent.js index 2eae6f9..3d46f0a 100644 --- a/src/hooks/encryption/useDecryptContent.js +++ b/src/hooks/encryption/useDecryptContent.js @@ -29,17 +29,25 @@ export const useDecryptContent = () => { return await inProgressMap.current.get(cacheKey); } catch (error) { // If the existing promise rejects, we'll try again below - console.warn('Previous decryption attempt failed, retrying'); + if (error.name !== 'AbortError') { + console.warn('Previous decryption attempt failed, retrying'); + } } } + // Create abort controller for this request + const abortController = new AbortController(); + // Create a new decryption promise for this content const decryptPromise = (async () => { try { setIsLoading(true); setError(null); - const response = await axios.post('/api/decrypt', { encryptedContent }); + const response = await axios.post('/api/decrypt', + { encryptedContent }, + { signal: abortController.signal } + ); if (response.status !== 200) { throw new Error(`Failed to decrypt: ${response.statusText}`); @@ -52,6 +60,11 @@ export const useDecryptContent = () => { return decryptedContent; } catch (error) { + // Handle abort errors specifically + if (axios.isCancel(error)) { + throw new DOMException('Decryption aborted', 'AbortError'); + } + setError(error.message || 'Decryption failed'); // Re-throw to signal failure to awaiter throw error; @@ -62,9 +75,19 @@ export const useDecryptContent = () => { } })(); - // Store the promise in our map + // Store the promise and abort controller in our map + const abortablePromise = { + promise: decryptPromise, + abort: () => abortController.abort() + }; + inProgressMap.current.set(cacheKey, decryptPromise); + // Function to handle timeouts from parent callers + decryptPromise.cancel = () => { + abortController.abort(); + }; + // Return the promise try { return await decryptPromise; diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 4a92a1e..b03be74 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -196,19 +196,32 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons, router) processingRef.current = true; setLoading(true); + // Start the decryption process + const decryptionPromise = decryptContent(currentLesson.content); + // Add safety timeout to prevent infinite processing - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Decryption timeout')), 10000) - ); + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + // Cancel the in-flight request when timeout occurs + if (decryptionPromise.cancel) { + decryptionPromise.cancel(); + } + reject(new Error('Decryption timeout')); + }, 10000); + }); // Use a separate try-catch for the race let decryptedContent; try { // Race between decryption and timeout decryptedContent = await Promise.race([ - decryptContent(currentLesson.content), + decryptionPromise, timeoutPromise ]); + + // Clear the timeout if decryption wins + clearTimeout(timeoutId); } catch (error) { // If timeout or network error, schedule a retry setTimeout(() => { From 1aed72ab780d002ae57895d4f55735fdebc55060 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sat, 10 May 2025 17:08:04 -0500 Subject: [PATCH 5/5] remove test pubkey from config --- src/config/appConfig.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/config/appConfig.js b/src/config/appConfig.js index 4363377..a8325ba 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,8 +11,7 @@ const appConfig = { ], authorPubkeys: [ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', - 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345', - '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4' + 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' ], customLightningAddresses: [ {