Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 202 additions & 29 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,55 @@ const api = typeof browser !== "undefined" ? browser : chrome;
// =============================================================================

const FEEDLY_API_BASE = "https://api.feedly.com";
const TOKEN_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
Comment thread
maro114510 marked this conversation as resolved.

// =============================================================================
// Error Handling
// =============================================================================

const ErrorCode = {
NO_TOKEN: 'NO_TOKEN',
AUTH_FAILED: 'AUTH_FAILED',
RATE_LIMITED: 'RATE_LIMITED',
NETWORK_ERROR: 'NETWORK_ERROR',
SERVER_ERROR: 'SERVER_ERROR',
WRONG_PAGE: 'WRONG_PAGE',
UNKNOWN: 'UNKNOWN'
};

const UserMessages = {
NO_TOKEN: "Please sign in to Feedly first.",
AUTH_FAILED: "Session expired. Please refresh the page.",
Comment thread
maro114510 marked this conversation as resolved.
Outdated
RATE_LIMITED: "Too many requests. Please wait a moment.",
NETWORK_ERROR: "Network error. Please check your connection.",
SERVER_ERROR: "Feedly service is temporarily unavailable.",
WRONG_PAGE: "Please open a Feedly Read Later page.",
UNKNOWN: "Something went wrong. Please try again."
};

class FeedlyError extends Error {
constructor(code, technicalDetail) {
super(technicalDetail);
this.name = 'FeedlyError';
this.code = code;
this.userMessage = UserMessages[code] || UserMessages.UNKNOWN;
}

getUserMessage() {
return this.userMessage;
}

getDebugInfo() {
return `[${this.code}] ${this.message}`;
}
}

function classifyHttpError(status) {
if (status === 401 || status === 403) return ErrorCode.AUTH_FAILED;
if (status === 429) return ErrorCode.RATE_LIMITED;
if (status >= 500) return ErrorCode.SERVER_ERROR;
return ErrorCode.UNKNOWN;
Comment on lines +53 to +58
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The classifyHttpError function does not handle 4xx client errors other than 401, 403, and 429. For example, a 400 Bad Request or 404 Not Found would be classified as ErrorCode.UNKNOWN, which has the generic message "Something went wrong. Please try again." Consider adding a case for other 4xx errors (e.g., if (status >= 400 && status < 500) return ErrorCode.CLIENT_ERROR) with a more specific user message like "Invalid request. Please try again or contact support."

Copilot uses AI. Check for mistakes.
}
Comment thread
maro114510 marked this conversation as resolved.

// =============================================================================
// Constants
Expand Down Expand Up @@ -55,15 +104,67 @@ function normalizeCount(count) {
// Feedly API Functions
// =============================================================================

// Token storage for communication between page and content script
let cachedAccessToken = null;
// Token storage with metadata for TTL and change detection
let tokenCache = {
token: null,
cachedAt: 0,
sourceHash: null
};

/**
* Generate a simple hash for change detection (djb2 algorithm).
* @param {string} str - String to hash
* @returns {number} Simple numeric hash
*/
function simpleHash(str) {
if (!str) return 0;
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
Comment thread
maro114510 marked this conversation as resolved.
Outdated
}
return hash;
}

