From 77fdd872f6a53cc1d243e95ee0e8b7a63dc2381f Mon Sep 17 00:00:00 2001 From: sebastjanartic <45803536-sebastjanartic@users.noreply.replit.com> Date: Sun, 1 Mar 2026 18:01:27 +0000 Subject: [PATCH] Add Dropbox integration for secure image storage and retrieval Integrates Dropbox OAuth for authentication, adds API routes for authorization, token exchange, and fetching gallery images, and updates the gallery API to support fetching images directly from Dropbox. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: e2398e24-3175-4cbf-91ff-003984c8d042 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/ls5p9ni Replit-Helium-Checkpoint-Created: true --- server/dropbox.ts | 267 ++++++++++++++++++++++++++++++++++++++++++++++ server/routes.ts | 57 +++++++++- 2 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 server/dropbox.ts diff --git a/server/dropbox.ts b/server/dropbox.ts new file mode 100644 index 0000000..0ebb5d6 --- /dev/null +++ b/server/dropbox.ts @@ -0,0 +1,267 @@ +import fs from "fs"; +import path from "path"; + +const TOKEN_PATH = path.join(process.cwd(), "server/dropbox-token.json"); +const DROPBOX_FOLDER = "/Folx News Gallery"; + +interface DropboxTokens { + access_token: string; + refresh_token: string; + expires_at: number; +} + +interface GalleryImage { + folder: string; + fileName: string; + thumb: string; + large: string; + full: string; +} + +function getAppKey(): string { + return process.env.DROPBOX_APP_KEY || ""; +} + +function getAppSecret(): string { + return process.env.DROPBOX_APP_SECRET || ""; +} + +export function getAuthUrl(redirectUri: string): string { + const params = new URLSearchParams({ + client_id: getAppKey(), + response_type: "code", + redirect_uri: redirectUri, + token_access_type: "offline", + }); + return `https://www.dropbox.com/oauth2/authorize?${params.toString()}`; +} + +export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise { + const resp = await fetch("https://api.dropboxapi.com/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + client_id: getAppKey(), + client_secret: getAppSecret(), + }), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error(`Token exchange failed: ${err}`); + } + + const data = await resp.json(); + const tokens: DropboxTokens = { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: Date.now() + data.expires_in * 1000, + }; + + fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2)); + return tokens; +} + +function loadTokens(): DropboxTokens | null { + try { + if (fs.existsSync(TOKEN_PATH)) { + return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8")); + } + } catch {} + return null; +} + +async function refreshAccessToken(refreshToken: string): Promise { + const resp = await fetch("https://api.dropboxapi.com/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: getAppKey(), + client_secret: getAppSecret(), + }), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error(`Token refresh failed: ${err}`); + } + + const data = await resp.json(); + const tokens: DropboxTokens = { + access_token: data.access_token, + refresh_token: refreshToken, + expires_at: Date.now() + data.expires_in * 1000, + }; + + fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2)); + return tokens; +} + +async function getValidAccessToken(): Promise { + let tokens = loadTokens(); + if (!tokens) return null; + + if (Date.now() > tokens.expires_at - 60000) { + tokens = await refreshAccessToken(tokens.refresh_token); + } + + return tokens.access_token; +} + +export function isConnected(): boolean { + return loadTokens() !== null; +} + +async function listFolder(accessToken: string, folderPath: string): Promise { + const entries: any[] = []; + let cursor: string | null = null; + let hasMore = true; + + while (hasMore) { + const url = cursor + ? "https://api.dropboxapi.com/2/files/list_folder/continue" + : "https://api.dropboxapi.com/2/files/list_folder"; + + const body = cursor + ? { cursor } + : { path: folderPath, recursive: false, limit: 2000 }; + + const resp = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error(`List folder failed: ${err}`); + } + + const data = await resp.json(); + entries.push(...data.entries); + hasMore = data.has_more; + cursor = data.cursor; + } + + return entries; +} + +async function getThumbnailBatch(accessToken: string, paths: string[]): Promise> { + const results = new Map(); + const batchSize = 25; + + for (let i = 0; i < paths.length; i += batchSize) { + const batch = paths.slice(i, i + batchSize); + const entries = batch.map((p) => ({ + path: p, + format: "jpeg", + size: "w256h256", + mode: "fitone_bestfit", + })); + + try { + const resp = await fetch("https://content.dropboxapi.com/2/files/get_thumbnail_batch", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ entries }), + }); + + if (resp.ok) { + const data = await resp.json(); + for (const entry of data.entries) { + if (entry[".tag"] === "success") { + results.set(entry.metadata.path_lower, `data:image/jpeg;base64,${entry.thumbnail}`); + } + } + } + } catch {} + } + + return results; +} + +async function getTemporaryLinks(accessToken: string, paths: string[]): Promise> { + const results = new Map(); + + for (const p of paths) { + try { + const resp = await fetch("https://api.dropboxapi.com/2/files/get_temporary_link", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ path: p }), + }); + + if (resp.ok) { + const data = await resp.json(); + results.set(p.toLowerCase(), data.link); + } + } catch {} + } + + return results; +} + +const galleryCache: { data: GalleryImage[]; timestamp: number } = { data: [], timestamp: 0 }; +const CACHE_DURATION = 30 * 60 * 1000; + +export async function fetchGalleryFromDropbox(): Promise { + if (galleryCache.data.length > 0 && Date.now() - galleryCache.timestamp < CACHE_DURATION) { + return galleryCache.data; + } + + const accessToken = await getValidAccessToken(); + if (!accessToken) return []; + + try { + const topEntries = await listFolder(accessToken, DROPBOX_FOLDER); + const folders = topEntries.filter((e: any) => e[".tag"] === "folder"); + const images: GalleryImage[] = []; + + for (const folder of folders) { + const folderName = folder.name; + const folderEntries = await listFolder(accessToken, folder.path_lower); + const imageFiles = folderEntries.filter( + (e: any) => e[".tag"] === "file" && /\.(jpg|jpeg|png|webp|gif)$/i.test(e.name) + ); + + const paths = imageFiles.map((f: any) => f.path_lower); + const tempLinks = await getTemporaryLinks(accessToken, paths); + + for (const file of imageFiles) { + const link = tempLinks.get(file.path_lower) || ""; + images.push({ + folder: folderName, + fileName: file.name, + thumb: link ? `${link}&size=256x256` : "", + large: link, + full: link, + }); + } + } + + galleryCache.data = images; + galleryCache.timestamp = Date.now(); + + const galleryPath = path.join(process.cwd(), "server/gallery-data.json"); + fs.writeFileSync(galleryPath, JSON.stringify(images, null, 2)); + + return images; + } catch (err: any) { + console.error("Dropbox gallery fetch error:", err.message); + return []; + } +} diff --git a/server/routes.ts b/server/routes.ts index f145864..897453c 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -6,6 +6,7 @@ import { seedDatabase } from "./seed"; import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope } from "./horoscope-generator"; import { analyzeAllArticleImages, getCachedFocalPoints } from "./focal-point"; import { optimizeImage } from "./image-optimizer"; +import { getAuthUrl, exchangeCodeForTokens, isConnected, fetchGalleryFromDropbox } from "./dropbox"; import multer from "multer"; import path from "path"; import fs from "fs"; @@ -195,12 +196,58 @@ export async function registerRoutes( } }); - // Gallery API - serves shuffled photos from Dropbox - app.get("/api/gallery", (_req, res) => { + // Dropbox OAuth + app.get("/api/dropbox/auth", (_req, res) => { + const host = _req.headers.host || "news-folx-tv.replit.app"; + const protocol = _req.headers["x-forwarded-proto"] || "https"; + const redirectUri = `${protocol}://${host}/api/dropbox/callback`; + const url = getAuthUrl(redirectUri); + res.redirect(url); + }); + + app.get("/api/dropbox/callback", async (req, res) => { + const code = req.query.code as string; + if (!code) { + return res.status(400).send("Manjka avtorizacijska koda."); + } try { - const galleryPath = path.join(process.cwd(), "server/gallery-data.json"); - const data = JSON.parse(fs.readFileSync(galleryPath, "utf-8")); - // Shuffle using Fisher-Yates + const host = req.headers.host || "news-folx-tv.replit.app"; + const protocol = req.headers["x-forwarded-proto"] || "https"; + const redirectUri = `${protocol}://${host}/api/dropbox/callback`; + await exchangeCodeForTokens(code, redirectUri); + res.send("

Dropbox povezan!

Lahko zaprete to stran.

"); + } catch (err: any) { + res.status(500).send(`Napaka: ${err.message}`); + } + }); + + app.get("/api/dropbox/status", (_req, res) => { + res.json({ connected: isConnected() }); + }); + + app.post("/api/dropbox/refresh-gallery", async (_req, res) => { + try { + const images = await fetchGalleryFromDropbox(); + res.json({ count: images.length }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + // Gallery API - serves shuffled photos from Dropbox + app.get("/api/gallery", async (_req, res) => { + try { + let data: any[] = []; + + if (isConnected()) { + data = await fetchGalleryFromDropbox(); + } + + if (data.length === 0) { + const galleryPath = path.join(process.cwd(), "server/gallery-data.json"); + data = JSON.parse(fs.readFileSync(galleryPath, "utf-8")); + } + const shuffled = [...data]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1));