@@ -5,6 +5,58 @@ 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+ CLIENT_ERROR : 'CLIENT_ERROR' ,
21+ WRONG_PAGE : 'WRONG_PAGE' ,
22+ UNKNOWN : 'UNKNOWN'
23+ } ;
24+
25+ const UserMessages = {
26+ NO_TOKEN : "Please sign in to Feedly first." ,
27+ AUTH_FAILED : "Authentication failed. Please sign in to Feedly again." ,
28+ RATE_LIMITED : "Too many requests. Please wait a moment." ,
29+ NETWORK_ERROR : "Network error. Please check your connection." ,
30+ SERVER_ERROR : "Feedly service is temporarily unavailable." ,
31+ CLIENT_ERROR : "Invalid request. Please try again." ,
32+ WRONG_PAGE : "Please open a Feedly Read Later page." ,
33+ UNKNOWN : "Something went wrong. Please try again."
34+ } ;
35+
36+ class FeedlyError extends Error {
37+ constructor ( code , technicalDetail ) {
38+ super ( technicalDetail ) ;
39+ this . name = 'FeedlyError' ;
40+ this . code = code ;
41+ this . userMessage = UserMessages [ code ] || UserMessages . UNKNOWN ;
42+ }
43+
44+ getUserMessage ( ) {
45+ return this . userMessage ;
46+ }
47+
48+ getDebugInfo ( ) {
49+ return `[${ this . code } ] ${ this . message } ` ;
50+ }
51+ }
52+
53+ function classifyHttpError ( status ) {
54+ if ( status === 401 || status === 403 ) return ErrorCode . AUTH_FAILED ;
55+ if ( status === 429 ) return ErrorCode . RATE_LIMITED ;
56+ if ( status >= 500 ) return ErrorCode . SERVER_ERROR ;
57+ if ( status >= 400 ) return ErrorCode . CLIENT_ERROR ;
58+ return ErrorCode . UNKNOWN ;
59+ }
860
961// =============================================================================
1062// Constants
@@ -55,15 +107,67 @@ function normalizeCount(count) {
55107// Feedly API Functions
56108// =============================================================================
57109
58- // Token storage for communication between page and content script
59- let cachedAccessToken = null ;
110+ // Token storage with metadata for TTL and change detection
111+ let tokenCache = {
112+ token : null ,
113+ cachedAt : 0 ,
114+ sourceHash : null
115+ } ;
116+
117+ /**
118+ * Generate a simple hash for change detection (djb2 algorithm).
119+ * @param {string } str - String to hash
120+ * @returns {number } Simple numeric hash
121+ */
122+ function simpleHash ( str ) {
123+ if ( ! str ) return 0 ;
124+ let hash = 0 ;
125+ for ( let i = 0 ; i < str . length ; i ++ ) {
126+ const char = str . charCodeAt ( i ) ;
127+ hash = ( ( hash << 5 ) - hash ) + char ;
128+ hash = hash | 0 ; // Convert to 32-bit signed integer
129+ }
130+ return hash ;
131+ }
132+
133+ /**
134+ * Check if cached token is still valid.
135+ * Validates TTL expiration and localStorage data integrity.
136+ * @returns {boolean } True if cache is valid
137+ */
138+ function isTokenCacheValid ( ) {
139+ if ( ! tokenCache . token ) {
140+ return false ;
141+ }
142+
143+ // Check TTL expiration
144+ if ( Date . now ( ) - tokenCache . cachedAt > TOKEN_CACHE_TTL_MS ) {
145+ return false ;
146+ }
147+
148+ // Validate against current localStorage
149+ try {
150+ const currentSessionData = localStorage . getItem ( "feedly.session" ) ;
151+ if ( simpleHash ( currentSessionData ) !== tokenCache . sourceHash ) {
152+ return false ;
153+ }
154+ } catch ( e ) {
155+ return false ;
156+ }
157+
158+ return true ;
159+ }
60160
61161/**
62162 * Clear the cached access token.
63163 * Called when authentication fails to allow re-fetching from localStorage.
64164 */
65165function clearAccessTokenCache ( ) {
66- cachedAccessToken = null ;
166+ tokenCache = {
167+ token : null ,
168+ cachedAt : 0 ,
169+ sourceHash : null
170+ } ;
67171}
68172
69173/**
@@ -77,18 +181,25 @@ function clearAccessTokenCache() {
77181 * @returns {Promise<string|null> } Access token or null if not available
78182 */
79183async function getAccessToken ( ) {
80- // Return cached token if available
81- if ( cachedAccessToken ) {
82- return cachedAccessToken ;
184+ // Return cached token if still valid
185+ if ( isTokenCacheValid ( ) ) {
186+ return tokenCache . token ;
83187 }
84188
189+ // Clear expired/invalid cache
190+ clearAccessTokenCache ( ) ;
191+
85192 try {
86193 const sessionData = localStorage . getItem ( "feedly.session" ) ;
87194 if ( sessionData ) {
88195 const session = JSON . parse ( sessionData ) ;
89196 if ( session . feedlyToken ) {
90- cachedAccessToken = session . feedlyToken ;
91- return cachedAccessToken ;
197+ tokenCache = {
198+ token : session . feedlyToken ,
199+ cachedAt : Date . now ( ) ,
200+ sourceHash : simpleHash ( sessionData )
201+ } ;
202+ return tokenCache . token ;
92203 }
93204 }
94205 } catch ( e ) {
@@ -107,34 +218,53 @@ async function getAccessToken() {
107218async function feedlyApiRequest ( endpoint , options = { } ) {
108219 const token = await getAccessToken ( ) ;
109220 if ( ! token ) {
110- throw new Error ( " No Feedly access token available" ) ;
221+ throw new FeedlyError ( ErrorCode . NO_TOKEN , ' No access token in localStorage' ) ;
111222 }
112223
113224 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- } ) ;
225+
226+ let response ;
227+ try {
228+ response = await fetch ( url , {
229+ ...options ,
230+ headers : {
231+ "Authorization" : `Bearer ${ token } ` ,
232+ "Content-Type" : "application/json" ,
233+ ...options . headers
234+ }
235+ } ) ;
236+ } catch ( networkError ) {
237+ throw new FeedlyError (
238+ ErrorCode . NETWORK_ERROR ,
239+ `Fetch failed: ${ networkError . message } `
240+ ) ;
241+ }
122242
123243 if ( ! response . ok ) {
124244 // Clear token cache on authentication errors to allow retry with fresh token
125245 if ( response . status === 401 || response . status === 403 ) {
126246 clearAccessTokenCache ( ) ;
127247 }
128- const errorText = await response . text ( ) . catch ( ( ) => "Unknown error" ) ;
129- throw new Error ( `Feedly API error ${ response . status } : ${ errorText } ` ) ;
248+ const errorBody = await response . text ( ) . catch ( ( ) => "" ) ;
249+ throw new FeedlyError (
250+ classifyHttpError ( response . status ) ,
251+ `API ${ response . status } : ${ errorBody . substring ( 0 , 200 ) } `
252+ ) ;
130253 }
131254
132255 // DELETE requests may return empty body
133256 if ( response . status === 204 || response . headers . get ( "content-length" ) === "0" ) {
134257 return { success : true } ;
135258 }
136259
137- return response . json ( ) ;
260+ try {
261+ return await response . json ( ) ;
262+ } catch ( parseError ) {
263+ throw new FeedlyError (
264+ ErrorCode . SERVER_ERROR ,
265+ `Invalid JSON response: ${ parseError . message } `
266+ ) ;
267+ }
138268}
139269
140270/**
@@ -144,7 +274,7 @@ async function feedlyApiRequest(endpoint, options = {}) {
144274async function getUserId ( ) {
145275 const profile = await feedlyApiRequest ( "/v3/profile" ) ;
146276 if ( ! profile . id ) {
147- throw new Error ( "User ID not found in profile response" ) ;
277+ throw new FeedlyError ( ErrorCode . SERVER_ERROR , "User ID not found in profile response" ) ;
148278 }
149279 return profile . id ;
150280}
@@ -242,11 +372,7 @@ async function unsaveEntriesViaAPI(userId, entryIds) {
242372 * @returns {Promise<Object> } Result object with ok, urls, and method
243373 */
244374async function handleOpenViaAPI ( settings ) {
245- const token = await getAccessToken ( ) ;
246- if ( ! token ) {
247- throw new Error ( "No access token available. Please ensure you are logged into Feedly." ) ;
248- }
249-
375+ // Token check is handled by feedlyApiRequest() called from getUserId()
250376 const userId = await getUserId ( ) ;
251377
252378 // Fetch entries: use pagination for "all" mode, single request for "count" mode
@@ -605,7 +731,7 @@ function findScrollContainer(startNode) {
605731 */
606732async function handleOpenViaDOM ( settings ) {
607733 if ( ! ( await waitForReadLaterPage ( 2000 ) ) ) {
608- return { ok : false , error : "This tab is not a Feedly Read Later page." } ;
734+ throw new FeedlyError ( ErrorCode . WRONG_PAGE , `Not on Read Later page: ${ location . href } ` ) ;
609735 }
610736
611737 if ( settings . mode === "all" ) {
@@ -639,28 +765,44 @@ async function handleOpen(settings) {
639765 result = await handleOpenViaAPI ( settings ) ;
640766 } catch ( e ) {
641767 apiError = e ;
642- console . warn ( "[Feedly Opener] API operation failed, falling back to DOM:" , e . message ) ;
768+ if ( e instanceof FeedlyError ) {
769+ console . warn ( "[Feedly Opener] API failed:" , e . getDebugInfo ( ) ) ;
770+ } else {
771+ console . warn ( "[Feedly Opener] API failed:" , e . message ) ;
772+ }
643773 }
644774
645775 // Fallback to DOM-based approach if API failed
646776 if ( ! result || ! result . ok ) {
647777 try {
648778 result = await handleOpenViaDOM ( settings ) ;
649- if ( apiError ) {
650- result . apiError = apiError . message ;
651- }
652779 } catch ( domError ) {
653- console . error ( "[Feedly Opener] DOM operation also failed:" , domError ) ;
780+ if ( domError instanceof FeedlyError ) {
781+ console . error ( "[Feedly Opener] DOM failed:" , domError . getDebugInfo ( ) ) ;
782+ } else {
783+ console . error ( "[Feedly Opener] DOM failed:" , domError . message ) ;
784+ }
785+
786+ // Prioritize DOM WRONG_PAGE error as it's more actionable for users
787+ let userMessage ;
788+ if ( domError instanceof FeedlyError && domError . code === ErrorCode . WRONG_PAGE ) {
789+ userMessage = domError . getUserMessage ( ) ;
790+ } else if ( apiError instanceof FeedlyError ) {
791+ userMessage = apiError . getUserMessage ( ) ;
792+ } else {
793+ userMessage = UserMessages . UNKNOWN ;
794+ }
795+
654796 return {
655797 ok : false ,
656- error : `API error: ${ apiError ?. message || "unknown" } . DOM error: ${ domError . message } ` ,
798+ error : userMessage ,
657799 method : "failed"
658800 } ;
659801 }
660802 }
661803
662- // Always reload after successful operation to reflect UI changes
663- if ( result . ok ) {
804+ // Reload after successful operation if enabled (default: true)
805+ if ( result . ok && settings . reload ) {
664806 setTimeout ( ( ) => {
665807 location . reload ( ) ;
666808 } , 1000 ) ;
@@ -706,6 +848,39 @@ function isRecentlyReadLater() {
706848 return location . origin === "https://feedly.com" && lastReadLaterUrl . length > 0 ;
707849}
708850
851+ // =============================================================================
852+ // Message Security
853+ // =============================================================================
854+
855+ /**
856+ * Validate message sender is from our own extension.
857+ * NOTE: This is defense-in-depth. Messages via runtime.onMessage
858+ * should only come from our extension, but we validate explicitly
859+ * to ensure messages are from the expected source.
860+ * @param {Object } sender - Message sender object
861+ * @returns {boolean } True if sender is valid
862+ */
863+ function validateSender ( sender ) {
864+ return sender && sender . id === api . runtime . id ;
865+ }
866+
867+ /**
868+ * Validate and sanitize settings from message.
869+ * @param {Object } raw - Raw settings object from message
870+ * @returns {Object } Validated settings with safe defaults
871+ */
872+ function validateSettings ( raw ) {
873+ const validModes = [ 'all' , 'count' ] ;
874+ const rawCount = Number ( raw ?. count ) ;
875+ const parsedCount = Number . isFinite ( rawCount ) ? Math . floor ( rawCount ) : 10 ;
876+ const safeCount = parsedCount > 0 ? parsedCount : 10 ;
877+ return {
878+ mode : validModes . includes ( raw ?. mode ) ? raw . mode : 'all' ,
879+ count : Math . max ( 1 , Math . min ( 999 , safeCount ) ) ,
880+ reload : typeof raw ?. reload === 'boolean' ? raw . reload : true
881+ } ;
882+ }
883+
709884// =============================================================================
710885// Message Listener
711886// =============================================================================
@@ -717,7 +892,23 @@ if (!window.__feedlyReadLaterOpenerListenerAdded) {
717892 return false ;
718893 }
719894
720- handleOpen ( message . settings || { } ) . then ( sendResponse ) ;
895+ // Validate sender is from our own extension
896+ if ( ! validateSender ( sender ) ) {
897+ sendResponse ( { ok : false , error : "Invalid message sender" } ) ;
898+ return true ;
899+ }
900+
901+ // Validate and sanitize settings
902+ const settings = validateSettings ( message . settings ) ;
903+
904+ handleOpen ( settings )
905+ . then ( sendResponse )
906+ // Defensive catch for unexpected errors (normal flow returns result object)
907+ . catch ( ( e ) => {
908+ console . error ( "[Feedly Opener]" , e ) ;
909+ const userMessage = e instanceof FeedlyError ? e . getUserMessage ( ) : UserMessages . UNKNOWN ;
910+ sendResponse ( { ok : false , error : userMessage } ) ;
911+ } ) ;
721912 return true ;
722913 } ) ;
723914}
0 commit comments