diff --git a/buffit/privacy.html b/buffit/privacy.html index 5f1e5ef..91662ca 100644 --- a/buffit/privacy.html +++ b/buffit/privacy.html @@ -207,6 +207,7 @@
  • OAuth handling: Securely exchanges your authorization code for an access token (client secret never exposed to browser).
  • Token refresh: Automatically checks if your access token is expiring and refreshes it server-side before making API calls. Refreshed tokens are returned to your browser via response headers and stored automatically.
  • Media storage: Accepts file uploads and stores them in Cloudflare R2 object storage. Files are publicly accessible via the CDN.
  • +
  • Media cleanup: Automatically deletes uploaded media files after 15 minutes if they haven't been posted. This reduces storage costs for unused uploads.
  • Post creation: Handles GraphQL mutations to create posts on Buffer, executed server-side with your authenticated token.
  • Channel caching: Caches your Buffer channels and account info for 1 hour to reduce API calls to Buffer. Cache is keyed by a hash of your access token and is only accessed with valid authorization.
  • @@ -218,7 +219,7 @@
  • Your access and refresh tokens are stored in your browser's session storage only. Refresh tokens are sent to our Worker to enable automatic token renewal before expiration (within 5 minutes).
  • When tokens are refreshed server-side, the new tokens are returned to your browser via secure response headers and stored in your browser session.
  • Cached channel and account data expires automatically after 1 hour.
  • -
  • Uploaded media files are stored indefinitely on our CDN until manually deleted.
  • +
  • Uploaded media files are stored on our CDN and automatically deleted after 15 minutes if not posted. Posted media files (included in posts to Buffer) remain on the CDN indefinitely until manually deleted.
  • We do not log or store your posts, channels, or account information.
  • diff --git a/buffit/worker.js b/buffit/worker.js index 7517e2f..c856be6 100644 --- a/buffit/worker.js +++ b/buffit/worker.js @@ -1,4 +1,4 @@ -// BuffIT Worker — OAuth relay + media upload to R2 + channels caching +// BuffIT Worker — OAuth relay + media upload to R2 + channels caching + auto-cleanup // Deploy this as a Cloudflare Worker, e.g. at buffit-proxy..workers.dev // Not part of the website build — kept here for reference/deploy only. // @@ -13,10 +13,15 @@ // Environment variables in wrangler.toml: // MEDIA_PUBLIC_URL — public CDN URL (e.g. https://public-cdn.madebydanny.uk) // CHANNELS_CACHE_TTL — cache TTL in seconds (default 3600 = 1 hour) +// +// Scheduled handler in wrangler.toml: +// [[triggers.crons]] +// cron = "*/10 * * * *" # Run every 10 minutes to cleanup old media const ALLOWED_ORIGIN = 'https://danielmorrisey.com'; const MEDIA_FOLDER = 'buffit'; const TOKEN_EXPIRY_BUFFER = 300; // Refresh if within 5 minutes of expiry +const MEDIA_TTL_MINUTES = 15; // Delete uploaded media after 15 minutes const UPSTREAM = { '/api': 'https://api.buffer.com', @@ -100,6 +105,11 @@ async function ensureValidToken(env, authHeader, refreshToken) { } export default { + async scheduled(event, env) { + const result = await cleanupOldMedia(env); + console.log(`Cleaned up ${result.deleted} old media files`); + }, + async fetch(request, env) { const url = new URL(request.url); const origin = request.headers.get('Origin') || ''; @@ -129,6 +139,8 @@ export default { httpMetadata: { contentType }, }); + await storeUploadMetadata(env, r2Key, Date.now()); + const publicUrl = env.MEDIA_PUBLIC_URL ? `${env.MEDIA_PUBLIC_URL}/${r2Key}` : `https://public-cdn.madebydanny.uk/${r2Key}`; @@ -327,6 +339,46 @@ async function hashToken(token) { return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } +async function storeUploadMetadata(env, r2Key, timestamp) { + if (!env.CHANNELS_KV) return; + const uploads = await env.CHANNELS_KV.get('_buffit_uploads', { type: 'json' }) || []; + uploads.push({ r2Key, timestamp }); + await env.CHANNELS_KV.put('_buffit_uploads', JSON.stringify(uploads)); +} + +async function cleanupOldMedia(env) { + if (!env.CHANNELS_KV || !env.MEDIA_BUCKET) return { deleted: 0 }; + + const uploads = await env.CHANNELS_KV.get('_buffit_uploads', { type: 'json' }) || []; + const now = Date.now(); + const ttlMs = MEDIA_TTL_MINUTES * 60 * 1000; + + let deleted = 0; + const remaining = []; + + for (const upload of uploads) { + if (now - upload.timestamp > ttlMs) { + try { + await env.MEDIA_BUCKET.delete(upload.r2Key); + deleted++; + } catch (e) { + console.error(`Failed to delete ${upload.r2Key}:`, e.message); + remaining.push(upload); + } + } else { + remaining.push(upload); + } + } + + if (remaining.length > 0) { + await env.CHANNELS_KV.put('_buffit_uploads', JSON.stringify(remaining)); + } else { + await env.CHANNELS_KV.delete('_buffit_uploads'); + } + + return { deleted }; +} + async function fetchChannelsFromBuffer(authHeader) { const gqlQuery = async (query, variables) => { const res = await fetch('https://api.buffer.com', {