/**
* Check if cached token is still valid.
* Validates TTL expiration and localStorage data integrity.
* @returns {boolean} True if cache is valid
*/
function isTokenCacheValid() {
if (!tokenCache.token) {
return false;
}

// Check TTL expiration
if (Date.now() - tokenCache.cachedAt > TOKEN_CACHE_TTL_MS) {
return false;
}

// Validate against current localStorage
try {
const currentSessionData = localStorage.getItem("feedly.session");
if (simpleHash(currentSessionData) !== tokenCache.sourceHash) {
return false;
Comment on lines +151 to +152
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hash-based change detection in isTokenCacheValid could produce false positives (cache invalidation when data hasn't changed) due to hash collisions. While the djb2 algorithm is generally good, collisions are still possible. A more robust approach would be to store the actual token value in the cache and compare it directly with the parsed token from localStorage, or use a more collision-resistant comparison method. This is particularly important for security-sensitive token validation.

Copilot uses AI. Check for mistakes.
}
} catch (e) {
return false;
}

return true;
}

/**
* Clear the cached access token.
* Called when authentication fails to allow re-fetching from localStorage.
*/
function clearAccessTokenCache() {
cachedAccessToken = null;
tokenCache = {
token: null,
cachedAt: 0,
sourceHash: null
};
}

/**
Expand All @@ -77,18 +178,25 @@ function clearAccessTokenCache() {
* @returns {Promise<string|null>} Access token or null if not available
*/
async function getAccessToken() {
// Return cached token if available
if (cachedAccessToken) {
return cachedAccessToken;
// Return cached token if still valid
if (isTokenCacheValid()) {
return tokenCache.token;
}

// Clear expired/invalid cache
clearAccessTokenCache();

try {
const sessionData = localStorage.getItem("feedly.session");
if (sessionData) {
const session = JSON.parse(sessionData);
if (session.feedlyToken) {
cachedAccessToken = session.feedlyToken;
return cachedAccessToken;
tokenCache = {
token: session.feedlyToken,
cachedAt: Date.now(),
sourceHash: simpleHash(sessionData)
};
return tokenCache.token;
}
}
} catch (e) {
Expand All @@ -107,26 +215,38 @@ async function getAccessToken() {
async function feedlyApiRequest(endpoint, options = {}) {
const token = await getAccessToken();
if (!token) {
throw new Error("No Feedly access token available");
throw new FeedlyError(ErrorCode.NO_TOKEN, 'No access token in localStorage');
}

const url = `${FEEDLY_API_BASE}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
...options.headers
}
});

let response;
try {
response = await fetch(url, {
...options,
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
...options.headers
}
});
} catch (networkError) {
throw new FeedlyError(
ErrorCode.NETWORK_ERROR,
`Fetch failed: ${networkError.message}`
);
}

if (!response.ok) {
// Clear token cache on authentication errors to allow retry with fresh token
if (response.status === 401 || response.status === 403) {
clearAccessTokenCache();
}
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(`Feedly API error ${response.status}: ${errorText}`);
const errorBody = await response.text().catch(() => "");
throw new FeedlyError(
classifyHttpError(response.status),
`API ${response.status}: ${errorBody.substring(0, 200)}`
);
}

// DELETE requests may return empty body
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response.json() call at line 257 is not wrapped in error handling. If the response body is not valid JSON, this will throw an error that is not a FeedlyError, breaking the consistent error handling pattern. Consider wrapping this in a try-catch block and throwing a FeedlyError with an appropriate error code (e.g., ErrorCode.SERVER_ERROR) if JSON parsing fails.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -244,7 +364,7 @@ async function unsaveEntriesViaAPI(userId, entryIds) {
async function handleOpenViaAPI(settings) {
const token = await getAccessToken();
if (!token) {
throw new Error("No access token available. Please ensure you are logged into Feedly.");
throw new FeedlyError(ErrorCode.NO_TOKEN, 'No access token available');
}
Comment thread
maro114510 marked this conversation as resolved.
Outdated

const userId = await getUserId();
Expand Down Expand Up @@ -605,7 +725,7 @@ function findScrollContainer(startNode) {
*/
async function handleOpenViaDOM(settings) {
if (!(await waitForReadLaterPage(2000))) {
return { ok: false, error: "This tab is not a Feedly Read Later page." };
throw new FeedlyError(ErrorCode.WRONG_PAGE, `Not on Read Later page: ${location.href}`);
}

if (settings.mode === "all") {
Expand Down Expand Up @@ -639,22 +759,33 @@ async function handleOpen(settings) {
result = await handleOpenViaAPI(settings);
} catch (e) {
apiError = e;
console.warn("[Feedly Opener] API operation failed, falling back to DOM:", e.message);
if (e instanceof FeedlyError) {
console.warn("[Feedly Opener] API failed:", e.getDebugInfo());
} else {
console.warn("[Feedly Opener] API failed:", e.message);
}
}

// Fallback to DOM-based approach if API failed
if (!result || !result.ok) {
try {
result = await handleOpenViaDOM(settings);
if (apiError) {
result.apiError = apiError.message;
}
} catch (domError) {
console.error("[Feedly Opener] DOM operation also failed:", domError);
if (domError instanceof FeedlyError) {
console.error("[Feedly Opener] DOM failed:", domError.getDebugInfo());
} else {
console.error("[Feedly Opener] DOM failed:", domError.message);
}

const userMessage = apiError instanceof FeedlyError
? apiError.getUserMessage()
: UserMessages.UNKNOWN;
Comment thread
maro114510 marked this conversation as resolved.
Outdated

return {
ok: false,
error: `API error: ${apiError?.message || "unknown"}. DOM error: ${domError.message}`,
method: "failed"
error: userMessage,
method: "failed",
errorCode: apiError instanceof FeedlyError ? apiError.code : ErrorCode.UNKNOWN
Comment thread
maro114510 marked this conversation as resolved.
Outdated
};
Comment thread
maro114510 marked this conversation as resolved.
Outdated
}
}
Expand Down Expand Up @@ -706,6 +837,33 @@ function isRecentlyReadLater() {
return location.origin === "https://feedly.com" && lastReadLaterUrl.length > 0;
}

// =============================================================================
// Message Security
// =============================================================================

/**
* Validate message sender is from our own extension.
* @param {Object} sender - Message sender object
* @returns {boolean} True if sender is valid
*/
function validateSender(sender) {
return sender && sender.id === api.runtime.id;
}
Comment thread
maro114510 marked this conversation as resolved.

/**
* Validate and sanitize settings from message.
* @param {Object} raw - Raw settings object from message
* @returns {Object} Validated settings with safe defaults
*/
function validateSettings(raw) {
const validModes = ['all', 'count'];
return {
mode: validModes.includes(raw?.mode) ? raw.mode : 'all',
count: Math.max(1, Math.min(999, Math.floor(Number(raw?.count)) || 10)),
Comment thread
maro114510 marked this conversation as resolved.
Outdated
Comment thread
maro114510 marked this conversation as resolved.
Outdated
reload: typeof raw?.reload === 'boolean' ? raw.reload : true
Comment thread
maro114510 marked this conversation as resolved.
Outdated
};
Comment thread
maro114510 marked this conversation as resolved.
}

// =============================================================================
// Message Listener
// =============================================================================
Expand All @@ -717,7 +875,22 @@ if (!window.__feedlyReadLaterOpenerListenerAdded) {
return false;
}

handleOpen(message.settings || {}).then(sendResponse);
// Validate sender is from our own extension
if (!validateSender(sender)) {
sendResponse({ ok: false, error: "Invalid message sender" });
return true;
}

// Validate and sanitize settings
const settings = validateSettings(message.settings);

handleOpen(settings)
.then(sendResponse)
.catch((e) => {
console.error("[Feedly Opener]", e);
const userMessage = e instanceof FeedlyError ? e.getUserMessage() : UserMessages.UNKNOWN;
sendResponse({ ok: false, error: userMessage });
});
Comment thread
maro114510 marked this conversation as resolved.
return true;
});
}
Loading