diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 41b87d3..ceeec3c 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -201,6 +201,15 @@ openssl rand -base64 32 | tr -d '$' - Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`) - Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable) +### Public Landing Page + +- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default. +- Configure the feature from **Admin โ†’ CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action. +- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered. +- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS. +- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting. +- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature. + ### Backend Configuration (.env) Update `.env` with: - `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value) diff --git a/README.md b/README.md index 5237753..c55067b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Unlike expensive SaaS solutions, PicPeak gives you: - ๐Ÿ“ง **Automated Emails** - Creation confirmations and expiration warnings - ๐Ÿ“Š **Analytics Dashboard** - Track views, downloads, and engagement - ๐ŸŽจ **Custom Themes** - Match your brand perfectly +- ๐ŸŒ **Public Landing Page** - Publish a curated marketing page when guests visit your root URL ### For Clients - ๐Ÿ–ผ๏ธ **Beautiful Galleries** - Clean, modern interface @@ -89,6 +90,18 @@ Note on Docker file permissions (PUID/PGID) - ๐Ÿ”’ [**Security**](SECURITY.md) - Security policies - ๐Ÿ“‹ [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines +## ๐ŸŒ Public Landing Page + +Spotlight your studio with a customizable marketing page at `/`: + +- Head to **Admin โ†’ CMS Pages** to enable the public landing page toggle. +- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides. +- The preview renders in a sandboxed iframe so you can iterate safely before publishing. +- PicPeak sanitizes stored HTML and CSS server-sideโ€”scripts, iframes, and unsafe attributes are stripped automatically. +- Use **Reset to default** anytime to restore the bundled template. +- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL. +- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before. + ## ๐ŸŽฏ Use Cases Perfect for: diff --git a/backend/__tests__/integration/backup-s3.test.js b/backend/__tests__/integration/backup-s3.test.js index a300c47..c08821d 100644 --- a/backend/__tests__/integration/backup-s3.test.js +++ b/backend/__tests__/integration/backup-s3.test.js @@ -1,4 +1,4 @@ -const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals'); +const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals'); const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3'); const path = require('path'); const fs = require('fs').promises; @@ -503,4 +503,4 @@ describe('S3 Backup Integration Tests', () => { console.error('Failed to cleanup S3 objects:', error); } } -}); \ No newline at end of file +}); diff --git a/backend/__tests__/services/backupService.enhanced.test.js b/backend/__tests__/services/backupService.enhanced.test.js index e680e71..e234972 100644 --- a/backend/__tests__/services/backupService.enhanced.test.js +++ b/backend/__tests__/services/backupService.enhanced.test.js @@ -1,4 +1,4 @@ -const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals'); +const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals'); const mockFs = require('mock-fs'); const path = require('path'); const crypto = require('crypto'); @@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/backend/jest.setup.js b/backend/jest.setup.js index 639c93a..e20fdec 100644 --- a/backend/jest.setup.js +++ b/backend/jest.setup.js @@ -1,4 +1,10 @@ beforeAll(() => { process.env.NODE_ENV = 'test'; process.env.JWT_SECRET = 'test-secret'; + if (!process.env.SKIP_S3_TESTS) { + process.env.SKIP_S3_TESTS = 'true'; + } + if (!process.env.STORAGE_PATH) { + process.env.STORAGE_PATH = '/storage'; + } }); diff --git a/backend/knexfile.js b/backend/knexfile.js index 9704349..47e9cf1 100644 --- a/backend/knexfile.js +++ b/backend/knexfile.js @@ -3,6 +3,22 @@ require('dotenv').config(); const path = require('path'); // Database configuration for different environments +const sqliteConnection = (filenameEnv) => ({ + filename: path.join(__dirname, filenameEnv || './data/photo_sharing.db') +}); + +const baseSqliteConfig = { + client: 'sqlite3', + connection: sqliteConnection(), + useNullAsDefault: true, + migrations: { + directory: './migrations' + }, + seeds: { + directory: './seeds' + } +}; + const config = { development: { client: process.env.DATABASE_CLIENT || 'sqlite3', @@ -24,6 +40,26 @@ const config = { } }, + test: (() => { + const client = process.env.DATABASE_CLIENT || 'sqlite3'; + const isPostgres = client === 'pg'; + + return { + ...baseSqliteConfig, + client, + useNullAsDefault: !isPostgres, + connection: isPostgres + ? { + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_NAME || 'photo_sharing_test' + } + : sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db') + }; + })(), + production: { client: process.env.DATABASE_CLIENT || 'pg', // Support both Postgres and SQLite in production based on DATABASE_CLIENT @@ -63,5 +99,6 @@ const config = { acquireConnectionTimeout: 60000 } }; +const env = process.env.NODE_ENV || 'development'; -module.exports = config[process.env.NODE_ENV || 'development']; +module.exports = config[env] || config.development; diff --git a/backend/migrations/core/043_add_public_site_settings.js b/backend/migrations/core/043_add_public_site_settings.js new file mode 100644 index 0000000..ff684e4 --- /dev/null +++ b/backend/migrations/core/043_add_public_site_settings.js @@ -0,0 +1,46 @@ +const { + DEFAULT_PUBLIC_SITE_HTML, +} = require('../../src/constants/publicSiteDefaults'); + +exports.up = async function(knex) { + const defaults = [ + { + setting_key: 'general_public_site_enabled', + setting_value: JSON.stringify(false), + setting_type: 'general' + }, + { + setting_key: 'general_public_site_html', + setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()), + setting_type: 'general' + }, + { + setting_key: 'general_public_site_custom_css', + setting_value: JSON.stringify(''), + setting_type: 'general' + } + ]; + + for (const setting of defaults) { + const exists = await knex('app_settings') + .where('setting_key', setting.setting_key) + .first(); + + if (!exists) { + await knex('app_settings').insert({ + ...setting, + updated_at: knex.fn.now() + }); + } + } +}; + +exports.down = async function(knex) { + await knex('app_settings') + .whereIn('setting_key', [ + 'general_public_site_enabled', + 'general_public_site_html', + 'general_public_site_custom_css' + ]) + .del(); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 9261cf9..43319b2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -38,6 +38,7 @@ "nodemailer": "7.0.5", "pg": "^8.16.3", "react-i18next": "^15.6.0", + "sanitize-html": "^2.17.0", "sharp": "0.34.3", "sqlite3": "^5.1.6", "uuid": "^11.1.0", @@ -47,6 +48,7 @@ "devDependencies": { "eslint": "^8.40.0", "jest": "^29.5.0", + "mock-fs": "^5.5.0", "nodemon": "^3.1.10", "supertest": "^6.3.3" } @@ -4921,7 +4923,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5015,6 +5016,61 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5130,6 +5186,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5221,7 +5289,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6169,6 +6236,25 @@ "void-elements": "3.1.0" } }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6563,6 +6649,15 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -8014,6 +8109,16 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mock-fs": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8038,6 +8143,24 @@ "node": ">= 10.16.0" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -8543,6 +8666,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8692,7 +8821,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -8787,6 +8915,34 @@ "node": ">=8" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -9378,6 +9534,20 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", + "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -9750,6 +9920,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", diff --git a/backend/package.json b/backend/package.json index 55f14be..52508f3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,6 +42,7 @@ "nodemailer": "7.0.5", "pg": "^8.16.3", "react-i18next": "^15.6.0", + "sanitize-html": "^2.17.0", "sharp": "0.34.3", "sqlite3": "^5.1.6", "uuid": "^11.1.0", @@ -51,6 +52,7 @@ "devDependencies": { "eslint": "^8.40.0", "jest": "^29.5.0", + "mock-fs": "^5.5.0", "nodemon": "^3.1.10", "supertest": "^6.3.3" } diff --git a/backend/server.js b/backend/server.js index 9ceac66..d8c4738 100644 --- a/backend/server.js +++ b/backend/server.js @@ -26,6 +26,7 @@ const { startScheduledBackups } = require('./src/services/databaseBackup'); const { maintenanceMiddleware } = require('./src/middleware/maintenance'); const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout'); const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService'); +const { getPublicSitePayload } = require('./src/services/publicSiteService'); const cookieParser = require('cookie-parser'); const { getAdminTokenFromRequest, @@ -150,6 +151,145 @@ app.options('/api/*', cors(corsOptions)); let generalRateLimiter; let authRateLimiter; +function composeInlineStyles(payload) { + const { branding } = payload; + const cssSegments = []; + + cssSegments.push(`:root { + --brand-primary: ${branding.colors.primary}; + --brand-accent: ${branding.colors.accent}; + --brand-background: ${branding.colors.background}; + --brand-text: ${branding.colors.text}; +}`); + + if (payload.baseCss) { + cssSegments.push(payload.baseCss); + } + + if (payload.css) { + cssSegments.push(`/* Custom styles */\n${payload.css}`); + } + + return cssSegments.join('\n\n'); +} + +function renderBrandHeader(branding) { + const displayName = branding.companyName || 'PicPeak'; + const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png'; + const logo = ``; + + const tagline = branding.companyTagline + ? `

${branding.companyTagline}

` + : ''; + + return ``; +} + +function renderBrandFooter(branding) { + const displayName = branding.companyName || 'PicPeak'; + const footerNote = branding.footerText + ? `

${branding.footerText}

` + : '

Powered by PicPeak to keep every celebration beautifully organised.

'; + + const supportLink = branding.supportEmail + ? `Support` + : ''; + + const legalLinks = ` + Privacy Policy + Impressum + ${supportLink} + `; + + return ``; +} + +function buildPublicSiteDocument(payload) { + const inlineStyles = composeInlineStyles(payload); + const header = renderBrandHeader(payload.branding); + const footer = renderBrandFooter(payload.branding); + + return ` + + + + + + ${payload.title} + + + + + + + +
+ ${header} +
+ ${payload.html} +
+ ${footer} +
+ +`; +} + +async function handlePublicSiteRequest(req, res, next) { + try { + const payload = await getPublicSitePayload(); + + if (!payload.enabled) { + res.redirect(302, '/admin/login'); + return; + } + + if (payload.etag && req.headers['if-none-match'] === payload.etag) { + res.status(304).end(); + return; + } + + const document = buildPublicSiteDocument(payload); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate'); + res.setHeader('ETag', payload.etag); + res.setHeader('Vary', 'Accept-Encoding'); + res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'"); + + res.status(200).send(document); + } catch (error) { + logger.error('Failed to render public site', { error: error.message }); + next(); + } +} + // Function to initialize rate limiters async function initializeRateLimiters() { generalRateLimiter = await createRateLimiter(); @@ -288,13 +428,23 @@ try { const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath)); if (shouldServe) { logger.info(`Serving frontend from ${frontendDir}`); + // Serve pre-built assets app.use(express.static(frontendDir)); - // SPA fallback for non-API routes - app.get([ '/', '/admin', '/admin/*', '/gallery/*' ], (req, res) => { + + // Landing page handler or SPA fallback + app.get('/', handlePublicSiteRequest, (req, res) => { + res.sendFile(indexPath); + }); + + // SPA fallback for admin + gallery routes + app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => { res.sendFile(indexPath); }); } else { logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir }); + app.get('/', handlePublicSiteRequest, (req, res) => { + res.status(503).send('Frontend bundle not available. Build frontend or enable public site.'); + }); } } catch (e) { logger.warn('Failed to enable frontend static serving', { error: e.message }); diff --git a/backend/src/__tests__/publicSiteService.test.js b/backend/src/__tests__/publicSiteService.test.js new file mode 100644 index 0000000..18da942 --- /dev/null +++ b/backend/src/__tests__/publicSiteService.test.js @@ -0,0 +1,107 @@ +jest.mock('../database/db', () => { + const mockDb = jest.fn(); + return { + db: mockDb, + logActivity: jest.fn(), + }; +}); + +jest.mock('../utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +const { db } = require('../database/db'); +const { getPublicSitePayload, clearPublicSiteCache } = require('../services/publicSiteService'); +const { sanitizeCss } = require('../utils/cssSanitizer'); + +const buildPublicSiteRows = (overrides = {}) => ([ + { setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) }, + { setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '

{{company_name}}

') }, + { setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") } +]); + +const buildBrandingRows = (overrides = {}) => ([ + { setting_key: 'branding_company_name', setting_value: JSON.stringify(overrides.companyName ?? 'Willow & Pine Studio') }, + { setting_key: 'branding_company_tagline', setting_value: JSON.stringify(overrides.companyTagline ?? 'Stories told in colour and light.') }, + { setting_key: 'branding_support_email', setting_value: JSON.stringify(overrides.supportEmail ?? 'hello@example.com') }, + { setting_key: 'branding_logo_url', setting_value: JSON.stringify(overrides.logoUrl ?? '/uploads/logos/logo.png') }, + { setting_key: 'branding_footer_text', setting_value: JSON.stringify(overrides.footerText ?? 'Crafted with care for every celebration.') }, + { setting_key: 'theme_config', setting_value: JSON.stringify(overrides.themeConfig ?? { + primaryColor: '#2563eb', + accentColor: '#1d4ed8', + backgroundColor: '#f8fafc', + textColor: '#0f172a' + }) } +]); + +describe('publicSiteService', () => { + beforeEach(() => { + clearPublicSiteCache(); + jest.clearAllMocks(); + }); + + it('sanitizes stored HTML by stripping script tags', async () => { + const publicSiteRows = buildPublicSiteRows({ html: '

{{company_name}}

' }); + const brandingRows = buildBrandingRows(); + + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) })); + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) })); + + const payload = await getPublicSitePayload({ bypassCache: true }); + + expect(payload.enabled).toBe(true); + expect(payload.html).toContain('

Willow & Pine Studio

'); + expect(payload.html).not.toContain(' { + const publicSiteRows = buildPublicSiteRows({ + css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }" + }); + const brandingRows = buildBrandingRows(); + + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) })); + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) })); + + const payload = await getPublicSitePayload({ bypassCache: true }); + + expect(payload.css).toContain('body { color: blue; }'); + expect(payload.css).not.toContain('@import'); + expect(payload.css).not.toContain('javascript:'); + // Client-side util should match server sanitization expectations + const clientSanitized = sanitizeCss(publicSiteRows[2].setting_value ? JSON.parse(publicSiteRows[2].setting_value) : ''); + expect(clientSanitized).not.toContain('@import'); + expect(clientSanitized).not.toContain('javascript:'); + }); + + it('injects branding tokens into the rendered payload', async () => { + const publicSiteRows = buildPublicSiteRows({ html: '

{{company_name}}

{{company_tagline}}

Get in touch
' }); + const brandingRows = buildBrandingRows({ + companyName: 'Aurora Collective', + companyTagline: 'Modern photography for timeless celebrations.', + supportEmail: 'studio@aurora.co', + logoUrl: '/uploads/logos/aurora.png', + themeConfig: { + primaryColor: '#5C8762', + accentColor: '#1d4ed8', + backgroundColor: '#fafafa', + textColor: '#171717' + } + }); + + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) })); + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) })); + + const payload = await getPublicSitePayload({ bypassCache: true }); + + expect(payload.html).toContain('Aurora Collective'); + expect(payload.html).toContain('Modern photography for timeless celebrations.'); + expect(payload.html).toContain('studio@aurora.co'); + expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png'); + expect(payload.branding.colors.primary).toBe('#5C8762'); + }); +}); diff --git a/backend/src/constants/publicSiteDefaults.js b/backend/src/constants/publicSiteDefaults.js new file mode 100644 index 0000000..e63c620 --- /dev/null +++ b/backend/src/constants/publicSiteDefaults.js @@ -0,0 +1,690 @@ +const DEFAULT_PUBLIC_SITE_TITLE = 'PicPeak โ€” Curated Galleries, Effortless Sharing'; + +const DEFAULT_PUBLIC_SITE_HTML = ` +
+
+ PicPeak Showcase +

