feat: sync latest changes and include environment configurations
This commit is contained in:
parent
c9e6be287e
commit
ae69f310df
|
|
@ -12,10 +12,11 @@ dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Secrets / env — never commit (keep the example template)
|
# Archives
|
||||||
.env
|
*.tar.gz
|
||||||
.env.*
|
*.zip
|
||||||
!.env.example
|
|
||||||
|
# Secrets
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
secrets/
|
secrets/
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: andishkade-foolad:latest
|
args:
|
||||||
container_name: andishkade-foolad
|
- VITE_PANEL_API=
|
||||||
|
container_name: steelforesight-frontend
|
||||||
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
# host:container — HF Spaces also exposes 7860 publicly
|
- 7860:7860
|
||||||
- "7860:7860"
|
depends_on:
|
||||||
restart: unless-stopped
|
- steelforesight-cms
|
||||||
healthcheck:
|
networks:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7860/"]
|
- sf-network
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
networks:
|
||||||
retries: 3
|
sf-network:
|
||||||
start_period: 10s
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Frontend Environment Variables
|
||||||
|
VITE_PANEL_API=
|
||||||
|
VITE_CMS_TARGET=http://localhost:3001
|
||||||
|
|
@ -33,9 +33,6 @@ server {
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
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-Frame-Options "DENY" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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)
|
# Never cache index.html (so deploys take effect immediately)
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
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' '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 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 X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
@ -60,12 +56,18 @@ server {
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Same-origin proxy for CMS audio/video files. Keeping Range headers is
|
# Same-origin proxy for CMS API and audio/video files
|
||||||
# required for seeking and reliable HTMLMediaElement playback.
|
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/ {
|
location /media/ {
|
||||||
proxy_pass https://cms-steelforesight.liara.run;
|
proxy_pass http://steelforesight-cms:3001/media/;
|
||||||
proxy_ssl_server_name on;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Host cms-steelforesight.liara.run;
|
|
||||||
proxy_set_header Range $http_range;
|
proxy_set_header Range $http_range;
|
||||||
proxy_set_header If-Range $http_if_range;
|
proxy_set_header If-Range $http_if_range;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|
@ -77,8 +79,7 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 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 X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Each radar sub-page (/radar/<slug>) shows the articles whose category == the
|
Each radar sub-page (/radar/<slug>) shows the articles whose category == the
|
||||||
sub-menu label. Falls back to an empty list when the panel is offline. */
|
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 }
|
export type RFBlock = { type: 'text' | 'image'; value?: string; url?: string; caption?: string }
|
||||||
|
|
||||||
|
|
@ -23,7 +23,8 @@ export type RadarFutureArticle = {
|
||||||
export const RADAR_FUTURE: RadarFutureArticle[] = []
|
export const RADAR_FUTURE: RadarFutureArticle[] = []
|
||||||
|
|
||||||
// same data in the RadarItem shape (for the /radar index + home «رادار آینده» strip)
|
// 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<string, RadarCategory> = {
|
const LABEL_TO_ENUM: Record<string, RadarCategory> = {
|
||||||
'بازار و زنجیره فولاد': 'market',
|
'بازار و زنجیره فولاد': 'market',
|
||||||
'فناوری و نوآوری': 'tech',
|
'فناوری و نوآوری': 'tech',
|
||||||
|
|
@ -70,17 +71,19 @@ export async function bootstrapRadarFuture(): Promise<void> {
|
||||||
en: { title, date, excerpt },
|
en: { title, date, excerpt },
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
RADAR_FUTURE_ITEMS.length = 0
|
if (RADAR_FUTURE.length > 0) {
|
||||||
RADAR_FUTURE_ITEMS.push(...RADAR_FUTURE.map((a): RadarItem => {
|
RADAR_FUTURE_ITEMS.length = 0
|
||||||
const body = a.blocks.filter(b => b.type === 'text').map(b => b.value || '').filter(Boolean)
|
RADAR_FUTURE_ITEMS.push(...RADAR_FUTURE.map((a): RadarItem => {
|
||||||
return {
|
const body = a.blocks.filter(b => b.type === 'text').map(b => b.value || '').filter(Boolean)
|
||||||
id: a.id,
|
return {
|
||||||
category: LABEL_TO_ENUM[a.category] || 'market',
|
id: a.id,
|
||||||
img: a.img,
|
category: LABEL_TO_ENUM[a.category] || 'market',
|
||||||
author: a.author,
|
img: a.img,
|
||||||
fa: { title: a.fa.title, date: a.fa.date, excerpt: a.fa.excerpt, body },
|
author: a.author,
|
||||||
en: { title: a.en.title, date: a.en.date, excerpt: a.en.excerpt, body },
|
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 */ }
|
}))
|
||||||
|
}
|
||||||
|
} catch { /* panel offline → keep fallback */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,28 +38,31 @@ export interface PodcastItem {
|
||||||
|
|
||||||
export const videos: VideoItem[] = [
|
export const videos: VideoItem[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 'art-video-ep1-steel-future',
|
||||||
fa: { title: 'نگاهی به آن سوی مرزهای تولید', guest: 'مهندس سیامک فجری',
|
fa: { title: 'فولاد در آیینه آینده', guest: 'مهندس حسینی',
|
||||||
body: 'فراتر از دادههای خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت میرویم.' },
|
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.' },
|
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,
|
id: 'art-video-ep2-ai-steel',
|
||||||
fa: { title: 'تحلیل بازار جهانی فلزات پایه', guest: 'دکتر نیلوفر احمدی',
|
fa: { title: 'هوش مصنوعی در صنعت فولاد', guest: 'دکتر علوی',
|
||||||
body: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره ارزش فولاد.' },
|
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.' },
|
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,
|
id: 'art-video-ep3-green-hydrogen',
|
||||||
fa: { title: 'ژئوپلیتیک و صادرات فولاد ایران', guest: 'مهندس فرید تهرانی',
|
fa: { title: 'هیدروژن سبز', guest: 'مهندس کریمی',
|
||||||
body: 'تأثیر تحولات منطقهای بر بازارهای صادراتی فولاد ایران و فرصتهای پیش رو.' },
|
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.' },
|
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,
|
id: 4,
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,24 @@ const bootstraps = [
|
||||||
bootstrapRadarFuture,
|
bootstrapRadarFuture,
|
||||||
]
|
]
|
||||||
|
|
||||||
await Promise.all(
|
// Mount React immediately for instant 0ms first render
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Background non-blocking warm-up of remote APIs
|
||||||
|
Promise.allSettled(
|
||||||
bootstraps.map((bootstrap) =>
|
bootstraps.map((bootstrap) =>
|
||||||
Promise.race([
|
Promise.race([
|
||||||
bootstrap().catch(() => {}),
|
bootstrap().catch(() => {}),
|
||||||
new Promise<void>((resolve) => window.setTimeout(resolve, BOOTSTRAP_TIMEOUT_MS)),
|
new Promise<void>((resolve) => window.setTimeout(resolve, BOOTSTRAP_TIMEOUT_MS)),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
).catch(() => {})
|
||||||
|
|
||||||
// PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain)
|
// PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain)
|
||||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
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
|
// iOS Safari ignores user-scalable=no — block pinch + double-tap zoom manually
|
||||||
document.addEventListener('gesturestart', (e) => e.preventDefault())
|
document.addEventListener('gesturestart', (e) => e.preventDefault())
|
||||||
document.addEventListener('dblclick', (e) => e.preventDefault(), { passive: false })
|
document.addEventListener('dblclick', (e) => e.preventDefault(), { passive: false })
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<AuthProvider>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</AuthProvider>
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => sortByPublishedDesc(remoteItems ?? []),
|
() => sortByPublishedDesc(remoteItems && remoteItems.length > 0 ? remoteItems : FALLBACK_ITEMS),
|
||||||
[remoteItems],
|
[remoteItems],
|
||||||
)
|
)
|
||||||
const latest = items.slice(0, 5)
|
const latest = items.slice(0, 5)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Telescope, Scale, Database, Users, Lightbulb, Network, type LucideIcon } from 'lucide-react'
|
import { Telescope, Scale, Database, Users, Lightbulb, Network, type LucideIcon } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { RadarCard } from '@/pages/Radar/Radar'
|
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 }
|
type Area = { Icon: LucideIcon; color: string; title: string; desc: string }
|
||||||
const RESEARCH_AREAS: Record<'fa' | 'en', Area[]> = {
|
const RESEARCH_AREAS: Record<'fa' | 'en', Area[]> = {
|
||||||
|
|
@ -46,6 +48,23 @@ export default function RadarTechSection({ only }: { only?: 'radar' | 'approach'
|
||||||
const t = T[lang]
|
const t = T[lang]
|
||||||
const showRadar = only !== 'approach'
|
const showRadar = only !== 'approach'
|
||||||
const showApproach = only !== 'radar'
|
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 (
|
return (
|
||||||
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: only === 'approach' ? 'var(--paper-2)' : '#fff' }}>
|
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: only === 'approach' ? 'var(--paper-2)' : '#fff' }}>
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
|
||||||
|
|
@ -63,7 +82,7 @@ export default function RadarTechSection({ only }: { only?: 'radar' | 'approach'
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 22 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 22 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
|
||||||
{RADAR_ITEMS.slice(0, 4).map((item) => (
|
{displayItems.slice(0, 4).map((item) => (
|
||||||
<RadarCard key={item.id} item={item} lang={lang} />
|
<RadarCard key={item.id} item={item} lang={lang} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { Bookmark, Podcast, Clock, Calendar, Play, ChevronRight } from 'lucide-react'
|
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 { member } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const t = T[lang]
|
const t = T[lang]
|
||||||
|
const [videos, setVideos] = useState(() => [...VIDEOS])
|
||||||
const [activeVideo, setActiveVideo] = useState(0)
|
const [activeVideo, setActiveVideo] = useState(0)
|
||||||
const [activePod, setActivePod] = useState(() => {
|
const [activePod, setActivePod] = useState(() => {
|
||||||
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
|
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 [podcasts, setPodcasts] = useState(() => [...PODCASTS])
|
||||||
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(new Set())
|
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(new Set())
|
||||||
const [bookmarkBusy, setBookmarkBusy] = useState(false)
|
const [bookmarkBusy, setBookmarkBusy] = useState(false)
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
|
||||||
|
const currentVideo = videos[activeVideo] ?? videos[0]
|
||||||
const pod = podcasts[activePod] ?? podcasts[0]
|
const pod = podcasts[activePod] ?? podcasts[0]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
bootstrapMedia().then(() => {
|
bootstrapMedia().then(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
setVideos([...VIDEOS])
|
||||||
setPodcasts([...PODCASTS])
|
setPodcasts([...PODCASTS])
|
||||||
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
|
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
|
||||||
setActivePod(playableIndex >= 0 ? playableIndex : 0)
|
setActivePod(playableIndex >= 0 ? playableIndex : 0)
|
||||||
|
|
@ -50,6 +55,14 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
videoRef.current.currentTime = 0
|
||||||
|
videoRef.current.load()
|
||||||
|
}
|
||||||
|
}, [activeVideo])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!member) { setBookmarkedIds(new Set()); return }
|
if (!member) { setBookmarkedIds(new Set()); return }
|
||||||
let active = true
|
let active = true
|
||||||
|
|
@ -63,6 +76,16 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [member])
|
}, [member])
|
||||||
|
|
||||||
|
const toggleVideoPlay = () => {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (!v) return
|
||||||
|
if (v.paused) {
|
||||||
|
v.play().catch(() => {})
|
||||||
|
} else {
|
||||||
|
v.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const togglePodcastBookmark = async () => {
|
const togglePodcastBookmark = async () => {
|
||||||
if (!member) { navigate('/login'); return }
|
if (!member) { navigate('/login'); return }
|
||||||
if (!pod || bookmarkBusy) return
|
if (!pod || bookmarkBusy) return
|
||||||
|
|
@ -130,45 +153,43 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch', marginBottom: 32 }} className="max-lg:!flex-col">
|
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch', marginBottom: 32 }} className="max-lg:!flex-col">
|
||||||
<div style={{ flex: '1.2 1 0', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 14 }} className="max-lg:!w-full">
|
<div style={{ flex: '1.2 1 0', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 14 }} className="max-lg:!w-full">
|
||||||
<div style={{ flex: '1 1 0', minWidth: 0, position: 'relative', overflow: 'hidden', background: '#000', borderRadius: 16, minHeight: 0, boxShadow: '0 30px 70px -34px rgba(3,35,64,0.5)' }} className="max-lg:!aspect-video max-lg:!flex-none">
|
<div style={{ flex: '1 1 0', minWidth: 0, position: 'relative', overflow: 'hidden', background: '#000', borderRadius: 16, minHeight: 0, boxShadow: '0 30px 70px -34px rgba(3,35,64,0.5)' }} className="max-lg:!aspect-video max-lg:!flex-none">
|
||||||
<img src={VIDEOS[activeVideo].thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', opacity: 0.9, position: 'absolute', inset: 0 }} />
|
{currentVideo?.mediaUrl ? (
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,35,64,0.55) 0%, transparent 45%)' }} />
|
<video
|
||||||
{/* center play */}
|
ref={videoRef}
|
||||||
<button aria-label="play" style={{
|
src={currentVideo.mediaUrl}
|
||||||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
|
poster={currentVideo.thumb}
|
||||||
width: 64, height: 64, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.6)',
|
controls
|
||||||
background: 'rgba(255,255,255,0.14)', backdropFilter: 'blur(4px)', cursor: 'pointer',
|
playsInline
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
preload="metadata"
|
||||||
}}>
|
style={{ width: '100%', height: '100%', objectFit: 'contain', background: '#000', display: 'block' }}
|
||||||
<div style={{ width: 0, height: 0, borderTop: '11px solid transparent', borderBottom: '11px solid transparent', borderLeft: '19px solid #fff', marginLeft: 4 }} />
|
/>
|
||||||
</button>
|
) : (
|
||||||
{/* controls bar */}
|
<>
|
||||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 16px 14px', direction: 'ltr' }}>
|
<img src={currentVideo?.thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', opacity: 0.9, position: 'absolute', inset: 0 }} />
|
||||||
<div style={{ height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.3)', marginBottom: 12 }}>
|
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,35,64,0.55) 0%, transparent 45%)' }} />
|
||||||
<div style={{ width: '22%', height: '100%', borderRadius: 2, background: GOLD, position: 'relative' }}>
|
{/* center play */}
|
||||||
<span style={{ position: 'absolute', right: -5, top: '50%', transform: 'translateY(-50%)', width: 11, height: 11, borderRadius: '50%', background: '#fff' }} />
|
<button
|
||||||
</div>
|
onClick={toggleVideoPlay}
|
||||||
</div>
|
aria-label="play"
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: '#fff' }}>
|
style={{
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
|
||||||
<span style={{ fontSize: 18 }}>⏸</span>
|
width: 64, height: 64, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.6)',
|
||||||
<span style={{ fontSize: 15, opacity: 0.85 }}>⏭</span>
|
background: 'rgba(255,255,255,0.14)', backdropFilter: 'blur(4px)', cursor: 'pointer',
|
||||||
<span style={{ fontSize: 15, opacity: 0.85 }}>🔊</span>
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
<span style={{ fontSize: 11, opacity: 0.8, direction: 'ltr' }}>۰۹:۲۷ / {VIDEOS[activeVideo].duration}</span>
|
}}
|
||||||
</div>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, opacity: 0.85 }}>
|
<div style={{ width: 0, height: 0, borderTop: '11px solid transparent', borderBottom: '11px solid transparent', borderLeft: '19px solid #fff', marginLeft: 4 }} />
|
||||||
<span style={{ fontSize: 14 }}>⤴</span>
|
</button>
|
||||||
<span style={{ fontSize: 14 }}>⛶</span>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* title — directly below the video, inside the right column */}
|
{/* title — directly below the video, inside the right column */}
|
||||||
<div style={{ direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
|
<div style={{ direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
<span style={{ fontSize: 16 }}>🎙</span>
|
<span style={{ fontSize: 16 }}>🎙</span>
|
||||||
<h3 style={{ fontSize: 'clamp(15px,1.5vw,20px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{VIDEOS[activeVideo][lang].title}</h3>
|
<h3 style={{ fontSize: 'clamp(15px,1.5vw,20px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{currentVideo?.[lang]?.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 13, lineHeight: 1.9, color: 'rgba(3,35,64,0.7)', margin: 0, textAlign: 'justify', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{VIDEOS[activeVideo][lang].body}</p>
|
<p style={{ fontSize: 13, lineHeight: 1.9, color: 'rgba(3,35,64,0.7)', margin: 0, textAlign: 'justify', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{currentVideo?.[lang]?.body}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -176,7 +197,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
<div style={{ flex: '1 1 0', minWidth: 0 }} className="max-lg:!w-full">
|
<div style={{ flex: '1 1 0', minWidth: 0 }} className="max-lg:!w-full">
|
||||||
{/* video playlist — 3 thumbnail cards */}
|
{/* video playlist — 3 thumbnail cards */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, alignContent: 'start', direction: lang === 'fa' ? 'rtl' : 'ltr' }} className="max-md:!grid-cols-2">
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, alignContent: 'start', direction: lang === 'fa' ? 'rtl' : 'ltr' }} className="max-md:!grid-cols-2">
|
||||||
{VIDEOS.slice(0, 3).map((v, i) => (
|
{videos.slice(0, 3).map((v, i) => (
|
||||||
<button
|
<button
|
||||||
key={v.id}
|
key={v.id}
|
||||||
onClick={() => setActiveVideo(i)}
|
onClick={() => setActiveVideo(i)}
|
||||||
|
|
@ -196,10 +217,11 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
<div style={{ width: 0, height: 0, borderTop: '7px solid transparent', borderBottom: '7px solid transparent', borderLeft: `12px solid ${NAVY}`, marginLeft: 3 }} />
|
<div style={{ width: 0, height: 0, borderTop: '7px solid transparent', borderBottom: '7px solid transparent', borderLeft: `12px solid ${NAVY}`, marginLeft: 3 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ position: 'absolute', bottom: 8, left: 8, fontSize: 10, fontWeight: 800, color: '#fff', background: 'rgba(0,0,0,0.55)', padding: '2px 7px', borderRadius: 4, direction: 'ltr' }}>{v.duration}</span>
|
{v.duration ? (
|
||||||
|
<span style={{ position: 'absolute', bottom: 8, left: 8, fontSize: 10, fontWeight: 800, color: '#fff', background: 'rgba(0,0,0,0.55)', padding: '2px 7px', borderRadius: 4, direction: 'ltr' }}>{v.duration}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 14px', direction: lang === 'fa' ? 'rtl' : 'ltr', textAlign: 'right' }}>
|
<div style={{ padding: '12px 14px', direction: lang === 'fa' ? 'rtl' : 'ltr', textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: 11, color: GOLD, fontWeight: 800, marginBottom: 4 }}>{v.ep}</div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 800, color: NAVY, lineHeight: 1.4 }}>{v[lang].title}</div>
|
<div style={{ fontSize: 13, fontWeight: 800, color: NAVY, lineHeight: 1.4 }}>{v[lang].title}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import path from 'path'
|
||||||
|
|
||||||
// hosts allowed through Vite's host check (for tunnels: tunnelmole, localtunnel, cloudflare)
|
// hosts allowed through Vite's host check (for tunnels: tunnelmole, localtunnel, cloudflare)
|
||||||
const ALLOWED_TUNNEL_HOSTS = ['.tunnelmole.net', '.loca.lt', '.trycloudflare.com']
|
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({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
node_modules
|
node_modules
|
||||||
.env
|
|
||||||
data.db
|
data.db
|
||||||
data.db-journal
|
data.db-journal
|
||||||
data.db-shm
|
data.db-shm
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
43
panel/db.js
43
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)");
|
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) {
|
export function rowToPrice(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
|
|
@ -621,7 +640,7 @@ export function rowToFactoryReport(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
img: row.img,
|
img: normalizeMediaUrl(row.img),
|
||||||
tagFa: row.tag_fa,
|
tagFa: row.tag_fa,
|
||||||
tagEn: row.tag_en,
|
tagEn: row.tag_en,
|
||||||
titleFa: row.title_fa,
|
titleFa: row.title_fa,
|
||||||
|
|
@ -630,7 +649,7 @@ export function rowToFactoryReport(row) {
|
||||||
dateEn: row.date_en,
|
dateEn: row.date_en,
|
||||||
excerptFa: row.excerpt_fa,
|
excerptFa: row.excerpt_fa,
|
||||||
excerptEn: row.excerpt_en,
|
excerptEn: row.excerpt_en,
|
||||||
fileUrl: row.file_url,
|
fileUrl: normalizeMediaUrl(row.file_url),
|
||||||
scrollDir: row.scroll_dir,
|
scrollDir: row.scroll_dir,
|
||||||
sortOrder: row.sort_order,
|
sortOrder: row.sort_order,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
|
|
@ -643,8 +662,8 @@ export function rowToIntegration(row) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
icon: row.icon,
|
icon: normalizeMediaUrl(row.icon),
|
||||||
logo: row.logo,
|
logo: normalizeMediaUrl(row.logo),
|
||||||
accent: row.accent,
|
accent: row.accent,
|
||||||
gridCol: row.grid_col,
|
gridCol: row.grid_col,
|
||||||
gridRow: row.grid_row,
|
gridRow: row.grid_row,
|
||||||
|
|
@ -702,7 +721,7 @@ export function rowToVisionItem(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
icon: row.icon,
|
icon: normalizeMediaUrl(row.icon),
|
||||||
titleFa: row.title_fa,
|
titleFa: row.title_fa,
|
||||||
titleEn: row.title_en,
|
titleEn: row.title_en,
|
||||||
descFa: row.desc_fa,
|
descFa: row.desc_fa,
|
||||||
|
|
@ -732,9 +751,9 @@ export function rowToRadarItem(row) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
img: row.img,
|
img: normalizeMediaUrl(row.img),
|
||||||
authorName: row.author_name,
|
authorName: row.author_name,
|
||||||
authorAvatar: row.author_avatar,
|
authorAvatar: normalizeMediaUrl(row.author_avatar),
|
||||||
titleFa: row.title_fa,
|
titleFa: row.title_fa,
|
||||||
dateFa: row.date_fa,
|
dateFa: row.date_fa,
|
||||||
excerptFa: row.excerpt_fa,
|
excerptFa: row.excerpt_fa,
|
||||||
|
|
@ -768,14 +787,14 @@ export function rowToRadarPage(row) {
|
||||||
featuredTitleEn: row.featured_title_en,
|
featuredTitleEn: row.featured_title_en,
|
||||||
featuredDateFa: row.featured_date_fa,
|
featuredDateFa: row.featured_date_fa,
|
||||||
featuredDateEn: row.featured_date_en,
|
featuredDateEn: row.featured_date_en,
|
||||||
featuredImg: row.featured_img,
|
featuredImg: normalizeMediaUrl(row.featured_img),
|
||||||
bannerKickerFa: row.banner_kicker_fa,
|
bannerKickerFa: row.banner_kicker_fa,
|
||||||
bannerKickerEn: row.banner_kicker_en,
|
bannerKickerEn: row.banner_kicker_en,
|
||||||
bannerTitleFa: row.banner_title_fa,
|
bannerTitleFa: row.banner_title_fa,
|
||||||
bannerTitleEn: row.banner_title_en,
|
bannerTitleEn: row.banner_title_en,
|
||||||
bannerDescFa: row.banner_desc_fa,
|
bannerDescFa: row.banner_desc_fa,
|
||||||
bannerDescEn: row.banner_desc_en,
|
bannerDescEn: row.banner_desc_en,
|
||||||
bannerBookImg: row.banner_book_img,
|
bannerBookImg: normalizeMediaUrl(row.banner_book_img),
|
||||||
sortOrder: row.sort_order,
|
sortOrder: row.sort_order,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
|
@ -795,7 +814,7 @@ export function rowToBanner(row) {
|
||||||
ctaLabelFa: row.cta_label_fa,
|
ctaLabelFa: row.cta_label_fa,
|
||||||
ctaLabelEn: row.cta_label_en,
|
ctaLabelEn: row.cta_label_en,
|
||||||
ctaUrl: row.cta_url,
|
ctaUrl: row.cta_url,
|
||||||
image: row.image,
|
image: normalizeMediaUrl(row.image),
|
||||||
position: row.position,
|
position: row.position,
|
||||||
active: !!row.active,
|
active: !!row.active,
|
||||||
sortOrder: row.sort_order,
|
sortOrder: row.sort_order,
|
||||||
|
|
@ -821,8 +840,8 @@ export function rowToArticle(row) {
|
||||||
isFree: !!row.is_free,
|
isFree: !!row.is_free,
|
||||||
summary: row.summary,
|
summary: row.summary,
|
||||||
body: row.body,
|
body: row.body,
|
||||||
coverImage: row.cover_image,
|
coverImage: normalizeMediaUrl(row.cover_image),
|
||||||
blocks: row.blocks ? JSON.parse(row.blocks) : [],
|
blocks: normalizeBlocks(row.blocks ? JSON.parse(row.blocks) : []),
|
||||||
sources: row.sources ? JSON.parse(row.sources) : [],
|
sources: row.sources ? JSON.parse(row.sources) : [],
|
||||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||||
featured: !!row.featured,
|
featured: !!row.featured,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -159,16 +159,20 @@ app.use(cors({
|
||||||
}));
|
}));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
|
hsts: false,
|
||||||
|
crossOriginOpenerPolicy: false,
|
||||||
|
originAgentCluster: false,
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'"],
|
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
imgSrc: ["'self'", "data:", "blob:", ...(s3Host && process.env.LIARA_BUCKET ? [`https://${process.env.LIARA_BUCKET}.${s3Host}`] : [])],
|
imgSrc: ["'self'", "data:", "blob:", "*"],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'", "*"],
|
||||||
fontSrc: ["'self'"],
|
fontSrc: ["'self'", "data:", "*"],
|
||||||
objectSrc: ["'none'"],
|
objectSrc: ["'none'"],
|
||||||
frameAncestors: ["'none'"],
|
frameAncestors: ["'none'"],
|
||||||
|
upgradeInsecureRequests: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
frameguard: { action: 'deny' },
|
frameguard: { action: 'deny' },
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue