const express = require("express"); const app = express(); const cors = require("cors"); const multer = require("multer"); const upload = multer({ dest: "uploads/" }); const utils = require("ethereumjs-util"); const { Client } = require('pg'); const { Readable } = require("stream"); const fs = require("fs"); const Contract = require('web3-eth-contract'); const Web3Eth = require('web3-eth'); const Web3Util = require('web3-utils'); const uuid = require('uuid-random'); const Feed = require('feed').Feed; const gm = require('gm').subClass({imageMagick: true}); const https = require('https'); const Cache = require('timed-cache'); const { create } = require('ipfs-http-client'); const streams = require('memory-streams'); const Sentry = require("@sentry/node"); const Tracing = require("@sentry/tracing"); const storeAbi = require('./store.abi.json'); const settings = require(process.argv[2]); const LATEST_QUERY = fs.readFileSync('./latest.sql', 'utf8'); const NOTIFICATIONS_QUERY = fs.readFileSync('./notifications.sql', 'utf8'); process.on('unhandledRejection', up => { throw up }) const cache = new Cache(); const ipfsNodes = settings.ipfs.nodes.map(n => create(n) ); async function addToIpfs(data) { const promises = ipfsNodes.map(n => n.add(data)); const results = await Promise.allSettled(promises); const output = results.find(r => r.status === 'fulfilled'); if (typeof output === 'object') { return `ipfs://${output.value.cid}`; } else return null; } Sentry.init({ dsn: settings.sentry.api, tracesSampleRate: 1.0 }); let eth = new Web3Eth(settings.web3.url); // For frontend testing purposes!!! do NOT include in release // This forces a 1 second wait before returning from every call, used for testing loading states. /* console.warn("WARN: DELAY TEST MODE ENABLED") async function forceWait(req, res, next) { console.log("Waiting..."); await setTimeout(() => { console.log("waited."); // keep executing the router middleware next(); }, 250); } app.use(forceWait) */ const reconnectWeb3 = function(e) { if (e.code != 1006) console.error("web3 had an error", e); //quietly ignore disconnects eth = new Web3Eth(settings.web3.url); eth.currentProvider.on('error', reconnectWeb3); Contract.setProvider(eth.currentProvider); } eth.currentProvider.on('error', reconnectWeb3); Contract.setProvider(eth.currentProvider); function verifySignature(msg, address, sgn) { const getNakedAddress = address => { return address.toLowerCase().replace('0x', ''); } let sig = utils.toBuffer(sgn); sig[64] = sig[64] == 0 || sig[64] == 1 ? sig[64] + 27 : sig[64]; const hash = utils.hashPersonalMessage(utils.toBuffer(msg)) const pub = utils.ecrecover(hash, sig[64], sig.slice(0, 32), sig.slice(32, 64)); const adr = '0x' + utils.pubToAddress(pub).toString('hex') return adr == address.toLowerCase() } function generatePreview(filename) { return new Promise((resolve, reject) => { const previewFilename = `${filename}.preview.jpg`; gm(filename+"[0]") .resize(1024, 1024, '>') .write(previewFilename, err => { if (err) { reject(err); } else { resolve(previewFilename); } }); }); } // The request handler must be the first middleware on the app app.use(Sentry.Handlers.requestHandler()); app.use(cors()); //FIXME remove this after testing const bodyParser = require('body-parser'); app.use(bodyParser.json({limit:'200mb'})); app.use(bodyParser.urlencoded({extended:true, limit:'200mb'})); app.listen(settings.http.port, '0.0.0.0', async () => { console.log(`server is listening on ${settings.http.port} port`); const client = new Client(settings.db); await client.connect(); /** * @api {get} /api/ping Ping * @apiName Ping * @apiDescription A simple way to check the service is up and responding. Returns HTTP 200 with an empty json object. * @apiGroup Utility */ app.get("/api/ping", (req, res) => { res.status(200).json({}); }); /** * @api {get} /api/stores/:creator Fetch stores by creator * @apiName Fetch stores by creator * @apiDescription Fetch all stores created by the given address. * @apiGroup Store * * @apiParam {String} creator creator's address * * @apiSuccess {Object[]} - array of stores * @apiSuccess {String} -.address contract address * @apiSuccess {String} -.name full name * @apiSuccess {String} -.symbol symbol * @apiSuccess {String} -.creator creator address * @apiSampleRequest /api/stores/0xB618aaCb9DcDc21Ca69D310A6fC04674D293A193 * @apiSuccessExample {json} Example response * [ { "address": "0x46b241084Eb4d5fce223066fc31fe59D561B10f4", "name": "FEDIVERSE CLASSICS", "symbol": "FEDICLASS", "creator": "0xB618aaCb9DcDc21Ca69D310A6fC04674D293A193" }, { "address": "0x7e69b6193a2117204c223f4A7F41C07be9177989", "name": "10GRANS SAGA", "symbol": "GRANSAGA", "creator": "0xB618aaCb9DcDc21Ca69D310A6fC04674D293A193" } ] */ app.get("/api/stores/:creator", async (req, res) => { const result = await client.query( "select address, name, symbol, creator from store where creator = $1 and hidden=false order by inserted desc", [ req.params.creator ] ); const retval = []; for (const row of result.rows) { retval.push({ address: row.address, name: row.name, symbol: row.symbol, creator: row.creator }); } res.status(200).json(retval); }); /** * @api {get} /api/stores/:creator Fetch all stores * @apiDescription Fetch all stores * @apiName Fetch all stores * @apiGroup Store * * @apiSuccess {Object[]} - array of stores * @apiSuccess {String} -.address contract address * @apiSuccess {String} -.name full name * @apiSuccess {String} -.symbol symbol * @apiSuccess {String} -.creator creator address * @apiSampleRequest /api/stores * @apiSuccessExample {json} Example response * [ { "address": "0x46b241084Eb4d5fce223066fc31fe59D561B10f4", "name": "FEDIVERSE CLASSICS", "symbol": "FEDICLASS", "creator": "0xB618aaCb9DcDc21Ca69D310A6fC04674D293A193" }, { "address": "0x7e69b6193a2117204c223f4A7F41C07be9177989", "name": "10GRANS SAGA", "symbol": "GRANSAGA", "creator": "0xB618aaCb9DcDc21Ca69D310A6fC04674D293A193" } ] */ app.get("/api/stores", async (req, res) => { const result = await client.query("select address, name, symbol, creator from store where hidden=false order by inserted desc"); const retval = []; for (const row of result.rows) { retval.push({ address: row.address, name: row.name, symbol: row.symbol, creator: row.creator, }); } res.status(200).json(retval); }); /** * @api {get} /api/stores/:creator Fetch store by address * @apiName Fetch store by address * @apiDescription Fetch a store given the contract address. * @apiGroup Store * * @apiParam {String} address store contract address * * @apiSuccess {String} address contract address * @apiSuccess {String} name full name * @apiSuccess {String} symbol symbol * @apiSuccess {String} creator creator address * @apiSampleRequest /api/store/0x7e69b6193a2117204c223f4A7F41C07be9177989 * @apiSuccessExample {json} Example response { "address": "0x7e69b6193a2117204c223f4A7F41C07be9177989", "name": "10GRANS SAGA", "symbol": "GRANSAGA", "creator": "0xB618aaCb9DcDc21Ca69D310A6fC04674D293A193" } */ app.get("/api/store/:address", async (req, res) => { let retval; const result = await client.query( "select address, name, symbol, creator from store where address = $1 and hidden=false limit 1", [ req.params.address ] ); if (result.rows.length == 0) { retval = null; } else { retval = { address: result.rows[0].address, name: result.rows[0].name, symbol: result.rows[0].symbol, creator: result.rows[0].creator } } res.status(200).json(retval); }); // NOTE: These APIs are commented out because they are currently unused. Frontend is using the /latest api with store= parameter instead // because it does better price calculation and ordering. Feel free to uncomment these /* app.get("/api/store/:address/sets", async (req, res) => { const QUERY = "select metadata->'properties'->'set'->>'name' as name, count(*) as count, array_agg(nft_id::text order by nft_id) as nft_ids from nft where store = $1 and owner<>'0x0000000000000000000000000000000000000000' and metadata->'properties'->'set'->>'name' is not null group by metadata->'properties'->'set'->>'name' order by lower(metadata->'properties'->'set'->>'name')"; const params = [ req.params.address ]; const result = await client.query(QUERY, params); const retval = []; for (const row of result.rows) { if (row.name.length > 0) { retval.push({name: row.name, count: row.count, nftIds: row.nft_ids}); // magically assume that sets are ordered by nft ids } } res.setHeader("Connection", "close"); res.status(200).json(retval); res.connection.end(); }); app.get("/api/store/:address/nfts", async (req, res) => { const adultClause = req.query.adult ? "" : "and metadata->'properties'->>'adult' <> 'true'"; const setClause = req.query.set ? "and n2.metadata->'properties'->'set'->>'name' = $2" : ""; const QUERY = `select nft_id, meta_id, store, owner, metadata, metadata_uri, eth_price, eth_for_sale, token_price, token_for_sale from nft where store = $1 and base=false and hidden=false and owner<>'0x0000000000000000000000000000000000000000' ${adultClause} order by inserted desc`; const params = [ req.params.address ]; if (req.query.set) { params.push(req.query.set); } const result = await client.query( QUERY, params ); const retval = []; for (const row of result.rows) { retval.push({ nftId: row.nft_id, store: row.store, metadata: row.metadata, metadataUri: row.metadata_uri, metaId: row.meta_id, ethPrice: row.eth_price, ethForSale: row.eth_for_sale, tokenPrice: row.token_price, tokenForSale: row.token_for_sale }); } res.setHeader("Connection", "close"); res.status(200).json(retval); res.connection.end(); }); */ /** * @api {get} /api/store/:store/meta/:meta/hide Hide NFT * @apiName Hide NFT * @apiDescription Administrator action to mark an NFT as hidden, which filters it from other responses. Kind of like a soft delete * @apiGroup Admin */ app.put("/api/store/:store/meta/:meta/hide", async (req, res) => { try { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { if (authStatus.user.admin) { const result = await client.query("update nft set hidden = true where store = $1 and meta_id = $2", [ req.params.store, req.params.meta ]); res.status(204).send(); } else { res.status(403).send(); } } else { res.status(403).send(); } } catch (e) { console.error("failed to hide meta id", e); Sentry.captureException(e); } res.connection.end(); }); /** * @api {get} /api/store/:store/meta/:meta/hide Unhide NFT * @apiName Unhide NFT * @apiDescription Administrator action to un-mark an NFT as hidden. * @apiGroup Admin */ app.delete("/api/store/:store/meta/:meta/hide", async (req, res) => { try { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { if (authStatus.user.admin) { const result = await client.query("update nft set hidden = false where store = $1 and meta_id = $2", [ req.params.store, req.params.meta ]); res.status(204).send(); } else { res.status(403).send(); } } else { res.status(403).send(); } } catch (e) { console.error("failed to unhide meta id", e); Sentry.captureException(e); } res.connection.end(); }); /** * @api {get} /api/store/:store/meta/:meta Fetch NFT by Meta ID * @apiName Fetch NFT by Meta ID * @apiDescription Given a meta ID (aka nft set id), fetch NFTs in this set * @apiGroup NFT */ app.get("/api/store/:store/meta/:meta", async (req, res) => { let retval; const result = await client.query( "select nft_id, store, owner, metadata, metadata_uri, eth_price, eth_for_sale, token_price, token_for_sale from nft where store = $1 and meta_id = $2 and hidden = false order by inserted", [ req.params.store, req.params.meta ] ); if (result.rows.length > 0) { const r1 = result.rows[0]; if (!r1.metadata) { // probably messed up during minting. return res.status(200).json({}); } let count = 1; if (r1.metadata.properties.set) { count = r1.metadata.properties.set.of; } retval = { name: r1.metadata.name, adult: r1.metadata.properties.adult ? r1.metadata.properties.adult : false, description: r1.metadata.description, image: r1.metadata.image, cnt: count, extra: r1.metadata.properties, nfts: [] } for (const r of result.rows) { let number = -1; if (r.metadata && r.metadata.properties.set) { number = r.metadata.properties.set.number; } else { number = 1; } retval.nfts.push({ id: r.nft_id, owner: r.owner, number, price: r.eth_price, forSale: r.eth_for_sale, tokenPrice: r.token_price, tokenForSale: r.token_for_sale }); } } else { console.log("meta id not found") retval = {} } res.status(200).json(retval); }); /** * @api {get} /api/store/:store/nft/:nft Fetch NFT by NFT ID * @apiName Fetch NFT by NFT ID * @apiDescription Given an NFT ID, return the NFT. NFT IDs are globally unique. * @apiGroup NFT */ app.get("/api/store/:store/nft/:nft", async (req, res) => { let retval; const result = await client.query( "select nft_id, store, owner, metadata, metadata_uri, eth_price, eth_for_sale, token_price, token_for_sale, meta_id from nft where store = $1 and nft_id = $2 and hidden=false limit 1", [ req.params.store, req.params.nft ] ); if (result.rows.length == 0) { retval = null; } else { const r = result.rows[0]; retval = { nftId: r.nft_id, store: r.store, owner: r.owner, metadata: r.metadata, metadataUri: r.metadata_uri, ethPrice: r.eth_price, ethForSale: r.eth_for_sale, tokenPrice: r.token_price, tokenForSale: r.token_for_sale, metaId: r.meta_id } } res.status(200).json(retval); }); /** * @api {get} /api/store/:address/meta/:meta/favorites/count Fetch favorites count * @apiName Fetch favorites count * @apiDescription Fetch # of times a given NFT set has been favorited * @apiGroup Favorites */ app.get("/api/store/:address/meta/:meta/favorites/count", async (req, res) => { try { const result = await client.query( "select count(favorites) from (select f.*, a.username from favorite f, account a where f.account = a.id and f.store = $1 and f.meta_id = $2) as favorites", [ req.params.address, req.params.meta ] ); const ret = { count: 0 }; if (result.rows.length > 0) { ret.count = result.rows[0].count; } res.json(ret); } catch (e) { console.error(e); res.status(500).json({ error: "unknown error" }); } }); /** * @api {get} /api/store/:address/meta/:meta/favorites Fetch favorites * @apiName Fetch favorites * @apiDescription Fetch a list of favorites for a given NFT meta id * @apiGroup Favorites */ app.get("/api/store/:store/meta/:meta/favorites", async (req, res) => { try { const pgResult = await client.query( "select f.*, a.username from favorite f, account a where f.account = a.id and f.store = $1 and f.meta_id = $2 order by f.inserted asc", [ req.params.store, req.params.meta ] ); const ret = { favorites: [] } for (const r of pgResult.rows) { ret.favorites.push({ username: r.username, account: r.id, favorited: r.inserted }); } res.json(ret); } catch (e) { console.error(e); res.status(500).json({ error: "unknown error" }); } }); /** * @api {put} /api/store/:address/meta/:meta/favorite Favorite NFT * @apiName Favorite NFT * @apiDescription Set an NFT as being one of your 'favorite' NFTs * @apiGroup Favorites */ app.put("/api/store/:store/meta/:meta/favorite", async (req, res) => { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { const store = Web3Util.toChecksumAddress(req.params.store); try { const pgResult = await client.query( "insert into favorite (account, store, meta_id) values ($1, $2, $3) on conflict (store, meta_id, account) do nothing", [ authStatus.user.id, req.params.store, req.params.meta ] ); res.status(204).send(); } catch (e) { console.error("failed to create favorite", e); res.status(500).json({ error: "unknown error"}); } } else { res.status(403).json(authStatus); } }); /** * @api {delete} /api/store/:address/meta/:meta/favorite Un-favorite NFT * @apiName Un-favorite NFT * @apiDescription Unset an NFT as being one of your 'favorite' NFTs * @apiGroup Favorites */ app.delete("/api/store/:store/meta/:meta/favorite", async (req, res) => { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { const store = Web3Util.toChecksumAddress(req.params.store); try { const pgResult = await client.query( "delete from favorite where account = $1 and store = $2 and meta_id = $3", [ authStatus.user.id, store, req.params.meta ] ); res.status(204).send(); } catch (e) { console.error("failed to delete favorite", e); res.status(500).json({ error: "unknown error"}); } } else { res.status(403).json(authStatus); } }); async function authenticateRequest(req) { const localNow = Date.now(); const now = req.header('X-NftStore-Now'); const remoteNow = new Date(now).getTime(); // Verify request made within roughly n minute in either direction const recent = Math.abs(localNow - remoteNow) < 5 * 60 * 1000; if (!recent) { console.error(`Request wasn't timely enough. ${localNow} ${remoteNow}`); // we no longer error out on this because it annoyed users. } const account = Web3Util.toChecksumAddress(req.header('X-NftStore-Account')); const user = await getUser(account, false); if (user == false) { return { error: "no such authenticating account" } } const message = `${now} ${account} ${user.token} ${req.originalUrl}`; //console.log("expecting hash " + message) const hash = Web3Util.keccak256(message); if (req.header('Authorization') != `Bearer ${hash}`) { console.error("Bad MAC."); return { error: "bad MAC" } } return { error: "success", user } } //select id from nft where nft_id = '10352619520185845683212120351233445841884757229969180748948823255023616' /** * @api {get} /api/id/:nft_id Fetch internal ID * @apiName Fetch internal ID * @apiDescription Fetch the internal database ID for an NFT. Used for the nfts.su link shortener * @apiGroup NFT */ app.get("/api/id/:nft_id", async (req, res) => { try { const pgResult = await client.query( "select id from nft where nft_id = $1;", [ req.params.nft_id ] ); if (pgResult.rows.length == 0) { res.status(404).send("404 NFT not found"); } const ret = {id: pgResult.rows[0].id}; res.json(ret); } catch (e) { console.error(e); res.status(500).json({ error: "unknown error" }); } }); async function handleHtmlNft(r, res) { const title = escapeHtml(r.metadata && r.metadata.name ? r.metadata.name : "none"); const description = escapeHtml(r.metadata && r.metadata.description ? r.metadata.description.substring(0, 100) : "none"); let image = `${settings.baseUrl}/question.png`; if (r.metadata) { const truncatedIpfs = (r.metadata.preview ? r.metadata.preview : r.metadata.image).replace("ipfs://", ""); image = `${settings.baseUrl}/ipfs/${truncatedIpfs}`; } const url = `${settings.baseUrl}/collection/${r.store}/nft/${r.meta_id}`; const h = ` ${title}

