@@ -5,6 +5,55 @@ const api = typeof browser !== "undefined" ? browser : chrome;
55// =============================================================================
66
77const FEEDLY_API_BASE = "https://api.feedly.com" ;
8+ const TOKEN_CACHE_TTL_MS = 10 * 60 * 1000 ; // 10 minutes
9+
10+ // =============================================================================
11+ // Error Handling
12+ // =============================================================================
13+
14+ const ErrorCode = {
15+ NO_TOKEN : 'NO_TOKEN' ,
16+ AUTH_FAILED : 'AUTH_FAILED' ,
17+ RATE_LIMITED : 'RATE_LIMITED' ,
18+ NETWORK_ERROR : 'NETWORK_ERROR' ,
19+ SERVER_ERROR : 'SERVER_ERROR' ,
20+ WRONG_PAGE : 'WRONG_PAGE' ,
21+ UNKNOWN : 'UNKNOWN'
22+ } ;
23+
24+ const UserMessages = {
25+ NO_TOKEN : "Please sign in to Feedly first." ,
26+ AUTH_FAILED : "Session expired. Please refresh the page." ,
27+ RATE_LIMITED : "Too many requests. Please wait a moment." ,
28+ NETWORK_ERROR : "Network error. Please check your connection." ,
29+ SERVER_ERROR : "Feedly service is temporarily unavailable." ,
30+ WRONG_PAGE : "Please open a Feedly Read Later page." ,
31+ UNKNOWN : "Something went wrong. Please try again."
32+ } ;
33+
34+ class FeedlyError extends Error {
35+ constructor ( code , technicalDetail ) {
36+ super ( technicalDetail ) ;
37+ this . name = 'FeedlyError' ;
38+ this . code = code ;
39+ this . userMessage = UserMessages [ code ] || UserMessages . UNKNOWN ;
40+ }
41+
42+ getUserMessage ( ) {
43+ return this . userMessage ;
44+ }
45+
46+ getDebugInfo ( ) {
47+ return `[${ this . code } ] ${ this . message } ` ;
48+ }
49+ }
50+
51+ function classifyHttpError ( status ) {
52+ if ( status === 401 || status === 403 ) return ErrorCode . AUTH_FAILED ;
53+ if ( status === 429 ) return ErrorCode . RATE_LIMITED ;
54+ if ( status >= 500 ) return ErrorCode . SERVER_ERROR ;
55+ return ErrorCode . UNKNOWN ;
56+ }
857
958// =============================================================================
1059// Constants
@@ -55,15 +104,67 @@ function normalizeCount(count) {
55104// Feedly API Functions
56105// =============================================================================
57106
58- // Token storage for communication between page and content script
59- let cachedAccessToken = null ;
107+ // Token storage with metadata for TTL and change detection
108+ let tokenCache = {
109+ token : null ,
110+ cachedAt : 0 ,
111+ sourceHash : null
112+ } ;
113+
114+ /**
115+ * Generate a simple hash for change detection (djb2 algorithm).
116+ * @param {string } str - String to hash
117+ * @returns {number } Simple numeric hash
118+ */
119+ function simpleHash ( str ) {
120+ if ( ! str ) return 0 ;
121+ let hash = 0 ;
122+ for ( let i = 0 ; i < str . length ; i ++ ) {
123+ const char = str . charCodeAt ( i ) ;
124+ hash = ( ( hash << 5 ) - hash ) + char ;
125+ hash = hash & hash ;
126+ }
127+ return hash ;
128+ }
129+
130+ /**
131+ * Check if cached token is still valid.
132+ * Validates TTL expiration and localStorage data integrity.
133+ * @returns {boolean } True if cache is valid
134+ */
135+ function isTokenCacheValid ( ) {
136+ if ( ! tokenCache . token ) {
137+ return false ;
138+ }
139+
140+ // Check TTL expiration
141+ if ( Date . now ( ) - tokenCache . cachedAt > TOKEN_CACHE_TTL_MS ) {
142+ return false ;
143+ }
144+
145+ // Validate against current localStorage
146+ try {
147+ const currentSessionData = localStorage . getItem ( "feedly.session" ) ;
148+ if ( simpleHash ( currentSessionData ) !== tokenCache . sourceHash ) {
149+ return false ;
150+ }
151+ } catch ( e ) {
152+ return false ;
153+ }
154+
155+ return true ;
156+ }
60157
61158/**
62159 * Clear the cached access token.
63160 * Called when authentication fails to allow re-fetching from localStorage.
64161 */
65162function clearAccessTokenCache ( ) {
66- cachedAccessToken = null ;
163+ tokenCache = {
164+ token : null ,
165+ cachedAt : 0 ,
166+ sourceHash : null
167+ } ;
67168}
68169
69170/**
@@ -77,18 +178,25 @@ function clearAccessTokenCache() {
77178 * @returns {Promise<string|null> } Access token or null if not available
78179 */
79180async function getAccessToken ( ) {
80- // Return cached token if available
81- if ( cachedAccessToken ) {
82- return cachedAccessToken ;
181+ // Return cached token if still valid
182+ if ( isTokenCacheValid ( ) ) {
183+ return tokenCache . token ;
83184 }
84185
186+ // Clear expired/invalid cache
187+ clearAccessTokenCache ( ) ;
188+
85189 try {
86190 const sessionData = localStorage . getItem ( "feedly.session" ) ;
87191 if ( sessionData ) {
88192 const session = JSON . parse ( sessionData ) ;
89193 if ( session . feedlyToken ) {
90- cachedAccessToken = session . feedlyToken ;
91- return cachedAccessToken ;
194+ tokenCache = {
195+ token : session . feedlyToken ,
196+ cachedAt : Date . now ( ) ,
197+ sourceHash : simpleHash ( sessionData )
198+ } ;
199+ return tokenCache . token ;
92200 }
93201 }
94202 } catch ( e ) {
@@ -107,26 +215,38 @@ async function getAccessToken() {
107215async function feedlyApiRequest ( endpoint , options = { } ) {
108216 const token = await getAccessToken ( ) ;
109217 if ( ! token ) {
110- throw new Error ( " No Feedly access token available" ) ;
218+ throw new FeedlyError ( ErrorCode . NO_TOKEN , ' No access token in localStorage' ) ;
111219 }
112220
113221 const url = `${ FEEDLY_API_BASE } ${ endpoint } ` ;
114- const response = await fetch ( url , {
115- ...options ,
116- headers : {
117- "Authorization" : `Bearer ${ token } ` ,
118- "Content-Type" : "application/json" ,
119- ...options . headers
120- }
121- } ) ;
222+
223+ let response ;
224+ try {
225+ response = await fetch ( url , {
226+ ...options ,
227+ headers : {
228+ "Authorization" : `Bearer ${ token } ` ,
229+ "Content-Type" : "application/json" ,
230+ ...options . headers
231+ }
232+ } ) ;
233+ } catch ( networkError ) {
234+ throw new FeedlyError (
235+ ErrorCode . NETWORK_ERROR ,
236+ `Fetch failed: ${ networkError . message } `
237+ ) ;
238+ }
122239
123240 if ( ! response . ok ) {
124241 // Clear token cache on authentication errors to allow retry with fresh token
125242 if ( response . status === 401 || response . status === 403 ) {
126243 clearAccessTokenCache ( ) ;
127244 }
128- const errorText = await response . text ( ) . catch ( ( ) => "Unknown error" ) ;
129- throw new Error ( `Feedly API error ${ response . status } : ${ errorText } ` ) ;
245+ const errorBody = await response . text ( ) . catch ( ( ) => "" ) ;
246+ throw new FeedlyError (
247+ classifyHttpError ( response . status ) ,
248+ `API ${ response . status } : ${ errorBody . substring ( 0 , 200 ) } `
249+ ) ;
130250 }
131251
132252 // DELETE requests may return empty body
@@ -244,7 +364,7 @@ async function unsaveEntriesViaAPI(userId, entryIds) {
244364async function handleOpenViaAPI ( settings ) {
245365 const token = await getAccessToken ( ) ;
246366 if ( ! token ) {
247- throw new Error ( " No access token available. Please ensure you are logged into Feedly." ) ;
367+ throw new FeedlyError ( ErrorCode . NO_TOKEN , ' No access token available' ) ;
248368 }
249369
250370 const userId = await getUserId ( ) ;
@@ -605,7 +725,7 @@ function findScrollContainer(startNode) {
605725 */
606726async function handleOpenViaDOM ( settings ) {
607727 if ( ! ( await waitForReadLaterPage ( 2000 ) ) ) {
608- return { ok : false , error : "This tab is not a Feedly Read Later page." } ;
728+ throw new FeedlyError ( ErrorCode . WRONG_PAGE , `Not on Read Later page: ${ location . href } ` ) ;
609729 }
610730
611731 if ( settings . mode === "all" ) {
@@ -639,22 +759,33 @@ async function handleOpen(settings) {
639759 result = await handleOpenViaAPI ( settings ) ;
640760 } catch ( e ) {
641761 apiError = e ;
642- console . warn ( "[Feedly Opener] API operation failed, falling back to DOM:" , e . message ) ;
762+ if ( e instanceof FeedlyError ) {
763+ console . warn ( "[Feedly Opener] API failed:" , e . getDebugInfo ( ) ) ;
764+ } else {
765+ console . warn ( "[Feedly Opener] API failed:" , e . message ) ;
766+ }
643767 }
644768
645769 // Fallback to DOM-based approach if API failed
646770 if ( ! result || ! result . ok ) {
647771 try {
648772 result = await handleOpenViaDOM ( settings ) ;
649- if ( apiError ) {
650- result . apiError = apiError . message ;
651- }
652773 } catch ( domError ) {
653- console . error ( "[Feedly Opener] DOM operation also failed:" , domError ) ;
774+ if ( domError instanceof FeedlyError ) {
775+ console . error ( "[Feedly Opener] DOM failed:" , domError . getDebugInfo ( ) ) ;
776+ } else {
777+ console . error ( "[Feedly Opener] DOM failed:" , domError . message ) ;
778+ }
779+
780+ const userMessage = apiError instanceof FeedlyError
781+ ? apiError . getUserMessage ( )
782+ : UserMessages . UNKNOWN ;
783+
654784 return {
655785 ok : false ,
656- error : `API error: ${ apiError ?. message || "unknown" } . DOM error: ${ domError . message } ` ,
657- method : "failed"
786+ error : userMessage ,
787+ method : "failed" ,
788+ errorCode : apiError instanceof FeedlyError ? apiError . code : ErrorCode . UNKNOWN
658789 } ;
659790 }
660791 }
@@ -706,6 +837,33 @@ function isRecentlyReadLater() {
706837 return location . origin === "https://feedly.com" && lastReadLaterUrl . length > 0 ;
707838}
708839
840+ // =============================================================================
841+ // Message Security
842+ // =============================================================================
843+
844+ /**
845+ * Validate message sender is from our own extension.
846+ * @param {Object } sender - Message sender object
847+ * @returns {boolean } True if sender is valid
848+ */
849+ function validateSender ( sender ) {
850+ return sender && sender . id === api . runtime . id ;
851+ }
852+
853+ /**
854+ * Validate and sanitize settings from message.
855+ * @param {Object } raw - Raw settings object from message
856+ * @returns {Object } Validated settings with safe defaults
857+ */
858+ function validateSettings ( raw ) {
859+ const validModes = [ 'all' , 'count' ] ;
860+ return {
861+ mode : validModes . includes ( raw ?. mode ) ? raw . mode : 'all' ,
862+ count : Math . max ( 1 , Math . min ( 999 , Math . floor ( Number ( raw ?. count ) ) || 10 ) ) ,
863+ reload : typeof raw ?. reload === 'boolean' ? raw . reload : true
864+ } ;
865+ }
866+
709867// =============================================================================
710868// Message Listener
711869// =============================================================================
@@ -717,7 +875,22 @@ if (!window.__feedlyReadLaterOpenerListenerAdded) {
717875 return false ;
718876 }
719877
720- handleOpen ( message . settings || { } ) . then ( sendResponse ) ;
878+ // Validate sender is from our own extension
879+ if ( ! validateSender ( sender ) ) {
880+ sendResponse ( { ok : false , error : "Invalid message sender" } ) ;
881+ return true ;
882+ }
883+
884+ // Validate and sanitize settings
885+ const settings = validateSettings ( message . settings ) ;
886+
887+ handleOpen ( settings )
888+ . then ( sendResponse )
889+ . catch ( ( e ) => {
890+ console . error ( "[Feedly Opener]" , e ) ;
891+ const userMessage = e instanceof FeedlyError ? e . getUserMessage ( ) : UserMessages . UNKNOWN ;
892+ sendResponse ( { ok : false , error : userMessage } ) ;
893+ } ) ;
721894 return true ;
722895 } ) ;
723896}
0 commit comments