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
This commit is contained in:
sebastjanartic 2026-03-01 18:01:27 +00:00
parent 6279a35ab2
commit 77fdd872f6
2 changed files with 319 additions and 5 deletions

267
server/dropbox.ts Normal file
View File

@ -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<DropboxTokens> {
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<DropboxTokens> {
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<string | null> {
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<any[]> {
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<Map<string, string>> {
const results = new Map<string, string>();
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<Map<string, string>> {
const results = new Map<string, string>();
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<GalleryImage[]> {
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 [];
}
}

View File

@ -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("<html><body style='background:#111;color:#fff;font-family:sans-serif;text-align:center;padding:60px'><h1>Dropbox povezan!</h1><p>Lahko zaprete to stran.</p></body></html>");
} 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));