diff --git a/docker-compose.yml b/docker-compose.yml index 3cbf7e5..1118b35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ services: - PORT=3000 - NODE_ENV=production - LOG_LEVEL=info + # Canonical Public Base URL / FQDN (e.g. https://recipes.yourdomain.com). + # Used for public recipe share links, shopping list share links, and OAuth callbacks. + # If unset, dynamically resolves via reverse proxy headers (X-Forwarded-*) or browser origin. + - APP_URL=${APP_URL:-} # Rotate container logs (~50 MB max on disk). The "tag" is used by the # observability stack's Fluent Bit collector to route these logs to the # "forkfolio" stream in OpenObserve (see observability/README.md). diff --git a/public/app.js b/public/app.js index 280fb79..905582b 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,27 @@ let activeShoppingListId = null; let consolidateItems = true; let shoppingGroupBy = 'aisle'; +// Canonical base URL / FQDN tracking (resolved from server or window.location fallback) +let systemAppUrl = ''; + +function getBaseUrl() { + return systemAppUrl || window.location.origin; +} + +async function fetchAppConfig() { + try { + const res = await fetch('/api/app-config'); + if (res.ok) { + const data = await res.json(); + if (data && data.appUrl) { + systemAppUrl = data.appUrl.replace(/\/+$/, ''); + } + } + } catch (err) { + console.debug('Failed to fetch app configuration, defaulting to window.location.origin:', err); + } +} + // Page elements caching const elements = { themeToggle: document.getElementById('theme-toggle'), @@ -245,6 +266,7 @@ const elements = { window.addEventListener('DOMContentLoaded', () => { initTheme(); registerEvents(); + fetchAppConfig(); initAuth(); }); @@ -859,7 +881,7 @@ async function shareActiveRecipe() { // If public and has a share token if (activeRecipe.shareToken) { - const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`; + const shareLink = `${getBaseUrl()}/share.html?token=${activeRecipe.shareToken}`; try { await copyTextToClipboard(shareLink); if (elements.btnDetailShare) { @@ -1814,7 +1836,7 @@ function renderAdminSharedRecipes(recipes) { day: 'numeric' }); - const shareLink = `${window.location.origin}/share.html?token=${recipe.shareToken}`; + const shareLink = `${getBaseUrl()}/share.html?token=${recipe.shareToken}`; const copyActionId = `btn-copy-share-${recipe.id}`; const revokeActionId = `btn-revoke-share-${recipe.id}`; @@ -2610,6 +2632,9 @@ async function syncUserPreferencesFromServer() { const response = await fetch('/api/settings', { headers: getAuthHeaders() }); if (response.ok) { const data = await response.json(); + if (data && data.appUrl) { + systemAppUrl = data.appUrl.replace(/\/+$/, ''); + } if (typeof data.defaultConsolidateLists === 'boolean') { applyUserDefaultConsolidationPreference(data.defaultConsolidateLists); } @@ -3030,7 +3055,7 @@ async function handleShareShoppingList() { const updated = await response.json(); updateShoppingListInState(updated); - const shareUrl = `${window.location.origin}/share-list.html?token=${updated.shareToken}`; + const shareUrl = `${getBaseUrl()}/share-list.html?token=${updated.shareToken}`; await copyTextToClipboard(shareUrl); if (elements.btnShoppingShare) { @@ -3818,7 +3843,7 @@ async function handleAuthorizeGoogleDrive() { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ - origin: window.location.origin + origin: getBaseUrl() }) }); const data = await res.json(); diff --git a/server.js b/server.js index a9fe126..7bde0b6 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,23 @@ puppeteer.use(StealthPlugin()); const app = express(); const PORT = process.env.PORT || 3000; +const APP_URL = (process.env.APP_URL || '').trim().replace(/\/+$/, ''); + +// Enable proxy header trust (e.g. X-Forwarded-Proto, X-Forwarded-Host) when behind reverse proxies / SSL termination +app.set('trust proxy', 1); + +// Helper to determine canonical application base URL +function getBaseUrl(req) { + if (APP_URL) { + return APP_URL; + } + if (req) { + const protocol = req.protocol || 'http'; + const host = req.get('host') || `localhost:${PORT}`; + return `${protocol}://${host}`; + } + return `http://localhost:${PORT}`; +} // Setup directories for persistence const DATA_DIR = path.join(__dirname, 'data'); @@ -479,6 +496,13 @@ function formatRecipe(row) { }; } +// Public runtime configuration for client apps (e.g. canonical base URL) +app.get('/api/app-config', (req, res) => { + res.json({ + appUrl: getBaseUrl(req) + }); +}); + // Auth API: User Registration (Requires Admin session) app.post('/api/auth/register', requireAuth, (req, res) => { if (!req.user.isAdmin) { @@ -1655,7 +1679,8 @@ app.get('/api/settings', requireAuth, (req, res) => { res.json({ geminiApiKey: settings ? settings.geminiApiKey : '', geminiModel: (settings && settings.geminiModel) ? settings.geminiModel : 'gemini-2.5-flash', - defaultConsolidateLists + defaultConsolidateLists, + appUrl: getBaseUrl(req) }); } catch (e) { log.error('Failed to load settings:', e); @@ -4063,7 +4088,7 @@ app.post('/api/admin/backups/gdrive/auth-url', requireAuth, async (req, res) => const effectiveClientId = customClientId || DEFAULT_GDRIVE_CLIENT_ID; let redirectUri = await ensureGdriveLoopbackServer(); if (!redirectUri || customClientId) { - const origin = req.body?.origin || `http://localhost:${PORT}`; + const origin = APP_URL || req.body?.origin || getBaseUrl(req); redirectUri = `${origin}/api/admin/backups/gdrive/callback`; } @@ -4092,7 +4117,7 @@ app.get('/api/admin/backups/gdrive/callback', async (req, res) => { return res.status(400).send(`

Google Drive Authorization Failed

${error || 'No authorization code returned.'}

`); } try { - const redirectUri = `${req.protocol}://${req.get('host')}/api/admin/backups/gdrive/callback`; + const redirectUri = `${getBaseUrl(req)}/api/admin/backups/gdrive/callback`; await completeGoogleDriveOAuthCode(code, redirectUri); res.send(renderOAuthSuccessHtml()); } catch (err) {