add NFT minting (frontend + Pinata/IPFS backend)

This commit is contained in:
Sara Jane (SJ)
2026-01-09 22:24:46 +00:00
parent 6aacc68cdc
commit 660dc742ad
8 changed files with 2232 additions and 161 deletions

View File

@ -0,0 +1,23 @@
# =========================
# Environment Setup
# =========================
# 1. In your project, go to the `nft_mint_server` folder.
# 2. Create a new file in this folder and name it exactly: `.env`
# (right-click → New File → type `.env`).
# 3. Open `.env.template` (this file) and copy all its contents.
# 4. Paste the contents into your new `.env` file.
# =========================
# Pinata Credentials
# =========================
# 5. Go to https://app.pinata.cloud/developers/api-keys
# 6. Click "New Key", toggle to **Admin**, and click "Create".
# 7. Copy the JWT Token and paste them into your `.env` below.
PINATA_API_KEY=YOUR_PINATA_API_KEY_GOES_HERE
PINATA_API_SECRET=YOUR_PINATA_API_SECRET_GOES_HERE
# =========================
# Server Config
# =========================
PORT=3001

View File

@ -0,0 +1,134 @@
import express from 'express'
import cors from 'cors'
import multer from 'multer'
import pinataSDK from '@pinata/sdk'
import dotenv from 'dotenv'
import { Readable } from 'stream'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Load local .env for dev. In Vercel, env vars come from platform.
dotenv.config({ path: path.resolve(__dirname, '.env') })
const app = express()
// Allow local + prod (comma-separated in env), or * by default for dev
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '*')
.split(',')
.map((o) => o.trim())
app.use(
cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true) // same-origin, curl, Postman
if (allowedOrigins.includes('*')) return cb(null, true)
if (allowedOrigins.includes(origin)) return cb(null, true)
// Allow any frontend on vercel.app (great for forks)
try {
const host = new URL(origin).hostname
if (host.endsWith('.vercel.app')) return cb(null, true)
} catch (_) {}
return cb(null, false)
},
credentials: false,
})
)
// IMPORTANT: for multipart/form-data, multer populates req.body
// but JSON parsing is still useful for other endpoints
app.use(express.json())
// Pinata client
const pinata = process.env.PINATA_JWT
? new pinataSDK({ pinataJWTKey: process.env.PINATA_JWT })
: new pinataSDK(process.env.PINATA_API_KEY, process.env.PINATA_API_SECRET)
// Optional: test credentials at cold start
;(async () => {
try {
const auth = await pinata.testAuthentication?.()
console.log('Pinata auth OK:', auth || 'ok')
} catch (e) {
console.error('Pinata authentication FAILED. Check env vars.', e?.message || e)
}
})()
// health
app.get('/health', (_req, res) => {
res.set('Cache-Control', 'no-store')
res.json({ ok: true, ts: Date.now() })
})
// uploads
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB safety limit (matches frontend hint)
},
})
// Small helpers
function safeTrim(v) {
return typeof v === 'string' ? v.trim() : ''
}
function safeJsonParse(v, fallback) {
try {
if (typeof v !== 'string' || !v.trim()) return fallback
return JSON.parse(v)
} catch {
return fallback
}
}
app.post('/api/pin-image', upload.single('file'), async (req, res) => {
try {
const file = req.file
if (!file) return res.status(400).json({ error: 'No file uploaded' })
// NEW: read optional fields from multipart form-data (sent by frontend)
const metaName = safeTrim(req.body?.metaName) || 'NFT Example'
const metaDescription = safeTrim(req.body?.metaDescription) || 'This is an unchangeable NFT'
const properties = safeJsonParse(req.body?.properties, {})
// Pin image
const stream = Readable.from(file.buffer)
// Pinata SDK expects a path/filename sometimes
// @ts-ignore
stream.path = file.originalname || 'upload'
const imageOptions = {
pinataMetadata: { name: file.originalname || `${metaName} Image` },
}
const imageResult = await pinata.pinFileToIPFS(stream, imageOptions)
const imageUrl = `ipfs://${imageResult.IpfsHash}`
// Pin metadata JSON
const metadata = {
name: metaName,
description: metaDescription,
image: imageUrl,
properties,
}
const jsonOptions = { pinataMetadata: { name: `${metaName} Metadata` } }
const jsonResult = await pinata.pinJSONToIPFS(metadata, jsonOptions)
const metadataUrl = `ipfs://${jsonResult.IpfsHash}`
res.status(200).json({ metadataUrl })
} catch (error) {
const msg =
error?.response?.data?.error ||
error?.response?.data ||
error?.message ||
'Failed to pin to IPFS.'
res.status(500).json({ error: msg })
}
})
export default app

View File

@ -0,0 +1,2 @@
import app from './app.js'
export default app

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
{
"name": "nft_mint_server",
"private": true,
"type": "module",
"engines": { "node": ">=18 <21" },
"scripts": {
"dev": "node server.js",
"start": "node server.js"
},
"dependencies": {
"@pinata/sdk": "^2.1.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^2.0.2"
}
}

View File

@ -0,0 +1,7 @@
import app from './app.js'
const port = process.env.PORT || 3001
app.listen(port, '0.0.0.0', () => {
console.log(`✅ Backend listening at http://localhost:${port}`)
})

View File

@ -65,6 +65,7 @@
"integrity": "sha512-jILpK3PlSJ55crS8+Dl7iIiADEj/VowokTGSVEEneDG41XM3jMmogGVpCNipBil/YAGC2eh2yc4l9iHnfQdT/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer": "^6.0.3"
},
@ -1489,6 +1490,7 @@
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.51.0",
@ -1876,6 +1878,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1939,6 +1942,7 @@
"integrity": "sha512-frhGtZl1JvfrLRKmMvUm880wj4OiWsWo2FhbreNWh7pdFsKuWPj60fV682wt/CYefLI70iwHavPOwGBkTVt0VA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"algorand-msgpack": "^1.1.0",
"hi-base32": "^0.5.1",
@ -2591,6 +2595,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -3980,6 +3985,7 @@
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -4696,7 +4702,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tweetnacl": {
"version": "1.0.3",
@ -4724,6 +4731,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -4761,8 +4769,7 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/upath": {
"version": "2.0.1",
@ -4798,6 +4805,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",