${title}

${description}

` res.setHeader("content-type", "text/html; charset=UTF-8"); res.send(h); } /** * This is for the link shortener URL */ app.get("/:id", async (req, res) => { let r = await client.query( "select nft_id, meta_id, store, owner, metadata, metadata_uri from nft where id = $1 and hidden=false limit 1", [ req.params.id ] ); if (r.rowCount == 0) { res.status(404).send("404 NFT not found"); } else { await handleHtmlNft(r.rows[0], res); } }); /** * This is for the link shortener URL * * TODO: this should be based on meta ID, not nft ID. */ app.get("/api/static/store/:store/nft/:nft.html", async (req, res) => { let r = await client.query( "select nft_id, store, owner, metadata, metadata_uri, eth_price, eth_for_sale, token_price, token_for_sale from nft where store = $1 and nft_id = $2 and hidden=false limit 1", [ req.params.store, req.params.nft ] ); if (r.rowCount == 0) { res.status(404).send("404 NFT not found"); } else { await handleHtmlNft(r.rows[0], res); } }); /** * @api {get} /api/account/:address/created Fetch all NFTs created by an account * @apiName Fetch NFTs created by account * @apiDescription Given an account, fetch all NFTs this user created. * @apiGroup NFT */ app.get("/api/account/:address/created", async (req, res) => { const result = await client.query( "select n.* from nft as n, store as s where n.store = s.address and s.creator = $1 and s.hidden = false and n.hidden = false order by n.inserted desc", [ req.params.address ] ); const retval = []; for (const row of result.rows) { const nft = { inserted: row.inserted, store: row.store, nftId: row.nft_id, metaId: row.meta_id, owner: row.owner, metadata: row.metadata, ethForSale: row.eth_for_sale, ethPrice: row.eth_price, tokenForSale: row.token_price, tokenPrice: row.token_price } retval.push(nft); } res.status(200).json(retval); }); /** * @api {get} /api/account/:address/nfts Fetch NFTs by account * @apiName Fetch NFTs by account * @apiDescription Given an account, fetch all NFTs this user owns. * @apiGroup NFT */ app.get("/api/account/:address/nfts", async (req, res) => { const adultClause = req.query.adult ? "" : "and not coalesce((metadata->'properties'->>'adult')::boolean, 'false')"; const result = await client.query( `select nft_id, meta_id, store, owner, metadata, metadata_uri, eth_price, eth_for_sale, token_price, token_for_sale from nft where owner = $1 and base=false and hidden=false and owner<>'0x0000000000000000000000000000000000000000' ${adultClause} order by inserted desc`, [ req.params.address ] ); const retval = []; for (const row of result.rows) { let count = 1; if (row.metadata && row.metadata.properties.set) count = row.metadata.properties.set.of; retval.push({ nftId: row.nft_id, metaId: row.meta_id, cnt: count, store: row.store, owner: row.owner, metadata: row.metadata, metadataUri: row.metadata_uri, ethPrice: row.eth_price, ethForSale: row.eth_for_sale, tokenPrice: row.token_price, tokenForSale: row.token_for_sale }); } res.status(200).json(retval); }); // Commented out as gallery functionality is currently unused. Feel free to uncomment /* app.get("/api/account/:address/gallery", async (req, res) => { res.setHeader("Connection", "close"); const result = await client.query( "select gallery from account where id = $1 limit 1", [ req.params.address ] ); if (result.rows.length) { const gallery = result.rows[0].gallery ? result.rows[0].gallery : {}; if (gallery.set) { const second = gallery.set.map(r=> `('${r.store}', ${r.id})`); const str = second.join(',') const result2 = await client.query("select nft.nft_id, nft.store, nft.owner, nft.metadata, nft.metadata_uri, nft.hidden, nft.eth_price, nft.token_price, nft.eth_for_sale, nft.token_for_sale from ( values "+str+") as gallery(store, id) left join nft on nft.store=gallery.store and nft.nft_id=gallery.id"); //console.log(`retval: ${gallery.set.length}, result2: ${result2.rows.length}`); if (gallery.set.length !== result2.rows.length) { res.status(500).json({ error: "missing NFTs"}); res.connection.end(); return; } for (let i=0;i { res.setHeader("Connection", "close"); const localNow = Date.now(); const now = req.header('X-NftStore-Now'); const remoteNow = new Date(now).getTime(); //console.log(remoteNow); const recent = Math.abs(localNow - remoteNow) < 5 * 60 * 1000; if (!recent) { res.status(401).json({ error: "not timely enough" }); res.connection.end(); return; } const account = req.header('X-NftStore-Account'); if (!account) { res.status(401).json({ error: "no such authenticating account" }); res.connection.end(); return; } const user = await getUser(account); if (user == false) { res.status(401).json({ error: "no such authenticating account" }); res.connection.end(); return; } const message = `${now} ${account} ${user.token} /api/account/${req.params.address}/gallery`; const hash = Web3Util.keccak256(message); if (req.header('Authorization') !== `Bearer ${hash}`) { console.error("Bad MAC."); res.status(401).json({ error: "bad MAC" }); res.connection.end(); return; } let validated = true; let error = ""; const stores = {}; // not actually cleansed, it will just be discarded if validation fails let cleansedObject = { set: [] }; if (typeof req.body.name !== 'string' || req.body.name.trim().length === 0 || req.body.name.trim().length > 140) { validated = false; error = "invalid name"; } else if (typeof req.body.description !== 'string' || req.body.description.trim().length === 0 || req.body.description.trim().length > 280) { validated = false; error = "invalid description"; } else { const setIsArray = Array.isArray(req.body.set); if (!setIsArray) { validated = false; error = "invalid set"; } else if (req.body.set.length === 0) { validated = false; error = "empty set"; } else { // iterate over all the set items and validate them. const numericReg = /^\d+$/; const addrReg = /^0x[a-fA-F0-9]{40}$/ let cnt = -1; for (const item of req.body.set) { cnt++; const isObject = typeof item === 'object' && item !== null; if (!isObject) { validated = false; error = "invalid set"; break; } else { if (typeof item.annotation !== 'string' || item.annotation.trim().length === 0 || item.annotation.trim().length > 140) { validated = false; error = `invalid item ${cnt} annotation`; break; } else if (typeof item.id !== 'string' || !numericReg.test(item.id)) { validated = false; error = `invalid item ${cnt} id`; break; } else if (typeof item.store !== 'string' || !addrReg.test(item.store)) { validated = false; error = `invalid item ${cnt} store`; break; } else { // check if it's actually on the blockchain let contract = null; if (stores[item.store]) { contract = stores[item.store]; } else { try { contract = new Contract(storeAbi, item.store); stores[item.store] = contract; const storeName = await contract.methods.name().call(); } catch(e) { console.log("not a valid store"); validated = false; error = `invalid item ${cnt} store`; break; } } let quantity = -1; try { quantity = await contract.methods.quantityForId(item.id).call(); } catch {} if (quantity != 1) { validated = false; error = `invalid item ${cnt} id`; break; } else { console.log("item seems to be ok"); cleansedObject.set.push({ store: item.store, id: item.id, annotation: item.annotation.trim() }); } } } } } } if (!validated) { res.status(400).json({ success: false, error }); res.connection.end(); return; } else { cleansedObject.name = req.body.name.trim() cleansedObject.description = req.body.description.trim(); try { const result = await client.query("update account set gallery = $1 where id = $2", [ cleansedObject, account ]); res.status(200).json({ success: true }); res.connection.end(); return; } catch(e) { console.error(e); Sentry.captureException(e); res.status(500).json({ success: false, error: "error updating record"}); res.connection.end(); return; } } }); */ /** * @api {get} /api/nfts/latest Get latest NFTs * @apiName Get latest NFTs * @apiDescription Returns most-recently created NFT sets. * * The 'latest' API has become somewhat overloaded. It returns a list of NFTS; the NFTs are deduplicated, * such that if there is more than one in a set, this api will return the best-priced for-sale NFT in the set. * It can be filtered by adult, ubiq vs grans, owner and store, and is paginated. * @apiGroup NFT */ app.get("/api/nfts/latest", async (req, res) => { let page = typeof req.query.page === 'undefined' ? '1' : req.query.page; if (isNaN(page)) page = "1"; page = parseInt(page); const nftsPerPage = 41; // 10 rows, plus 1 for Featured nft const offset = (page - 1) * nftsPerPage; let saleClause = ""; if ("ubiq" == req.query.forsale) { saleClause = " and y.eth_for_sale=true"; } if ("grans" == req.query.forsale) { saleClause = " and y.token_for_sale=true"; } if ("any" == req.query.forsale) { saleClause = " and (y.eth_for_sale=true or y.token_for_sale=true)"; } const adultClause = req.query.adult ? "" : " and not coalesce((y.metadata->'properties'->>'adult')::boolean, 'false')"; let subst = 3; // postgres substitution variable- $3, $4, $5... if (typeof req.query.owner == "string") { // add a subst for the owner param subst++; } // handle store clauses let stores = []; let storeClause = ""; if (typeof req.query.store == "string") { stores = [req.query.store]; } else if (Array.isArray(req.query.store)) { stores = req.query.store; } if (stores.length > 0) { storeClause = " and y.store in ("; for (let i = 0; i < stores.length; i++) { storeClause += "$" + subst; subst += 1; if (i != stores.length - 1) { storeClause += ", "; } } storeClause += ")" } let result; if (typeof req.query.owner == "string") { // LATEST_QUERY comes from ./latest.sql let QUERY = LATEST_QUERY.replace("OWNER_CLAUSE", "and i.owner = $3"); QUERY = QUERY + saleClause + adultClause + storeClause + " order by y.inserted desc limit $1 offset $2" ; result = await client.query( QUERY, [ nftsPerPage, offset, req.query.owner, ...stores ] ); } else { // verified only let verifiedClause = ""; if ( typeof req.query.showunverified === 'undefined' || req.query.showunverified === 'false' ) { verifiedClause = " and z.verified=true"; } let QUERY = LATEST_QUERY.replace("OWNER_CLAUSE", ""); QUERY = QUERY + saleClause + adultClause + storeClause + verifiedClause + " order by y.inserted desc limit $1 offset $2" ; result = await client.query( QUERY, [ nftsPerPage, offset, ...stores ] ); } const retval = []; for (const row of result.rows) { retval.push({ nftId: row.nft_id, store: row.store, metaId: row.meta_id, inserted: row.inserted, metadata: row.metadata, metadataUri: row.metadata_uri, ethPrice: row.eth_price, ethForSale: row.eth_for_sale, tokenPrice: row.token_price, tokenForSale: row.token_for_sale, dollarPrice: row.dollar_price, cnt: row.cnt }); } res.status(200).json(retval); }); /** * @api {get} /api/notifications/:account/last Fetch date of most recent notification * @apiName Fetch date of most recent notification * @apiDescription Fetch the date of the most recent notification for an account. * Used by the frontend as a lightweight way to determine whether the user has a new notification to check. * If no recent notification for the user, returns empty. * @apiGroup Notifications */ app.get("/api/notifications/:account/last", async (req, res) => { try { const result = await client.query( NOTIFICATIONS_QUERY, [ req.params.account, 1, 0 ] ); const ret = { "last": ""}; if (result.rows.length > 0) { ret.last = result.rows[0].inserted; } res.json(ret); } catch (e) { console.error(e); res.status(500).json({ error: "unknown error" }); } }); /** * @api {get} /api/notifications/:account Fetch nofitications for account * @apiName Fetch notifications * @apiDescription Fetch the notifications relevant to an account. Paginated, returns last 10 results by default. * @apiGroup Notifications */ app.get("/api/notifications/:account", async (req, res) => { try { const ret = []; let page = typeof req.query.page === 'undefined' ? '1' : req.query.page; if (isNaN(page)) page = "1"; page = parseInt(page); let resultsPerPage = typeof req.query.resultsPerPage === 'undefined' ? '5' : req.query.resultsPerPage; if (isNaN(resultsPerPage)) resultsPerPage = "5"; resultsPerPage = parseInt(resultsPerPage); const offset = (page - 1) * resultsPerPage; // This query is a union between the Event and Comment tables. const result = await client.query( NOTIFICATIONS_QUERY, [ req.params.account, resultsPerPage, offset ] ); for (const r of result.rows) { // get metadata for these NFTs let nftLookup; if (r.name == "Comment") { // look up via store+meta_id nftLookup = await client.query( "select store, metadata from nft where store = $1 and meta_id = $2;", [ r.return_values.store, r.return_values.meta_id ] ); if (nftLookup.rows.length == 0) { console.error(`Unable to look up NFT by store ${r.return_values.store}, meta_id ${r.return_values.meta_id}!`); continue; } } else { // look up via id nftLookup = await client.query( "select store, metadata from nft where nft_id = $1;", [ r.return_values.id ] ); if (nftLookup.rows.length == 0) { console.error(`Unable to look up NFT by id ${r.return_values.id}!`); continue; } } ret.push({ date: r.inserted, type: r.name, event: r.return_values, metadata: nftLookup.rows[0].metadata, store: nftLookup.rows[0].store, }); } res.json(ret); } catch (e) { console.error(e); res.status(500).json({ error: "unknown error" }); } }); /** * @api {post} /api/store/:address/meta/:meta/comment Add comment * @apiName Add comment * @apiDescription Adds a comment to an NFT set. * @apiGroup Comments */ app.post("/api/store/:address/meta/:meta/comment", upload.none(), async (req, res) => { try { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { const result1 = await client.query("select id from nft where store = $1 and meta_id = $2", [ req.params.address, req.params.meta ]); if (result1.rows.length === 0) { res.status(404).send(); } else { if (typeof req.body.content === 'undefined' || req.body.content.trim().length === 0) { res.status(400).json({ error: "empty content" }); } else if (req.body.content.length > 280) { res.status(400).json({ error: "content over 280 characters" }); } else { const result2 = await client.query("insert into comment (author, store, meta_id, content) values ($1, $2, $3, $4) returning id", [ authStatus.user.id, req.params.address, req.params.meta, req.body.content.trim() ]); res.json({ error: "success", id: result2.rows[0].id }); } } } else { res.status(403).json(authStatus); } } catch (e) { console.error("failed to make comment", e); res.status(500).json({ error: "unknown error" }); } }); /** * @api {get} /api/store/:address/meta/:meta/comments/count Fetch comments count * @apiName Fetch comments count * @apiDescription Returns the # of comments on a given NFT set * @apiGroup Comments */ app.get("/api/store/:address/meta/:meta/comments/count", async (req, res) => { const ret = { count: 0 }; const result = await client.query( "select count(comments) from (select distinct on (id) c.id, a.username, c.* from comment as c, nft as n, store as s, account as a where s.address = $1 and n.meta_id = $2 and n.store = s.address and c.store = n.store and c.meta_id = n.meta_id and c.author = a.id and c.hidden = false order by id) as comments;", [ req.params.address, req.params.meta ] ); if (result.rows.length > 0) { ret.count = result.rows[0].count; } res.json(ret); }); /** * @api {get} /api/store/:address/meta/:meta/comments/count Fetch comments * @apiName Fetch comments * @apiDescription Returns the comments for a given NFT set * @apiGroup Comments */ app.get("/api/store/:address/meta/:meta/comments", async (req, res) => { let page = typeof req.query.page === 'undefined' ? '1' : req.query.page; if (isNaN(page) || page == 0) page = "1"; page = parseInt(page); const itemsPerPage = 5; const offset = (page - 1) * itemsPerPage; const ret = { comments: [] } const result = await client.query( `select c.* from ( select distinct a.username, a.verified, c.* from comment as c, nft as n, store as s, account as a where s.address = $1 and n.meta_id = $2 and n.store = s.address and c.store = n.store and c.meta_id = n.meta_id and c.author = a.id and c.hidden = false ) as c order by case when c.promoted = true then 1 else 0 end desc, inserted desc limit $3 offset $4;`, [ req.params.address, req.params.meta, itemsPerPage, offset ] ); if (result.rows.length > 0) { for (const r of result.rows) { ret.comments.push({ id: r.id, author: r.author, username: r.username, verified: r.verified, promoted: r.promoted, inserted: r.inserted, content: r.content }); } } res.json(ret); }); /** * @api {delete} /api/store/:address/meta/:meta/comment/:comment Remove comment * @apiName Remove comment * @apiDescription Removes a comment from NFT set given comment ID. * @apiGroup Comments */ app.delete("/api/store/:address/meta/:meta/comment/:comment", upload.none(), async (req, res) => { try { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { if (authStatus.user.admin) { const result1 = await client.query("update comment set hidden = true where id = $1", [ req.params.comment ]); res.status(204).send(); } else { res.status(403).send(); } } else { res.status(403).send(); } } catch (e) { console.error(e); res.status(500).json({ error: "unknown error" }); } }); // FIXME this method is not implemented correctly! // Originally, comment promotion was done per-nft, but we've changed it to per-meta-nft. // The `const result1 ...` ownership check is only returning the first owner, but for a meta-nft, there could be multiple owners. /* app.post("/api/store/:address/meta/:meta/comment/:comment/promote", upload.none(), async (req, res) => { try { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { // they're authed, but make sure they're the owner of the store of the nft being commented on const result1 = await client.query( "select s.creator from store as s, comment as c, nft as n where s.address = $1 and n.meta_id = $2 and c.id = $3 and c.store = n.store and c.meta_id = n.meta_id and n.store = s.address limit 1;", [ req.params.address, req.params.meta, req.params.comment ] ); if (result1.rows.length == 1) { const creator = result1.rows[0].creator; if (creator === authStatus.user.id) { const result2 = await client.query("update comment set promoted = case when promoted = true then false else true end where id = $1 returning promoted", [ req.params.comment ]); res.json({ error: "success", promoted: result2.rows[0].promoted }); } else { res.status(403).send(); } } else { res.status(404).json({ error: "comment, store or nft not found" }); } } else { res.status(403).json(authStatus); } } catch (e) { console.error("failed to promote comment", e); res.status(500).json({ error: "unknown error" }); } }); */ /** * @api {get} /api/store/:address/nft/:nft/activity Get activities * @apiName Get activities * @apiDescription Retrieves activities (buy, price change, transfer, etc) that have occured for an NFT. * @apiGroup Activity */ app.get("/api/store/:address/nft/:nft/activity", async (req, res) => { const storeAddress = req.params.address; const nftId = req.params.nft; const ret = []; const result = await client.query( "select * from event where hidden=false and address = $1 and ( (name in ('BuySingleNft','TokenBuySingleNft') and return_values->>'id' = $2) or (name='TransferSingle' and return_values->>'id' = $2) or (name='PriceChange' and return_values->>'id' = $2) ) order by block desc, transaction_index desc, log_index desc", [ storeAddress, nftId ] ); for (const row of result.rows) { const name = row.name; // Event name. let activity; const block = await eth.getBlock(row.block); const timestamp = block.timestamp; if (name === 'TransferSingle') { const operator = row.return_values.operator; const from = row.return_values.from; const to = row.return_values.to; const fromUser = await getUser(from); const toUser = await getUser(to); const operatorUser = await getUser(operator); activity = { name, id: row.id, block: row.block, timestamp, from, fromUser, to, toUser, operator, operatorUser }; const isMintOperation = (from == "0x0000000000000000000000000000000000000000"); const isBaseType = (isMintOperation && to == "0x0000000000000000000000000000000000000000"); const isBurnOperation = (!isBaseType && !isMintOperation && to == "0x0000000000000000000000000000000000000000"); const isTransfer = !isMintOperation & !isBurnOperation; if (isTransfer) { activity.activity = 'Transfer'; } else if (isBurnOperation) { activity.activity = 'Burn'; } else if (isMintOperation) { activity.activity = 'Mint'; } else { console.error("Unknown (transfer) event type."); } } else if (name === 'PriceChange') { const price = row.return_values.price; const forSale = row.return_values.forSale; const tokenPrice = row.return_values.tokenPrice; const tokenForSale = row.return_values.tokenForSale; const owner = row.return_values.owner; const ownerUser = await getUser(owner); activity = { activity: "Price Change", name, owner, ownerUser, id: row.id, block: row.block, timestamp, price, forSale, tokenPrice, tokenForSale }; } else if (name === 'BuySingleNft') { const from = row.return_values.from; const to = row.return_values.to; const fromUser = await getUser(from); const toUser = await getUser(to); const price = row.return_values.price; activity = { activity: "Purchase", name, to, toUser, from, fromUser, id: row.id, block: row.block, timestamp, price }; } else if (name === 'TokenBuySingleNft') { const from = row.return_values.from; const to = row.return_values.to; const fromUser = await getUser(from); const toUser = await getUser(to); const price = row.return_values.price; activity = { activity: "Purchase", name, to, toUser, from, fromUser, id: row.id, block: row.block, timestamp, tokenPrice: price }; } if (typeof activity === 'undefined') { console.warn("Ignoring unrecognized event type."); } else { ret.push(activity); } } res.status(200).json(ret); }); /** * @api {get} /api/store/:address/nft/:nft/activity Get cryptocurrency prices * @apiName Get cryptocurrency prices * @apiDescription Retrieves the current price of ubiq/USD and ubq/grans * @apiGroup Utility */ app.get("/api/money", async (req, res) => { const result = await client.query("select * from lookup where key in ('ubiqUsdRatio', 'ubiqGransRatio')"); ret = {} for (const r of result.rows) { ret[r.key] = parseFloat(r.val); } res.json(ret); }); /** * @api {post} /api/admin/account/:account Disable account * @apiName Disable account * @apiDescription Admin action to mark an account enabled or disabled * @apiGroup Admin */ app.post("/api/admin/account/:account", upload.none(), async (req, res) => { const disabled = req.body.disabled; const localNow = Date.now(); const now = req.header('X-NftStore-Now'); const remoteNow = new Date(now).getTime(); const recent = Math.abs(localNow - remoteNow) < 5 * 60 * 1000; if (!recent) { // we no longer error out on this } const account = req.header('X-NftStore-Account'); const user = await getUser(account, false); if (user == false) { res.status(401).json({ error: "no such authenticating account" }); return; } else if (!user.admin) { res.status(401).json({ error: "not an admin" }); return; } const message = `${now} ${account} ${user.token} /api/admin/account/${req.params.account} disabled=${disabled}`; const hash = Web3Util.keccak256(message); if (req.header('Authorization') != `Bearer ${hash}`) { console.error("Bad MAC."); res.status(401).json({ error: "bad MAC" }); return; } else { const result = client.query( "update account set disabled = $1 where id = $2", [ disabled, req.params.account ]); if (result.rowCount == 0) { res.status(404).json({ error: "target account not found" }); return; } else { res.status(200).json({}); return; } } }); /** * @api {post} /api/admin/account/:account Verify account * @apiName Verify account * @apiDescription Admin action to mark an account verified or unverified * @apiGroup Admin */ app.post("/api/admin/account/:account/verified", upload.none(), async (req, res) => { try { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { if (authStatus.user.admin) { if (req.body.verified === 'true' || req.body.verified === 'false') { const result = await client.query("update account set verified = $1 where id = $2", [ req.body.verified, req.params.account ]); res.status(204).send(); } else { res.status(400).json({ error: `bad verified status: ${req.body.verified}` }); } } else { res.status(403).send(); } } else { res.status(403).json(authStatus); } } catch (e) { console.log("failed to update verified status for user", e); res.status(500).json({ error: "unknown error" }); } }); // duplicate nfts are passed existing urls to image async function validateImageUrl(imageUrl) { try { var url = new URL(imageUrl); } catch(e) { console.log(`image url is invalid`); return false; } const rightProto = url.protocol === "ipfs:"; if (!rightProto) { console.log("URL was not an ipfs:// link"); return false; } const otherJunkOk = url.username === '' && url.password === '' & url.port === '' && url.search === '' && url.hash === ''; if (!otherJunkOk) { console.log("unspecified error with URL"); return false; } return true; } // TODO: write tests for this. // returns "" if nft is valid, otherwise an error message of what's wrong. function nftIsvalid(nft) { const ARBITRARY_FIELD_MAX = 1000; const NAME_MAX = 40; const SET_MAX = 100; const keys = Object.keys(nft); // make sure keys listed are in the nft for (const key of ['name', 'description', 'properties']) { if (!keys.includes(key)) { return `Missing property ${key}`; } } // all keys were present, make sure no extras if (keys.length > 5) { return "Too many properties in the object"; } if (typeof nft.name === 'undefined' || nft.name === null || nft.name === "") { return "Name is required"; } if (typeof nft.name !== 'string') { return "Name must be a string"; } if (nft.name.trim() !== nft.name) { return "Trim name"; } if (nft.name.length > NAME_MAX) { return `Name may only be ${NAME_MAX} characters`; } if (typeof nft.description === 'undefined' || nft.description === null || nft.description === "") { return "Description is required"; } if (nft.description.trim() !== nft.description) { return "Trim description"; } if (nft.description.length > ARBITRARY_FIELD_MAX) { return `Description may only be ${ARBITRARY_FIELD_MAX} characters`; } // image is optional // FIXME NEEDS MORE VALIDATION if ('image' in nft) { if (typeof nft.image !== 'string') { return "Image must be string"; } if (nft.image.trim() !== nft.image) { return "Trim image"; } if (nft.image.length > ARBITRARY_FIELD_MAX) { return `Image may only be ${ARBITRARY_FIELD_MAX} characters`; } } if ('decimals' in nft) { if (typeof nft.decimals !== 'number') { return "Decimals must be a number"; } if (nft.decimals < 0 || nft.decimals > 18) { return "Decimals must be 0-18"; } } if (typeof nft.properties !== 'object') { return "Properties must be object"; } const propertiesKeys = Object.keys(nft.properties); for (const key of propertiesKeys) { if (!['string', 'number', 'object', 'array'].includes(typeof key)) { return `properties ${key} must be of type string, number, object or array`; } if (key.length > NAME_MAX) { return `properties ${key} name longer than ${NAME_MAX} characters`; } if (typeof nft.properties[key] === 'string') { if (nft.properties[key].length > ARBITRARY_FIELD_MAX) { return `properties ${key} length greater than ${ARBITRARY_FIELD_MAX} characters` } if (nft.properties[key].trim() !== nft.properties[key]) { return `properties ${key} must be trimmed`; } if (nft.properties[key].length === 0) { return `properties ${key} must not be empty`; } } if (typeof nft.properties[key] === 'object' && 'length' in nft.properties[key]) { if (nft.properties[key].length === 0) { return `properties ${key} array cannot be empty`; } if (nft.properties[key].length > SET_MAX) { return `properties ${key} array cannot be larger than ${SET_MAX} elements` } for (const arrayElement of nft.properties[key]) { if (key === 'tags' && typeof arrayElement !== 'string') { return `tags must all be strings`; } if (key === 'tags' && arrayElement.includes(" ")) { return `tag ${arrayElement} must not have a space`; } if (key === 'tags' && (arrayElement.length === 0 || arrayElement.length > NAME_MAX)) { return `tags must be between 1 and ${NAME_MAX} length`; } if (typeof arrayElement === 'string') { if (arrayElement.length == 0 || arrayElement.length > ARBITRARY_FIELD_MAX) { return `properties ${key} array elements must be between 1 and ${ARBITRARY_FIELD_MAX} in length` } if (arrayElement.trim() !== arrayElement) { return `properties ${key} array elements must be trimmed`; } if (arrayElement === null) { return `properties ${key} array element cannot be null`; } } } } } return ""; } /** * @api {post} /api/nft/validate Validate NFT metadata * @apiName Validate NFT metadata * @apiDescription Validates NFT metadata blob prior to upload * @apiGroup Upload */ app.post("/api/nft/validate", upload.none(), (req, res) => { const metadata = JSON.parse(req.body.metadata); const errorMessage = nftIsvalid(metadata); if (errorMessage === "") { res.json({ error: "" }); } else { res.status(400).json({ error: errorMessage }); } }); /** * @api {post} /api/nft/upload Upload image to IPFS * @apiName Upload image to IPFS * @apiDescription Upload an NFT image to IPFS, and create a preview image. * * Given an image file, validate this file (check filetype, size) and upload it to IPFS. Return the IPFS hash for this image. * If the size is over 500kb, create a preview image and return that IPFS hash too. * * This is intended to run prior to submitting the NFT creation tranasction on-chain, so you can stuff the IPFS hashes * in your nft metadata. * @apiGroup Upload */ app.post("/api/nft/upload", upload.single("file"), async (req, res) => { console.log(`NFT metadata upload for store ${req.body.storeAddress}`); const localNow = Date.now(); const now = req.header('X-NftStore-Now'); const remoteNow = new Date(now).getTime(); const metadata = JSON.parse(req.body.metadata); const errorMessage = nftIsvalid(metadata); if (errorMessage !== "") { console.error("NFT metadata failed to validate: " + errorMessage); res.status(400).json({ error: errorMessage }); return; } const imageFileProvided = typeof metadata.image === 'undefined'; let ext; if (imageFileProvided) { // user uploaded an image switch (req.file.mimetype) { case "image/jpeg": ext = "jpg"; break; case "image/gif": ext = "gif"; break; case "image/png": ext = "png"; break; default: ext = "BAD"; break; } if (ext == "BAD") { console.error("Bad file type."); res.status(400).json({ error: "bad file type, use jpeg, gif or png" }); return; } } else { // nft metadata includes url of existing image url const unCached = typeof cache.get(metadata.image) === 'undefined'; if (unCached) { const isValidImage = await validateImageUrl(metadata.image); if (isValidImage) { // cache it because it's probably gonna be checked multiple times cache.put(metadata.image, metadata.image); } else { res.status(400).json({ error: "Image does not exist or image URL is invalid"}); return; } } } // Verify request made within roughly one minute either direction const recent = Math.abs(localNow - remoteNow) < 5 * 60 * 1000; if (!recent) { console.error("NFT metadata upload request wasn't timely enough."); // we no longer error out on this. } const account = Web3Util.toChecksumAddress(req.header('X-NftStore-Account')); let query = "select token from account where id = $1 and not disabled limit 1"; let result = await client.query(query, [ account ]); if (result.rows.length == 0) { res.status(403).json({ error: "user not found" }); res.connection.end(); return; } const token = result.rows[0].token; // VERIFY HASH const message = `${now} ${account} ${token} /api/nft/upload ${req.body.storeAddress} ${req.body.metadata}`; const hash = Web3Util.keccak256(message); if (req.header('Authorization') != `Bearer ${hash}`) { console.error("Bad MAC."); console.error(`Expected hash of message:\n\t${message}`); console.error(`received bad authentication header hash:\n\t${req.header('Authorization')}\n\t${hash}`); res.status(403).json({ error: "bad MAC" }); res.connection.end(); return; } // make sure only the store creator can update metadata query = "select creator from store where address = $1 limit 1"; result = await client.query(query, [ req.body.storeAddress ]); if (result.rows.length == 0) { console.error("Store not found."); res.status(403).json({ error: "store not found" }); res.connection.end(); return; } else if (result.rows[0].creator != account) { console.error("Sender is not store creator"); res.status(403).json({ error: "address not creator of store" }); res.connection.end(); return; } // Set metadata.preview let previewFilename; try { if ('preview' in req.body) { // If preview provided in request body, just use that metadata.preview = req.body.preview; } else if (imageFileProvided) { // Generate preview image if image is >500kb const uploadFileSize = (await fs.promises.stat(req.file.path)).size; if (uploadFileSize > 512000) { console.log("Generating preview..."); previewFilename = await generatePreview(req.file.path); const fileBuffer = await fs.promises.readFile(previewFilename); console.log("Uploading preview image to IPFS..."); const previewIpfs = await addToIpfs(fileBuffer); console.log(`IPFS preview image: ${previewIpfs}`); metadata.preview = previewIpfs; } } } catch (e) { console.error("Error generating image preview, or error uploading image preview to IPFS", e); res.status(500).json({ error: "Error generating image preview"}); res.connection.end(); return; } // Set metadata.image try { if (imageFileProvided) { console.log("Uploading image to IPFS..."); const fileBuffer = await fs.promises.readFile(req.file.path); const imageIpfs = await addToIpfs(fileBuffer); console.log(`IPFS image: ${imageIpfs}`); metadata.image = imageIpfs; } else { // metadata.image already set- nothing to do. console.log("Using referenced image, not uploading anything."); } } catch (e) { console.error("Error uploading image to IPFS", e); res.status(500).json({ error: "Error uploading image to IPFS"}); res.connection.end(); return; } // Upload metadata to IPFS let metadataIpfs; try { console.log("Uploading metadata to IPFS..."); metadataIpfs = await addToIpfs(JSON.stringify(metadata)); console.log(`IPFS metadata: ${metadataIpfs}`); } catch (e) { console.error("Error uploading NFT metadata to IPFS", e); res.status(500).json({ error: "Error uploading NFT metadata to IPFS"}); res.connection.end(); } if (imageFileProvided) { console.log("Deleting temporary image file..."); try { fs.unlinkSync(req.file.path); if (previewFilename) { fs.unlinkSync(previewFilename); } } catch (e) { console.error(`Failed to delete temporary image file(s)`, e); } } const retval = { image: metadata.image, metadata: metadataIpfs } if ('preview' in metadata) { retval.preview = metadata.preview; } res.status(200).json(retval); }); function validateUsername(username) { let error = ''; if (username == null || username.length == 0) { error = 'Username required'; } else if (username.length < 3) { error = 'Username must be at least three characters'; } else if (username.length > 20) { error = 'Username may only be 20 characters'; } else if (!username.match(/^[a-z0-9_]+$/i)) { error = 'Username must be Latin alphanumeric/underscore'; } else if (username[0].match(/^[0-9_]+$/)) { error = 'Username must not start with a number or underscore'; } return error; } /** * @api {post} /api/user/login Log in as user * @apiName Log in as user * @apiDescription Retrieves information for current user. User must authenticate, so it's also used as a test of whether * the user can sign a message. Does not affect database. * @apiGroup User */ app.post("/api/user/login", upload.none(), async (req, res) => { const remoteNow = new Date(req.body.now).getTime(); const localNow = Date.now(); const recent = Math.abs(localNow - remoteNow) < 5 * 60 * 1000; if (!recent) { // we no longer error out on this. } let account; try { account = Web3Util.toChecksumAddress(req.body.account); } catch (e) { res.status(401).json({ error: 'Bad account address' }); return; } const signature = req.body.signature; const message = `${req.body.now} ${account} login`; const signatureValid = verifySignature(message, account, signature); if (signatureValid) { const user = await getUser(account, false); delete user.metadata; if (user.disabled) { res.status(401).json({ error: "disabled user" }); } else { res.status(200).json(user); } } else { res.status(401).json({ error: 'Bad signature' }); } }); /** * @api {get} /api/user/check/:username Check if username is available * @apiName Check if username is available * @apiDescription Checks if given username is in-use. Intended for use during user registration * @apiGroup User */ app.get("/api/user/check/:username", upload.none(), async (req, res) => { const checkUsername = req.params.username.toLowerCase().trim(); if (checkUsername.length > 40) { res.json({ error: "Username maximum 40 characters" }); } else { const result = await client.query( "select true from account where lower(username) = $1 limit 1", [checkUsername], (err, rows) => { if (err) { console.error(err); Sentry.captureException(err); res.status(500).json({ error: "Internal server error"}); } else { res.json({ taken: rows.rowCount == 1 }); } } ); } }); /** * @api {get} /api/user/register Register new user * @apiName Register new user * @apiDescription Registers a new user given a username. Assigns a token to the user as a random UUID, used for * later authentication. * @apiGroup User */ app.post("/api/user/register", upload.none(), async (req, res) => { const remoteNow = new Date(req.body.now).getTime(); const localNow = Date.now(); const recent = Math.abs(localNow - remoteNow) < 5 * 60 * 1000; if (!recent) { // we no longer error out on this. } let account; try { account = Web3Util.toChecksumAddress(req.body.account); } catch (e) { res.status(401).json({ error: 'Bad account address' }); return; } const username = req.body.username; const usernameValidation = validateUsername(username); if (usernameValidation != '') { res.status(401).json({ error: usernameValidation }); return; } const signature = req.body.signature; const message = `${req.body.now} ${account} ${username}`; const signatureValid = verifySignature(message, account, signature); if (signatureValid) { const token = uuid(); await client.query( "insert into account (id, inserted, username, token) values ($1, $2, $3, $4)", [ account, new Date(req.body.now), username, token] ); return res.status(200).json({ id: account, inserted: req.body.now, username: username, token: token }); } else { res.status(401).json({ error: "Signature validation failed" }); return; } }); // Gets a user given either the address or username async function getUser(username, cleansed = true) { let clause, val; if (username.startsWith('0x')) { clause = 'id'; try { val = Web3Util.toChecksumAddress(username); } catch (e) { return false; } } else { clause = 'lower(username)'; val = username.toLowerCase(); } const query = "select id, inserted, username, disabled, metadata, token, admin, about, verified from account where "+clause+" = $1 limit 1"; const result = await client.query(query, [ val ]); if (result.rows.length == 0) { return false; } else { const retval = { id: result.rows[0].id, inserted: result.rows[0].inserted, username: result.rows[0].username, metadata: result.rows[0].metadata, token: result.rows[0].token, admin: result.rows[0].admin, about: result.rows[0].about, verified: result.rows[0].verified }; if (result.rows[0].disabled) retval.disabled = true; if (cleansed == true) { delete retval.token; delete retval.metadata; } return retval; } } /** * @api {get} /api/user/:username Get user * @apiName Get user * @apiDescription Retrieves info for user given either the address or username. * * If a username is provided, the lookup is case-insensitive. * @apiGroup User */ app.get("/api/user/:username", async (req, res) => { const user = await getUser(req.params.username); res.status(200).json( user ); }); /** * @api {post} /api/user/:username Set user about field * @apiName Set user about field * @apiDescription Sets the About field for the current user (max 4096 characters) * @apiGroup User */ app.post("/api/user/:username", upload.none(), async (req, res) => { const authStatus = await authenticateRequest(req); if (authStatus.error === 'success') { if (authStatus.user.username !== req.params.username) { res.status(403).json({ error: "incorrect user" }); } else { const about = req.body.about; if (about.length > 4096) { res.status(400).json({ error: "about field must be at most 4096 characters" }); } else { try { const result = await client.query( "update account set about = $1 where id = $2", [ about, authStatus.user.id ] ); res.status(204).send(); } catch (e) { console.error(e); res.status(500).json({ error: "unknown error" }); } } } } else { res.status(500).json(authStatus); // FIXME handle different status codes } }); function escapeHtml(unsafe) { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } let cachedFeed = ''; let feedLastUpdatedTs = null; /** * @api {get} /api/feed Get RSS feed * @apiName Get RSS feed * @apiGroup Utility */ app.get("/api/feed", async (req, res) => { const QUERY = ` select e.id as event_id, a.username as username, a.id as account, case when e.name = 'TransferSingle' then 'Minted' when e.name in ('BuySingleNft', 'TokenBuySingleNft') then 'Bought' end as activity, e.block as block, e.name as event_name, e.return_values->>'price' as price, s.name as store_name, s.address as store_address, n.nft_id as nft_id, n.metadata->>'name' as nft_name, n.metadata->>'description' as nft_description, n.metadata->>'image' as nft_image from event as e left join store as s on e.address = s.address inner join nft as n on n.hidden = false and n.base = false and e.address = n.store and e.return_values->>'id' = n.nft_id::text and n.owner<>'0x0000000000000000000000000000000000000000' left join account a on a.id = e.return_values->>'to' where (e.name='TransferSingle' and e.return_values->>'from'='0x0000000000000000000000000000000000000000') or (e.name in ('BuySingleNft', 'TokenBuySingleNft')) and e.return_values->>'to'<>'0x0000000000000000000000000000000000000000' order by e.id desc limit 20 `; result = await client.query(QUERY); if (result.rowCount > 0) { const timestamp = (await eth.getBlock(result.rows[0].block)).timestamp * 1000; if (timestamp == feedLastUpdatedTs) { // hasn't changed res.setHeader('content-type', 'application/rss+xml'); res.send(cachedFeed); return; } else { feedLastUpdatedTs = timestamp; } const updated = new Date(timestamp); const feed = new Feed({ title: "Token Gallery", description: "Minting and selling of non-fungible tokens", id: "https://token.gallery/", link: "https://token.gallery/", language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes image: `${settings.baseUrl}/question.png`, copyright: "All rights reserved 2020, https://shitposter.club/users/Moon", updated, generator: "a computer", }); for (const r of result.rows) { const nftUrl = `${settings.baseUrl}/store/${r.store_address}/nft/${r.nft_id}`; const nftName = escapeHtml(r.nft_name); const nftDescription = r.nft_description; var title=''; if (r.activity=='Minted') title = `NFT "${r.nft_name}" Minted` else if (r.activity=='Bought') title = `NFT "${r.nft_name}" Sold`; feed.addItem({ title: title, id: `${nftUrl}#${r.event_id}`, link: nftUrl, description: nftDescription, // content: post.content, author: [ { name: escapeHtml(r.username), link: `${settings.baseUrl}/address/${r.account}` } ], date: new Date( (await eth.getBlock(r.block)).timestamp * 1000 ), image: r.image }); } cachedFeed = feed.rss2(); res.setHeader('content-type', 'application/rss+xml'); res.send(cachedFeed); } else { res.status(404).send("not found"); } }); // Must be AFTER controller stuff, BEFORE other error capturing middleware. app.use(Sentry.Handlers.errorHandler()); })