token-gallery-backend/index.js

2349 lines
75 KiB
JavaScript

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 = `<!doctype html>
<html>
<head prefix="og: https://ogp.me/ns#">
<title>${title}</title>
<meta property="og:title" content="${title}" />
<meta property="og:site_name" content="token gallery" />
<meta property="og:image" content="${image}" />
<meta property="og:description" content="${description}" />
<meta property="og:url" content="${url}" />
<meta name="theme-color" content="#5F2EEA">
<script>window.location='${url}';</script>
</head>
<body>
<h1>${title}</h1>
<p>
${description}
</p>
<div>
<img src="${image}" />
</div>
</body>
</html>
`
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<gallery.set.length; ++i) {
if (gallery.set[i].id != result2.rows[i].nft_id) {
console.error(`problem with row
${gallery.set[i].id}
${result2.rows[i].nft_id}`);
res.status(500).json({ error: "missing or out of order NFT"});
res.connection.end();
return;
}
gallery.set[i].nftId = result2.rows[i].nft_id;
gallery.set[i].owner = result2.rows[i].owner;
gallery.set[i].metadata = result2.rows[i].metadata;
gallery.set[i].metadataUri = result2.rows[i].metadata_uri;
gallery.set[i].hidden = result2.rows[i].hidden;
gallery.set[i].metaId = result2.rows[i].meta_id;
gallery.set[i].ethPrice = result2.rows[i].eth_price;
gallery.set[i].ethForSale = result2.rows[i].eth_for_sale;
gallery.set[i].tokenPrice = result2.rows[i].token_price;
gallery.set[i].tokenForSale = result2.rows[i].token_for_sale;
}
}
res.status(200).json(gallery);
res.connection.end();
return;
}
else {
res.status(404).json({});
res.connection.end();
return;
}
});
app.post("/api/account/:address/gallery", upload.none(), async (req, res) => {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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());
})