Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
263 changes: 227 additions & 36 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,58 @@ 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',
CLIENT_ERROR: 'CLIENT_ERROR',
WRONG_PAGE: 'WRONG_PAGE',
UNKNOWN: 'UNKNOWN'
};

const UserMessages = {
NO_TOKEN: "Please sign in to Feedly first.",
AUTH_FAILED: "Authentication failed. Please sign in to Feedly again.",
RATE_LIMITED: "Too many requests. Please wait a moment.",
NETWORK_ERROR: "Network error. Please check your connection.",
SERVER_ERROR: "Feedly service is temporarily unavailable.",
CLIENT_ERROR: "Invalid request. Please try again.",
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;
if (status >= 400) return ErrorCode.CLIENT_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.
}

// =============================================================================
// Constants
Expand Down Expand Up @@ -55,15 +107,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 | 0; // Convert to 32-bit signed integer
}
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 +181,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,34 +218,53 @@ 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.
if (response.status === 204 || response.headers.get("content-length") === "0") {
return { success: true };
}

return response.json();
try {
return await response.json();
} catch (parseError) {
throw new FeedlyError(
ErrorCode.SERVER_ERROR,
`Invalid JSON response: ${parseError.message}`
);
}
}

/**
Expand All @@ -144,7 +274,7 @@ async function feedlyApiRequest(endpoint, options = {}) {
async function getUserId() {
const profile = await feedlyApiRequest("/v3/profile");
if (!profile.id) {
throw new Error("User ID not found in profile response");
throw new FeedlyError(ErrorCode.SERVER_ERROR, "User ID not found in profile response");
}
return profile.id;
}
Expand Down Expand Up @@ -242,11 +372,7 @@ async function unsaveEntriesViaAPI(userId, entryIds) {
* @returns {Promise<Object>} Result object with ok, urls, and method
*/
async function handleOpenViaAPI(settings) {
const token = await getAccessToken();
if (!token) {
throw new Error("No access token available. Please ensure you are logged into Feedly.");
}

// Token check is handled by feedlyApiRequest() called from getUserId()
const userId = await getUserId();

// Fetch entries: use pagination for "all" mode, single request for "count" mode
Expand Down Expand Up @@ -605,7 +731,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,28 +765,44 @@ 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);
}

// Prioritize DOM WRONG_PAGE error as it's more actionable for users
let userMessage;
if (domError instanceof FeedlyError && domError.code === ErrorCode.WRONG_PAGE) {
userMessage = domError.getUserMessage();
} else if (apiError instanceof FeedlyError) {
userMessage = apiError.getUserMessage();
} else {
userMessage = UserMessages.UNKNOWN;
}

return {
ok: false,
error: `API error: ${apiError?.message || "unknown"}. DOM error: ${domError.message}`,
error: userMessage,
method: "failed"
};
}
}

// Always reload after successful operation to reflect UI changes
if (result.ok) {
// Reload after successful operation if enabled (default: true)
if (result.ok && settings.reload) {
setTimeout(() => {
location.reload();
}, 1000);
Expand Down Expand Up @@ -706,6 +848,39 @@ function isRecentlyReadLater() {
return location.origin === "https://feedly.com" && lastReadLaterUrl.length > 0;
}

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

/**
* Validate message sender is from our own extension.
* NOTE: This is defense-in-depth. Messages via runtime.onMessage
* should only come from our extension, but we validate explicitly
* to ensure messages are from the expected source.
* @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'];
const rawCount = Number(raw?.count);
const parsedCount = Number.isFinite(rawCount) ? Math.floor(rawCount) : 10;
const safeCount = parsedCount > 0 ? parsedCount : 10;
return {
mode: validModes.includes(raw?.mode) ? raw.mode : 'all',
count: Math.max(1, Math.min(999, safeCount)),
reload: typeof raw?.reload === 'boolean' ? raw.reload : true
};
Comment thread
maro114510 marked this conversation as resolved.
}

// =============================================================================
// Message Listener
// =============================================================================
Expand All @@ -717,7 +892,23 @@ 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)
// Defensive catch for unexpected errors (normal flow returns result object)
.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;
});
}
6 changes: 4 additions & 2 deletions popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ const usesPromises = typeof browser !== "undefined";
const SETTINGS_KEY = "feedlyOpenerSettings";
const DEFAULT_SETTINGS = {
mode: "all",
count: 10
count: 10,
reload: true
};

const storageArea =
Expand Down Expand Up @@ -190,7 +191,8 @@ function readSettingsFromForm() {

return {
mode,
count
count,
reload: DEFAULT_SETTINGS.reload
};
}

Expand Down