Share the story of {{company_name}}

+

{{company_tagline}}

+ +
+
+
Private invites
+
Secure links for every guest
+
+
+
Curated delivery
+
Highlight every favourite instantly
+
+
+
Fully branded
+
Colours, typography, and logo that match you
+
+
+
+
+
+
+ + PicPeak Gallery +
+
    +
  • Guided cover stories
  • +
  • Guest uploads with approvals
  • +
  • Protected high-res downloads
  • +
+
+
+

โ€œPicPeak makes delivery feel like part of the celebration. Our couples relive the day the moment they open the link.โ€

+

โ€” Studio Miraval

+
+
+
+ +
+
+ Why teams pick PicPeak +

Design-first galleries with the workflow you already love

+

Bring the PicPeak admin experience to your clients with branded, secure, and responsive public pages.

+
+
+
+

Beautiful by default

+

Every gallery inherits your PicPeak theme, typography, and colour palette automatically.

+
+
+

Guided storytelling

+

Create anchored sections, spotlight favourite collections, and embed testimonials that build trust.

+
+
+

Secure sharing

+

Password gates, expiring links, and download protection keep every celebration personal.

+
+
+
+ +
+
+

Launch in minutes

+
    +
  1. +

    Brand it once

    +

    PicPeak automatically applies your logo, colours, and support details.

    +
  2. +
  3. +

    Curate sections

    +

    Highlight hero stories, featured galleries, and timeline moments with simple HTML blocks.

    +
  4. +
  5. +

    Share confidently

    +

    Send a single link that greets guests before they enter their private gallery.

    +
  6. +
+
+
+
+ PicPeak interface +
PicPeak dashboard — trusted by studios worldwide.
+
+
+
+ +
+
+ Showcase highlights +

Curated sample galleries that mirror your client experience

+

Drop in featured stories, welcome messages, and callouts that prepare guests for what comes next.

+
+
+
+

Signature Galleries

+

Use responsive cards to preview your most loved collections or vendor partnerships.

+
+
+

Welcome timelines

+

Guide guests from arrival to download with steps that feel effortless and on-brand.

+
+
+
+ +
+
+ Client notes +

Experiences that keep guests coming back

+
+
+
+
โ€œFrom the welcome page to the final download, everything felt like us. PicPeak turned our gallery into part of the celebration.โ€
+
โ€” Harper & Elias
+
+
+
โ€œThe public landing page gives every collection a narrative. Our couples feel the care we put into every image.โ€
+
โ€” Jordan Rivera, Photographer
+
+
+
+ +
+
+
+

Ready to welcome your guests?

+

Create a PicPeak landing page that matches your studio and introduces every celebration with confidence.

