2349 lines
75 KiB
JavaScript
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, "&")
|
||
|
.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());
|
||
|
|
||
|
})
|
||
|
|