Add canonical base URL support via APP_URL, reverse proxy trust, and dynamic client configuration

This commit is contained in:
Chris 2026-11-01 02:00:22 -05:00
parent eb1bae3b06
commit 095bd54684
3 changed files with 61 additions and 7 deletions

View file

@ -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).

View file

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

View file

@ -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(`<h3>Google Drive Authorization Failed</h3><p>${error || 'No authorization code returned.'}</p>`);
}
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) {