diff --git a/backend/package.json b/backend/package.json index 57cddbaa..cdd09d47 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "predev": "node scripts/free-ports.js 8080", "dev": "nodemon --watch server --watch .env --ignore logs --ignore uploads server", - "build": "node --check server/index.js && node --check server/server.js && node --check server/bootstrap.js && node --check server/config.js && node --check server/db/sequelize.js && node --check server/db/connect.js && node --check server/db/models/inbox.js && node --check server/db/models/chat.js && node --check server/db/models/chatRoomCounter.js && node --check server/db/models/messageReceipt.js && node --check server/db/models/chatDraft.js && node --check server/db/models/messageRequest.js && node --check server/db/models/chatTopic.js && node --check server/db/models/e2eeDeviceKey.js && node --check server/db/models/resumableUpload.js && node --check server/db/models/chatAiConfig.js && node --check server/db/models/adminSocialAuthConfig.js && node --check server/db/models/profile.js && node --check server/db/models/group.js && node --check server/db/models/channel.js && node --check server/db/models/adminStorageConfig.js && node --check server/db/models/adminCallConfig.js && node --check server/db/models/callHistory.js && node --check server/db/models/profilePhoto.js && node --check server/db/models/nativePushDevice.js && node --check server/helpers/storageConfig.js && node --check server/helpers/callConfig.js && node --check server/helpers/callHistory.js && node --check server/helpers/callState.js && node --check server/helpers/avatarDefaults.js && node --check server/helpers/ensureAvatarDefaults.js && node --check server/helpers/ensureProfile.js && node --check server/helpers/privacy.js && node --check server/helpers/profilePhotos.js && node --check server/helpers/livekit.js && node --check server/helpers/nativePushConfig.js && node --check server/helpers/nativePush.js && node --check server/helpers/storage.js && node --check server/helpers/videoPipeline.js && node --check server/helpers/accountArchive.js && node --check server/helpers/accountExport.js && node --check server/helpers/logger.js && node --check server/helpers/chatAbuse.js && node --check server/helpers/chatMentions.js && node --check server/helpers/messageRequests.js && node --check server/helpers/chatReliability.js && node --check server/helpers/chatAiConfig.js && node --check server/helpers/socialAuthConfig.js && node --check server/helpers/e2eeKeyDirectory.js && node --check server/helpers/chatMaintenance.js && node --check server/middleware/chatSendIdempotency.js && node --check server/middleware/roomAccess.js && node --check server/controllers/avatar.js && node --check server/controllers/profile.js && node --check server/controllers/chatUpload.js && node --check server/controllers/chatSecurity.js && node --check server/controllers/chatDeletion.js && node --check server/controllers/chatV2.js && node --check server/controllers/chatAiAdmin.js && node --check server/controllers/chatSuggestions.js && node --check server/controllers/chatResumableUpload.js && node --check server/controllers/socialAuth.js && node --check server/controllers/socialAuthAdmin.js && node --check server/controllers/storageAdmin.js && node --check server/controllers/callingAdmin.js && node --check server/controllers/callingPushAdmin.js && node --check server/controllers/callingConfig.js && node --check server/controllers/nativePush.js && node --check server/controllers/accountStorage.js && node --check server/routes/profile.js && node --check server/routes/inbox.js && node --check server/routes/chat.js && node --check server/routes/chatV2.js && node --check server/routes/chatAiAdmin.js && node --check server/routes/socialAuthAdmin.js && node --check server/routes/storageAdmin.js && node --check server/routes/callingAdmin.js && node --check server/routes/callingPushAdmin.js && node --check server/routes/callingConfig.js && node --check server/routes/setting.js && node --check server/helpers/socketAdapter.js && node --check server/socket/auth.js && node --check server/socket/index.js && node --check server/socket/events/chatV2.js && node --check server/socket/events/calling.js && node --check server/socket/events/callRealtime.js && node --check server/socket/events/backgroundCalling.js && node --check server/socket/events/groupModeration.js && node --check server/socket/events/room.js && node --check server/routes/cron.js && node --check api/index.js", + "build": "node --check server/index.js && node --check server/server.js && node --check server/bootstrap.js && node --check server/config.js && node --check server/db/sequelize.js && node --check server/db/connect.js && node --check server/db/models/inbox.js && node --check server/db/models/chat.js && node --check server/db/models/chatRoomCounter.js && node --check server/db/models/messageReceipt.js && node --check server/db/models/chatDraft.js && node --check server/db/models/messageRequest.js && node --check server/db/models/chatTopic.js && node --check server/db/models/e2eeDeviceKey.js && node --check server/db/models/resumableUpload.js && node --check server/db/models/chatAiConfig.js && node --check server/db/models/adminSocialAuthConfig.js && node --check server/db/models/profile.js && node --check server/db/models/group.js && node --check server/db/models/channel.js && node --check server/db/models/adminStorageConfig.js && node --check server/db/models/adminCallConfig.js && node --check server/db/models/callHistory.js && node --check server/db/models/profilePhoto.js && node --check server/db/models/nativePushDevice.js && node --check server/helpers/storageConfig.js && node --check server/helpers/callConfig.js && node --check server/helpers/callHistory.js && node --check server/helpers/callState.js && node --check server/helpers/avatarDefaults.js && node --check server/helpers/ensureAvatarDefaults.js && node --check server/helpers/ensureProfile.js && node --check server/helpers/privacy.js && node --check server/helpers/profilePhotos.js && node --check server/helpers/livekit.js && node --check server/helpers/nativePushConfig.js && node --check server/helpers/nativePush.js && node --check server/helpers/storage.js && node --check server/helpers/videoPipeline.js && node --check server/helpers/accountArchive.js && node --check server/helpers/accountExport.js && node --check server/helpers/logger.js && node --check server/helpers/chatAbuse.js && node --check server/helpers/chatMentions.js && node --check server/helpers/messageRequests.js && node --check server/helpers/chatReliability.js && node --check server/helpers/chatAiConfig.js && node --check server/helpers/socialAuthConfig.js && node --check server/helpers/e2eeKeyDirectory.js && node --check server/helpers/chatMaintenance.js && node --check server/middleware/chatSendIdempotency.js && node --check server/middleware/roomAccess.js && node --check server/controllers/avatar.js && node --check server/controllers/profile.js && node --check server/controllers/chatUpload.js && node --check server/controllers/chatSecurity.js && node --check server/controllers/chatDeletion.js && node --check server/controllers/chatV2.js && node --check server/controllers/chatAiAdmin.js && node --check server/controllers/chatSuggestions.js && node --check server/controllers/chatResumableUpload.js && node --check server/controllers/socialAuth.js && node --check server/controllers/socialAuthAdmin.js && node --check server/controllers/storagePublic.js && node --check server/controllers/storageAdmin.js && node --check server/controllers/callingAdmin.js && node --check server/controllers/callingPushAdmin.js && node --check server/controllers/callingConfig.js && node --check server/controllers/nativePush.js && node --check server/controllers/accountStorage.js && node --check server/routes/profile.js && node --check server/routes/inbox.js && node --check server/routes/chat.js && node --check server/routes/chatV2.js && node --check server/routes/chatAiAdmin.js && node --check server/routes/socialAuthAdmin.js && node --check server/routes/storagePublic.js && node --check server/routes/storageAdmin.js && node --check server/routes/callingAdmin.js && node --check server/routes/callingPushAdmin.js && node --check server/routes/callingConfig.js && node --check server/routes/setting.js && node --check server/helpers/socketAdapter.js && node --check server/socket/auth.js && node --check server/socket/index.js && node --check server/socket/events/chatV2.js && node --check server/socket/events/calling.js && node --check server/socket/events/callRealtime.js && node --check server/socket/events/backgroundCalling.js && node --check server/socket/events/groupModeration.js && node --check server/socket/events/room.js && node --check server/routes/cron.js && node --check api/index.js", "start": "node server" }, "dependencies": { diff --git a/backend/server/controllers/storagePublic.js b/backend/server/controllers/storagePublic.js new file mode 100644 index 00000000..2ec3b070 --- /dev/null +++ b/backend/server/controllers/storagePublic.js @@ -0,0 +1,111 @@ +const path = require('path'); +const { getStorageConfig } = require('../helpers/storageConfig'); +const { readStorageFileToBuffer } = require('../helpers/storage'); + +const IMAGE_TYPES = new Map([ + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.webp', 'image/webp'], + ['.gif', 'image/gif'], + ['.svg', 'image/svg+xml'], + ['.ico', 'image/x-icon'], + ['.avif', 'image/avif'], +]); + +const safePathname = (value = '') => { + const raw = String(value || '').trim(); + if (!raw) return ''; + try { + return decodeURIComponent(new URL(raw, 'https://syncchat.invalid').pathname); + } catch (error0) { + return ''; + } +}; + +const resolveConfiguredStorageUrl = async (rawUrl = '') => { + const config = await getStorageConfig(); + if (!config.enabled || !config.publicBaseUrl) { + const error = new Error('Image storage is not configured'); + error.statusCode = 503; + throw error; + } + + const raw = String(rawUrl || '').trim(); + if (!raw) { + const error = new Error('Image URL is required'); + error.statusCode = 400; + throw error; + } + + const base = config.publicBaseUrl.replace(/\/+$/, ''); + if (raw === base || raw.startsWith(`${base}/`)) return raw; + + const pathname = safePathname(raw); + if (!pathname || pathname.includes('..')) { + const error = new Error('Invalid image URL'); + error.statusCode = 400; + throw error; + } + + let basePathname = ''; + try { + basePathname = new URL(base).pathname.replace(/\/+$/, ''); + } catch (error0) { + basePathname = ''; + } + + let relative = ''; + if (basePathname && pathname.startsWith(`${basePathname}/`)) { + relative = pathname.slice(basePathname.length + 1); + } else { + const marker = '/uploads/'; + const index = pathname.toLowerCase().indexOf(marker); + if (index >= 0) relative = pathname.slice(index + marker.length); + } + + relative = String(relative || '').replace(/^\/+/, ''); + if (!relative || relative.includes('..')) { + const error = new Error('Image URL does not belong to configured storage'); + error.statusCode = 400; + throw error; + } + + return `${base}/${relative}`; +}; + +exports.image = async (req, res) => { + try { + const storageUrl = await resolveConfiguredStorageUrl(req.query?.url); + const extension = path.extname(safePathname(storageUrl)).toLowerCase(); + const contentType = IMAGE_TYPES.get(extension); + if (!contentType) { + res.status(415).json({ success: false, message: 'Unsupported image type' }); + return; + } + + const buffer = await readStorageFileToBuffer(storageUrl); + if (!buffer?.length) { + res.status(404).json({ success: false, message: 'Image not found' }); + return; + } + + const maxBytes = 20 * 1024 * 1024; + if (buffer.length > maxBytes) { + res.status(413).json({ success: false, message: 'Image is too large to proxy' }); + return; + } + + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Length', String(buffer.length)); + res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400'); + res.status(200).send(buffer); + } catch (error0) { + res.status(error0.statusCode || 404).json({ + success: false, + message: error0.message || 'Image could not be loaded', + }); + } +}; + +module.exports.resolveConfiguredStorageUrl = resolveConfiguredStorageUrl; diff --git a/backend/server/routes/index.js b/backend/server/routes/index.js index 9ebb932f..c49a1008 100644 --- a/backend/server/routes/index.js +++ b/backend/server/routes/index.js @@ -33,6 +33,7 @@ const report = require('./report'); const contentControls = require('./contentControls'); const appConfig = require('./appConfig'); const callingConfig = require('./callingConfig'); +const storagePublic = require('./storagePublic'); const storageAdmin = require('./storageAdmin'); const callingAdmin = require('./callingAdmin'); const callingPushAdmin = require('./callingPushAdmin'); @@ -58,6 +59,7 @@ router.use(report); router.use(contentControls); router.use(appConfig); router.use(callingConfig); +router.use(storagePublic); router.use(storageAdmin); router.use(callingAdmin); router.use(callingPushAdmin); diff --git a/backend/server/routes/storagePublic.js b/backend/server/routes/storagePublic.js new file mode 100644 index 00000000..98853a47 --- /dev/null +++ b/backend/server/routes/storagePublic.js @@ -0,0 +1,6 @@ +const router = require('express').Router(); +const controller = require('../controllers/storagePublic'); + +router.get('/storage/image', controller.image); + +module.exports = router; diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 7d01aa70..98eba4a0 100644 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -5,7 +5,7 @@ - + SyncChat Admin diff --git a/frontend/admin/socialAuth.html b/frontend/admin/socialAuth.html index 4224b047..14399eff 100644 --- a/frontend/admin/socialAuth.html +++ b/frontend/admin/socialAuth.html @@ -5,7 +5,7 @@ - + SyncChat Admin — Social Login diff --git a/frontend/client/helpers/imageFallbacks.js b/frontend/client/helpers/imageFallbacks.js index 6ffec662..d4c83ff1 100644 --- a/frontend/client/helpers/imageFallbacks.js +++ b/frontend/client/helpers/imageFallbacks.js @@ -1,3 +1,5 @@ +import { toStorageImageProxyUrl } from './storageImageUrl'; + const FALLBACKS = [ { match: /(?:default-group-avatar\.png|\/assets\/icons\/group-avatar\.svg)(?:[?#].*)?$/i, @@ -20,6 +22,7 @@ const getLegacyFallback = (img) => { }; const getErrorFallback = (img) => { + const original = String(img?.dataset?.syncchatOriginalSrc || '').trim(); const legacyFallback = getLegacyFallback(img); if (legacyFallback) return legacyFallback; @@ -28,15 +31,22 @@ const getErrorFallback = (img) => { .toLowerCase(); if (alt === 'syncchat') return '/pwa-192x192.png'; + if (/(?:^|\/)uploads\/avatars\//i.test(original)) { + return '/assets/icons/user-avatar.svg'; + } + return ''; }; const bindImage = (img) => { if (!(img instanceof HTMLImageElement)) return; - const legacyFallback = getLegacyFallback(img); const current = String(img.getAttribute('src') || ''); + if (current && !img.dataset.syncchatOriginalSrc) { + img.dataset.syncchatOriginalSrc = current; + } + const legacyFallback = getLegacyFallback(img); if ( legacyFallback && current && @@ -50,11 +60,20 @@ const bindImage = (img) => { img.dataset.syncchatFallbackBound = '1'; img.addEventListener('error', () => { - const fallback = getErrorFallback(img); - if (!fallback) return; + const active = String(img.getAttribute('src') || '').trim(); + const original = String(img.dataset.syncchatOriginalSrc || active).trim(); + + if (img.dataset.syncchatStorageProxyTried !== '1') { + const proxyUrl = toStorageImageProxyUrl(original); + if (proxyUrl && proxyUrl !== active) { + img.dataset.syncchatStorageProxyTried = '1'; + img.src = proxyUrl; + return; + } + } - const active = String(img.getAttribute('src') || ''); - if (active.endsWith(fallback)) return; + const fallback = getErrorFallback(img); + if (!fallback || active.endsWith(fallback)) return; img.src = fallback; }); }; diff --git a/frontend/client/helpers/runtimeBranding.js b/frontend/client/helpers/runtimeBranding.js new file mode 100644 index 00000000..6fd14efe --- /dev/null +++ b/frontend/client/helpers/runtimeBranding.js @@ -0,0 +1,42 @@ +import axios from 'axios'; +import config from '../config'; +import { toStorageImageProxyUrl } from './storageImageUrl'; + +const setIconHref = (href) => { + const value = String(href || '').trim() || '/pwa-192x192.png'; + let icon = document.querySelector('link[rel="icon"]'); + if (!icon) { + icon = document.createElement('link'); + icon.setAttribute('rel', 'icon'); + document.head.appendChild(icon); + } + icon.removeAttribute('type'); + icon.setAttribute('href', value); +}; + +const applyRuntimeBranding = (payload = {}) => { + if (payload.appName) { + config.brandName = String(payload.appName || config.brandName); + document.title = config.brandName; + } + + const appLogo = String(payload.appLogo || '').trim(); + if (appLogo) { + config.brandLogo = appLogo; + setIconHref(toStorageImageProxyUrl(appLogo) || appLogo); + } else { + setIconHref('/pwa-192x192.png'); + } +}; + +const installRuntimeBranding = () => { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + + axios + .get('/app-config', { baseURL: config.apiBaseUrl }) + .then(({ data }) => applyRuntimeBranding(data?.payload || {})) + .catch(() => setIconHref('/pwa-192x192.png')); +}; + +export { applyRuntimeBranding }; +export default installRuntimeBranding; diff --git a/frontend/client/helpers/storageImageUrl.js b/frontend/client/helpers/storageImageUrl.js new file mode 100644 index 00000000..d7100aff --- /dev/null +++ b/frontend/client/helpers/storageImageUrl.js @@ -0,0 +1,26 @@ +import config from '../config'; + +const normalizeApiBase = () => String(config.apiBaseUrl || '/api').replace(/\/+$/, ''); + +const isStorageProxyUrl = (value = '') => + String(value || '').includes('/storage/image?url='); + +const isStoredUploadUrl = (value = '') => { + const raw = String(value || '').trim(); + if (!raw || raw.startsWith('data:') || raw.startsWith('blob:')) return false; + + try { + const parsed = new URL(raw, window.location.origin); + return /(?:^|\/)uploads\//i.test(parsed.pathname); + } catch (error0) { + return /(?:^|\/)uploads\//i.test(raw); + } +}; + +const toStorageImageProxyUrl = (value = '') => { + const raw = String(value || '').trim(); + if (!raw || !isStoredUploadUrl(raw) || isStorageProxyUrl(raw)) return ''; + return `${normalizeApiBase()}/storage/image?url=${encodeURIComponent(raw)}`; +}; + +export { isStoredUploadUrl, isStorageProxyUrl, toStorageImageProxyUrl }; diff --git a/frontend/client/index.html b/frontend/client/index.html index 93c6c6ea..3df51c1b 100644 --- a/frontend/client/index.html +++ b/frontend/client/index.html @@ -8,7 +8,7 @@ - + diff --git a/frontend/client/index.jsx b/frontend/client/index.jsx index c23c157c..9dd17798 100644 --- a/frontend/client/index.jsx +++ b/frontend/client/index.jsx @@ -16,6 +16,7 @@ import installChatHttpReliability from './helpers/chatHttpReliability'; import installMentionAutocompleteV2 from './helpers/mentionAutocompleteV2'; import installChatToolsSettingsBridge from './helpers/chatToolsSettingsBridge'; import installImageFallbacks from './helpers/imageFallbacks'; +import installRuntimeBranding from './helpers/runtimeBranding'; import './styles/chatToolsTheme.css'; import './styles/layoutFixes.css'; @@ -43,6 +44,7 @@ installChatDraftV2(); installMentionAutocompleteV2(); installChatToolsSettingsBridge(); installImageFallbacks(); +installRuntimeBranding(); registerServiceWorker(); requestNotificationPermission(); diff --git a/frontend/vercel.json b/frontend/vercel.json index afd15d32..11315777 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -4,15 +4,15 @@ "buildCommand": "npm run build", "outputDirectory": "client/public", "redirects": [ - { "source": "/favicon.ico", "destination": "https://syncchat.smshagor.com/uploads/favicon.png", "permanent": false }, - { "source": "/favicon.png", "destination": "https://syncchat.smshagor.com/uploads/favicon.png", "permanent": false }, - { "source": "/assets/images/favicon.png", "destination": "https://syncchat.smshagor.com/uploads/favicon.png", "permanent": false }, + { "source": "/favicon.ico", "destination": "/pwa-192x192.png", "permanent": false }, + { "source": "/favicon.png", "destination": "/pwa-192x192.png", "permanent": false }, + { "source": "/assets/images/favicon.png", "destination": "/pwa-192x192.png", "permanent": false }, { "source": "/default-avatar.png", "destination": "/assets/icons/user-avatar.svg", "permanent": false }, { "source": "/assets/images/default-avatar.png", "destination": "/assets/icons/user-avatar.svg", "permanent": false }, { "source": "/default-group-avatar.png", "destination": "/assets/icons/group-avatar.svg", "permanent": false }, { "source": "/assets/images/default-group-avatar.png", "destination": "/assets/icons/group-avatar.svg", "permanent": false }, - { "source": "/error.ico", "destination": "https://syncchat.smshagor.com/uploads/error.ico", "permanent": false }, - { "source": "/assets/images/error.ico", "destination": "https://syncchat.smshagor.com/uploads/error.ico", "permanent": false }, + { "source": "/error.ico", "destination": "/pwa-192x192.png", "permanent": false }, + { "source": "/assets/images/error.ico", "destination": "/pwa-192x192.png", "permanent": false }, { "source": "/assets/images/default-channel-avatar.png", "destination": "/assets/icons/channel-avatar.svg", "permanent": false } ], "rewrites": [ diff --git a/vercel.json b/vercel.json index d8df1494..7e40086c 100644 --- a/vercel.json +++ b/vercel.json @@ -6,15 +6,15 @@ "devCommand": "npm run dev --prefix frontend", "outputDirectory": "frontend/client/public", "redirects": [ - { "source": "/favicon.ico", "destination": "https://syncchat.smshagor.com/uploads/favicon.png", "permanent": false }, - { "source": "/favicon.png", "destination": "https://syncchat.smshagor.com/uploads/favicon.png", "permanent": false }, - { "source": "/assets/images/favicon.png", "destination": "https://syncchat.smshagor.com/uploads/favicon.png", "permanent": false }, + { "source": "/favicon.ico", "destination": "/pwa-192x192.png", "permanent": false }, + { "source": "/favicon.png", "destination": "/pwa-192x192.png", "permanent": false }, + { "source": "/assets/images/favicon.png", "destination": "/pwa-192x192.png", "permanent": false }, { "source": "/default-avatar.png", "destination": "/assets/icons/user-avatar.svg", "permanent": false }, { "source": "/assets/images/default-avatar.png", "destination": "/assets/icons/user-avatar.svg", "permanent": false }, { "source": "/default-group-avatar.png", "destination": "/assets/icons/group-avatar.svg", "permanent": false }, { "source": "/assets/images/default-group-avatar.png", "destination": "/assets/icons/group-avatar.svg", "permanent": false }, - { "source": "/error.ico", "destination": "https://syncchat.smshagor.com/uploads/error.ico", "permanent": false }, - { "source": "/assets/images/error.ico", "destination": "https://syncchat.smshagor.com/uploads/error.ico", "permanent": false }, + { "source": "/error.ico", "destination": "/pwa-192x192.png", "permanent": false }, + { "source": "/assets/images/error.ico", "destination": "/pwa-192x192.png", "permanent": false }, { "source": "/assets/images/default-channel-avatar.png", "destination": "/assets/icons/channel-avatar.svg", "permanent": false } ], "rewrites": [