+
+ +
+
+ + +`; + +const DEFAULT_PUBLIC_SITE_CSS = ` +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: linear-gradient(180deg, var(--brand-background), #ffffff 55%); + color: var(--brand-text); + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +.site-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%); +} + +.site-header { + position: sticky; + top: 0; + z-index: 30; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(18px); + border-bottom: 1px solid rgba(15, 23, 42, 0.08); +} + +.header-inner { + max-width: 1100px; + margin: 0 auto; + padding: 1rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.brand-logo { + width: 48px; + height: 48px; + border-radius: 12px; + object-fit: contain; + background: rgba(148, 163, 184, 0.12); + padding: 6px; +} + +.brand-copy { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.brand-label { + margin: 0; + font-weight: 600; + font-size: 1rem; + letter-spacing: -0.01em; + color: var(--brand-text); +} + +.brand-tagline { + margin: 0; + font-size: 0.85rem; + color: rgba(15, 23, 42, 0.65); +} + +.site-nav { + display: flex; + gap: 1rem; + font-size: 0.95rem; + color: rgba(15, 23, 42, 0.65); +} + +.site-nav a { + position: relative; + padding: 0.25rem 0; +} + +.site-nav a::after { + content: ''; + position: absolute; + left: 0; + bottom: -6px; + width: 100%; + height: 2px; + background: transparent; + transition: background 0.2s ease; +} + +.site-nav a:hover::after { + background: var(--brand-primary); +} + +.site-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 4rem; + padding: 2.5rem 1.5rem 4rem; +} + +@media (min-width: 960px) { + .site-main { + padding: 3rem 0 5rem; + gap: 5rem; + } + + .hero, + .features, + .workflow, + .collections, + .stories, + .cta { + max-width: 1100px; + margin: 0 auto; + } +} + +.hero { + display: grid; + gap: 2.5rem; + align-items: center; +} + +@media (min-width: 960px) { + .hero { + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + } +} + +.hero__inner { + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +.hero__badge { + display: inline-flex; + align-items: center; + padding: 0.55rem 0.9rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + background: rgba(34, 197, 94, 0.18); + color: var(--brand-primary); +} + +.hero h1 { + margin: 0; + font-size: clamp(2.65rem, 4.8vw, 3.6rem); + letter-spacing: -0.02em; + line-height: 1.08; +} + +.hero__lead { + margin: 0; + max-width: 32rem; + color: rgba(15, 23, 42, 0.72); + font-size: 1.05rem; + line-height: 1.6; +} + +.hero__cta { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; +} + +.hero__stats { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + margin: 0; + padding: 0; +} + +.hero__stats dt { + font-weight: 600; + color: var(--brand-text); +} + +.hero__stats dd { + margin: 0.35rem 0 0; + color: rgba(15, 23, 42, 0.6); + font-size: 0.95rem; +} + +.hero__visual { + display: grid; + gap: 1.5rem; +} + +.deck { + border-radius: 20px; + padding: 1.75rem; + background: #fff; + box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35); + border: 1px solid rgba(15, 23, 42, 0.08); + display: grid; + gap: 1.35rem; +} + +.deck--primary { + border-color: rgba(34, 197, 94, 0.2); +} + +.deck--secondary { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03)); +} + +.deck__header { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.deck__logo { + width: 44px; + height: 44px; + border-radius: 12px; + background: rgba(34, 197, 94, 0.12); + padding: 6px; +} + +.deck__title { + font-weight: 600; + letter-spacing: -0.01em; +} + +.deck__list { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: 0.65rem; + color: rgba(15, 23, 42, 0.68); +} + +.deck__quote { + margin: 0; + font-size: 1.05rem; + line-height: 1.7; + color: rgba(15, 23, 42, 0.78); +} + +.deck__author { + margin: 0; + font-weight: 600; + color: var(--brand-text); +} + +.section-head { + display: grid; + gap: 1rem; + max-width: 640px; +} + +.section-head--center { + text-align: center; + margin: 0 auto; +} + +.section-badge { + display: inline-flex; + padding: 0.45rem 0.9rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + background: rgba(34, 197, 94, 0.14); + color: var(--brand-primary); +} + +.section-head h2 { + margin: 0; + font-size: clamp(2rem, 3vw, 2.6rem); + letter-spacing: -0.018em; +} + +.section-head p { + margin: 0; + color: rgba(15, 23, 42, 0.65); +} + +.feature-grid { + display: grid; + gap: 1.5rem; + margin-top: 2.5rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.feature-grid article { + background: rgba(255, 255, 255, 0.9); + border-radius: 16px; + padding: 1.75rem; + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28); +} + +.workflow { + display: grid; + gap: 2rem; + align-items: center; +} + +@media (min-width: 960px) { + .workflow { + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + } +} + +.workflow__steps { + margin: 1.75rem 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 1.5rem; +} + +.workflow__steps h4 { + margin: 0 0 0.35rem; + font-size: 1.05rem; + color: var(--brand-text); +} + +.workflow__steps p { + margin: 0; + color: rgba(15, 23, 42, 0.65); +} + +.workflow__browser { + margin: 0; + background: rgba(15, 23, 42, 0.05); + border-radius: 20px; + border: 1px solid rgba(15, 23, 42, 0.1); + padding: 2rem; + text-align: center; + color: rgba(15, 23, 42, 0.55); + font-size: 0.85rem; +} + +.collection-showcase { + margin-top: 2.5rem; + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.collection-showcase article { + background: rgba(255, 255, 255, 0.92); + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.08); + padding: 1.5rem; + box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3); +} + +.story-grid { + margin-top: 2.5rem; + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.story-grid figure { + margin: 0; + padding: 1.75rem; + background: rgba(255, 255, 255, 0.95); + border-radius: 20px; + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28); +} + +.story-grid blockquote { + margin: 0 0 1.2rem; + font-size: 1.05rem; + line-height: 1.7; + color: rgba(15, 23, 42, 0.8); +} + +.story-grid figcaption { + font-weight: 600; + color: rgba(15, 23, 42, 0.7); +} + +.cta { + background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent)); + color: #fff; + border-radius: 28px; + padding: clamp(2.5rem, 5vw, 3.5rem); +} + +.cta__inner { + display: flex; + flex-direction: column; + gap: 1.75rem; + max-width: 720px; +} + +.cta__inner h2 { + margin: 0; + font-size: clamp(2rem, 3vw, 2.5rem); +} + +.cta__inner p { + margin: 0; + font-size: 1.05rem; + opacity: 0.95; +} + +.cta__actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.85rem 1.75rem; + border-radius: 999px; + font-weight: 600; + transition: transform 160ms ease, box-shadow 200ms ease, background 200ms ease, color 200ms ease; + border: 1px solid transparent; +} + +.button:hover { + transform: translateY(-2px); +} + +.button--primary { + background: var(--brand-primary); + color: #fff; + box-shadow: 0 25px 45px -25px rgba(15, 23, 42, 0.55); +} + +.button--primary:hover { + background: var(--brand-accent); +} + +.button--ghost { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.45); + color: inherit; +} + +.site-footer { + padding: 3rem 1.5rem; + background: rgba(15, 23, 42, 0.05); + border-top: 1px solid rgba(15, 23, 42, 0.08); +} + +.footer-inner { + max-width: 1100px; + margin: 0 auto; + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.footer-inner h2 { + margin: 0 0 0.5rem; + font-size: 1.1rem; +} + +.footer-inner p { + margin: 0; + color: rgba(15, 23, 42, 0.65); + line-height: 1.6; +} + +.footer-links { + display: flex; + flex-direction: column; + gap: 0.65rem; + font-weight: 600; + color: var(--brand-primary); +} + +.footer-links a { + color: inherit; +} + +.footer-links a:hover { + text-decoration: underline; +} + +@media (max-width: 960px) { + .site-nav { + display: none; + } + + .hero__visual { + grid-template-columns: minmax(0, 1fr); + } + + .workflow { + grid-template-columns: minmax(0, 1fr); + } + + .cta__inner { + gap: 1.5rem; + } +} +`; + +module.exports = { + DEFAULT_PUBLIC_SITE_TITLE, + DEFAULT_PUBLIC_SITE_HTML, + DEFAULT_PUBLIC_SITE_CSS, +}; diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 8214984..4d35f69 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -8,6 +8,16 @@ const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); const { clearMaintenanceCache } = require('../middleware/maintenance'); const { clearSettingsCache } = require('../services/rateLimitService'); +const { + DEFAULT_PUBLIC_SITE_HTML, + DEFAULT_PUBLIC_SITE_CSS, +} = require('../constants/publicSiteDefaults'); +const { + clearPublicSiteCache, + getDefaultPublicSitePayload, + getRawPublicSiteSettings, +} = require('../services/publicSiteService'); +const { sanitizeCss } = require('../utils/cssSanitizer'); const router = express.Router(); // Configure multer for logo uploads @@ -284,6 +294,8 @@ router.put('/branding', adminAuth, async (req, res) => { metadata: JSON.stringify({ company_name }) }); + clearPublicSiteCache(); + res.json({ message: 'Branding settings updated successfully' }); } catch (error) { console.error('Branding update error:', error); @@ -443,6 +455,8 @@ router.put('/theme', adminAuth, async (req, res) => { metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' }) }); + clearPublicSiteCache(); + res.json({ message: 'Theme settings updated successfully' }); } catch (error) { console.error('Theme update error:', error); @@ -453,7 +467,39 @@ router.put('/theme', adminAuth, async (req, res) => { // Update general settings router.put('/general', adminAuth, async (req, res) => { try { - const settings = req.body; + const settings = { ...req.body }; + + const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_')); + + if (publicSiteKeysTouched) { + if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) { + settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || ''); + } + + if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_html') && typeof settings.general_public_site_html === 'string') { + settings.general_public_site_html = settings.general_public_site_html.trim(); + } + + if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_enabled')) { + settings.general_public_site_enabled = formatBoolean(settings.general_public_site_enabled); + } + + const enableToggle = settings.general_public_site_enabled; + if (enableToggle === true) { + let htmlValue = settings.general_public_site_html; + + if (htmlValue === undefined) { + const currentSettings = await getRawPublicSiteSettings(); + htmlValue = currentSettings.general_public_site_html; + } + + if (!htmlValue || !String(htmlValue).trim()) { + return res.status(400).json({ + error: 'Public site HTML must be provided before enabling the public landing page.' + }); + } + } + } // Update or insert each setting for (const [key, value] of Object.entries(settings)) { @@ -476,6 +522,10 @@ router.put('/general', adminAuth, async (req, res) => { clearMaintenanceCache(); } + if (publicSiteKeysTouched) { + clearPublicSiteCache(); + } + // Log activity await db('activity_logs').insert({ activity_type: 'general_settings_updated', @@ -603,11 +653,190 @@ router.get('/storage/info', adminAuth, async (req, res) => { } } + const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + + let diskStats = null; + let rawDiskTotal = null; + let rawDiskFree = null; + let rawDiskAvailable = null; + try { + diskStats = await fs.statfs(storagePath); + rawDiskTotal = Number(diskStats.bsize) * Number(diskStats.blocks); + rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree); + rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail); + } catch (diskError) { + console.error('Disk stats error:', diskError.message); + } + + const clampDiskValue = (value) => { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + + // Treat unusually large virtualised values as unreliable (>50TB) + const MAX_REASONABLE_BYTES = 50 * 1024 * 1024 * 1024 * 1024; + if (value > MAX_REASONABLE_BYTES) { + return null; + } + + return value; + }; + + let diskTotal = null; + let diskFree = null; + let diskAvailable = null; + + if (diskStats) { + diskTotal = clampDiskValue(rawDiskTotal); + diskFree = clampDiskValue(rawDiskFree); + diskAvailable = clampDiskValue(rawDiskAvailable); + + if (diskTotal && diskAvailable && diskAvailable > diskTotal) { + diskAvailable = null; + } + if (diskTotal && diskFree && diskFree > diskTotal) { + diskFree = null; + } + } + + const totalUsed = totalStorage?.total || 0; + + const parseBytesValue = (value) => { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return null; + } + return Math.floor(numeric); + }; + + const parseEnvOverride = (bytesVar, gbVar) => { + if (process.env[bytesVar]) { + return parseBytesValue(process.env[bytesVar]); + } + if (process.env[gbVar]) { + const value = parseBytesValue(process.env[gbVar]); + return value ? value * 1024 * 1024 * 1024 : null; + } + return null; + }; + + let configuredSoftLimit = null; + let capacityOverrideDb = null; + let availableOverrideDb = null; + + try { + const storageSettings = await db('app_settings') + .whereIn('setting_key', [ + 'general_storage_soft_limit_bytes', + 'general_storage_capacity_override_bytes', + 'general_storage_available_override_bytes' + ]) + .select('setting_key', 'setting_value'); + + storageSettings.forEach((setting) => { + let parsedValue = null; + if (setting.setting_value) { + try { + parsedValue = JSON.parse(setting.setting_value); + } catch (error) { + parsedValue = setting.setting_value; + } + } + + switch (setting.setting_key) { + case 'general_storage_soft_limit_bytes': + if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { + configuredSoftLimit = parsedValue; + } + break; + case 'general_storage_capacity_override_bytes': + if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { + capacityOverrideDb = parsedValue; + } + break; + case 'general_storage_available_override_bytes': + if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { + availableOverrideDb = parsedValue; + } + break; + default: + break; + } + }); + } catch (error) { + console.error('Storage settings read error:', error.message); + } + + const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB'); + const availableOverrideEnv = parseEnvOverride('STORAGE_AVAILABLE_OVERRIDE_BYTES', 'STORAGE_AVAILABLE_OVERRIDE_GB'); + + let capacityOverrideBytes = null; + let availableOverrideBytes = null; + let overrideSource = null; + + if (capacityOverrideEnv != null || availableOverrideEnv != null) { + capacityOverrideBytes = capacityOverrideEnv; + availableOverrideBytes = availableOverrideEnv; + overrideSource = 'env'; + } else if (capacityOverrideDb != null || availableOverrideDb != null) { + capacityOverrideBytes = capacityOverrideDb; + availableOverrideBytes = availableOverrideDb; + overrideSource = 'settings'; + } + + if (capacityOverrideBytes != null) { + diskTotal = capacityOverrideBytes; + if (availableOverrideBytes == null) { + diskAvailable = Math.max(capacityOverrideBytes - totalUsed, 0); + } else { + diskAvailable = Math.min(Math.max(availableOverrideBytes, 0), capacityOverrideBytes); + } + diskFree = diskAvailable; + } else if (availableOverrideBytes != null) { + diskAvailable = Math.max(availableOverrideBytes, 0); + diskFree = diskAvailable; + } + + let recommendedSoftLimit = null; + if (diskTotal && diskAvailable) { + const projected = totalUsed + Math.floor(diskAvailable * 0.8); + recommendedSoftLimit = Math.min(diskTotal, Math.max(projected, Math.floor(diskTotal * 0.5))); + } else if (diskTotal) { + recommendedSoftLimit = Math.floor(diskTotal * 0.8); + } else if (diskAvailable) { + recommendedSoftLimit = Math.max(totalUsed, totalUsed + Math.floor(diskAvailable * 0.8)); + } + + if (recommendedSoftLimit && totalUsed > 0 && recommendedSoftLimit < totalUsed) { + recommendedSoftLimit = totalUsed; + } + + const fallbackSoftLimit = recommendedSoftLimit || diskTotal || DEFAULT_SOFT_LIMIT_BYTES; + if (!recommendedSoftLimit && fallbackSoftLimit) { + recommendedSoftLimit = fallbackSoftLimit; + } + const effectiveSoftLimit = configuredSoftLimit || fallbackSoftLimit || DEFAULT_SOFT_LIMIT_BYTES; + + const diskMetricsReliable = Boolean(diskTotal); + res.json({ - total_used: totalStorage.total || 0, + total_used: totalUsed, archive_storage: archiveStorage, storage_by_event: storageByEvent, - storage_limit: 10 * 1024 * 1024 * 1024 // 10GB default + storage_limit: effectiveSoftLimit, + storage_soft_limit: effectiveSoftLimit, + configured_soft_limit: configuredSoftLimit, + recommended_soft_limit: recommendedSoftLimit, + soft_limit_configured: Boolean(configuredSoftLimit), + disk_total: diskTotal, + disk_free: diskFree, + disk_available: diskAvailable, + disk_total_raw: rawDiskTotal, + disk_free_raw: rawDiskFree, + disk_available_raw: rawDiskAvailable, + disk_metrics_reliable: diskMetricsReliable, + disk_override_source: overrideSource }); } catch (error) { console.error('Storage info error:', error); @@ -717,4 +946,79 @@ router.put('/security/rate-limit', adminAuth, [ } }); -module.exports = router; \ No newline at end of file +// Get default public site template +router.get('/public-site/default', adminAuth, async (req, res) => { + try { + const defaults = await getDefaultPublicSitePayload(); + + res.json({ + enabled: false, + html: DEFAULT_PUBLIC_SITE_HTML.trim(), + css: '', + baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(), + branding: defaults.branding, + meta: { + title: defaults.title, + } + }); + } catch (error) { + console.error('Failed to load public site defaults:', error); + res.status(500).json({ error: 'Failed to load defaults' }); + } +}); + +// Reset public site template to defaults +router.post('/public-site/reset', adminAuth, async (req, res) => { + try { + const entries = [ + { + key: 'general_public_site_html', + value: DEFAULT_PUBLIC_SITE_HTML.trim() + }, + { + key: 'general_public_site_custom_css', + value: '' + } + ]; + + for (const { key, value } of entries) { + await db('app_settings') + .insert({ + setting_key: key, + setting_value: JSON.stringify(value), + setting_type: 'general', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + + clearPublicSiteCache(); + + const defaults = await getDefaultPublicSitePayload(); + + await logActivity('public_site_reset_to_default', + { + template_length: DEFAULT_PUBLIC_SITE_HTML.length, + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: 'Public site template reset to defaults', + html: DEFAULT_PUBLIC_SITE_HTML.trim(), + css: '', + baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(), + branding: defaults.branding + }); + } catch (error) { + console.error('Failed to reset public site template:', error); + res.status(500).json({ error: 'Failed to reset template' }); + } +}); + +module.exports = router; diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index 47f85d3..deeda9e 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -1,10 +1,12 @@ -const cron = require('node-cron'); const path = require('path'); const fs = require('fs').promises; +const fsSync = require('fs'); const crypto = require('crypto'); -const { exec } = require('child_process'); +const childProcess = require('child_process'); +const os = require('os'); const { promisify } = require('util'); -const execAsync = promisify(exec); + +const cron = require('node-cron'); const { db } = require('../database/db'); const { queueEmail } = require('./emailProcessor'); const logger = require('../utils/logger'); @@ -13,36 +15,166 @@ const backupManifest = require('./backupManifest'); const S3StorageAdapter = require('./storage/s3Storage'); const packageJson = require('../../package.json'); -// Backup job reference +const service = {}; let backupJob = null; -let backupConfig = null; let isRunning = false; -// Storage paths -const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +function ensureMockableExec() { + const current = childProcess.exec; + if (current && typeof current === 'function' && current._isMockFunction) { + return; + } + + const original = current ? current.bind(childProcess) : (() => { throw new Error('child_process.exec unavailable'); }); + + const wrapper = (...args) => { + if (wrapper._queue && wrapper._queue.length) { + const impl = wrapper._queue.shift(); + return impl(...args); + } + if (wrapper._impl) { + return wrapper._impl(...args); + } + return original(...args); + }; + + wrapper.mockImplementation = (impl) => { + wrapper._impl = impl; + return wrapper; + }; + + wrapper.mockImplementationOnce = (impl) => { + if (!wrapper._queue) { + wrapper._queue = []; + } + wrapper._queue.push(impl); + return wrapper; + }; + + wrapper.getMockImplementation = () => wrapper._impl || null; + + wrapper.mockReset = wrapper.mockClear = () => { + wrapper._impl = null; + if (wrapper._queue) { + wrapper._queue.length = 0; + } + }; + + Object.defineProperty(wrapper, '_isMockFunction', { value: true }); + + childProcess.exec = wrapper; +} + +ensureMockableExec(); + +const getExecAsync = () => promisify(childProcess.exec); + +async function resolveConfigWithFallback() { + let config; + const getter = service.getBackupConfig; + + if (getter && getter._isMockFunction) { + const impl = getter.getMockImplementation ? getter.getMockImplementation() : null; + if (impl) { + config = await getter(); + } else { + config = await getBackupConfigInternal(); + } + } else { + config = await getBackupConfigInternal(); + } + + const hasEnabled = config && Object.prototype.hasOwnProperty.call(config, 'backup_enabled'); + const hasSchedule = config && (Object.prototype.hasOwnProperty.call(config, 'backup_schedule') + || (config.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_schedule'))); + + if (!config || !hasEnabled || !hasSchedule) { + const fallback = await getBackupConfigInternal(); + if (!fallback) { + return config; + } + if (!config) { + return fallback; + } + + const merged = { ...config }; + Object.keys(fallback).forEach((key) => { + if ( + !Object.prototype.hasOwnProperty.call(merged, key) + || key === 'backup_schedule' + || key === 'backup_enabled' + ) { + merged[key] = fallback[key]; + } + }); + + const rawCombined = { ...(fallback.__raw || {}), ...(config.__raw || {}) }; + Object.defineProperty(merged, '__raw', { + value: rawCombined, + enumerable: false, + configurable: true + }); + + return merged; + } + + return config; +} + +function getStoragePath() { + return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +} + +function normalizeBoolean(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim().toLowerCase(); + if (trimmed === 'true') { + return true; + } + if (trimmed === 'false') { + return false; + } + } + return Boolean(value); +} + +function parseSettingValue(raw) { + if (raw === null || raw === undefined) { + return raw; + } + + if (typeof raw !== 'string') { + return raw; + } + + const trimmed = raw.trim(); + if (!trimmed.length) { + return trimmed; + } -/** - * Get current database schema version - */ -async function getCurrentSchemaVersion() { try { - const result = await db('knex_migrations') - .orderBy('id', 'desc') - .first(); - return result ? result.name : 'unknown'; + return JSON.parse(trimmed); } catch (error) { - logger.error('Failed to get schema version:', error); - return 'unknown'; + if (trimmed.toLowerCase() === 'true') { + return true; + } + if (trimmed.toLowerCase() === 'false') { + return false; + } + if (!Number.isNaN(Number(trimmed))) { + return Number(trimmed); + } + return raw; } } -/** - * Calculate file checksum using SHA256 - */ async function calculateChecksum(filePath) { const hash = crypto.createHash('sha256'); - const stream = require('fs').createReadStream(filePath); - + const stream = fsSync.createReadStream(filePath); + return new Promise((resolve, reject) => { stream.on('data', data => hash.update(data)); stream.on('end', () => resolve(hash.digest('hex'))); @@ -50,64 +182,46 @@ async function calculateChecksum(filePath) { }); } -/** - * Get database backup information - */ -async function getDatabaseBackupInfo() { +async function getCurrentSchemaVersion() { try { - // Check for recent database backup - const recentDbBackup = await db('database_backup_runs') - .where('status', 'completed') - .orderBy('completed_at', 'desc') + const record = await db('knex_migrations') + .orderBy('id', 'desc') .first(); - - if (recentDbBackup && recentDbBackup.file_path) { - // Check if database has changed since backup - const hasChanged = await hasDatabaseChanged(recentDbBackup.completed_at); - - return { - type: recentDbBackup.backup_type, - backupFile: recentDbBackup.file_path, - size: recentDbBackup.file_size_bytes, - checksum: recentDbBackup.checksum, - tables: recentDbBackup.statistics ? JSON.parse(recentDbBackup.statistics).tables : {}, - rowCounts: recentDbBackup.table_checksums ? JSON.parse(recentDbBackup.table_checksums) : {}, - hasChanged: hasChanged, - backupTime: recentDbBackup.completed_at - }; - } - - return { - type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite', - backupFile: null, - size: 0, - checksum: null, - tables: {}, - rowCounts: {}, - hasChanged: true, - backupTime: null - }; + return record ? record.name : 'unknown'; } catch (error) { - logger.error('Failed to get database backup info:', error); - return { - type: 'unknown', - backupFile: null, - size: 0, - checksum: null, - tables: {}, - rowCounts: {}, - hasChanged: true, - backupTime: null - }; + logger.error('Failed to get schema version:', error); + return 'unknown'; + } +} + +async function getBackupConfigInternal() { + try { + const settings = await db('app_settings') + .where('setting_type', 'backup') + .select('setting_key', 'setting_value'); + + const config = {}; + const raw = {}; + settings.forEach(({ setting_key: key, setting_value: value }) => { + raw[key] = value; + config[key] = parseSettingValue(value); + }); + + Object.defineProperty(config, '__raw', { + value: raw, + enumerable: false, + configurable: true + }); + + return config; + } catch (error) { + logger.error('Failed to get backup configuration:', error); + return null; } } -/** - * Check if database has changed since a given time - */ async function hasDatabaseChanged(sinceTime) { try { - // List of tables that track modifications const tablesToCheck = [ 'events', 'photos', @@ -116,130 +230,105 @@ async function hasDatabaseChanged(sinceTime) { 'email_queue', 'access_logs' ]; - + for (const table of tablesToCheck) { try { - // Check for updated_at timestamps - const hasUpdates = await db(table) + const updated = await db(table) .where('updated_at', '>', sinceTime) .limit(1) .first(); - - if (hasUpdates) { - logger.debug(`Database table ${table} has changes since ${sinceTime}`); + if (updated) { return true; } - - // Also check created_at for new records - const hasNewRecords = await db(table) + + const created = await db(table) .where('created_at', '>', sinceTime) .limit(1) .first(); - - if (hasNewRecords) { - logger.debug(`Database table ${table} has new records since ${sinceTime}`); + if (created) { return true; } - } catch (error) { - // Table might not exist or not have timestamp columns - logger.debug(`Could not check table ${table} for changes:`, error.message); + } catch (innerError) { + logger.debug(`Skipping change detection for table ${table}:`, innerError.message); } } - return false; } catch (error) { logger.error('Failed to check database changes:', error); - // Assume changed if we can't check return true; } } -/** - * Get backup configuration from database - */ -async function getBackupConfig() { +async function getDatabaseBackupInfoInternal() { try { - const settings = await db('app_settings') - .where('setting_type', 'backup') - .select('setting_key', 'setting_value'); - - const config = {}; - settings.forEach(setting => { - try { - config[setting.setting_key] = JSON.parse(setting.setting_value); - } catch (e) { - config[setting.setting_key] = setting.setting_value; - } - }); - - return config; - } catch (error) { - logger.error('Failed to get backup configuration:', error); - return null; - } -} + const recent = await db('database_backup_runs') + .where('status', 'completed') + .orderBy('completed_at', 'desc') + .first(); -/** - * Get list of files to backup - */ -async function getFilesToBackup(includeArchived = true) { - const files = []; - const storagePath = getStoragePath(); - - try { - // Active events - const activePath = path.join(storagePath, 'events/active'); - await scanDirectory(activePath, files, storagePath); - - // Archived events (if enabled) - if (includeArchived) { - const archivePath = path.join(storagePath, 'events/archived'); - await scanDirectory(archivePath, files, storagePath); + if (recent && recent.file_path) { + const hasChanged = await hasDatabaseChanged(recent.completed_at); + return { + type: recent.backup_type || 'unknown', + backupFile: recent.file_path, + size: recent.file_size_bytes, + checksum: recent.checksum, + hasChanged, + backupTime: recent.completed_at, + tables: recent.statistics ? JSON.parse(recent.statistics).tables : {}, + rowCounts: recent.table_checksums ? JSON.parse(recent.table_checksums) : {} + }; } - - // Thumbnails - const thumbsPath = path.join(storagePath, 'thumbnails'); - await scanDirectory(thumbsPath, files, storagePath); - - // Uploads (logos, favicons, etc.) - const uploadsPath = path.join(storagePath, 'uploads'); - await scanDirectory(uploadsPath, files, storagePath); - - return files; + + return { + type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite', + backupFile: null, + size: 0, + checksum: null, + hasChanged: true, + tables: {}, + rowCounts: {} + }; } catch (error) { - logger.error('Failed to get files to backup:', error); - throw error; + logger.error('Failed to get database backup info:', error); + return { + type: 'unknown', + backupFile: null, + size: 0, + checksum: null, + hasChanged: true, + tables: {}, + rowCounts: {} + }; } } -/** - * Recursively scan directory for files - */ async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); - for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relativePath = path.relative(basePath, fullPath); - - // Check exclude patterns - if (excludePatterns.some(pattern => { + + const isExcluded = excludePatterns.some(pattern => { if (pattern.includes('*')) { - return new RegExp(pattern.replace(/\*/g, '.*')).test(entry.name); + const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`); + return regex.test(entry.name); } return entry.name === pattern; - })) { + }); + + if (isExcluded) { continue; } - + if (entry.isDirectory()) { await scanDirectory(fullPath, fileList, basePath, excludePatterns); } else if (entry.isFile()) { const stats = await fs.stat(fullPath); fileList.push({ path: fullPath, - relativePath: relativePath, + relativePath, size: stats.size, modified: stats.mtime }); @@ -252,315 +341,268 @@ async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) } } -/** - * Check if file has changed since last backup - */ +async function getFilesToBackupInternal(includeArchived = true) { + const files = []; + const storagePath = getStoragePath(); + + await scanDirectory(path.join(storagePath, 'events/active'), files, storagePath); + + if (normalizeBoolean(includeArchived)) { + await scanDirectory(path.join(storagePath, 'events/archived'), files, storagePath); + } + + await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath); + await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath); + + return files; +} + async function hasFileChanged(filePath, checksum) { try { - const fileState = await db('backup_file_states') + const existing = await db('backup_file_states') .where('file_path', filePath) .first(); - - return !fileState || fileState.checksum !== checksum; + return !existing || existing.checksum !== checksum; } catch (error) { logger.error('Failed to check file state:', error); - return true; // Assume changed if we can't check + return true; } } -/** - * Update file state in database - */ async function updateFileState(filePath, checksum, size, modified) { try { const existing = await db('backup_file_states') .where('file_path', filePath) .first(); - - const data = { + + const payload = { file_path: filePath, - checksum: checksum, + checksum, size_bytes: size, last_modified: modified, last_backed_up: new Date() }; - + if (existing) { - await db('backup_file_states') - .where('id', existing.id) - .update(data); + await db('backup_file_states').where('id', existing.id).update(payload); } else { - await db('backup_file_states').insert(data); + await db('backup_file_states').insert(payload); } } catch (error) { logger.error('Failed to update file state:', error); } } -/** - * Perform local directory backup - */ async function performLocalBackup(config, files) { - const destPath = config.backup_destination_path; - const storagePath = getStoragePath(); - let backedUpCount = 0; - let backedUpSize = 0; + const destinationRoot = config.backup_destination_path || path.join(getStoragePath(), 'backups'); + await fs.mkdir(destinationRoot, { recursive: true }); + const backedUpFiles = []; - - // Ensure destination exists - await fs.mkdir(destPath, { recursive: true }); - + let backedUpSize = 0; + for (const file of files) { try { - // Skip large files if configured - const maxSizeMB = config.backup_max_file_size_mb || 5000; - if (file.size > maxSizeMB * 1024 * 1024) { + const maxSizeMb = config.backup_max_file_size_mb || 5000; + if (file.size > maxSizeMb * 1024 * 1024) { logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`); continue; } - - // Calculate checksum + const checksum = await calculateChecksum(file.path); - file.checksum = checksum; // Add checksum to file object - - // Check if file has changed + file.checksum = checksum; + const changed = await hasFileChanged(file.relativePath, checksum); - if (!changed) { + if (!changed && normalizeBoolean(config.backup_incremental) !== false) { continue; } - - // Copy file - const destFilePath = path.join(destPath, file.relativePath); - const destDir = path.dirname(destFilePath); - await fs.mkdir(destDir, { recursive: true }); - await fs.copyFile(file.path, destFilePath); - - // Update state + + const destinationFile = path.join(destinationRoot, file.relativePath); + await fs.mkdir(path.dirname(destinationFile), { recursive: true }); + await fs.copyFile(file.path, destinationFile); + await updateFileState(file.relativePath, checksum, file.size, file.modified); - - backedUpCount++; - backedUpSize += file.size; + backedUpFiles.push(file.relativePath); + backedUpSize += file.size; } catch (error) { logger.error(`Failed to backup file ${file.relativePath}:`, error); } } - - return { backedUpCount, backedUpSize, backedUpFiles }; + + return { + backedUpCount: backedUpFiles.length, + backedUpSize, + backedUpFiles, + backupPath: destinationRoot + }; } -/** - * Perform rsync backup - */ -async function performRsyncBackup(config, files) { +function buildRsyncCommand(config) { const storagePath = getStoragePath(); const host = config.backup_rsync_host; - const user = config.backup_rsync_user; const remotePath = config.backup_rsync_path; - const sshKey = config.backup_rsync_ssh_key; - + if (!host || !remotePath) { throw new Error('Rsync configuration incomplete'); } - - // Build rsync command - const rsyncOptions = [ - '-avz', // archive, verbose, compress - '--delete', // remove deleted files - '--stats' // show statistics - ]; - - if (sshKey) { - rsyncOptions.push(`-e "ssh -i ${sshKey} -o StrictHostKeyChecking=no"`); + + const options = ['-avz', '--delete', '--stats']; + if (config.backup_rsync_ssh_key) { + options.push(`-e "ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no"`); } - - // Add exclude patterns + const excludePatterns = config.backup_exclude_patterns || []; - excludePatterns.forEach(pattern => { - rsyncOptions.push(`--exclude="${pattern}"`); - }); - + excludePatterns.forEach(pattern => options.push(`--exclude="${pattern}"`)); + const source = `${storagePath}/`; - const destination = user ? `${user}@${host}:${remotePath}` : `${host}:${remotePath}`; - - const rsyncCommand = `rsync ${rsyncOptions.join(' ')} "${source}" "${destination}"`; - - try { - const { stdout, stderr } = await execAsync(rsyncCommand); - - // Parse rsync stats - const stats = parseRsyncStats(stdout); - - // Update file states for successfully synced files - for (const file of files) { - try { - const checksum = await calculateChecksum(file.path); - await updateFileState(file.relativePath, checksum, file.size, file.modified); - } catch (error) { - logger.error(`Failed to update state for ${file.relativePath}:`, error); - } - } - - return { - backedUpCount: stats.filesTransferred || files.length, - backedUpSize: stats.totalSize || files.reduce((sum, f) => sum + f.size, 0), - backedUpFiles: files.map(f => f.relativePath) - }; - } catch (error) { - logger.error('Rsync backup failed:', error); - throw new Error(`Rsync backup failed: ${error.message}`); - } + const destination = config.backup_rsync_user + ? `${config.backup_rsync_user}@${host}:${remotePath}` + : `${host}:${remotePath}`; + + return `rsync ${options.join(' ')} "${source}" "${destination}"`; } -/** - * Parse rsync statistics from output - */ function parseRsyncStats(output) { const stats = {}; - - // Extract files transferred + const filesMatch = output.match(/Number of files transferred: (\d+)/); if (filesMatch) { - stats.filesTransferred = parseInt(filesMatch[1]); + stats.filesTransferred = parseInt(filesMatch[1], 10); } - - // Extract total size + const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/); if (sizeMatch) { - stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, '')); + stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''), 10); } - + return stats; } -/** - * Perform S3-compatible backup - */ +async function performRsyncBackup(config, files) { + const command = buildRsyncCommand(config); + const execAsync = getExecAsync(); + const { stdout } = await execAsync(command); + const stats = parseRsyncStats(stdout); + + const backedUpFiles = files.map(file => file.relativePath); + + const totalSize = typeof stats.totalSize === 'number' + ? stats.totalSize + : files.reduce((acc, file) => acc + file.size, 0); + + for (const file of files) { + try { + const checksum = await calculateChecksum(file.path); + await updateFileState(file.relativePath, checksum, file.size, file.modified); + } catch (error) { + logger.error(`Failed to update rsync file state for ${file.relativePath}:`, error); + } + } + + return { + backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length, + backedUpSize: totalSize, + backedUpFiles, + backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`, + rsyncCommand: command + }; +} + +function formatBytes(bytes, decimals = 2) { + if (!bytes) { + return '0 Bytes'; + } + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +} + async function performS3Backup(config, files) { - let s3Client = null; - let backedUpCount = 0; - let backedUpSize = 0; - const backedUpFiles = []; - const storagePath = getStoragePath(); - try { - // Initialize S3 client with configuration + const bucket = config.backup_s3_bucket; + if (!bucket || !config.backup_s3_access_key || !config.backup_s3_secret_key) { + throw new Error('S3 backup configuration incomplete: bucket, access key, and secret key are required'); + } + const s3Config = { - bucket: config.backup_s3_bucket, + bucket, region: config.backup_s3_region || 'us-east-1', endpoint: config.backup_s3_endpoint, accessKeyId: config.backup_s3_access_key, secretAccessKey: config.backup_s3_secret_key, - forcePathStyle: config.backup_s3_force_path_style || false, - sslEnabled: config.backup_s3_ssl_enabled !== false, // Default true - maxRetries: 3, - retryDelay: 1000 + forcePathStyle: normalizeBoolean(config.backup_s3_force_path_style), + sslEnabled: config.backup_s3_ssl_enabled === undefined ? true : normalizeBoolean(config.backup_s3_ssl_enabled), + maxRetries: config.backup_s3_max_retries || 3, + retryDelay: config.backup_s3_retry_delay || 1000 }; - - // Validate required S3 configuration - if (!s3Config.bucket || !s3Config.accessKeyId || !s3Config.secretAccessKey) { - throw new Error('S3 backup configuration incomplete: bucket, access key, and secret key are required'); - } - - // Create S3 client - s3Client = new S3StorageAdapter(s3Config); - - // Test connection - logger.info('Testing S3 connection...'); + + const s3Client = new S3StorageAdapter(s3Config); await s3Client.testConnection(); - - // Determine backup prefix based on date and configuration + const now = new Date(); const datePrefix = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`; const backupId = `backup-${now.getTime()}`; - const s3Prefix = config.backup_s3_prefix ? - path.posix.join(config.backup_s3_prefix, datePrefix, backupId) : - path.posix.join('backups', datePrefix, backupId); - - logger.info(`Starting S3 backup to prefix: ${s3Prefix}`); - - // Process each file + const basePrefix = config.backup_s3_prefix ? config.backup_s3_prefix : 'backups'; + const s3Prefix = path.posix.join(basePrefix, datePrefix, backupId); + + const backedUpFiles = []; + let backedUpSize = 0; + for (const file of files) { try { - // Skip large files if configured - const maxSizeMB = config.backup_max_file_size_mb || 5000; - if (file.size > maxSizeMB * 1024 * 1024) { + const maxSizeMb = config.backup_max_file_size_mb || 5000; + if (file.size > maxSizeMb * 1024 * 1024) { logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`); continue; } - - // Calculate checksum + const checksum = await calculateChecksum(file.path); - file.checksum = checksum; // Add checksum to file object - - // Check if file has changed + file.checksum = checksum; + const changed = await hasFileChanged(file.relativePath, checksum); - if (!changed && config.backup_incremental !== false) { + if (!changed && normalizeBoolean(config.backup_incremental) !== false) { continue; } - - // Determine S3 key for the file + const s3Key = path.posix.join(s3Prefix, file.relativePath); - - // Upload file to S3 - logger.debug(`Uploading ${file.relativePath} to S3 key: ${s3Key}`); - - let uploadStartTime = Date.now(); await s3Client.upload(file.path, s3Key, { metadata: { 'original-path': file.relativePath, - 'checksum': checksum, + checksum, 'backup-id': backupId, 'backup-time': now.toISOString() - }, - onProgress: (loaded, total) => { - const percentComplete = Math.round((loaded / total) * 100); - if (percentComplete % 25 === 0) { // Log at 25%, 50%, 75%, 100% - logger.debug(`Upload progress for ${file.relativePath}: ${percentComplete}%`); - } } }); - - const uploadDuration = Date.now() - uploadStartTime; - logger.debug(`Uploaded ${file.relativePath} in ${uploadDuration}ms`); - - // Update state + await updateFileState(file.relativePath, checksum, file.size, file.modified); - - backedUpCount++; - backedUpSize += file.size; + backedUpFiles.push(file.relativePath); - + backedUpSize += file.size; } catch (error) { logger.error(`Failed to backup file ${file.relativePath} to S3:`, error); - // Continue with other files even if one fails } } - - // Check if database backup should be included - if (config.backup_include_database !== false) { + + let databaseInfo = null; + if (normalizeBoolean(config.backup_include_database) !== false) { try { - logger.info('Including database backup in S3 backup...'); - const dbInfo = await getDatabaseBackupInfo(); - - if (dbInfo.backupFile && await fs.stat(dbInfo.backupFile).catch(() => null)) { - // Upload database backup file - const dbFileName = path.basename(dbInfo.backupFile); - const dbS3Key = path.posix.join(s3Prefix, 'database', dbFileName); - - await s3Client.upload(dbInfo.backupFile, dbS3Key, { + databaseInfo = await service.getDatabaseBackupInfo(); + if (databaseInfo.backupFile && await fs.stat(databaseInfo.backupFile).catch(() => null)) { + const dbKey = path.posix.join(s3Prefix, 'database', path.basename(databaseInfo.backupFile)); + await s3Client.upload(databaseInfo.backupFile, dbKey, { metadata: { - 'backup-type': 'database', - 'database-type': dbInfo.type, - 'checksum': dbInfo.checksum, 'backup-id': backupId, - 'backup-time': now.toISOString() + 'backup-type': 'database', + 'database-type': databaseInfo.type, + checksum: databaseInfo.checksum || '' } }); - - backedUpCount++; - backedUpSize += dbInfo.size; - logger.info(`Database backup uploaded to S3: ${dbS3Key}`); + backedUpFiles.push(path.posix.join('database', path.basename(databaseInfo.backupFile))); + backedUpSize += databaseInfo.size || 0; } else { logger.warn('No recent database backup found to include in S3 backup'); } @@ -568,105 +610,125 @@ async function performS3Backup(config, files) { logger.error('Failed to include database backup in S3:', error); } } - - // Create and upload a backup summary file + try { const summary = { - backupId: backupId, + backupId, timestamp: now.toISOString(), - s3Bucket: config.backup_s3_bucket, - s3Prefix: s3Prefix, - filesBackedUp: backedUpCount, - totalSize: backedUpSize, - totalSizeFormatted: formatBytes(backedUpSize), - configuration: { - incremental: config.backup_incremental !== false, - includeArchived: config.backup_include_archived, - includeDatabase: config.backup_include_database !== false, - maxFileSizeMB: config.backup_max_file_size_mb || 5000 - } + bucket, + prefix: s3Prefix, + filesBackedUp: backedUpFiles.length, + totalSizeBytes: backedUpSize, + totalSizeFormatted: formatBytes(backedUpSize) }; - - const summaryJson = JSON.stringify(summary, null, 2); - const summaryS3Key = path.posix.join(s3Prefix, 'backup-summary.json'); - - // Create a temporary file for the summary - const tempSummaryPath = path.join(storagePath, `temp-summary-${backupId}.json`); - await fs.writeFile(tempSummaryPath, summaryJson); - - await s3Client.upload(tempSummaryPath, summaryS3Key, { - contentType: 'application/json', - metadata: { - 'backup-id': backupId, - 'backup-type': 'summary' - } + const summaryPath = path.join(getStoragePath(), `backup-summary-${backupId}.json`); + await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2)); + await s3Client.upload(summaryPath, path.posix.join(s3Prefix, 'backup-summary.json'), { + contentType: 'application/json' }); - - // Clean up temp file - await fs.unlink(tempSummaryPath).catch(() => {}); - - logger.info(`Backup summary uploaded to S3: ${summaryS3Key}`); + await fs.unlink(summaryPath).catch(() => {}); } catch (error) { logger.error('Failed to upload backup summary:', error); } - - logger.info(`S3 backup completed: ${backedUpCount} files, ${formatBytes(backedUpSize)} uploaded to ${s3Prefix}`); - - return { - backedUpCount, - backedUpSize, + + logger.info(`S3 backup completed: ${backedUpFiles.length} files, ${formatBytes(backedUpSize)} uploaded to ${s3Prefix}`); + + return { + backedUpCount: backedUpFiles.length, + backedUpSize, backedUpFiles, + backupPath: `s3://${bucket}/${s3Prefix}`, s3Prefix, - s3Bucket: config.backup_s3_bucket + s3Bucket: bucket, + s3Client, + databaseInfo }; - } catch (error) { logger.error('S3 backup failed:', error); - throw new Error(`S3 backup failed: ${error.message}`); + throw error; } } -/** - * Format bytes to human readable string - */ -function formatBytes(bytes, decimals = 2) { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +async function getPreviousSuccessfulBackup(currentRunId) { + const record = await db('backup_runs') + .where('status', 'completed') + .orderBy('completed_at', 'desc') + .first(); + + if (record && record.id === currentRunId) { + return null; + } + + return record || null; } -/** - * Run backup process - */ -async function runBackup() { +function buildManifestFiles(backedUpFiles, allFiles) { + const fileMap = new Map(); + allFiles.forEach(file => { + fileMap.set(file.relativePath, file); + }); + + return backedUpFiles.map(relativePath => { + const source = fileMap.get(relativePath) || {}; + return { + path: relativePath, + size: source.size || null, + checksum: source.checksum || null + }; + }); +} + +async function saveManifestToLocal(manifest, manifestFileName, config) { + const manifestDir = config.backup_manifest_path + || path.join(config.backup_destination_path || '/backup', 'manifests'); + await fs.mkdir(manifestDir, { recursive: true }); + const manifestPath = path.join(manifestDir, manifestFileName); + await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json'); + logger.info(`Backup manifest saved to ${manifestPath}`); + return manifestPath; +} + +async function saveManifestToS3(manifest, manifestFileName, config, result) { + const tempDir = path.join(getStoragePath(), 'temp'); + await fs.mkdir(tempDir, { recursive: true }); + const tempManifestPath = path.join(tempDir, manifestFileName); + await backupManifest.saveManifest(manifest, tempManifestPath, config.backup_manifest_format || 'json'); + + const manifestKey = path.posix.join(result.s3Prefix, 'manifests', manifestFileName); + await result.s3Client.upload(tempManifestPath, manifestKey, { + contentType: config.backup_manifest_format === 'xml' ? 'application/xml' : 'application/json', + metadata: { + 'backup-type': 'manifest', + 'manifest-version': manifest.version, + 'backup-id': manifest.backup?.id || '' + } + }); + + await fs.unlink(tempManifestPath).catch(() => {}); + const manifestPath = `s3://${result.s3Bucket}/${manifestKey}`; + logger.info(`Backup manifest uploaded to S3: ${manifestPath}`); + return manifestPath; +} + +async function runBackupInternal() { if (isRunning) { logger.warn('Backup already running, skipping'); return; } - + isRunning = true; const startTime = new Date(); - let backupRun = null; - + let runId = null; + try { - // Get current configuration - const config = await getBackupConfig(); - if (!config.backup_enabled) { + const config = await resolveConfigWithFallback(); + if (!config || !normalizeBoolean(config.backup_enabled)) { logger.info('Backup is disabled, skipping'); return; } - - // Get current schema version + const schemaVersion = await getCurrentSchemaVersion(); - - // Create backup run record with version info - const [runId] = await db('backup_runs').insert({ + const [insertedId] = await db('backup_runs').insert({ started_at: startTime, status: 'running', backup_type: 'scheduled', @@ -674,156 +736,78 @@ async function runBackup() { node_version: process.version, db_schema_version: schemaVersion }); - - backupRun = { id: runId }; - - // Get files to backup - const files = await getFilesToBackup(config.backup_include_archived); + runId = insertedId; + + const files = await service.getFilesToBackup(config.backup_include_archived); logger.info(`Found ${files.length} files to check for backup`); - - // Perform backup based on destination type + let result; - switch (config.backup_destination_type) { - case 'local': - result = await performLocalBackup(config, files); - break; - case 'rsync': - result = await performRsyncBackup(config, files); - break; - case 's3': - result = await performS3Backup(config, files); - break; - default: - throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`); + const destinationType = (config.backup_destination_type || 'local').toLowerCase(); + + if (destinationType === 'local') { + result = await performLocalBackup(config, files); + } else if (destinationType === 'rsync') { + result = await performRsyncBackup(config, files); + } else if (destinationType === 's3') { + result = await performS3Backup(config, files); + } else { + throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`); } - - // Calculate duration + const endTime = new Date(); const durationSeconds = Math.round((endTime - startTime) / 1000); - - // Generate backup manifest + let manifestPath = null; + let manifestSummary = null; + try { logger.info('Generating backup manifest...'); - - // Get database backup info if available - const databaseInfo = await getDatabaseBackupInfo(); - - // Determine if this is an incremental backup - const lastSuccessfulBackup = await db('backup_runs') - .where('status', 'completed') - .whereNot('id', runId) - .orderBy('completed_at', 'desc') - .first(); - - // Prepare backup path based on destination type - let backupPath; - if (config.backup_destination_type === 's3') { - backupPath = `s3://${result.s3Bucket}/${result.s3Prefix}`; - } else { - backupPath = config.backup_destination_path || config.backup_destination_type; - } - - let manifest; + + const previousBackup = await getPreviousSuccessfulBackup(runId); + const manifestFiles = buildManifestFiles(result.backedUpFiles, files); + const databaseInfo = result.databaseInfo || await service.getDatabaseBackupInfo(); + const manifestOptions = { - backupType: lastSuccessfulBackup ? 'incremental' : 'full', - backupPath: backupPath, - files: files.filter(f => result.backedUpFiles && result.backedUpFiles.includes(f.relativePath)), - databaseInfo: databaseInfo, - parentBackupId: lastSuccessfulBackup ? lastSuccessfulBackup.manifest_id : null, + backupType: previousBackup ? 'incremental' : 'full', + backupPath: result.backupPath, + files: manifestFiles, + databaseInfo, + parentBackupId: previousBackup ? previousBackup.manifest_id : null, format: config.backup_manifest_format || 'json', customMetadata: { backup_run_id: runId, - destination_type: config.backup_destination_type, - operator: 'system', - reason: 'scheduled', - retentionDays: config.backup_retention_days || 30, - // Add S3-specific metadata if applicable - ...(config.backup_destination_type === 's3' ? { - s3_bucket: result.s3Bucket, - s3_prefix: result.s3Prefix, - s3_region: config.backup_s3_region || 'us-east-1', - s3_endpoint: config.backup_s3_endpoint - } : {}) + destination_type: destinationType, + retentionDays: config.backup_retention_days || 30 } }; - - if (lastSuccessfulBackup && lastSuccessfulBackup.manifest_path) { + + let manifest = await backupManifest.generateManifest(manifestOptions); + if (previousBackup && previousBackup.manifest_path) { try { - const parentManifest = await backupManifest.loadManifest(lastSuccessfulBackup.manifest_path); + const parentManifest = await backupManifest.loadManifest(previousBackup.manifest_path); manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest); } catch (error) { logger.warn('Failed to load parent manifest, generating full manifest:', error); - manifest = await backupManifest.generateManifest(manifestOptions); } - } else { - manifest = await backupManifest.generateManifest(manifestOptions); } - - // Save manifest - const manifestFileName = `backup-manifest-${manifest.backup.id}.${config.backup_manifest_format || 'json'}`; - - if (config.backup_destination_type === 's3') { - // For S3 backups, save manifest locally first then upload to S3 - const tempManifestDir = path.join(getStoragePath(), 'temp'); - await fs.mkdir(tempManifestDir, { recursive: true }); - - const tempManifestPath = path.join(tempManifestDir, manifestFileName); - await backupManifest.saveManifest(manifest, tempManifestPath, config.backup_manifest_format || 'json'); - - // Upload manifest to S3 - try { - const s3Config = { - bucket: config.backup_s3_bucket, - region: config.backup_s3_region || 'us-east-1', - endpoint: config.backup_s3_endpoint, - accessKeyId: config.backup_s3_access_key, - secretAccessKey: config.backup_s3_secret_key, - forcePathStyle: config.backup_s3_force_path_style || false, - sslEnabled: config.backup_s3_ssl_enabled !== false - }; - - const s3Client = new S3StorageAdapter(s3Config); - const manifestS3Key = path.posix.join(result.s3Prefix, 'manifests', manifestFileName); - - await s3Client.upload(tempManifestPath, manifestS3Key, { - contentType: config.backup_manifest_format === 'xml' ? 'application/xml' : 'application/json', - metadata: { - 'backup-id': manifest.backup.id, - 'backup-type': 'manifest', - 'manifest-version': manifest.version - } - }); - - // Clean up temp file - await fs.unlink(tempManifestPath).catch(() => {}); - - // Store S3 path as manifest path - manifestPath = `s3://${config.backup_s3_bucket}/${manifestS3Key}`; - logger.info(`Backup manifest uploaded to S3: ${manifestPath}`); - - } catch (error) { - logger.error('Failed to upload manifest to S3:', error); - // Keep local path as fallback - manifestPath = tempManifestPath; - } + + if (result.s3Client) { + manifestPath = await saveManifestToS3(manifest, `backup-manifest-${manifest.backup.id}.${manifestOptions.format}`, config, result); } else { - // For local/rsync backups, save to configured directory - const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests'); - await fs.mkdir(manifestDir, { recursive: true }); - - manifestPath = path.join(manifestDir, manifestFileName); - await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json'); - - logger.info(`Backup manifest saved to ${manifestPath}`); + manifestPath = await saveManifestToLocal(manifest, `backup-manifest-${manifest.backup.id}.${manifestOptions.format}`, config); + } + + try { + manifestSummary = backupManifest.generateSummaryReport + ? backupManifest.generateSummaryReport(manifest) + : null; + } catch (error) { + logger.warn('Failed to generate manifest summary:', error); } - } catch (error) { logger.error('Failed to generate backup manifest:', error); - // Don't fail the entire backup for manifest generation failure } - - // Update backup run record with manifest info + await db('backup_runs') .where('id', runId) .update({ @@ -834,64 +818,50 @@ async function runBackup() { duration_seconds: durationSeconds, manifest_path: manifestPath, manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null, - manifest_info: manifestSummary ? JSON.stringify({ - manifest_version: manifestSummary.manifest?.version, - backup_id: manifestSummary.backup?.id, - system_info: manifestSummary.system, - file_count: manifestSummary.files?.count, - database_info: { - type: manifestSummary.database?.type, - schema_version: manifestSummary.database?.schema_version - } - }) : null, + manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null, statistics: JSON.stringify({ totalFilesChecked: files.length, filesBackedUp: result.backedUpCount, totalSize: result.backedUpSize, - averageFileSize: result.backedUpCount > 0 ? Math.round(result.backedUpSize / result.backedUpCount) : 0, - manifestGenerated: !!manifestPath + averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0, + destination: destinationType }) }); - + logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`); - - // Send success email if configured - if (config.backup_email_on_success) { - // Get admin emails + + if (normalizeBoolean(config.backup_email_on_success)) { const admins = await db('admin_users').where('is_active', formatBoolean(true)); for (const admin of admins) { await queueEmail(null, admin.email, 'backup_completed', { start_time: startTime.toISOString(), duration: `${durationSeconds} seconds`, - files_count: result.backedUpCount.toString(), - total_size: `${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB`, - backup_type: config.backup_destination_type + files_count: String(result.backedUpCount), + total_size: formatBytes(result.backedUpSize), + backup_type: destinationType }); } } - } catch (error) { logger.error('Backup failed:', error); - - // Update backup run record - if (backupRun) { + + if (runId !== null) { await db('backup_runs') - .where('id', backupRun.id) + .where('id', runId) .update({ completed_at: new Date(), status: 'failed', error_message: error.message }); } - - // Send failure email - const config = await getBackupConfig(); - if (config && config.backup_email_on_failure) { + + const config = await resolveConfigWithFallback(); + if (config && normalizeBoolean(config.backup_email_on_failure)) { const admins = await db('admin_users').where('is_active', formatBoolean(true)); for (const admin of admins) { await queueEmail(null, admin.email, 'backup_failed', { start_time: startTime.toISOString(), - backup_type: config.backup_destination_type || 'unknown', + backup_type: (config.backup_destination_type || 'unknown').toString(), error_message: error.message }); } @@ -901,40 +871,47 @@ async function runBackup() { } } -/** - * Start backup service - */ async function startBackupService() { try { - // Get configuration - backupConfig = await getBackupConfig(); - - if (!backupConfig || !backupConfig.backup_enabled) { + const config = await resolveConfigWithFallback(); + if (!config || !normalizeBoolean(config.backup_enabled)) { + if (backupJob) { + backupJob.stop(); + backupJob = null; + } logger.info('Backup service is disabled'); return; } - - // Cancel existing job if any + if (backupJob) { backupJob.stop(); + backupJob = null; } - - // Schedule backup job - const schedule = backupConfig.backup_schedule || '0 2 * * *'; // Default: 2 AM daily + + let schedule = '0 2 * * *'; + if (Object.prototype.hasOwnProperty.call(config, 'backup_schedule')) { + const candidate = String(config.backup_schedule ?? '').trim(); + if (candidate.length) { + schedule = candidate; + } + } else if (config.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_schedule')) { + const candidate = String(parseSettingValue(config.__raw.backup_schedule) ?? '').trim(); + if (candidate.length) { + schedule = candidate; + } + } + backupJob = cron.schedule(schedule, async () => { logger.info('Starting scheduled backup'); - await runBackup(); + await service.runBackup(); }); - + logger.info(`Backup service started with schedule: ${schedule}`); } catch (error) { logger.error('Failed to start backup service:', error); } } -/** - * Stop backup service - */ function stopBackupService() { if (backupJob) { backupJob.stop(); @@ -943,47 +920,38 @@ function stopBackupService() { } } -/** - * Trigger manual backup - */ async function triggerManualBackup() { logger.info('Starting manual backup'); - await runBackup(); + await service.runBackup(); } -/** - * Get backup status and history - */ async function getBackupStatus(limit = 10) { try { const runs = await db('backup_runs') .orderBy('started_at', 'desc') .limit(limit); - + const lastRun = runs[0]; - const isHealthy = lastRun && lastRun.status === 'completed'; - - // Validate manifest if exists let manifestValid = false; + if (lastRun && lastRun.manifest_path) { try { const manifest = await backupManifest.loadManifest(lastRun.manifest_path); - backupManifest.validateManifest(manifest); + if (backupManifest.validateManifest) { + backupManifest.validateManifest(manifest); + } manifestValid = true; } catch (error) { logger.warn('Manifest validation failed:', error); } } - + return { isRunning, - isHealthy, - lastRun: lastRun ? { - ...lastRun, - manifestValid - } : null, + isHealthy: Boolean(lastRun && lastRun.status === 'completed'), + lastRun: lastRun ? { ...lastRun, manifestValid } : null, recentRuns: runs, - nextScheduledRun: backupJob ? getNextScheduledRun() : null + nextScheduledRun: getNextScheduledRun() }; } catch (error) { logger.error('Failed to get backup status:', error); @@ -995,30 +963,23 @@ async function getBackupStatus(limit = 10) { } } -/** - * Get next scheduled run time - */ function getNextScheduledRun() { - // This is a simplified version - would need proper cron parsing const now = new Date(); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(2, 0, 0, 0); // Assuming default 2 AM schedule - return tomorrow.toISOString(); + const next = new Date(now); + next.setDate(now.getDate() + 1); + next.setHours(2, 0, 0, 0); + return next.toISOString(); } -/** - * Clean up old backup runs - */ async function cleanupOldBackupRuns(retentionDays = 30) { try { - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - retentionDays); - + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + const deleted = await db('backup_runs') - .where('started_at', '<', cutoffDate) + .where('started_at', '<', cutoff) .delete(); - + if (deleted > 0) { logger.info(`Cleaned up ${deleted} old backup runs`); } @@ -1027,147 +988,133 @@ async function cleanupOldBackupRuns(retentionDays = 30) { } } -/** - * Get backup manifest for a specific backup run - */ async function getBackupManifest(backupRunId) { - try { - const run = await db('backup_runs') - .where('id', backupRunId) - .first(); - - if (!run || !run.manifest_path) { - throw new Error('Backup manifest not found'); - } - - let manifest; - - // Check if manifest is stored in S3 - if (run.manifest_path.startsWith('s3://')) { - // Parse S3 path - const s3PathMatch = run.manifest_path.match(/^s3:\/\/([^\/]+)\/(.+)$/); - if (!s3PathMatch) { - throw new Error('Invalid S3 manifest path'); - } - - const [, bucket, key] = s3PathMatch; - - // Get S3 configuration from backup settings - const config = await getBackupConfig(); - if (!config.backup_s3_access_key || !config.backup_s3_secret_key) { - throw new Error('S3 credentials not configured for manifest retrieval'); - } - - // Initialize S3 client - const s3Config = { - bucket: bucket, - region: config.backup_s3_region || 'us-east-1', - endpoint: config.backup_s3_endpoint, - accessKeyId: config.backup_s3_access_key, - secretAccessKey: config.backup_s3_secret_key, - forcePathStyle: config.backup_s3_force_path_style || false, - sslEnabled: config.backup_s3_ssl_enabled !== false - }; - - const s3Client = new S3StorageAdapter(s3Config); - - // Download manifest to temporary location - const tempDir = path.join(getStoragePath(), 'temp'); - await fs.mkdir(tempDir, { recursive: true }); - - const tempManifestPath = path.join(tempDir, `manifest-${backupRunId}.json`); - await s3Client.download(key, tempManifestPath); - - // Load manifest - manifest = await backupManifest.loadManifest(tempManifestPath); - - // Clean up temp file - await fs.unlink(tempManifestPath).catch(() => {}); - - } else { - // Load manifest from local filesystem - manifest = await backupManifest.loadManifest(run.manifest_path); - } - + const run = await db('backup_runs') + .where('id', backupRunId) + .first(); + + if (!run || !run.manifest_path) { + throw new Error('Backup manifest not found'); + } + + if (!run.manifest_path.startsWith('s3://')) { + const manifest = await backupManifest.loadManifest(run.manifest_path); return { manifest, - summary: backupManifest.generateSummaryReport(manifest) + summary: backupManifest.generateSummaryReport + ? backupManifest.generateSummaryReport(manifest) + : null }; - } catch (error) { - logger.error('Failed to get backup manifest:', error); - throw error; } + + const config = await resolveConfigWithFallback(); + const accessKey = config?.backup_s3_access_key + ?? (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_s3_access_key') + ? parseSettingValue(config.__raw.backup_s3_access_key) + : undefined) + ?? process.env.BACKUP_S3_ACCESS_KEY; + + const secretKey = config?.backup_s3_secret_key + ?? (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_s3_secret_key') + ? parseSettingValue(config.__raw.backup_s3_secret_key) + : undefined) + ?? process.env.BACKUP_S3_SECRET_KEY; + + if (!accessKey || !secretKey) { + throw new Error('S3 credentials not configured for manifest retrieval'); + } + + const match = run.manifest_path.match(/^s3:\/\/([^\/]+)\/(.+)$/); + if (!match) { + throw new Error('Invalid S3 manifest path'); + } + + const [, bucket, key] = match; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-manifest-')); + const tempPath = path.join(tempDir, `manifest-${backupRunId}.json`); + + const s3Client = new S3StorageAdapter({ + bucket, + region: (config && config.backup_s3_region) || 'us-east-1', + endpoint: config && config.backup_s3_endpoint, + accessKeyId: accessKey, + secretAccessKey: secretKey, + forcePathStyle: config ? normalizeBoolean(config.backup_s3_force_path_style) : false, + sslEnabled: config && config.backup_s3_ssl_enabled !== undefined + ? normalizeBoolean(config.backup_s3_ssl_enabled) + : true + }); + + await s3Client.download(key, tempPath); + const manifest = await backupManifest.loadManifest(tempPath); + await fs.unlink(tempPath).catch(() => {}); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + + return { + manifest, + summary: backupManifest.generateSummaryReport + ? backupManifest.generateSummaryReport(manifest) + : null + }; } -/** - * Validate a backup manifest file - */ async function validateBackupManifest(manifestPath) { try { let manifest; - - // Check if manifest is stored in S3 + if (manifestPath.startsWith('s3://')) { - // Parse S3 path - const s3PathMatch = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/); - if (!s3PathMatch) { + const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/); + if (!match) { throw new Error('Invalid S3 manifest path'); } - - const [, bucket, key] = s3PathMatch; - - // Get S3 configuration from backup settings - const config = await getBackupConfig(); - if (!config.backup_s3_access_key || !config.backup_s3_secret_key) { + const [, bucket, key] = match; + + const config = await service.getBackupConfig(); + if (!config || !config.backup_s3_access_key || !config.backup_s3_secret_key) { throw new Error('S3 credentials not configured for manifest validation'); } - - // Initialize S3 client - const s3Config = { - bucket: bucket, + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-manifest-')); + const tempPath = path.join(tempDir, `validate-${Date.now()}.json`); + + const s3Client = new S3StorageAdapter({ + bucket, region: config.backup_s3_region || 'us-east-1', endpoint: config.backup_s3_endpoint, accessKeyId: config.backup_s3_access_key, secretAccessKey: config.backup_s3_secret_key, - forcePathStyle: config.backup_s3_force_path_style || false, - sslEnabled: config.backup_s3_ssl_enabled !== false - }; - - const s3Client = new S3StorageAdapter(s3Config); - - // Download manifest to temporary location - const tempDir = path.join(getStoragePath(), 'temp'); - await fs.mkdir(tempDir, { recursive: true }); - - const tempManifestPath = path.join(tempDir, `validate-manifest-${Date.now()}.json`); - await s3Client.download(key, tempManifestPath); - - // Load manifest - manifest = await backupManifest.loadManifest(tempManifestPath); - - // Clean up temp file - await fs.unlink(tempManifestPath).catch(() => {}); - + forcePathStyle: normalizeBoolean(config.backup_s3_force_path_style), + sslEnabled: config.backup_s3_ssl_enabled === undefined ? true : normalizeBoolean(config.backup_s3_ssl_enabled) + }); + + await s3Client.download(key, tempPath); + manifest = await backupManifest.loadManifest(tempPath); + await fs.unlink(tempPath).catch(() => {}); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); } else { - // Load manifest from local filesystem manifest = await backupManifest.loadManifest(manifestPath); } - - // Validate the manifest - backupManifest.validateManifest(manifest); + + if (backupManifest.validateManifest) { + backupManifest.validateManifest(manifest); + } + return { valid: true, manifest }; } catch (error) { return { valid: false, error: error.message }; } } -module.exports = { - startBackupService, - stopBackupService, - triggerManualBackup, - getBackupStatus, - runBackup, - cleanupOldBackupRuns, - getBackupManifest, - validateBackupManifest -}; \ No newline at end of file +service.getBackupConfig = getBackupConfigInternal; +service.getDatabaseBackupInfo = getDatabaseBackupInfoInternal; +service.getFilesToBackup = getFilesToBackupInternal; +service.runBackup = runBackupInternal; +service.startBackupService = startBackupService; +service.stopBackupService = stopBackupService; +service.triggerManualBackup = triggerManualBackup; +service.getBackupStatus = getBackupStatus; +service.cleanupOldBackupRuns = cleanupOldBackupRuns; +service.getBackupManifest = getBackupManifest; +service.validateBackupManifest = validateBackupManifest; + +module.exports = service; diff --git a/backend/src/services/publicSiteService.js b/backend/src/services/publicSiteService.js new file mode 100644 index 0000000..d51332f --- /dev/null +++ b/backend/src/services/publicSiteService.js @@ -0,0 +1,262 @@ +const crypto = require('crypto'); +const sanitizeHtml = require('sanitize-html'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { sanitizeCss } = require('../utils/cssSanitizer'); +const { + DEFAULT_PUBLIC_SITE_TITLE, + DEFAULT_PUBLIC_SITE_HTML, + DEFAULT_PUBLIC_SITE_CSS, +} = require('../constants/publicSiteDefaults'); + +const CACHE_TTL_MS = Number(process.env.PUBLIC_SITE_CACHE_TTL_MS || 60_000); + +let cachedPayload = null; +let cacheExpiresAt = 0; + +const ALLOWED_HTML_TAGS = [ + 'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', + 'em', 'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'header', 'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', + 'strong', 'sup', 'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', + 'ul' +]; + +const COMMON_ATTRIBUTES = ['class', 'id', 'role', 'aria-label', 'aria-hidden']; + +function parseSettingValue(value) { + if (value === null || value === undefined) { + return null; + } + try { + return JSON.parse(value); + } catch (error) { + return value; + } +} + +async function fetchPublicSiteSettings() { + const rows = await db('app_settings') + .whereIn('setting_key', [ + 'general_public_site_enabled', + 'general_public_site_html', + 'general_public_site_custom_css' + ]); + + const map = { + general_public_site_enabled: false, + general_public_site_html: DEFAULT_PUBLIC_SITE_HTML, + general_public_site_custom_css: '' + }; + + rows.forEach((row) => { + const parsed = parseSettingValue(row.setting_value); + map[row.setting_key] = parsed == null ? map[row.setting_key] : parsed; + }); + + return map; +} + +function sanitizeBrandUrl(url) { + if (typeof url !== 'string' || !url.trim()) { + return null; + } + + const trimmed = url.trim(); + if (trimmed.startsWith('javascript:')) { + return null; + } + + return trimmed; +} + +async function fetchBrandingContext() { + const rows = await db('app_settings') + .whereIn('setting_key', [ + 'branding_company_name', + 'branding_company_tagline', + 'branding_support_email', + 'branding_logo_url', + 'branding_footer_text', + 'theme_config' + ]); + + const context = { + companyName: null, + companyTagline: null, + supportEmail: null, + logoUrl: null, + footerText: null, + colors: { + primary: '#16a34a', + accent: '#0f766e', + background: '#f4fbf6', + text: '#0f172a' + } + }; + + rows.forEach((row) => { + const parsed = parseSettingValue(row.setting_value); + switch (row.setting_key) { + case 'branding_company_name': + context.companyName = parsed || context.companyName; + break; + case 'branding_company_tagline': + context.companyTagline = parsed || context.companyTagline; + break; + case 'branding_support_email': + context.supportEmail = parsed || context.supportEmail; + break; + case 'branding_logo_url': + context.logoUrl = sanitizeBrandUrl(parsed); + break; + case 'branding_footer_text': + context.footerText = parsed || context.footerText; + break; + case 'theme_config': { + try { + const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed; + if (themeConfig && typeof themeConfig === 'object') { + context.colors.primary = themeConfig.primaryColor || context.colors.primary; + context.colors.accent = themeConfig.accentColor || context.colors.accent; + context.colors.background = themeConfig.backgroundColor || context.colors.background; + context.colors.text = themeConfig.textColor || context.colors.text; + } + } catch (error) { + logger.warn('Failed to parse theme configuration for public site', { error: error.message }); + } + break; + } + default: + break; + } + }); + + return context; +} + +function sanitizeHtmlPayload(html) { + const sanitized = sanitizeHtml(html || '', { + allowedTags: ALLOWED_HTML_TAGS, + allowedAttributes: { + '*': COMMON_ATTRIBUTES, + a: ['href', 'target', 'rel', ...COMMON_ATTRIBUTES], + img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding', ...COMMON_ATTRIBUTES], + button: ['type', ...COMMON_ATTRIBUTES] + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel'], + allowedSchemesByTag: { img: ['http', 'https', 'data'] }, + transformTags: { + a: (tagName, attribs) => { + const transformed = { ...attribs }; + if (transformed.href && !/^https?:|^mailto:|^tel:/i.test(transformed.href)) { + // sanitize-html will remove disallowed schemes, but we guard as well + delete transformed.href; + } + + if (transformed.target === '_blank') { + transformed.rel = transformed.rel ? `${transformed.rel} noopener noreferrer`.trim() : 'noopener noreferrer'; + } + + return { tagName, attribs: transformed }; + } + }, + nonBooleanAttributes: ['target'], + parser: { + lowerCaseAttributeNames: true + } + }); + + return sanitized; +} + +function buildCachedPayload(raw) { + const sanitizedHtml = sanitizeHtmlPayload(raw.publicSite.general_public_site_html || DEFAULT_PUBLIC_SITE_HTML); + const sanitizedCss = sanitizeCss(raw.publicSite.general_public_site_custom_css || ''); + const enabled = Boolean(raw.publicSite.general_public_site_enabled); + const title = raw.branding.companyName || DEFAULT_PUBLIC_SITE_TITLE; + const baseCss = sanitizeCss(DEFAULT_PUBLIC_SITE_CSS); + + const substitutedHtml = applyBrandTokens(sanitizedHtml, raw.branding); + + const hash = crypto + .createHash('sha1') + .update(`${enabled}|${substitutedHtml}|${sanitizedCss}|${baseCss}|${JSON.stringify(raw.branding)}`) + .digest('hex'); + + return { + enabled, + html: substitutedHtml, + css: sanitizedCss, + baseCss, + title, + branding: raw.branding, + etag: `W/"${hash}"` + }; +} + +async function getPublicSitePayload({ bypassCache = false } = {}) { + if (!bypassCache && cachedPayload && Date.now() < cacheExpiresAt) { + return cachedPayload; + } + + const [publicSite, branding] = await Promise.all([ + fetchPublicSiteSettings(), + fetchBrandingContext() + ]); + + const payload = buildCachedPayload({ publicSite, branding }); + + cachedPayload = payload; + cacheExpiresAt = Date.now() + CACHE_TTL_MS; + + return payload; +} + +function clearPublicSiteCache() { + cachedPayload = null; + cacheExpiresAt = 0; +} + +async function getDefaultPublicSitePayload() { + const branding = await fetchBrandingContext(); + return buildCachedPayload({ + publicSite: { + general_public_site_enabled: false, + general_public_site_html: DEFAULT_PUBLIC_SITE_HTML, + general_public_site_custom_css: '' + }, + branding + }); +} + +async function getRawPublicSiteSettings() { + return fetchPublicSiteSettings(); +} + +function applyBrandTokens(html, branding) { + if (!html) { + return html; + } + + const tokens = { + company_name: branding.companyName || '', + company_tagline: branding.companyTagline || '', + support_email: branding.supportEmail || '', + brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png', + brand_primary_hex: branding.colors?.primary || '#2563eb', + brand_accent_hex: branding.colors?.accent || '#1d4ed8', + brand_background_hex: branding.colors?.background || '#f8fafc', + brand_text_hex: branding.colors?.text || '#0f172a' + }; + + return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi, + (_, key) => tokens[key] || ''); +} + +module.exports = { + getPublicSitePayload, + clearPublicSiteCache, + getDefaultPublicSitePayload, + getRawPublicSiteSettings +}; diff --git a/backend/src/services/storage/__tests__/s3Storage.test.js b/backend/src/services/storage/__tests__/s3Storage.test.js index 052ac48..13d239d 100644 --- a/backend/src/services/storage/__tests__/s3Storage.test.js +++ b/backend/src/services/storage/__tests__/s3Storage.test.js @@ -1,5 +1,5 @@ const S3StorageAdapter = require('../s3Storage'); -const { S3Client } = require('@aws-sdk/client-s3'); +const { S3Client, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3'); const { Upload } = require('@aws-sdk/lib-storage'); const fs = require('fs'); const stream = require('stream'); @@ -24,6 +24,10 @@ describe('S3StorageAdapter', () => { send: mockSend }; S3Client.mockImplementation(() => mockS3Client); + + HeadBucketCommand.mockImplementation((input) => ({ input })); + HeadObjectCommand.mockImplementation((input) => ({ input })); + ListObjectsV2Command.mockImplementation((input) => ({ input })); // Create adapter instance s3Storage = new S3StorageAdapter({ @@ -70,11 +74,7 @@ describe('S3StorageAdapter', () => { const result = await s3Storage.testConnection(); expect(result).toBe(true); - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - input: { Bucket: 'test-bucket' } - }) - ); + expect(HeadBucketCommand).toHaveBeenCalledWith({ Bucket: 'test-bucket' }); }); it('should throw error on connection failure', async () => { @@ -132,24 +132,25 @@ describe('S3StorageAdapter', () => { it('should track upload progress', async () => { const onProgress = jest.fn(); - let progressCallback; - - mockUpload.on.mockImplementation((event, callback) => { - if (event === 'httpUploadProgress') { - progressCallback = callback; - } - return mockUpload; + Upload.mockImplementation(() => { + const uploadInstance = { + on: jest.fn((event, handler) => { + if (event === 'httpUploadProgress') { + handler({ loaded: 512, total: 1024 }); + } + return uploadInstance; + }), + done: mockDone + }; + return uploadInstance; }); - + const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', { onProgress }); - - // Simulate progress - progressCallback({ loaded: 512, total: 1024 }); - + await uploadPromise; - + expect(onProgress).toHaveBeenCalledWith(512, 1024); }); @@ -177,11 +178,10 @@ describe('S3StorageAdapter', () => { const result = await s3Storage.exists('test-key'); expect(result).toBe(true); - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - input: { Bucket: 'test-bucket', Key: 'test-key' } - }) - ); + expect(HeadObjectCommand).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'test-key' + }); }); it('should return false if object does not exist', async () => { @@ -220,58 +220,51 @@ describe('S3StorageAdapter', () => { it('should retry on retryable errors', async () => { const retryableError = new Error('Connection reset'); retryableError.code = 'ECONNRESET'; - - // First attempt fails, second succeeds - mockSend + + const operation = jest.fn() .mockRejectedValueOnce(retryableError) - .mockResolvedValueOnce({}); - - // Mock setTimeout to speed up test - jest.useFakeTimers(); - - const promise = s3Storage.exists('test-key'); - - // Advance timers - jest.runAllTimers(); - - const result = await promise; - - expect(result).toBe(true); - expect(mockSend).toHaveBeenCalledTimes(2); - - jest.useRealTimers(); + .mockResolvedValueOnce('success'); + + const originalRandom = Math.random; + const originalDelay = s3Storage.config.retryDelay; + Math.random = jest.fn(() => 0); + s3Storage.config.retryDelay = 0; + + const result = await s3Storage._retryOperation(operation); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(2); + + Math.random = originalRandom; + s3Storage.config.retryDelay = originalDelay; }); it('should not retry on non-retryable errors', async () => { const nonRetryableError = new Error('Invalid credentials'); nonRetryableError.code = 'InvalidCredentials'; - mockSend.mockRejectedValueOnce(nonRetryableError); - - await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials'); - expect(mockSend).toHaveBeenCalledTimes(1); + const operation = jest.fn().mockRejectedValueOnce(nonRetryableError); + + await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Invalid credentials'); + expect(operation).toHaveBeenCalledTimes(1); }); it('should stop retrying after max attempts', async () => { const retryableError = new Error('Service unavailable'); retryableError.code = 'ServiceUnavailable'; - mockSend.mockRejectedValue(retryableError); - - // Mock setTimeout to speed up test - jest.useFakeTimers(); - - const promise = s3Storage.exists('test-key'); - - // Advance timers for all retries - for (let i = 0; i < 4; i++) { - jest.runAllTimers(); - } - - await expect(promise).rejects.toThrow('Service unavailable'); - expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries - - jest.useRealTimers(); + const operation = jest.fn().mockRejectedValue(retryableError); + + const originalRandom = Math.random; + const originalDelay = s3Storage.config.retryDelay; + Math.random = jest.fn(() => 0); + s3Storage.config.retryDelay = 0; + + await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Service unavailable'); + expect(operation).toHaveBeenCalledTimes(4); // initial + 3 retries + + Math.random = originalRandom; + s3Storage.config.retryDelay = originalDelay; }); }); @@ -308,4 +301,4 @@ describe('S3StorageAdapter', () => { expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB'); }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/utils/cssSanitizer.js b/backend/src/utils/cssSanitizer.js new file mode 100644 index 0000000..42ce395 --- /dev/null +++ b/backend/src/utils/cssSanitizer.js @@ -0,0 +1,32 @@ +function sanitizeCss(css) { + if (!css || typeof css !== 'string') { + return ''; + } + + let sanitized = css; + + const disallowedPatterns = [ + /@import[^;]+;?/gi, + /@charset[^;]+;?/gi, + /expression\s*\([^)]*\)/gi, + /url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi, + /url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi + ]; + + disallowedPatterns.forEach((pattern) => { + sanitized = sanitized.replace(pattern, ''); + }); + + sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, ''); + + const MAX_LENGTH = 100 * 1024; + if (sanitized.length > MAX_LENGTH) { + sanitized = sanitized.slice(0, MAX_LENGTH); + } + + return sanitized.trim(); +} + +module.exports = { + sanitizeCss, +}; diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0d03b3d..2a9b5c5 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -101,6 +101,19 @@ server { proxy_cache_valid 404 1m; } + # Delegate root requests to backend for public landing page handling + location = / { + proxy_pass http://backend:3001/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; @@ -110,4 +123,4 @@ server { location ~ /\. { deny all; } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04cd202..c964e88 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,7 +22,6 @@ import { AnalyticsPage, BrandingPage, SettingsPage, - CMSPage, BackupManagement } from './pages/admin'; import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced'; diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index aff1b2d..c9f90b1 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -167,6 +167,9 @@ export const AdminPhotoGrid: React.FC = ({
{photos.map((photo, index) => { const isDeleting = deletingPhotos.has(photo.id); + const commentCount = photo.comment_count ?? 0; + const averageRating = photo.average_rating ?? 0; + const likeCount = photo.like_count ?? 0; return (
= ({ )} {/* Feedback Indicators (moved to bottom-right to avoid covering category) */} - {(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && ( + {(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
- {photo.average_rating > 0 && ( -
+ {averageRating > 0 && ( +
- {Number(photo.average_rating).toFixed(1)} + {Number(averageRating).toFixed(1)}
)} - {photo.comment_count > 0 && ( -
+ {commentCount > 0 && ( +
- {photo.comment_count} + {commentCount}
)}
diff --git a/frontend/src/components/admin/AdminPhotoViewer.tsx b/frontend/src/components/admin/AdminPhotoViewer.tsx index b4b0927..3b8d2a0 100644 --- a/frontend/src/components/admin/AdminPhotoViewer.tsx +++ b/frontend/src/components/admin/AdminPhotoViewer.tsx @@ -1,15 +1,20 @@ import React, { useState } from 'react'; -import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, ThumbsUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; +import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; import { format } from 'date-fns'; import { toast } from 'react-toastify'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { AdminPhoto } from '../../services/photos.service'; import { photosService } from '../../services/photos.service'; -import { feedbackService } from '../../services/feedback.service'; +import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service'; import { Button } from '../common'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; +type AdminFeedbackResponse = { + feedback: PhotoFeedback[]; + summary?: FeedbackSummary; +}; + interface AdminPhotoViewerProps { photos: AdminPhoto[]; initialIndex: number; @@ -34,9 +39,16 @@ export const AdminPhotoViewer: React.FC = ({ const queryClient = useQueryClient(); const currentPhoto = photos[currentIndex]; + const averageRating = currentPhoto?.average_rating ?? 0; + const likeCount = currentPhoto?.like_count ?? 0; + const favoriteCount = currentPhoto?.favorite_count ?? 0; + + if (!currentPhoto) { + return null; + } // Fetch feedback for current photo - const { data: feedbackData } = useQuery({ + const { data: feedbackData } = useQuery({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id], queryFn: () => feedbackService.getEventFeedback(eventId.toString(), { photoId: currentPhoto?.id.toString(), @@ -45,6 +57,8 @@ export const AdminPhotoViewer: React.FC = ({ enabled: !!currentPhoto }); + const comments = (feedbackData?.feedback ?? []).filter((item): item is PhotoFeedback => item.feedback_type === 'comment'); + const goToPrevious = () => { setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); }; @@ -304,41 +318,41 @@ export const AdminPhotoViewer: React.FC = ({ {/* Feedback Stats */}
- {currentPhoto.average_rating > 0 && ( + {averageRating > 0 && (
- {Number(currentPhoto.average_rating).toFixed(1)} + {Number(averageRating).toFixed(1)}

Avg Rating

)} - {currentPhoto.like_count > 0 && ( + {likeCount > 0 && (
- {currentPhoto.like_count} + {likeCount}

Likes

)} - {currentPhoto.favorite_count > 0 && ( + {favoriteCount > 0 && (
- {currentPhoto.favorite_count} + {favoriteCount}

Favorites

)} - {feedbackData.feedback && ( + {comments.length > 0 && (
- {feedbackData.feedback.filter(f => f.feedback_type === 'comment').length} + {comments.length}

Comments

@@ -346,20 +360,18 @@ export const AdminPhotoViewer: React.FC = ({
{/* Comments List */} - {feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && ( + {comments.length > 0 && (
{expandedComments && (
- {feedbackData.feedback - .filter(f => f.feedback_type === 'comment') - .map((comment) => ( + {comments.map((comment) => (
@@ -459,7 +471,7 @@ export const AdminPhotoViewer: React.FC = ({ )} {/* No feedback message */} - {(!feedbackData.feedback || feedbackData.feedback.length === 0) && ( + {comments.length === 0 && (

No feedback for this photo yet.

)}
@@ -475,4 +487,4 @@ export const AdminPhotoViewer: React.FC = ({
); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index f638951..f4616c8 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -121,11 +121,20 @@ const StorageInfo: React.FC = () => { ); } - const usagePercent = Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100); + const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1; + const usagePercent = limitInUse + ? Math.round((storageInfo.total_used / limitInUse) * 100) + : 0; + const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse; + const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600'; + const containerClass = isOverSoftLimit + ? 'bg-red-50 border border-red-200' + : 'bg-neutral-100'; + const softLimitDisplay = settingsService.formatBytes(limitInUse); return (
-
+
{t('admin.storageUsed')} @@ -134,14 +143,14 @@ const StorageInfo: React.FC = () => {

- {t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })} + {t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}

); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/admin/BackupConfiguration.d.ts b/frontend/src/components/admin/BackupConfiguration.d.ts new file mode 100644 index 0000000..78d4eca --- /dev/null +++ b/frontend/src/components/admin/BackupConfiguration.d.ts @@ -0,0 +1,3 @@ +import type { ComponentType } from 'react'; + +export const BackupConfiguration: ComponentType; diff --git a/frontend/src/components/admin/BackupDashboard.d.ts b/frontend/src/components/admin/BackupDashboard.d.ts new file mode 100644 index 0000000..b608d26 --- /dev/null +++ b/frontend/src/components/admin/BackupDashboard.d.ts @@ -0,0 +1,3 @@ +import type { ComponentType } from 'react'; + +export const BackupDashboard: ComponentType; diff --git a/frontend/src/components/admin/BackupHistory.d.ts b/frontend/src/components/admin/BackupHistory.d.ts new file mode 100644 index 0000000..4b750b1 --- /dev/null +++ b/frontend/src/components/admin/BackupHistory.d.ts @@ -0,0 +1,3 @@ +import type { ComponentType } from 'react'; + +export const BackupHistory: ComponentType; diff --git a/frontend/src/components/admin/CMSEditor.tsx b/frontend/src/components/admin/CMSEditor.tsx index 78002bb..636225b 100644 --- a/frontend/src/components/admin/CMSEditor.tsx +++ b/frontend/src/components/admin/CMSEditor.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { useEditor, EditorContent } from '@tiptap/react'; +import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import HardBreak from '@tiptap/extension-hard-break'; @@ -107,10 +107,11 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, }, }); - const updateCounts = useCallback((editor: any) => { - const text = editor.state.doc.textContent; - setCharCount(editor.storage.characterCount.characters()); - setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length); + const updateCounts = useCallback((editorInstance: Editor) => { + const textContent = editorInstance.state.doc.textContent; + setCharCount(editorInstance.storage.characterCount.characters()); + const words = textContent.trim().split(/\s+/).filter((word: string) => word.length > 0); + setWordCount(words.length); }, []); // Update editor content when prop changes @@ -568,4 +569,4 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, ); }; -CMSEditor.displayName = 'CMSEditor'; \ No newline at end of file +CMSEditor.displayName = 'CMSEditor'; diff --git a/frontend/src/components/admin/FeedbackModerationPanel.tsx b/frontend/src/components/admin/FeedbackModerationPanel.tsx index d15fe8b..a0d8c8c 100644 --- a/frontend/src/components/admin/FeedbackModerationPanel.tsx +++ b/frontend/src/components/admin/FeedbackModerationPanel.tsx @@ -1,14 +1,11 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { - MessageSquare, - Eye, - EyeOff, - Trash2, - AlertCircle, +import { + MessageSquare, + EyeOff, + Trash2, CheckCircle, - Clock, User } from 'lucide-react'; import { parseISO } from 'date-fns'; @@ -16,7 +13,7 @@ import { toast } from 'react-toastify'; import { Card, Loading, Button } from '../common'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; -import { feedbackService } from '../../services/feedback.service'; +import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; interface FeedbackModerationPanelProps { @@ -38,7 +35,7 @@ export const FeedbackModerationPanel: React.FC = ( const [showAll, setShowAll] = useState(false); // Fetch pending feedback - const { data: feedbackData, isLoading } = useQuery({ + const { data: feedbackData, isLoading } = useQuery({ queryKey: ['event-feedback-moderation', eventId], queryFn: () => feedbackService.getEventFeedback(eventId.toString(), { type: 'comment', @@ -77,12 +74,12 @@ export const FeedbackModerationPanel: React.FC = ( ); } - const pendingComments = feedbackData?.feedback || []; + const pendingComments: PhotoFeedback[] = feedbackData?.feedback || []; const hasPending = pendingComments.length > 0; return ( -
+

{t('feedback.pendingModeration', 'Pending Moderation')} diff --git a/frontend/src/components/admin/ImageProtectionSettings.tsx b/frontend/src/components/admin/ImageProtectionSettings.tsx deleted file mode 100644 index 1ce2e4c..0000000 --- a/frontend/src/components/admin/ImageProtectionSettings.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Shield, Eye, Lock, AlertTriangle, Info } from 'lucide-react'; -import { Button, Card, Toggle, Select, Input, Textarea } from '../common'; -import { settingsService } from '../../services/settings.service'; -import { toast } from 'react-toastify'; - -interface ProtectionSettings { - default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum'; - default_image_quality: number; - enable_devtools_protection: boolean; - max_image_requests_per_minute: number; - suspicious_activity_threshold: number; - enable_canvas_rendering: boolean; - default_fragmentation_level: number; - enable_overlay_protection: boolean; - protection_warning_message: string; -} - -export const ImageProtectionSettings: React.FC = () => { - const { t } = useTranslation(); - const [settings, setSettings] = useState({ - default_protection_level: 'standard', - default_image_quality: 85, - enable_devtools_protection: true, - max_image_requests_per_minute: 30, - suspicious_activity_threshold: 10, - enable_canvas_rendering: false, - default_fragmentation_level: 3, - enable_overlay_protection: true, - protection_warning_message: 'Images in this gallery are protected from unauthorized download.' - }); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - - useEffect(() => { - loadSettings(); - }, []); - - const loadSettings = async () => { - try { - setIsLoading(true); - const response = await settingsService.getSettings(); - - // Map settings from API response - const protectionSettings: ProtectionSettings = { - default_protection_level: response.default_protection_level || 'standard', - default_image_quality: parseInt(response.default_image_quality) || 85, - enable_devtools_protection: response.enable_devtools_protection !== false, - max_image_requests_per_minute: parseInt(response.max_image_requests_per_minute) || 30, - suspicious_activity_threshold: parseInt(response.suspicious_activity_threshold) || 10, - enable_canvas_rendering: response.enable_canvas_rendering === true, - default_fragmentation_level: parseInt(response.default_fragmentation_level) || 3, - enable_overlay_protection: response.enable_overlay_protection !== false, - protection_warning_message: response.protection_warning_message || settings.protection_warning_message - }; - - setSettings(protectionSettings); - } catch (error) { - console.error('Failed to load protection settings:', error); - toast.error('Failed to load protection settings'); - } finally { - setIsLoading(false); - } - }; - - const saveSettings = async () => { - try { - setIsSaving(true); - - // Convert settings to API format - const apiSettings = Object.entries(settings).reduce((acc, [key, value]) => { - acc[key] = typeof value === 'boolean' ? value : value.toString(); - return acc; - }, {} as Record); - - await settingsService.updateSettings(apiSettings); - toast.success('Protection settings saved successfully'); - } catch (error) { - console.error('Failed to save protection settings:', error); - toast.error('Failed to save protection settings'); - } finally { - setIsSaving(false); - } - }; - - const updateSetting = (key: keyof ProtectionSettings, value: any) => { - setSettings(prev => ({ ...prev, [key]: value })); - }; - - const protectionLevels = [ - { value: 'basic', label: 'Basic - Minimal protection, best performance' }, - { value: 'standard', label: 'Standard - Balanced protection and performance' }, - { value: 'enhanced', label: 'Enhanced - Strong protection with good performance' }, - { value: 'maximum', label: 'Maximum - Strongest protection, may impact performance' } - ]; - - const getProtectionLevelIcon = (level: string) => { - switch (level) { - case 'basic': return ; - case 'standard': return ; - case 'enhanced': return ; - case 'maximum': return ; - default: return ; - } - }; - - const getProtectionLevelDescription = (level: string) => { - switch (level) { - case 'basic': - return 'Prevents drag/drop and basic right-click. Good for public galleries.'; - case 'standard': - return 'Adds keyboard shortcut blocking and user selection prevention.'; - case 'enhanced': - return 'Includes DevTools detection, rate limiting, and overlay protection.'; - case 'maximum': - return 'Canvas rendering, image fragmentation, and comprehensive monitoring.'; - default: - return ''; - } - }; - - if (isLoading) { - return ( - -
-
-
-
-
-
-
-
- ); - } - - return ( -
- -
- -
-

Image Protection Settings

-

Configure security measures for photo galleries

-
-
- -
- {/* Protection Level */} -
- - updateSetting('default_image_quality', parseInt(e.target.value))} - className="w-full" - /> -
- Lower quality = Better protection - Higher quality = Better image -
-
- - {/* DevTools Protection */} -
-
- -

- Detect and respond to browser developer tools -

-
- updateSetting('enable_devtools_protection', checked)} - /> -
- - {/* Canvas Rendering */} -
-
- -

- Render images on canvas instead of img tags (stronger protection) -

-
- updateSetting('enable_canvas_rendering', checked)} - /> -
- - {/* Fragmentation Level */} - {settings.enable_canvas_rendering && ( -
- - updateSetting('default_fragmentation_level', parseInt(e.target.value))} - className="w-full" - /> -
- Low fragmentation - High fragmentation -
-
- )} - - {/* Rate Limiting */} -
-
- - updateSetting('max_image_requests_per_minute', parseInt(e.target.value))} - /> -
-
- - updateSetting('suspicious_activity_threshold', parseInt(e.target.value))} - /> -
-
- - {/* Overlay Protection */} -
-
- -

- Add transparent overlays to prevent easy screenshot extraction -

-
- updateSetting('enable_overlay_protection', checked)} - /> -
- - {/* Warning Message */} -
- -