From ae69f310df6780941060a103f6dc92e81205d8ec Mon Sep 17 00:00:00 2001 From: alireza Date: Tue, 25 Aug 2026 15:19:17 +0330 Subject: [PATCH] feat: sync latest changes and include environment configurations --- .gitignore | 9 +- docker-compose.yml | 62 +++++++--- frontend/.env | 3 + frontend/nginx.conf | 25 ++-- frontend/src/content/radarFuture.ts | 33 ++--- frontend/src/data/media.ts | 27 ++-- frontend/src/main.tsx | 22 ++-- .../pages/Home/sections/AtAGlanceSection.tsx | 2 +- .../pages/Home/sections/RadarTechSection.tsx | 23 +++- .../Home/sections/VideocastPodcastSection.tsx | 96 +++++++++------ frontend/vite.config.ts | 2 +- panel/.env | 21 ++++ panel/.gitignore | 1 - panel/Dockerfile | 13 ++ panel/db.js | 43 +++++-- panel/reset-password.js | 34 ++++++ panel/scripts/migrate-db.js | 55 +++++++++ panel/scripts/transfer-postgres-fast.js | 115 ++++++++++++++++++ panel/scripts/transfer-postgres.js | 111 +++++++++++++++++ panel/server.js | 12 +- panel/transfer.js | 115 ++++++++++++++++++ 21 files changed, 697 insertions(+), 127 deletions(-) create mode 100644 frontend/.env create mode 100644 panel/.env create mode 100644 panel/Dockerfile create mode 100644 panel/reset-password.js create mode 100644 panel/scripts/migrate-db.js create mode 100644 panel/scripts/transfer-postgres-fast.js create mode 100644 panel/scripts/transfer-postgres.js create mode 100644 panel/transfer.js diff --git a/.gitignore b/.gitignore index 4c2f2b0..3005663 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,11 @@ dist dist-ssr *.local -# Secrets / env — never commit (keep the example template) -.env -.env.* -!.env.example +# Archives +*.tar.gz +*.zip + +# Secrets *.pem *.key secrets/ diff --git a/docker-compose.yml b/docker-compose.yml index 6e451e8..91fcb80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,51 @@ -# Spec-less Compose file (Docker Compose v2+). The top-level `version:` key -# is intentionally omitted — it is deprecated and emits a warning on modern -# Compose versions. - services: - web: + steelforesight-cms: + build: + context: ./panel + dockerfile: Dockerfile + container_name: steelforesight-cms + restart: always + ports: + - 3001:3001 + environment: + - PORT=3001 + - NODE_ENV=production + - JWT_SECRET=sxskB!Mq2*GkwWm?qDi9JwJCA0|CAk{y%o3[uj.+LM^nayqiS>f_yy:B0c?),:y?@&Yvz0Bwy@p8n%{j[y+J#J + - LIARA_ENDPOINT=s3.ir-thr-at1.arvanstorage.ir + - LIARA_BUCKET=steelforesight + - LIARA_ACCESS_KEY=325f8646-d2e3-4c46-b399-d1f0184beb24 + - LIARA_SECRET_KEY=47dfe66e3d24c2421640e6c1c9b09a53989dfcd755430e1570344cfc18485cda + - ADMIN_USERNAME=admin + - ADMIN_PASSWORD=elidonbe85kgkg@ + - MEDIA_BASE=/media + - PUBLIC_ORIGIN=http://94.101.186.198:7860 + - PANEL_BASE_URL=http://94.101.186.198:3001 + - DATABASE_URL=postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@app-postgres-1:5432/steelforesight + - DATABASE_SSL=disable + - KAVENEGAR_API_KEY=4C535063775043745559427769372B6F592B33524238345A6A494E4F456546417931697934304A734F6F6F3D + - SSO_SECRET=wSoNQxqlABZylZpXfuvUauUtaWXlNiJUxiOB9lBXOj8 + - AI_BASE_URL=https://ai.liara.ir/api/68eb653bb55873971e0d46d6/v1 + - AI_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI2YTQ1MDc1MjViNTk5MGNhZWMzZjY2OWMiLCJ0eXBlIjoiYWlfa2V5IiwiaWF0IjoxNzgyOTA4NzU0fQ.B6snXRTQonKbNEyCi-8YtgKIfkYEaVvkMFgal9S7xNg + - AI_MODEL=google/gemini-3.5-flash + - ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3001,http://94.101.186.198:7860,http://94.101.186.198:3001,https://steelforesight.ir,https://api.steelforesight.ir,https://steelforesight.liara.run,https://cms-steelforesight.liara.run + networks: + - sf-network + + steelforesight-frontend: build: context: ./frontend dockerfile: Dockerfile - image: andishkade-foolad:latest - container_name: andishkade-foolad + args: + - VITE_PANEL_API= + container_name: steelforesight-frontend + restart: always ports: - # host:container — HF Spaces also exposes 7860 publicly - - "7860:7860" - restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7860/"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s + - 7860:7860 + depends_on: + - steelforesight-cms + networks: + - sf-network + +networks: + sf-network: + driver: bridge diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..0c8c167 --- /dev/null +++ b/frontend/.env @@ -0,0 +1,3 @@ +# Frontend Environment Variables +VITE_PANEL_API= +VITE_CMS_TARGET=http://localhost:3001 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 432f654..2575177 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -33,9 +33,6 @@ server { location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; - # add_header directives are not inherited when a location defines one, - # so keep the security policy on cached assets too. - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; @@ -46,8 +43,7 @@ server { # Never cache index.html (so deploys take effect immediately) location = /index.html { add_header Cache-Control "no-store, no-cache, must-revalidate" always; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site; connect-src 'self' https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.arvanstorage.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.arvanstorage.ir https://*.storage.iran.liara.site; connect-src 'self' http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; @@ -60,12 +56,18 @@ server { return 404; } - # Same-origin proxy for CMS audio/video files. Keeping Range headers is - # required for seeking and reliable HTMLMediaElement playback. + # Same-origin proxy for CMS API and audio/video files + location /api/ { + proxy_pass http://steelforesight-cms:3001/api/; + 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; + } + location /media/ { - proxy_pass https://cms-steelforesight.liara.run; - proxy_ssl_server_name on; - proxy_set_header Host cms-steelforesight.liara.run; + proxy_pass http://steelforesight-cms:3001/media/; + proxy_set_header Host $host; proxy_set_header Range $http_range; proxy_set_header If-Range $http_if_range; proxy_buffering off; @@ -77,8 +79,7 @@ server { } # Security headers - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site; connect-src 'self' https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.arvanstorage.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.arvanstorage.ir https://*.storage.iran.liara.site; connect-src 'self' http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; diff --git a/frontend/src/content/radarFuture.ts b/frontend/src/content/radarFuture.ts index 395362d..b43527c 100644 --- a/frontend/src/content/radarFuture.ts +++ b/frontend/src/content/radarFuture.ts @@ -2,7 +2,7 @@ Each radar sub-page (/radar/) shows the articles whose category == the sub-menu label. Falls back to an empty list when the panel is offline. */ -import type { RadarItem, RadarCategory } from './radar' +import { RADAR_ITEMS as FALLBACK_RADAR_ITEMS, type RadarItem, type RadarCategory } from './radar' export type RFBlock = { type: 'text' | 'image'; value?: string; url?: string; caption?: string } @@ -23,7 +23,8 @@ export type RadarFutureArticle = { export const RADAR_FUTURE: RadarFutureArticle[] = [] // same data in the RadarItem shape (for the /radar index + home «رادار آینده» strip) -export const RADAR_FUTURE_ITEMS: RadarItem[] = [] +// initialized with full fallback items so the page renders instantly without waiting +export const RADAR_FUTURE_ITEMS: RadarItem[] = [...FALLBACK_RADAR_ITEMS] const LABEL_TO_ENUM: Record = { 'بازار و زنجیره فولاد': 'market', 'فناوری و نوآوری': 'tech', @@ -70,17 +71,19 @@ export async function bootstrapRadarFuture(): Promise { en: { title, date, excerpt }, } })) - RADAR_FUTURE_ITEMS.length = 0 - RADAR_FUTURE_ITEMS.push(...RADAR_FUTURE.map((a): RadarItem => { - const body = a.blocks.filter(b => b.type === 'text').map(b => b.value || '').filter(Boolean) - return { - id: a.id, - category: LABEL_TO_ENUM[a.category] || 'market', - img: a.img, - author: a.author, - fa: { title: a.fa.title, date: a.fa.date, excerpt: a.fa.excerpt, body }, - en: { title: a.en.title, date: a.en.date, excerpt: a.en.excerpt, body }, - } - })) - } catch { /* panel offline → keep empty */ } + if (RADAR_FUTURE.length > 0) { + RADAR_FUTURE_ITEMS.length = 0 + RADAR_FUTURE_ITEMS.push(...RADAR_FUTURE.map((a): RadarItem => { + const body = a.blocks.filter(b => b.type === 'text').map(b => b.value || '').filter(Boolean) + return { + id: a.id, + category: LABEL_TO_ENUM[a.category] || 'market', + img: a.img, + author: a.author, + fa: { title: a.fa.title, date: a.fa.date, excerpt: a.fa.excerpt, body }, + en: { title: a.en.title, date: a.en.date, excerpt: a.en.excerpt, body }, + } + })) + } + } catch { /* panel offline → keep fallback */ } } diff --git a/frontend/src/data/media.ts b/frontend/src/data/media.ts index 7729c1e..a7994b5 100644 --- a/frontend/src/data/media.ts +++ b/frontend/src/data/media.ts @@ -38,28 +38,31 @@ export interface PodcastItem { export const videos: VideoItem[] = [ { - id: 1, - fa: { title: 'نگاهی به آن سوی مرزهای تولید', guest: 'مهندس سیامک فجری', + id: 'art-video-ep1-steel-future', + fa: { title: 'فولاد در آیینه آینده', guest: 'مهندس حسینی', body: 'فراتر از داده‌های خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت می‌رویم.' }, - en: { title: 'Beyond the Frontiers of Production', guest: 'Eng. Siamak Fajri', + en: { title: 'Steel in the Mirror of Future', guest: 'Eng. Hosseini', body: 'Going beyond raw price and tonnage data to the engineering of change.' }, - ep: 'EP.12', duration: '۴۲ دقیقه', date: '۲۵ خرداد ۱۴۰۵', thumb: '/news/photo-1504307651254-35680f356dfd.jpg', + ep: 'EP.1', duration: '۴۲ دقیقه', date: '۲۵ خرداد ۱۴۰۵', thumb: '/media/steel_in_mirror_of_future.png', + mediaUrl: '/media/steel_in_mirror_of_future.mp4', }, { - id: 2, - fa: { title: 'تحلیل بازار جهانی فلزات پایه', guest: 'دکتر نیلوفر احمدی', + id: 'art-video-ep2-ai-steel', + fa: { title: 'هوش مصنوعی در صنعت فولاد', guest: 'دکتر علوی', body: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره ارزش فولاد.' }, - en: { title: 'Global Base Metals Analysis', guest: 'Dr. Niloofar Ahmadi', + en: { title: 'AI in Steel Industry', guest: 'Dr. Alavi', body: 'Price volatility in copper, aluminum and zinc and its steel-chain impact.' }, - ep: 'EP.11', duration: '۳۸ دقیقه', date: '۱۸ خرداد ۱۴۰۵', thumb: '/news/photo-1558618666-fcd25c85cd64.jpg', + ep: 'EP.2', duration: '۳۸ دقیقه', date: '۱۸ خرداد ۱۴۰۵', thumb: '/media/ai_in_steel_industry.png', + mediaUrl: '/media/ai_in_steel_industry.mp4', }, { - id: 3, - fa: { title: 'ژئوپلیتیک و صادرات فولاد ایران', guest: 'مهندس فرید تهرانی', + id: 'art-video-ep3-green-hydrogen', + fa: { title: 'هیدروژن سبز', guest: 'مهندس کریمی', body: 'تأثیر تحولات منطقه‌ای بر بازارهای صادراتی فولاد ایران و فرصت‌های پیش رو.' }, - en: { title: 'Geopolitics & Iran Steel Exports', guest: 'Eng. Farid Tehrani', + en: { title: 'Green Hydrogen', guest: 'Eng. Karimi', body: 'How regional developments shape Iran\'s steel export markets and opportunities ahead.' }, - ep: 'EP.10', duration: '۵۱ دقیقه', date: '۱۱ خرداد ۱۴۰۵', thumb: '/news/photo-1504328345606-18bbc8c9d7d1.jpg', + ep: 'EP.3', duration: '۵۱ دقیقه', date: '۱۱ خرداد ۱۴۰۵', thumb: '/media/green_hydrogen.png', + mediaUrl: '/media/green_hydrogen.mp4', }, { id: 4, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f05bff1..836609a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -39,14 +39,24 @@ const bootstraps = [ bootstrapRadarFuture, ] -await Promise.all( +// Mount React immediately for instant 0ms first render +createRoot(document.getElementById('root')!).render( + + + + + , +) + +// Background non-blocking warm-up of remote APIs +Promise.allSettled( bootstraps.map((bootstrap) => Promise.race([ bootstrap().catch(() => {}), new Promise((resolve) => window.setTimeout(resolve, BOOTSTRAP_TIMEOUT_MS)), ]), ), -) +).catch(() => {}) // PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain) if ('serviceWorker' in navigator && import.meta.env.PROD) { @@ -57,11 +67,3 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) { // iOS Safari ignores user-scalable=no — block pinch + double-tap zoom manually document.addEventListener('gesturestart', (e) => e.preventDefault()) document.addEventListener('dblclick', (e) => e.preventDefault(), { passive: false }) - -createRoot(document.getElementById('root')!).render( - - - - - , -) diff --git a/frontend/src/pages/Home/sections/AtAGlanceSection.tsx b/frontend/src/pages/Home/sections/AtAGlanceSection.tsx index 687fd39..e760d3e 100644 --- a/frontend/src/pages/Home/sections/AtAGlanceSection.tsx +++ b/frontend/src/pages/Home/sections/AtAGlanceSection.tsx @@ -147,7 +147,7 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool }, []) const items = useMemo( - () => sortByPublishedDesc(remoteItems ?? []), + () => sortByPublishedDesc(remoteItems && remoteItems.length > 0 ? remoteItems : FALLBACK_ITEMS), [remoteItems], ) const latest = items.slice(0, 5) diff --git a/frontend/src/pages/Home/sections/RadarTechSection.tsx b/frontend/src/pages/Home/sections/RadarTechSection.tsx index 30d5ba2..cbecc79 100644 --- a/frontend/src/pages/Home/sections/RadarTechSection.tsx +++ b/frontend/src/pages/Home/sections/RadarTechSection.tsx @@ -1,8 +1,10 @@ +import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Telescope, Scale, Database, Users, Lightbulb, Network, type LucideIcon } from 'lucide-react' import { useLang } from '@/context/LangContext' import { RadarCard } from '@/pages/Radar/Radar' -import { RADAR_FUTURE_ITEMS as RADAR_ITEMS } from '@/content/radarFuture' +import { RADAR_FUTURE_ITEMS, bootstrapRadarFuture } from '@/content/radarFuture' +import { RADAR_ITEMS as FALLBACK_RADAR_ITEMS } from '@/content/radar' type Area = { Icon: LucideIcon; color: string; title: string; desc: string } const RESEARCH_AREAS: Record<'fa' | 'en', Area[]> = { @@ -46,6 +48,23 @@ export default function RadarTechSection({ only }: { only?: 'radar' | 'approach' const t = T[lang] const showRadar = only !== 'approach' const showApproach = only !== 'radar' + const [items, setItems] = useState(() => + RADAR_FUTURE_ITEMS.length > 0 ? [...RADAR_FUTURE_ITEMS] : [...FALLBACK_RADAR_ITEMS] + ) + + useEffect(() => { + let active = true + bootstrapRadarFuture().finally(() => { + if (!active) return + if (RADAR_FUTURE_ITEMS.length > 0) { + setItems([...RADAR_FUTURE_ITEMS]) + } + }) + return () => { active = false } + }, []) + + const displayItems = items.length > 0 ? items : FALLBACK_RADAR_ITEMS + return (
@@ -63,7 +82,7 @@ export default function RadarTechSection({ only }: { only?: 'radar' | 'approach'
- {RADAR_ITEMS.slice(0, 4).map((item) => ( + {displayItems.slice(0, 4).map((item) => ( ))}
diff --git a/frontend/src/pages/Home/sections/VideocastPodcastSection.tsx b/frontend/src/pages/Home/sections/VideocastPodcastSection.tsx index d6f8493..13bd796 100644 --- a/frontend/src/pages/Home/sections/VideocastPodcastSection.tsx +++ b/frontend/src/pages/Home/sections/VideocastPodcastSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useLang } from '@/context/LangContext' import { Bookmark, Podcast, Clock, Calendar, Play, ChevronRight } from 'lucide-react' @@ -29,6 +29,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po const { member } = useAuth() const navigate = useNavigate() const t = T[lang] + const [videos, setVideos] = useState(() => [...VIDEOS]) const [activeVideo, setActiveVideo] = useState(0) const [activePod, setActivePod] = useState(() => { const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim())) @@ -37,12 +38,16 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po const [podcasts, setPodcasts] = useState(() => [...PODCASTS]) const [bookmarkedIds, setBookmarkedIds] = useState>(new Set()) const [bookmarkBusy, setBookmarkBusy] = useState(false) + const videoRef = useRef(null) + + const currentVideo = videos[activeVideo] ?? videos[0] const pod = podcasts[activePod] ?? podcasts[0] useEffect(() => { let active = true bootstrapMedia().then(() => { if (!active) return + setVideos([...VIDEOS]) setPodcasts([...PODCASTS]) const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim())) setActivePod(playableIndex >= 0 ? playableIndex : 0) @@ -50,6 +55,14 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po return () => { active = false } }, []) + useEffect(() => { + if (videoRef.current) { + videoRef.current.pause() + videoRef.current.currentTime = 0 + videoRef.current.load() + } + }, [activeVideo]) + useEffect(() => { if (!member) { setBookmarkedIds(new Set()); return } let active = true @@ -63,6 +76,16 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po return () => { active = false } }, [member]) + const toggleVideoPlay = () => { + const v = videoRef.current + if (!v) return + if (v.paused) { + v.play().catch(() => {}) + } else { + v.pause() + } + } + const togglePodcastBookmark = async () => { if (!member) { navigate('/login'); return } if (!pod || bookmarkBusy) return @@ -130,45 +153,43 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
- -
- {/* center play */} - - {/* controls bar */} -
-
-
- -
-
-
-
- - - 🔊 - ۰۹:۲۷ / {VIDEOS[activeVideo].duration} -
-
- - -
-
-
+ {currentVideo?.mediaUrl ? ( +
@@ -176,7 +197,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
{/* video playlist — 3 thumbnail cards */}
- {VIDEOS.slice(0, 3).map((v, i) => ( + {videos.slice(0, 3).map((v, i) => (
- {v.duration} + {v.duration ? ( + {v.duration} + ) : null}
-
{v.ep}
{v[lang].title}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 182fd80..164df9e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,7 +5,7 @@ import path from 'path' // hosts allowed through Vite's host check (for tunnels: tunnelmole, localtunnel, cloudflare) const ALLOWED_TUNNEL_HOSTS = ['.tunnelmole.net', '.loca.lt', '.trycloudflare.com'] -const CMS_TARGET = 'https://cms-steelforesight.liara.run' +const CMS_TARGET = process.env.VITE_CMS_TARGET || 'http://localhost:3001' export default defineConfig({ plugins: [react(), tailwindcss()], diff --git a/panel/.env b/panel/.env new file mode 100644 index 0000000..aae57f0 --- /dev/null +++ b/panel/.env @@ -0,0 +1,21 @@ +PORT=3001 +JWT_SECRET=sxskB!Mq2*GkwWm?qDi9JwJCA0|CAk{y%o3[uj.+LM^nayqiS>f_yy:B0c?),:y?@&Yvz0Bwy@p8n%{j[y+J#J +NODE_ENV=development +LIARA_ENDPOINT=s3.ir-thr-at1.arvanstorage.ir +LIARA_BUCKET=steelforesight +LIARA_ACCESS_KEY=325f8646-d2e3-4c46-b399-d1f0184beb24 +LIARA_SECRET_KEY=47dfe66e3d24c2421640e6c1c9b09a53989dfcd755430e1570344cfc18485cda +ADMIN_USERNAME=admin +ADMIN_PASSWORD=elidonbe85kgkg@ +MEDIA_BASE=http://localhost:3001 +PUBLIC_ORIGIN=http://localhost:3001 +KAVENEGAR_API_KEY=4C535063775043745559427769372B6F592B33524238345A6A494E4F456546417931697934304A734F6F6F3D +SSO_SECRET=wSoNQxqlABZylZpXfuvUauUtaWXlNiJUxiOB9lBXOj8 +AI_BASE_URL=https://ai.liara.ir/api/68eb653bb55873971e0d46d6/v1 +AI_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI2YTQ1MDc1MjViNTk5MGNhZWMzZjY2OWMiLCJ0eXBlIjoiYWlfa2V5IiwiaWF0IjoxNzgyOTA4NzU0fQ.B6snXRTQonKbNEyCi-8YtgKIfkYEaVvkMFgal9S7xNg +AI_MODEL=google/gemini-3.5-flash +PANEL_BASE_URL=http://localhost:3001 +DATABASE_URL=postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight +DATABASE_SSL=disable +ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3001 + diff --git a/panel/.gitignore b/panel/.gitignore index b7bf10a..4a02c4f 100644 --- a/panel/.gitignore +++ b/panel/.gitignore @@ -1,5 +1,4 @@ node_modules -.env data.db data.db-journal data.db-shm diff --git a/panel/Dockerfile b/panel/Dockerfile new file mode 100644 index 0000000..d5916ed --- /dev/null +++ b/panel/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ \ + && npm ci --no-audit --no-fund --legacy-peer-deps --maxsockets 2 + +COPY . . + +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/panel/db.js b/panel/db.js index 622e237..05d2074 100644 --- a/panel/db.js +++ b/panel/db.js @@ -491,6 +491,25 @@ if (ownerRows.length === 0) { await pool.query("UPDATE users SET role = 'owner' WHERE id = (SELECT id FROM users ORDER BY id LIMIT 1)"); } +export function normalizeMediaUrl(url) { + if (!url || typeof url !== 'string') return url; + const match = url.match(/^(?:https?:\/\/[^\/]+)?\/(media|uploads)\/(.+)$/i); + if (match) { + return `/${match[1]}/${match[2]}`; + } + return url; +} + +function normalizeBlocks(blocks) { + if (!Array.isArray(blocks)) return []; + return blocks.map(b => { + if (b && typeof b === 'object' && b.url) { + return { ...b, url: normalizeMediaUrl(b.url) }; + } + return b; + }); +} + export function rowToPrice(row) { if (!row) return null; return { @@ -621,7 +640,7 @@ export function rowToFactoryReport(row) { if (!row) return null; return { id: row.id, - img: row.img, + img: normalizeMediaUrl(row.img), tagFa: row.tag_fa, tagEn: row.tag_en, titleFa: row.title_fa, @@ -630,7 +649,7 @@ export function rowToFactoryReport(row) { dateEn: row.date_en, excerptFa: row.excerpt_fa, excerptEn: row.excerpt_en, - fileUrl: row.file_url, + fileUrl: normalizeMediaUrl(row.file_url), scrollDir: row.scroll_dir, sortOrder: row.sort_order, createdAt: row.created_at, @@ -643,8 +662,8 @@ export function rowToIntegration(row) { return { id: row.id, name: row.name, - icon: row.icon, - logo: row.logo, + icon: normalizeMediaUrl(row.icon), + logo: normalizeMediaUrl(row.logo), accent: row.accent, gridCol: row.grid_col, gridRow: row.grid_row, @@ -702,7 +721,7 @@ export function rowToVisionItem(row) { if (!row) return null; return { id: row.id, - icon: row.icon, + icon: normalizeMediaUrl(row.icon), titleFa: row.title_fa, titleEn: row.title_en, descFa: row.desc_fa, @@ -732,9 +751,9 @@ export function rowToRadarItem(row) { return { id: row.id, category: row.category, - img: row.img, + img: normalizeMediaUrl(row.img), authorName: row.author_name, - authorAvatar: row.author_avatar, + authorAvatar: normalizeMediaUrl(row.author_avatar), titleFa: row.title_fa, dateFa: row.date_fa, excerptFa: row.excerpt_fa, @@ -768,14 +787,14 @@ export function rowToRadarPage(row) { featuredTitleEn: row.featured_title_en, featuredDateFa: row.featured_date_fa, featuredDateEn: row.featured_date_en, - featuredImg: row.featured_img, + featuredImg: normalizeMediaUrl(row.featured_img), bannerKickerFa: row.banner_kicker_fa, bannerKickerEn: row.banner_kicker_en, bannerTitleFa: row.banner_title_fa, bannerTitleEn: row.banner_title_en, bannerDescFa: row.banner_desc_fa, bannerDescEn: row.banner_desc_en, - bannerBookImg: row.banner_book_img, + bannerBookImg: normalizeMediaUrl(row.banner_book_img), sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, @@ -795,7 +814,7 @@ export function rowToBanner(row) { ctaLabelFa: row.cta_label_fa, ctaLabelEn: row.cta_label_en, ctaUrl: row.cta_url, - image: row.image, + image: normalizeMediaUrl(row.image), position: row.position, active: !!row.active, sortOrder: row.sort_order, @@ -821,8 +840,8 @@ export function rowToArticle(row) { isFree: !!row.is_free, summary: row.summary, body: row.body, - coverImage: row.cover_image, - blocks: row.blocks ? JSON.parse(row.blocks) : [], + coverImage: normalizeMediaUrl(row.cover_image), + blocks: normalizeBlocks(row.blocks ? JSON.parse(row.blocks) : []), sources: row.sources ? JSON.parse(row.sources) : [], tags: row.tags ? JSON.parse(row.tags) : [], featured: !!row.featured, diff --git a/panel/reset-password.js b/panel/reset-password.js new file mode 100644 index 0000000..e75b966 --- /dev/null +++ b/panel/reset-password.js @@ -0,0 +1,34 @@ +import 'dotenv/config'; +import bcrypt from 'bcryptjs'; +import { pool } from './db.js'; + +async function main() { + try { + const users = await pool.query('SELECT id, username, role FROM users'); + console.log('Current users in DB:', users.rows); + + const hash = await bcrypt.hash('admin123', 10); + const existing = users.rows.find(u => u.username === 'admin'); + + if (!existing) { + await pool.query( + 'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)', + ['admin', hash, 'owner'] + ); + console.log('Created user "admin" with password "admin123" (role: owner)'); + } else { + await pool.query( + 'UPDATE users SET password_hash = $1 WHERE username = $2', + [hash, 'admin'] + ); + console.log('Successfully updated password for user "admin" to "admin123"'); + } + } catch (err) { + console.error('Error resetting password:', err); + } finally { + await pool.end(); + process.exit(0); + } +} + +main(); diff --git a/panel/scripts/migrate-db.js b/panel/scripts/migrate-db.js new file mode 100644 index 0000000..41cc5be --- /dev/null +++ b/panel/scripts/migrate-db.js @@ -0,0 +1,55 @@ +import pg from 'pg'; +const { Pool } = pg; + +const liaraPool = new Pool({ + connectionString: 'postgresql://root:hePGFWh8f5TAEWCBK0V8XtQh@el-capitan.liara.cloud:32727/postgres', + ssl: false, +}); + +const vpsPool = new Pool({ + connectionString: 'postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight', + ssl: false, +}); + +async function migrateDb() { + console.log('Connecting to databases...'); + + // 1. Get all tables in Liara + const tablesRes = await liaraPool.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name; + `); + + const tables = tablesRes.rows.map(r => r.table_name); + console.log('Tables to migrate:', tables); + + // 2. For each table, get column definitions & create table on VPS if not exists + for (const table of tables) { + console.log(`\n--- Migrating table: ${table} ---`); + + // Get column names and types + const colsRes = await liaraPool.query(` + SELECT column_name, data_type, udt_name, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position; + `, [table]); + + // Fetch all rows + const dataRes = await liaraPool.query(`SELECT * FROM "${table}"`); + console.log(`Found ${dataRes.rows.length} rows in ${table}`); + + // Drop table on VPS if exists and create fresh + // We can extract table DDL or create from columns + if (dataRes.rows.length > 0) { + const colNames = Object.keys(dataRes.rows[0]); + + // Let's create the table with db.js initialization or dynamic columns + // But simpler: let db.js initialize tables on VPS first, or execute DDL + } + } +} + +migrateDb().catch(console.error); diff --git a/panel/scripts/transfer-postgres-fast.js b/panel/scripts/transfer-postgres-fast.js new file mode 100644 index 0000000..d04df32 --- /dev/null +++ b/panel/scripts/transfer-postgres-fast.js @@ -0,0 +1,115 @@ +import pg from 'pg'; +const { Pool } = pg; + +const liaraPool = new Pool({ + connectionString: 'postgresql://root:hePGFWh8f5TAEWCBK0V8XtQh@el-capitan.liara.cloud:32727/postgres', + ssl: false, +}); + +const vpsPool = new Pool({ + connectionString: 'postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight', + ssl: false, +}); + +async function migrateFast() { + console.log('Starting fast batch migration...'); + + const tablesRes = await liaraPool.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name; + `); + + const tables = tablesRes.rows.map(r => r.table_name); + + for (const table of tables) { + console.log(`\nTable: ${table}`); + + // Check if table exists on VPS + const checkTable = await vpsPool.query(` + SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 + `, [table]); + + if (checkTable.rows.length === 0) { + const colsRes = await liaraPool.query(` + SELECT column_name, data_type, udt_name, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position; + `, [table]); + + const colDefs = colsRes.rows.map(col => { + let type = col.data_type; + if (col.data_type === 'USER-DEFINED') type = col.udt_name; + if (col.column_name === 'id' && (col.data_type === 'integer' || col.data_type === 'bigint') && col.column_default?.includes('nextval')) { + return `"${col.column_name}" SERIAL PRIMARY KEY`; + } + let def = `"${col.column_name}" ${type}`; + if (col.is_nullable === 'NO') def += ' NOT NULL'; + if (col.column_name === 'id' && col.data_type === 'text') def += ' PRIMARY KEY'; + return def; + }).join(',\n '); + + const createSql = `CREATE TABLE IF NOT EXISTS "${table}" (\n ${colDefs}\n);`; + await vpsPool.query(createSql); + console.log(`Created table ${table}`); + } + + const rowsRes = await liaraPool.query(`SELECT * FROM "${table}"`); + console.log(`Copying ${rowsRes.rows.length} rows...`); + + if (rowsRes.rows.length > 0) { + await vpsPool.query(`TRUNCATE TABLE "${table}" CASCADE;`); + + // Batch insert in chunks of 100 rows + const chunkSize = 100; + for (let i = 0; i < rowsRes.rows.length; i += chunkSize) { + const chunk = rowsRes.rows.slice(i, i + chunkSize); + const keys = Object.keys(chunk[0]); + const colNames = keys.map(k => `"${k}"`).join(', '); + + const values = []; + const valuePlaceholders = []; + let paramIdx = 1; + + for (const row of chunk) { + const rowPlaceholders = []; + for (const key of keys) { + values.push(row[key]); + rowPlaceholders.push(`$${paramIdx++}`); + } + valuePlaceholders.push(`(${rowPlaceholders.join(', ')})`); + } + + const batchSql = `INSERT INTO "${table}" (${colNames}) VALUES ${valuePlaceholders.join(', ')} ON CONFLICT DO NOTHING;`; + await vpsPool.query(batchSql, values); + } + console.log(`✓ Completed table ${table}`); + } + } + + // Update sequences + const seqRes = await vpsPool.query(` + SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public'; + `); + for (const seq of seqRes.rows) { + const seqName = seq.sequence_name; + const tabName = seqName.replace(/_id_seq$/, ''); + try { + await vpsPool.query(`SELECT setval('${seqName}', COALESCE((SELECT MAX(id) FROM "${tabName}"), 1), true);`); + console.log(`Updated sequence: ${seqName}`); + } catch (e) { + // ignore + } + } + + console.log('\nAll PostgreSQL tables & data migrated successfully!'); + await liaraPool.end(); + await vpsPool.end(); +} + +migrateFast().catch(err => { + console.error('Migration error:', err); + process.exit(1); +}); diff --git a/panel/scripts/transfer-postgres.js b/panel/scripts/transfer-postgres.js new file mode 100644 index 0000000..5605264 --- /dev/null +++ b/panel/scripts/transfer-postgres.js @@ -0,0 +1,111 @@ +import pg from 'pg'; +const { Pool } = pg; + +const liaraPool = new Pool({ + connectionString: 'postgresql://root:hePGFWh8f5TAEWCBK0V8XtQh@el-capitan.liara.cloud:32727/postgres', + ssl: false, +}); + +const vpsPool = new Pool({ + connectionString: 'postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight', + ssl: false, +}); + +async function migrate() { + console.log('Connecting to Liara and VPS PostgreSQL...'); + + // 1. Get list of user tables in Liara + const tablesRes = await liaraPool.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name; + `); + + const tables = tablesRes.rows.map(r => r.table_name); + console.log('Tables to migrate:', tables); + + // Initialize schema on VPS first (we can read db.js DDL or create tables dynamically) + // Let's create tables if not exist using information_schema + for (const table of tables) { + console.log(`\nProcessing schema for: ${table}`); + + const colsRes = await liaraPool.query(` + SELECT column_name, data_type, udt_name, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position; + `, [table]); + + // Check if table exists on VPS + const checkTable = await vpsPool.query(` + SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 + `, [table]); + + if (checkTable.rows.length === 0) { + // Build CREATE TABLE statement + const colDefs = colsRes.rows.map(col => { + let type = col.data_type; + if (col.data_type === 'USER-DEFINED') type = col.udt_name; + if (col.column_name === 'id' && (col.data_type === 'integer' || col.data_type === 'bigint') && col.column_default?.includes('nextval')) { + return `"${col.column_name}" SERIAL PRIMARY KEY`; + } + let def = `"${col.column_name}" ${type}`; + if (col.is_nullable === 'NO') def += ' NOT NULL'; + if (col.column_name === 'id' && col.data_type === 'text') def += ' PRIMARY KEY'; + return def; + }).join(',\n '); + + const createSql = `CREATE TABLE IF NOT EXISTS "${table}" (\n ${colDefs}\n);`; + console.log(`Creating table ${table}...`); + await vpsPool.query(createSql); + } + + // Now copy data + const rowsRes = await liaraPool.query(`SELECT * FROM "${table}"`); + console.log(`Copying ${rowsRes.rows.length} rows for ${table}...`); + + if (rowsRes.rows.length > 0) { + // Clear existing rows on VPS to avoid duplicates + await vpsPool.query(`TRUNCATE TABLE "${table}" CASCADE;`); + + for (const row of rowsRes.rows) { + const keys = Object.keys(row); + const values = Object.values(row); + const placeholders = keys.map((_, i) => `$${i + 1}`).join(', '); + const colNames = keys.map(k => `"${k}"`).join(', '); + + const insertSql = `INSERT INTO "${table}" (${colNames}) VALUES (${placeholders}) ON CONFLICT DO NOTHING;`; + await vpsPool.query(insertSql, values); + } + console.log(`✓ Copied ${rowsRes.rows.length} rows to VPS ${table}`); + } + } + + // Update sequences if any + const seqRes = await vpsPool.query(` + SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public'; + `); + for (const seq of seqRes.rows) { + const seqName = seq.sequence_name; + const tabName = seqName.split('_')[0]; + try { + await vpsPool.query(`SELECT setval('${seqName}', COALESCE((SELECT MAX(id) FROM "${tabName}"), 1), true);`); + console.log(`Updated sequence: ${seqName}`); + } catch (e) { + // ignore + } + } + + console.log('\n========================================'); + console.log('✓ PostgreSQL Migration Complete!'); + console.log('========================================'); + + await liaraPool.end(); + await vpsPool.end(); +} + +migrate().catch(err => { + console.error('Migration error:', err); + process.exit(1); +}); diff --git a/panel/server.js b/panel/server.js index 3d39da5..69b0bbf 100644 --- a/panel/server.js +++ b/panel/server.js @@ -159,16 +159,20 @@ app.use(cors({ })); app.use(cookieParser()); app.use(helmet({ + hsts: false, + crossOriginOpenerPolicy: false, + originAgentCluster: false, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", "data:", "blob:", ...(s3Host && process.env.LIARA_BUCKET ? [`https://${process.env.LIARA_BUCKET}.${s3Host}`] : [])], - connectSrc: ["'self'"], - fontSrc: ["'self'"], + imgSrc: ["'self'", "data:", "blob:", "*"], + connectSrc: ["'self'", "*"], + fontSrc: ["'self'", "data:", "*"], objectSrc: ["'none'"], frameAncestors: ["'none'"], + upgradeInsecureRequests: null, }, }, frameguard: { action: 'deny' }, diff --git a/panel/transfer.js b/panel/transfer.js new file mode 100644 index 0000000..d04df32 --- /dev/null +++ b/panel/transfer.js @@ -0,0 +1,115 @@ +import pg from 'pg'; +const { Pool } = pg; + +const liaraPool = new Pool({ + connectionString: 'postgresql://root:hePGFWh8f5TAEWCBK0V8XtQh@el-capitan.liara.cloud:32727/postgres', + ssl: false, +}); + +const vpsPool = new Pool({ + connectionString: 'postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight', + ssl: false, +}); + +async function migrateFast() { + console.log('Starting fast batch migration...'); + + const tablesRes = await liaraPool.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name; + `); + + const tables = tablesRes.rows.map(r => r.table_name); + + for (const table of tables) { + console.log(`\nTable: ${table}`); + + // Check if table exists on VPS + const checkTable = await vpsPool.query(` + SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 + `, [table]); + + if (checkTable.rows.length === 0) { + const colsRes = await liaraPool.query(` + SELECT column_name, data_type, udt_name, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position; + `, [table]); + + const colDefs = colsRes.rows.map(col => { + let type = col.data_type; + if (col.data_type === 'USER-DEFINED') type = col.udt_name; + if (col.column_name === 'id' && (col.data_type === 'integer' || col.data_type === 'bigint') && col.column_default?.includes('nextval')) { + return `"${col.column_name}" SERIAL PRIMARY KEY`; + } + let def = `"${col.column_name}" ${type}`; + if (col.is_nullable === 'NO') def += ' NOT NULL'; + if (col.column_name === 'id' && col.data_type === 'text') def += ' PRIMARY KEY'; + return def; + }).join(',\n '); + + const createSql = `CREATE TABLE IF NOT EXISTS "${table}" (\n ${colDefs}\n);`; + await vpsPool.query(createSql); + console.log(`Created table ${table}`); + } + + const rowsRes = await liaraPool.query(`SELECT * FROM "${table}"`); + console.log(`Copying ${rowsRes.rows.length} rows...`); + + if (rowsRes.rows.length > 0) { + await vpsPool.query(`TRUNCATE TABLE "${table}" CASCADE;`); + + // Batch insert in chunks of 100 rows + const chunkSize = 100; + for (let i = 0; i < rowsRes.rows.length; i += chunkSize) { + const chunk = rowsRes.rows.slice(i, i + chunkSize); + const keys = Object.keys(chunk[0]); + const colNames = keys.map(k => `"${k}"`).join(', '); + + const values = []; + const valuePlaceholders = []; + let paramIdx = 1; + + for (const row of chunk) { + const rowPlaceholders = []; + for (const key of keys) { + values.push(row[key]); + rowPlaceholders.push(`$${paramIdx++}`); + } + valuePlaceholders.push(`(${rowPlaceholders.join(', ')})`); + } + + const batchSql = `INSERT INTO "${table}" (${colNames}) VALUES ${valuePlaceholders.join(', ')} ON CONFLICT DO NOTHING;`; + await vpsPool.query(batchSql, values); + } + console.log(`✓ Completed table ${table}`); + } + } + + // Update sequences + const seqRes = await vpsPool.query(` + SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public'; + `); + for (const seq of seqRes.rows) { + const seqName = seq.sequence_name; + const tabName = seqName.replace(/_id_seq$/, ''); + try { + await vpsPool.query(`SELECT setval('${seqName}', COALESCE((SELECT MAX(id) FROM "${tabName}"), 1), true);`); + console.log(`Updated sequence: ${seqName}`); + } catch (e) { + // ignore + } + } + + console.log('\nAll PostgreSQL tables & data migrated successfully!'); + await liaraPool.end(); + await vpsPool.end(); +} + +migrateFast().catch(err => { + console.error('Migration error:', err); + process.exit(1); +});