This commit is contained in:
SaraJane
2026-01-20 01:49:19 +00:00
parent da8ea6be1e
commit f6a6cbc810
3 changed files with 65 additions and 41 deletions

View File

@ -1,10 +1,11 @@
import express from 'express' // server.js (ESM) — deployable on Vercel
import cors from 'cors'
import multer from 'multer'
import pinataSDK from '@pinata/sdk' import pinataSDK from '@pinata/sdk'
import cors from 'cors'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { Readable } from 'stream' import express from 'express'
import multer from 'multer'
import path from 'path' import path from 'path'
import { Readable } from 'stream'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
@ -15,33 +16,47 @@ dotenv.config({ path: path.resolve(__dirname, '.env') })
const app = express() const app = express()
// Allow local + prod (comma-separated in env), or * by default for dev /**
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '*') * CORS
.split(',') * - Allows localhost for local dev
* - Allows your main frontend via FRONTEND_ORIGIN
*/
const allowedOrigins = [
'http://localhost:5173',
process.env.FRONTEND_ORIGIN, // e.g. https://tokenize-rwa-template.vercel.app
]
.filter(Boolean)
.map((o) => o.trim()) .map((o) => o.trim())
app.use( app.use(
cors({ cors({
origin: (origin, cb) => { origin: (origin, cb) => {
if (!origin) return cb(null, true) // same-origin, curl, Postman // allow server-to-server / curl (no origin)
if (allowedOrigins.includes('*')) return cb(null, true) if (!origin) return cb(null, true)
if (allowedOrigins.includes(origin)) return cb(null, true) if (allowedOrigins.includes(origin)) return cb(null, true)
// Allow any frontend on vercel.app (great for forks) // Allow any frontend on vercel.app (great for forks + previews)
try { try {
const host = new URL(origin).hostname const host = new URL(origin).hostname
if (host.endsWith('.vercel.app')) return cb(null, true) if (host.endsWith('.vercel.app')) return cb(null, true)
} catch (_) {} } catch {
// ignore URL parse errors
}
return cb(null, false) return cb(null, false)
}, },
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: false, credentials: false,
}) }),
) )
// IMPORTANT: for multipart/form-data, multer populates req.body // Preflight for all routes
// but JSON parsing is still useful for other endpoints app.options('*', cors())
app.use(express.json())
// For non-multipart endpoints (multer handles multipart/form-data)
app.use(express.json({ limit: '15mb' }))
// Pinata client // Pinata client
const pinata = process.env.PINATA_JWT const pinata = process.env.PINATA_JWT
@ -51,8 +66,12 @@ const pinata = process.env.PINATA_JWT
// Optional: test credentials at cold start // Optional: test credentials at cold start
;(async () => { ;(async () => {
try { try {
const auth = await pinata.testAuthentication?.() if (typeof pinata.testAuthentication === 'function') {
console.log('Pinata auth OK:', auth || 'ok') await pinata.testAuthentication()
console.log('Pinata auth OK')
} else {
console.log('Pinata SDK loaded (no testAuthentication method)')
}
} catch (e) { } catch (e) {
console.error('Pinata authentication FAILED. Check env vars.', e?.message || e) console.error('Pinata authentication FAILED. Check env vars.', e?.message || e)
} }
@ -61,18 +80,15 @@ const pinata = process.env.PINATA_JWT
// health // health
app.get('/health', (_req, res) => { app.get('/health', (_req, res) => {
res.set('Cache-Control', 'no-store') res.set('Cache-Control', 'no-store')
res.json({ ok: true, ts: Date.now() }) res.status(200).json({ ok: true, ts: Date.now() })
}) })
// uploads // uploads
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileSize: 10 * 1024 * 1024, // 10MB safety limit (matches frontend hint)
},
}) })
// Small helpers
function safeTrim(v) { function safeTrim(v) {
return typeof v === 'string' ? v.trim() : '' return typeof v === 'string' ? v.trim() : ''
} }
@ -91,21 +107,20 @@ app.post('/api/pin-image', upload.single('file'), async (req, res) => {
const file = req.file const file = req.file
if (!file) return res.status(400).json({ error: 'No file uploaded' }) if (!file) return res.status(400).json({ error: 'No file uploaded' })
// NEW: read optional fields from multipart form-data (sent by frontend) // Optional multipart fields from frontend
const metaName = safeTrim(req.body?.metaName) || 'NFT Example' const metaName = safeTrim(req.body?.metaName) || 'NFT Example'
const metaDescription = safeTrim(req.body?.metaDescription) || 'This is an unchangeable NFT' const metaDescription = safeTrim(req.body?.metaDescription) || 'This is an unchangeable NFT'
const properties = safeJsonParse(req.body?.properties, {}) const properties = safeJsonParse(req.body?.properties, {})
// Pin image // Pin image
const stream = Readable.from(file.buffer) const stream = Readable.from(file.buffer)
// Pinata SDK expects a path/filename sometimes // Pinata SDK sometimes expects a .path/filename
// @ts-ignore
stream.path = file.originalname || 'upload' stream.path = file.originalname || 'upload'
const imageOptions = { const imageResult = await pinata.pinFileToIPFS(stream, {
pinataMetadata: { name: file.originalname || `${metaName} Image` }, pinataMetadata: { name: file.originalname || `${metaName} Image` },
} })
const imageResult = await pinata.pinFileToIPFS(stream, imageOptions)
const imageUrl = `ipfs://${imageResult.IpfsHash}` const imageUrl = `ipfs://${imageResult.IpfsHash}`
// Pin metadata JSON // Pin metadata JSON
@ -116,18 +131,16 @@ app.post('/api/pin-image', upload.single('file'), async (req, res) => {
properties, properties,
} }
const jsonOptions = { pinataMetadata: { name: `${metaName} Metadata` } } const jsonResult = await pinata.pinJSONToIPFS(metadata, {
const jsonResult = await pinata.pinJSONToIPFS(metadata, jsonOptions) pinataMetadata: { name: `${metaName} Metadata` },
})
const metadataUrl = `ipfs://${jsonResult.IpfsHash}` const metadataUrl = `ipfs://${jsonResult.IpfsHash}`
res.status(200).json({ metadataUrl }) return res.status(200).json({ metadataUrl })
} catch (error) { } catch (error) {
const msg = const msg = error?.response?.data?.error || error?.response?.data || error?.message || 'Failed to pin to IPFS.'
error?.response?.data?.error || return res.status(500).json({ error: msg })
error?.response?.data ||
error?.message ||
'Failed to pin to IPFS.'
res.status(500).json({ error: msg })
} }
}) })

