/** * Download images from legacy Parmin storage URLs and re-upload to our Parspack S3. * Updates Product/BlogPost imageUrl + imageKey, and rewrites URLs inside blog bodies. * * Usage: * npx ts-node -r tsconfig-paths/register scripts/migrate-images-to-s3.ts */ import { createHash, randomUUID } from 'crypto' import { extname } from 'path' import { PrismaClient } from '@prisma/client' import { config as loadEnv } from 'dotenv' import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' import { blogMediaKey, productMainKey, } from '../src/storage/storage-keys' loadEnv() const prisma = new PrismaClient() const LEGACY_HOST_RE = /madomotor\.sas\.amin\.parminstorage\.ir|sas\.amin\.parminstorage\.ir/i const EXT_MIME: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', } function requireEnv(name: string): string { const v = process.env[name] if (!v) throw new Error(`Missing env ${name}`) return v } function publicUrlBase(): string { return requireEnv('S3_PUBLIC_URL').replace(/\/$/, '') } function isAlreadyOurs(url: string | null | undefined): boolean { if (!url) return false return url.startsWith(publicUrlBase()) } function isLegacyUrl(url: string): boolean { try { const u = new URL(url) return LEGACY_HOST_RE.test(u.hostname) } catch { return false } } function guessExt(url: string, contentType: string | null): string { const pathPart = decodeURIComponent(new URL(url).pathname) const fromUrl = extname(pathPart).toLowerCase() if (fromUrl && fromUrl.length <= 5) return fromUrl if (contentType?.includes('png')) return '.png' if (contentType?.includes('webp')) return '.webp' if (contentType?.includes('gif')) return '.gif' return '.jpg' } function mimeFor(ext: string, contentType: string | null): string { if (contentType && contentType.startsWith('image/')) { return contentType.split(';')[0].trim() } return EXT_MIME[ext] ?? 'image/jpeg' } function extractUrls(text: string): string[] { const found = new Set() const patterns = [ /https?:\/\/[^\s"'<>]+/g, /src=["'](https?:\/\/[^"']+)["']/gi, ] for (const re of patterns) { for (const match of text.matchAll(re)) { const url = (match[1] ?? match[0]).replace(/&/g, '&') found.add(url) } } return [...found] } type Kind = 'product' | 'blog' | 'blog-body' function encodeFetchUrl(url: string): string { try { const u = new URL(url) // Encode each path segment so Persian filenames download reliably u.pathname = u.pathname .split('/') .map((seg) => { if (!seg) return seg try { return encodeURIComponent(decodeURIComponent(seg)) } catch { return encodeURIComponent(seg) } }) .join('/') return u.toString() } catch { return encodeURI(url) } } async function download(url: string): Promise<{ buffer: Buffer contentType: string | null }> { const candidates = [url, encodeFetchUrl(url)] let lastError: Error | null = null for (const candidate of candidates) { try { const res = await fetch(candidate, { redirect: 'follow', headers: { 'User-Agent': 'mado-motor-image-migrator/1.0', Accept: 'image/*,*/*', }, }) if (!res.ok) { lastError = new Error(`HTTP ${res.status} for ${candidate}`) continue } const contentType = res.headers.get('content-type') const buffer = Buffer.from(await res.arrayBuffer()) if (buffer.length < 32) { lastError = new Error(`Tiny response (${buffer.length}b) for ${candidate}`) continue } return { buffer, contentType } } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)) } } throw lastError ?? new Error(`Failed to download ${url}`) } async function main() { const endpoint = requireEnv('S3_ENDPOINT') const bucket = requireEnv('S3_BUCKET') const base = publicUrlBase() const client = new S3Client({ endpoint, region: process.env.S3_REGION || 'us-east-1', forcePathStyle: (process.env.S3_FORCE_PATH_STYLE || 'true') === 'true', credentials: { accessKeyId: requireEnv('S3_ACCESS_KEY_ID'), secretAccessKey: requireEnv('S3_SECRET_ACCESS_KEY'), }, }) const products = await prisma.product.findMany({ select: { id: true, imageUrl: true, imageKey: true }, }) const blogs = await prisma.blogPost.findMany({ select: { id: true, imageUrl: true, imageKey: true, body: true }, }) type Job = { url: string; kind: Kind; entityId?: string } const jobs: Job[] = [] const seen = new Set() const enqueue = (url: string, kind: Kind, entityId?: string) => { const clean = url.replace(/[),.;]+$/, '') if (!isLegacyUrl(clean) || isAlreadyOurs(clean)) return if (seen.has(clean)) return seen.add(clean) jobs.push({ url: clean, kind, entityId }) } for (const p of products) { if (p.imageUrl) enqueue(p.imageUrl, 'product', p.id) } for (const b of blogs) { if (b.imageUrl) enqueue(b.imageUrl, 'blog', b.id) for (const u of extractUrls(b.body || '')) { enqueue(u, 'blog-body') } } console.log(`Found ${jobs.length} unique legacy URLs to migrate`) const urlMap = new Map() let ok = 0 let failed = 0 for (const [i, job] of jobs.entries()) { const label = `[${i + 1}/${jobs.length}]` try { const { buffer, contentType } = await download(job.url) const ext = guessExt(job.url, contentType) const fileName = `${randomUUID()}${ext}` const key = job.kind === 'product' ? productMainKey(fileName) : blogMediaKey( job.kind === 'blog-body' ? `body/${fileName}` : fileName, ) const contentTypeFinal = mimeFor(ext, contentType) await client.send( new PutObjectCommand({ Bucket: bucket, Key: key, Body: buffer, ContentType: contentTypeFinal, }), ) const publicUrl = `${base}/${key}` urlMap.set(job.url, { url: publicUrl, key }) // also map common encoded/decoded variants try { urlMap.set(decodeURI(job.url), { url: publicUrl, key }) } catch { /* ignore */ } urlMap.set(encodeURI(decodeURIComponent(job.url)), { url: publicUrl, key, }) ok += 1 const hash = createHash('sha1').update(buffer).digest('hex').slice(0, 8) console.log( `${label} OK ${job.kind} ${buffer.length}b ${hash} → ${key}`, ) } catch (err) { failed += 1 console.error( `${label} FAIL ${job.url}`, err instanceof Error ? err.message : err, ) } } // Update products let productsUpdated = 0 for (const p of products) { if (!p.imageUrl) continue const mapped = urlMap.get(p.imageUrl) || urlMap.get(p.imageUrl.replace(/[),.;]+$/, '')) if (!mapped) continue await prisma.product.update({ where: { id: p.id }, data: { imageUrl: mapped.url, imageKey: mapped.key }, }) productsUpdated += 1 } // Update blogs (cover + body rewrite) let blogsUpdated = 0 for (const b of blogs) { let body = b.body || '' let changed = false for (const [oldUrl, mapped] of urlMap) { if (body.includes(oldUrl)) { body = body.split(oldUrl).join(mapped.url) changed = true } } // also rewrite any remaining legacy host URLs that we mapped by pathname const stillLegacy = extractUrls(body).filter(isLegacyUrl) for (const u of stillLegacy) { const clean = u.replace(/[),.;]+$/, '') const mapped = urlMap.get(clean) if (mapped && body.includes(u)) { body = body.split(u).join(mapped.url) changed = true } } const coverMapped = b.imageUrl ? urlMap.get(b.imageUrl) || urlMap.get(b.imageUrl.replace(/[),.;]+$/, '')) : undefined if (!changed && !coverMapped) continue await prisma.blogPost.update({ where: { id: b.id }, data: { ...(coverMapped ? { imageUrl: coverMapped.url, imageKey: coverMapped.key } : {}), ...(changed ? { body } : {}), }, }) blogsUpdated += 1 } console.log('Done:', { migrated: ok, failed, productsUpdated, blogsUpdated, publicBase: base, }) } main() .catch((err) => { console.error(err) process.exitCode = 1 }) .finally(async () => { await prisma.$disconnect() })