diff --git a/buffit/index.html b/buffit/index.html index 85a4e14..d69ed93 100644 --- a/buffit/index.html +++ b/buffit/index.html @@ -626,6 +626,18 @@ } catch {} return null; } + function updateTokensFromResponse(response) { + const newAccessToken = response.headers.get('X-New-Access-Token'); + const newRefreshToken = response.headers.get('X-New-Refresh-Token'); + if (newAccessToken) { + const tokens = loadTokens(); + if (tokens) { + tokens.access_token = newAccessToken; + if (newRefreshToken) tokens.refresh_token = newRefreshToken; + saveTokens(tokens); + } + } + } function setOAuthValue(key, value) { try { sessionStorage.setItem(key, value); } catch {} try { localStorage.setItem(key, value); } catch {} @@ -810,14 +822,23 @@ const [channelsRes, accountRes] = await Promise.all([ fetch(PROXY_BASE + '/channels', { method: 'GET', - headers: { 'Authorization': 'Bearer ' + tokens.access_token }, + headers: { + 'Authorization': 'Bearer ' + tokens.access_token, + 'X-Refresh-Token': tokens.refresh_token || '', + }, }), fetch(PROXY_BASE + '/account', { method: 'GET', - headers: { 'Authorization': 'Bearer ' + tokens.access_token }, + headers: { + 'Authorization': 'Bearer ' + tokens.access_token, + 'X-Refresh-Token': tokens.refresh_token || '', + }, }), ]); + updateTokensFromResponse(channelsRes); + updateTokensFromResponse(accountRes); + const channelsData = await channelsRes.json(); const accountData = await accountRes.json(); @@ -1039,6 +1060,7 @@ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + tokens.access_token, + 'X-Refresh-Token': tokens.refresh_token || '', }, body: JSON.stringify({ text: text, @@ -1046,6 +1068,7 @@ assets: assets, }), }).then(async (res) => { + updateTokensFromResponse(res); const data = await res.json(); if (!res.ok || !data.success) { throw new Error(data.error || 'Post failed'); @@ -1093,8 +1116,12 @@ try { const res = await fetch(PROXY_BASE + '/channels?nocache=1', { method: 'GET', - headers: { 'Authorization': 'Bearer ' + tokens.access_token }, + headers: { + 'Authorization': 'Bearer ' + tokens.access_token, + 'X-Refresh-Token': tokens.refresh_token || '', + }, }); + updateTokensFromResponse(res); const data = await res.json(); if (!res.ok || !data.success) { diff --git a/buffit/worker.js b/buffit/worker.js index b04d66a..7517e2f 100644 --- a/buffit/worker.js +++ b/buffit/worker.js @@ -16,6 +16,7 @@ const ALLOWED_ORIGIN = 'https://danielmorrisey.com'; const MEDIA_FOLDER = 'buffit'; +const TOKEN_EXPIRY_BUFFER = 300; // Refresh if within 5 minutes of expiry const UPSTREAM = { '/api': 'https://api.buffer.com', @@ -38,6 +39,66 @@ function json(data, status = 200, extra = {}) { }); } +function decodeJWT(token) { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payload = JSON.parse(atob(parts[1])); + return payload; + } catch { + return null; + } +} + +function isTokenExpired(token) { + const decoded = decodeJWT(token); + if (!decoded || !decoded.exp) return false; + const now = Math.floor(Date.now() / 1000); + return decoded.exp - now < TOKEN_EXPIRY_BUFFER; +} + +async function refreshAccessToken(env, refreshToken) { + const res = await fetch('https://auth.buffer.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: env.BUFFER_CLIENT_ID, + client_secret: env.BUFFER_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }); + const data = await res.json(); + if (!res.ok || !data.access_token) { + throw new Error(data.error_description || 'Token refresh failed'); + } + return data; +} + +async function ensureValidToken(env, authHeader, refreshToken) { + const accessToken = authHeader?.replace('Bearer ', ''); + if (!accessToken) return { accessToken: null, refreshToken, newTokens: null }; + + if (!isTokenExpired(accessToken)) { + return { accessToken, refreshToken, newTokens: null }; + } + + if (!refreshToken || !env.BUFFER_CLIENT_ID || !env.BUFFER_CLIENT_SECRET) { + return { accessToken, refreshToken, newTokens: null }; + } + + try { + const newTokens = await refreshAccessToken(env, refreshToken); + return { + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token || refreshToken, + newTokens, + }; + } catch (e) { + return { accessToken, refreshToken, newTokens: null }; + } +} + export default { async fetch(request, env) { const url = new URL(request.url); @@ -86,6 +147,7 @@ export default { // ── GET /account — fetch and cache account info from Buffer ────────── if (request.method === 'GET' && url.pathname === '/account') { const authHeader = request.headers.get('Authorization'); + const refreshToken = request.headers.get('X-Refresh-Token'); if (!authHeader) { return json({ success: false, error: 'Authorization required' }, 401, cors); } @@ -94,21 +156,36 @@ export default { return json({ success: false, error: 'Account cache not configured' }, 500, cors); } + const { accessToken, newTokens } = await ensureValidToken(env, authHeader, refreshToken); + if (!accessToken) { + return json({ success: false, error: 'Invalid token' }, 401, cors); + } + const nocache = url.searchParams.get('nocache') === '1'; const cacheKey = await hashToken(authHeader + ':account'); if (!nocache) { const cached = await env.CHANNELS_KV.get(cacheKey, { type: 'json' }); if (cached) { - return json(cached, 200, { ...cors, 'X-Cache': 'hit' }); + const extra = { ...cors, 'X-Cache': 'hit' }; + if (newTokens) { + extra['X-New-Access-Token'] = newTokens.access_token; + extra['X-New-Refresh-Token'] = newTokens.refresh_token; + } + return json(cached, 200, extra); } } try { - const account = await fetchAccountFromBuffer(authHeader); + const account = await fetchAccountFromBuffer('Bearer ' + accessToken); const ttl = parseInt(env.CHANNELS_CACHE_TTL || '3600'); await env.CHANNELS_KV.put(cacheKey, JSON.stringify(account), { expirationTtl: ttl }); - return json(account, 200, { ...cors, 'X-Cache': nocache ? 'clear' : 'miss' }); + const extra = { ...cors, 'X-Cache': nocache ? 'clear' : 'miss' }; + if (newTokens) { + extra['X-New-Access-Token'] = newTokens.access_token; + extra['X-New-Refresh-Token'] = newTokens.refresh_token; + } + return json(account, 200, extra); } catch (e) { return json({ success: false, error: `Failed to fetch account: ${e.message}` }, 500, cors); } @@ -117,6 +194,7 @@ export default { // ── POST /post — create a post on Buffer ──────────────────────────── if (request.method === 'POST' && url.pathname === '/post') { const authHeader = request.headers.get('Authorization'); + const refreshToken = request.headers.get('X-Refresh-Token'); if (!authHeader) { return json({ success: false, error: 'Authorization required' }, 401, cors); } @@ -128,9 +206,19 @@ export default { return json({ success: false, error: 'Invalid JSON body' }, 400, cors); } + const { accessToken, newTokens } = await ensureValidToken(env, authHeader, refreshToken); + if (!accessToken) { + return json({ success: false, error: 'Invalid token' }, 401, cors); + } + try { - const result = await createPostOnBuffer(authHeader, body); - return json(result, 200, cors); + const result = await createPostOnBuffer('Bearer ' + accessToken, body); + const extra = { ...cors }; + if (newTokens) { + extra['X-New-Access-Token'] = newTokens.access_token; + extra['X-New-Refresh-Token'] = newTokens.refresh_token; + } + return json(result, 200, extra); } catch (e) { return json({ success: false, error: `Failed to create post: ${e.message}` }, 500, cors); } @@ -139,6 +227,7 @@ export default { // ── GET /channels — fetch and cache channels from Buffer ───────────── if (request.method === 'GET' && url.pathname === '/channels') { const authHeader = request.headers.get('Authorization'); + const refreshToken = request.headers.get('X-Refresh-Token'); if (!authHeader) { return json({ success: false, error: 'Authorization required' }, 401, cors); } @@ -147,21 +236,36 @@ export default { return json({ success: false, error: 'Channels cache not configured' }, 500, cors); } + const { accessToken, newTokens } = await ensureValidToken(env, authHeader, refreshToken); + if (!accessToken) { + return json({ success: false, error: 'Invalid token' }, 401, cors); + } + const nocache = url.searchParams.get('nocache') === '1'; const cacheKey = await hashToken(authHeader); if (!nocache) { const cached = await env.CHANNELS_KV.get(cacheKey, { type: 'json' }); if (cached) { - return json(cached, 200, { ...cors, 'X-Cache': 'hit' }); + const extra = { ...cors, 'X-Cache': 'hit' }; + if (newTokens) { + extra['X-New-Access-Token'] = newTokens.access_token; + extra['X-New-Refresh-Token'] = newTokens.refresh_token; + } + return json(cached, 200, extra); } } try { - const channels = await fetchChannelsFromBuffer(authHeader); + const channels = await fetchChannelsFromBuffer('Bearer ' + accessToken); const ttl = parseInt(env.CHANNELS_CACHE_TTL || '3600'); await env.CHANNELS_KV.put(cacheKey, JSON.stringify(channels), { expirationTtl: ttl }); - return json(channels, 200, { ...cors, 'X-Cache': nocache ? 'clear' : 'miss' }); + const extra = { ...cors, 'X-Cache': nocache ? 'clear' : 'miss' }; + if (newTokens) { + extra['X-New-Access-Token'] = newTokens.access_token; + extra['X-New-Refresh-Token'] = newTokens.refresh_token; + } + return json(channels, 200, extra); } catch (e) { return json({ success: false, error: `Failed to fetch channels: ${e.message}` }, 500, cors); }