View File

@ -2,7 +2,9 @@
"name": "nft_mint_server", "name": "nft_mint_server",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "node": ">=18 <21" }, "engines": {
"node": ">=18 <21"
},
"scripts": { "scripts": {
"dev": "node server.js", "dev": "node server.js",
"start": "node server.js" "start": "node server.js"

View File

@ -46,7 +46,12 @@ type TransferMode = 'manual' | 'algo' | 'usdc'
function resolveBackendBase(): string { function resolveBackendBase(): string {
// 1) Respect explicit env (Vercel or custom) // 1) Respect explicit env (Vercel or custom)
const env = import.meta.env.VITE_API_URL?.trim() const env = import.meta.env.VITE_API_URL?.trim()
if (env) return env.replace(/\/$/, '') if (env) {
const cleaned = env.replace(/\/$/, '')
// If someone pastes "my-backend.vercel.app" (no protocol),
// the browser will treat it as a relative path. Force https.
return cleaned.startsWith('http://') || cleaned.startsWith('https://') ? cleaned : `https://${cleaned}`
}
// 2) Codespaces: convert current host to port 3001 // 2) Codespaces: convert current host to port 3001
// e.g. https://abc-5173.app.github.dev -> https://abc-3001.app.github.dev // e.g. https://abc-5173.app.github.dev -> https://abc-3001.app.github.dev
@ -602,8 +607,12 @@ export default function TokenizeAsset() {
}) })
resetDefaults() resetDefaults()
} catch (error) { } catch (error: any) {
enqueueSnackbar('Failed to tokenize asset (ASA creation failed).', { variant: 'error' }) console.error('[ASA create] error:', error)
const msg = error?.response?.body?.message || error?.response?.text || error?.message || String(error)
enqueueSnackbar(`ASA creation failed: ${msg}`, { variant: 'error' })
} finally { } finally {
setLoading(false) setLoading(false)
} }