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));