mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Add canonical base URL support via APP_URL, reverse proxy trust, and dynamic client configuration
This commit is contained in:
parent
eb1bae3b06
commit
095bd54684
3 changed files with 61 additions and 7 deletions
|
|
@ -13,6 +13,10 @@ services:
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- LOG_LEVEL=info
|
- 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
|
# 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
|
# observability stack's Fluent Bit collector to route these logs to the
|
||||||
# "forkfolio" stream in OpenObserve (see observability/README.md).
|
# "forkfolio" stream in OpenObserve (see observability/README.md).
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,27 @@ let activeShoppingListId = null;
|
||||||
let consolidateItems = true;
|
let consolidateItems = true;
|
||||||
let shoppingGroupBy = 'aisle';
|
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
|
// Page elements caching
|
||||||
const elements = {
|
const elements = {
|
||||||
themeToggle: document.getElementById('theme-toggle'),
|
themeToggle: document.getElementById('theme-toggle'),
|
||||||
|
|
@ -245,6 +266,7 @@ const elements = {
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
registerEvents();
|
registerEvents();
|
||||||
|
fetchAppConfig();
|
||||||
initAuth();
|
initAuth();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -859,7 +881,7 @@ async function shareActiveRecipe() {
|
||||||
|
|
||||||
// If public and has a share token
|
// If public and has a share token
|
||||||
if (activeRecipe.shareToken) {
|
if (activeRecipe.shareToken) {
|
||||||
const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`;
|
const shareLink = `${getBaseUrl()}/share.html?token=${activeRecipe.shareToken}`;
|
||||||
try {
|
try {
|
||||||
await copyTextToClipboard(shareLink);
|
await copyTextToClipboard(shareLink);
|
||||||
if (elements.btnDetailShare) {
|
if (elements.btnDetailShare) {
|
||||||
|
|
@ -1814,7 +1836,7 @@ function renderAdminSharedRecipes(recipes) {
|
||||||
day: 'numeric'
|
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 copyActionId = `btn-copy-share-${recipe.id}`;
|
||||||
const revokeActionId = `btn-revoke-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() });
|
const response = await fetch('/api/settings', { headers: getAuthHeaders() });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (data && data.appUrl) {
|
||||||
|
systemAppUrl = data.appUrl.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
if (typeof data.defaultConsolidateLists === 'boolean') {
|
if (typeof data.defaultConsolidateLists === 'boolean') {
|
||||||
applyUserDefaultConsolidationPreference(data.defaultConsolidateLists);
|
applyUserDefaultConsolidationPreference(data.defaultConsolidateLists);
|
||||||
}
|
}
|
||||||
|
|
@ -3030,7 +3055,7 @@ async function handleShareShoppingList() {
|
||||||
const updated = await response.json();
|
const updated = await response.json();
|
||||||
updateShoppingListInState(updated);
|
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);
|
await copyTextToClipboard(shareUrl);
|
||||||
|
|
||||||
if (elements.btnShoppingShare) {
|
if (elements.btnShoppingShare) {
|
||||||
|
|
@ -3818,7 +3843,7 @@ async function handleAuthorizeGoogleDrive() {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
origin: window.location.origin
|
origin: getBaseUrl()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
|
||||||
31
server.js
31
server.js
|
|
@ -18,6 +18,23 @@ puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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
|
// Setup directories for persistence
|
||||||
const DATA_DIR = path.join(__dirname, 'data');
|
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)
|
// Auth API: User Registration (Requires Admin session)
|
||||||
app.post('/api/auth/register', requireAuth, (req, res) => {
|
app.post('/api/auth/register', requireAuth, (req, res) => {
|
||||||
if (!req.user.isAdmin) {
|
if (!req.user.isAdmin) {
|
||||||
|
|
@ -1655,7 +1679,8 @@ app.get('/api/settings', requireAuth, (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
geminiApiKey: settings ? settings.geminiApiKey : '',
|
geminiApiKey: settings ? settings.geminiApiKey : '',
|
||||||
geminiModel: (settings && settings.geminiModel) ? settings.geminiModel : 'gemini-2.5-flash',
|
geminiModel: (settings && settings.geminiModel) ? settings.geminiModel : 'gemini-2.5-flash',
|
||||||
defaultConsolidateLists
|
defaultConsolidateLists,
|
||||||
|
appUrl: getBaseUrl(req)
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('Failed to load settings:', 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;
|
const effectiveClientId = customClientId || DEFAULT_GDRIVE_CLIENT_ID;
|
||||||
let redirectUri = await ensureGdriveLoopbackServer();
|
let redirectUri = await ensureGdriveLoopbackServer();
|
||||||
if (!redirectUri || customClientId) {
|
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`;
|
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>`);
|
return res.status(400).send(`<h3>Google Drive Authorization Failed</h3><p>${error || 'No authorization code returned.'}</p>`);
|
||||||
}
|
}
|
||||||
try {
|
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);
|
await completeGoogleDriveOAuthCode(code, redirectUri);
|
||||||
res.send(renderOAuthSuccessHtml());
|
res.send(renderOAuthSuccessHtml());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue