diff --git a/HttpError.js b/HttpError.js new file mode 100644 index 0000000..c60ffe7 --- /dev/null +++ b/HttpError.js @@ -0,0 +1,6 @@ +module.exports = class extends Error { + constructor(message, code) { + super(message); + this.code = code; + } +}; diff --git a/contracts.js b/contracts.js new file mode 100644 index 0000000..8a58c22 --- /dev/null +++ b/contracts.js @@ -0,0 +1,28 @@ +const { JsonRpcProvider, Contract } = require('ethers'); +const Whitelist = require('@synthetixio/synthetix-node-namespace/deployments/11155420/Whitelist'); +const Namespace = require('@synthetixio/synthetix-node-namespace/deployments/11155420/Namespace'); + +class EthereumContractError extends Error { + constructor(message, originalError) { + super(message); + this.name = 'EthereumContractError'; + this.originalError = originalError; + } +} + +const getContract = (address, abi) => { + try { + const provider = new JsonRpcProvider('https://sepolia.optimism.io'); + return new Contract(address, abi, provider); + } catch (err) { + throw new EthereumContractError('Failed to get contract', err); + } +}; + +const getNamespaceContract = () => getContract(Namespace.address, Namespace.abi); +const getWhitelistContract = () => getContract(Whitelist.address, Whitelist.abi); + +module.exports = { + getNamespaceContract, + getWhitelistContract, +}; diff --git a/createJwtApiToken.js b/createJwtApiToken.js new file mode 100644 index 0000000..dbbe7a1 --- /dev/null +++ b/createJwtApiToken.js @@ -0,0 +1,12 @@ +const jwt = require('jsonwebtoken'); + +const createJwtApiToken = async (walletAddress) => { + return new Promise((resolve, reject) => { + jwt.sign({ walletAddress }, process.env.JWT_SECRET_KEY, {}, (err, token) => { + if (err) reject(err); + resolve(token); + }); + }); +}; + +module.exports = createJwtApiToken; diff --git a/generateHash.js b/generateHash.js new file mode 100644 index 0000000..14c3539 --- /dev/null +++ b/generateHash.js @@ -0,0 +1,4 @@ +const crypto = require('node:crypto'); + +module.exports = (data) => + crypto.createHash('sha256').update(`${data}:${process.env.SECRET}`).digest('hex'); diff --git a/gundb.js b/gundb.js new file mode 100644 index 0000000..49bca87 --- /dev/null +++ b/gundb.js @@ -0,0 +1,26 @@ +const Gun = require('gun'); + +let gunInstance; + +const encrypt = async (data) => await Gun.SEA.encrypt(data, process.env.SECRET); +const decrypt = async (data) => await Gun.SEA.decrypt(data, process.env.SECRET); + +const initGun = (server) => { + if (!server) { + throw new Error('Server instance is required to initialize Gun'); + } + if (!gunInstance) { + gunInstance = Gun({ web: server, file: process.env.GUNDB_STORAGE_PATH }); + console.log('GunDB successfully initialized!'); + } + return gunInstance; +}; + +const getGun = () => { + if (!gunInstance) { + throw new Error('Gun is not initialized. Call initGun(server) first.'); + } + return gunInstance; +}; + +module.exports = { initGun, getGun, encrypt, decrypt }; diff --git a/removeMetaData.js b/removeMetaData.js new file mode 100644 index 0000000..09651f5 --- /dev/null +++ b/removeMetaData.js @@ -0,0 +1,4 @@ +module.exports = (o) => { + const { _, ...withoutMeta } = o; + return withoutMeta; +}; diff --git a/routeApiCheckApiToken.js b/routeApiCheckApiToken.js new file mode 100644 index 0000000..dec0c28 --- /dev/null +++ b/routeApiCheckApiToken.js @@ -0,0 +1,19 @@ +const checkApiTokenWithGun = (walletAddress) => { + return new Promise((resolve) => { + require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('api-tokens') + .once((tokenData) => { + resolve(!!tokenData); + }); + }); +}; + +module.exports = async (req, res, next) => { + try { + res.status(200).json({ apiTokenGenerated: await checkApiTokenWithGun(req.user.walletAddress) }); + } catch (err) { + next(err); + } +}; diff --git a/routeApiCids.js b/routeApiCids.js new file mode 100644 index 0000000..0471ef1 --- /dev/null +++ b/routeApiCids.js @@ -0,0 +1,34 @@ +const getCidsFromGeneratedKey = ({ walletAddress, key }) => { + return new Promise((resolve) => { + require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('generated-keys') + .get(key) + .get('cids') + .once((node) => { + if (!node) { + return resolve([]); + } + + const cids = Object.entries(require('removeMetaData')(node)) + .filter(([_, value]) => value !== null) + .map(([key]) => key); + + resolve(cids); + }); + }); +}; + +module.exports = async (req, res, next) => { + try { + res.status(200).json({ + cids: await getCidsFromGeneratedKey({ + walletAddress: req.user.walletAddress, + key: req.query.key, + }), + }); + } catch (err) { + next(err); + } +}; diff --git a/routeApiRegenerateApiToken.js b/routeApiRegenerateApiToken.js new file mode 100644 index 0000000..51c5b91 --- /dev/null +++ b/routeApiRegenerateApiToken.js @@ -0,0 +1,12 @@ +const { encrypt } = require('./gundb'); + +module.exports = async (req, res, next) => { + try { + const newApiToken = await require('./createJwtApiToken')(req.user.walletAddress); + const encryptedNewApiToken = await encrypt(require('./generateHash')(newApiToken)); + await require('./saveApiTokenToGun')(req.user.walletAddress, encryptedNewApiToken); + res.status(200).send({ apiToken: newApiToken }); + } catch (err) { + next(err); + } +}; diff --git a/routeApiRemoveCid.js b/routeApiRemoveCid.js new file mode 100644 index 0000000..6064728 --- /dev/null +++ b/routeApiRemoveCid.js @@ -0,0 +1,44 @@ +const HttpError = require('./HttpError'); + +const deleteCidFromGeneratedKey = ({ walletAddress, key, cid }) => { + return new Promise((resolve) => { + require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('generated-keys') + .get(key) + .get('cids') + .get(cid) + .put(null, resolve); + }); +}; + +module.exports = async (req, res, next) => { + try { + const { cid, key } = req.body; + + if (!cid) { + return next(new HttpError('CID missed.', 400)); + } + + const response = await fetch( + `${require('./env').UPSTREAM_IPFS_CLUSTER_URL}/api/v0/pin/rm?arg=${cid}`, + { + method: 'POST', + } + ); + if (!response.ok) { + throw new HttpError(`Failed to remove CID ${cid} from IPFS`); + } + + await deleteCidFromGeneratedKey({ + walletAddress: req.user.walletAddress, + key, + cid, + }); + + res.status(200).json({ success: true, cid }); + } catch (err) { + next(err); + } +}; diff --git a/routeApiScreenshot.js b/routeApiScreenshot.js new file mode 100644 index 0000000..31dcb8a --- /dev/null +++ b/routeApiScreenshot.js @@ -0,0 +1,38 @@ +const HttpError = require('./HttpError'); +const puppeteer = require('puppeteer'); + +module.exports = async (req, res, next) => { + const { url } = req.query; + + if (!url) { + return next(new HttpError('URL parameter is required', 400)); + } + + const urlPattern = /^(https?:\/\/[a-zA-Z0-9.-]+(:\d+)?\/ipns\/[a-zA-Z0-9\/_-]+)$/; + if (!urlPattern.test(url)) { + return next(new HttpError('Invalid url', 400)); + } + + let browser; + try { + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }); + const page = await browser.newPage(); + await page.setViewport({ width: 800, height: 600 }); + await page.goto(url, { waitUntil: 'networkidle2', timeout: 120000 }); + const screenshotBase64 = await page.screenshot({ + encoding: 'base64', + }); + await browser.close(); + + res.json({ image: `data:image/png;base64,${screenshotBase64}` }); + } catch { + next(new HttpError('Failed to generate screenshot', 500)); + } finally { + if (browser) { + await browser.close(); + } + } +}; diff --git a/routeApiUniqueGeneratedKey.js b/routeApiUniqueGeneratedKey.js new file mode 100644 index 0000000..ee8de77 --- /dev/null +++ b/routeApiUniqueGeneratedKey.js @@ -0,0 +1,20 @@ +const checkGeneratedKey = async ({ walletAddress, key }) => { + return require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('generated-keys') + .get(key); +}; + +module.exports = async (req, res, next) => { + try { + const unique = await checkGeneratedKey({ + walletAddress: req.user.walletAddress, + key: req.body.key, + }); + + res.status(200).json({ unique: !unique }); + } catch (err) { + next(err); + } +}; diff --git a/routeApiUniqueNamespace.js b/routeApiUniqueNamespace.js new file mode 100644 index 0000000..190266e --- /dev/null +++ b/routeApiUniqueNamespace.js @@ -0,0 +1,22 @@ +module.exports = async (req, res, next) => { + try { + const NamespaceContract = require('./contracts').getNamespaceContract(); + + const tokenId = await NamespaceContract.namespaceToTokenId(req.body.namespace); + if (!tokenId) { + res.status(200).json({ unique: true }); + return; + } + const owner = await NamespaceContract.ownerOf(tokenId); + if (owner.toLowerCase() === req.user.walletAddress.toLowerCase()) { + res.status(200).json({ unique: true }); + return; + } + if (owner.toLowerCase() !== req.user.walletAddress.toLowerCase()) { + res.status(200).json({ unique: false }); + return; + } + } catch (err) { + next(err); + } +}; diff --git a/routeApiV0KeyGen.js b/routeApiV0KeyGen.js new file mode 100644 index 0000000..b037703 --- /dev/null +++ b/routeApiV0KeyGen.js @@ -0,0 +1,47 @@ +const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware'); +const HttpError = require('./HttpError'); + +const saveGeneratedKey = ({ walletAddress, key, id }) => { + return new Promise((resolve, reject) => { + require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('generated-keys') + .get(key) + .put({ key, id, published: false }, (ack) => { + if (ack.err) { + reject(new HttpError(`Failed to save ipns keys to Gun: ${ack.err}`)); + } else { + resolve(); + } + }); + }); +}; + +module.exports = createProxyMiddleware({ + target: `${require('./env').UPSTREAM_IPFS_URL}/api/v0/key/gen`, + pathRewrite: { + '^/': '', + }, + selfHandleResponse: true, + on: { + proxyReq: function onProxyReq(proxyReq) { + proxyReq.removeHeader('authorization'); + }, + proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { + try { + res.removeHeader('trailer'); + if (proxyRes.statusCode < 400) { + await saveGeneratedKey({ + walletAddress: req.user.walletAddress, + key: req.query.arg, + id: JSON.parse(responseBuffer.toString('utf8')).Id, + }); + } + } catch (err) { + console.error('Error saving to Gun:', err.message); + } + return responseBuffer; + }), + }, +}); diff --git a/routeApiV0KeyRm.js b/routeApiV0KeyRm.js new file mode 100644 index 0000000..10dc647 --- /dev/null +++ b/routeApiV0KeyRm.js @@ -0,0 +1,47 @@ +const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware'); +const HttpError = require('./HttpError'); + +const deleteGeneratedKey = ({ walletAddress, key }) => { + return new Promise((resolve, reject) => { + require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('generated-keys') + .get(key) + .put(null, (ack) => { + if (ack.err) { + reject(new HttpError(`Failed to delete key: ${ack.err}`)); + } else { + resolve(); + } + }); + }); +}; + +module.exports = createProxyMiddleware({ + target: `${require('./env').UPSTREAM_IPFS_URL}/api/v0/key/rm`, + pathRewrite: { + '^/': '', + }, + selfHandleResponse: true, + on: { + proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { + try { + res.removeHeader('trailer'); + if (proxyRes.statusCode < 400) { + await Promise.all( + JSON.parse(responseBuffer.toString('utf8')).Keys.map((k) => + deleteGeneratedKey({ + walletAddress: req.user.walletAddress, + key: k.Name, + }) + ) + ); + } + } catch (err) { + console.error('Error processing proxy response:', err); + } + return responseBuffer; + }), + }, +}); diff --git a/routeApiV0NamePublish.js b/routeApiV0NamePublish.js new file mode 100644 index 0000000..a91780f --- /dev/null +++ b/routeApiV0NamePublish.js @@ -0,0 +1,47 @@ +const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware'); +const HttpError = require('./HttpError'); + +const updateGeneratedKey = ({ walletAddress, key, updates }) => { + return new Promise((resolve, reject) => { + require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('generated-keys') + .get(key) + .put(updates, (ack) => { + if (ack.err) { + reject(new HttpError(`Failed to update key: ${ack.err}`)); + } else { + resolve(); + } + }); + }); +}; + +module.exports = createProxyMiddleware({ + target: `${require('./env').UPSTREAM_IPFS_URL}/api/v0/name/publish`, + pathRewrite: { + '^/': '', + }, + selfHandleResponse: true, + on: { + proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { + try { + res.removeHeader('trailer'); + if (proxyRes.statusCode < 400) { + await updateGeneratedKey({ + walletAddress: req.user.walletAddress, + key: req.query.key, + updates: { + ipfs: JSON.parse(responseBuffer.toString('utf8')).Value, + published: true, + }, + }); + } + } catch (err) { + console.error('Error saving to Gun:', err.message); + } + return responseBuffer; + }), + }, +}); diff --git a/routeApiV0PinAdd.js b/routeApiV0PinAdd.js new file mode 100644 index 0000000..5c16cd9 --- /dev/null +++ b/routeApiV0PinAdd.js @@ -0,0 +1,41 @@ +const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware'); + +const addCidToGeneratedKey = ({ walletAddress, key, cid }) => { + return new Promise((resolve) => { + require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('generated-keys') + .get(key) + .get('cids') + .get(cid) + .put({ cid }, resolve); + }); +}; + +module.exports = createProxyMiddleware({ + target: `${require('./env').UPSTREAM_IPFS_CLUSTER_URL}/api/v0/pin/add`, + pathRewrite: { + '^/': '', + }, + selfHandleResponse: true, + on: { + proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { + try { + res.removeHeader('trailer'); + if (proxyRes.statusCode < 400) { + if (req.query.arg && req.query.customKey) { + await addCidToGeneratedKey({ + walletAddress: req.user.walletAddress, + key: req.query.customKey, + cid: req.query.arg, + }); + } + } + } catch (e) { + console.error(e); + } + return responseBuffer; + }), + }, +}); diff --git a/routeApiVerify.js b/routeApiVerify.js new file mode 100644 index 0000000..2103e23 --- /dev/null +++ b/routeApiVerify.js @@ -0,0 +1,38 @@ +const jwt = require('jsonwebtoken'); +const { getGun, encrypt } = require('./gundb'); +const HttpError = require('./HttpError'); + +const createJwtToken = async (walletAddress) => { + return new Promise((resolve, reject) => { + jwt.sign({ walletAddress }, process.env.JWT_SECRET_KEY, { expiresIn: '1d' }, (err, token) => { + if (err) reject(err); + resolve(token); + }); + }); +}; + +const saveTokenToGun = (walletAddress, encryptedToken) => { + return new Promise((resolve, reject) => { + getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('tokens') + .put(encryptedToken, (ack) => { + if (ack.err) { + reject(new HttpError('Failed to save token to Gun')); + } else { + resolve(); + } + }); + }); +}; + +module.exports = async (_req, res, next) => { + try { + const token = await createJwtToken(res.locals.address); + const encryptedToken = await encrypt(require('./generateHash')(token)); + await saveTokenToGun(res.locals.address, encryptedToken); + res.status(200).send({ token }); + } catch (err) { + next(err); + } +}; diff --git a/routeApiVerifyApiToken.js b/routeApiVerifyApiToken.js new file mode 100644 index 0000000..31289bd --- /dev/null +++ b/routeApiVerifyApiToken.js @@ -0,0 +1,12 @@ +const { encrypt } = require('./gundb'); + +module.exports = async (_req, res, next) => { + try { + const apiToken = await require('./createJwtApiToken')(res.locals.address); + const encryptedApiToken = await encrypt(require('./generateHash')(apiToken)); + await require('./saveApiTokenToGun')(res.locals.address, encryptedApiToken); + res.status(200).send({ apiToken }); + } catch (err) { + next(err); + } +}; diff --git a/saveApiTokenToGun.js b/saveApiTokenToGun.js new file mode 100644 index 0000000..87ce126 --- /dev/null +++ b/saveApiTokenToGun.js @@ -0,0 +1,19 @@ +const HttpError = require('./HttpError'); + +const saveApiTokenToGun = (walletAddress, encryptedToken) => { + return new Promise((resolve, reject) => { + require('./gundb') + .getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('api-tokens') + .put(encryptedToken, (ack) => { + if (ack.err) { + reject(new HttpError('Failed to save api token to Gun')); + } else { + resolve(); + } + }); + }); +}; + +module.exports = saveApiTokenToGun; diff --git a/server.js b/server.js index e71c302..9d7e43f 100644 --- a/server.js +++ b/server.js @@ -1,23 +1,23 @@ const express = require('express'); -const { ethers, JsonRpcProvider, Contract } = require('ethers'); +const { ethers } = require('ethers'); const crypto = require('node:crypto'); const cors = require('cors'); -const puppeteer = require('puppeteer'); -const Whitelist = require('@synthetixio/synthetix-node-namespace/deployments/11155420/Whitelist'); -const Namespace = require('@synthetixio/synthetix-node-namespace/deployments/11155420/Namespace'); +const { getNamespaceContract, getWhitelistContract } = require('./contracts'); +const { getGun, decrypt } = require('./gundb'); //const Multicall3 = require('./Multicall3/11155420/Multicall3'); -const Gun = require('gun'); +const HttpError = require('./HttpError'); +const validateNamespace = require('./validateNamespace'); const jwt = require('jsonwebtoken'); +const { initGun } = require('./gundb'); const app = express(); require('dotenv').config(); -const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware'); +const { createProxyMiddleware } = require('http-proxy-middleware'); const basicAuth = require('basic-auth'); const { // PORT, UPSTREAM_IPFS_URL, - UPSTREAM_IPFS_CLUSTER_URL, GRAPH_API_ENDPOINT, } = require('./env'); @@ -32,38 +32,7 @@ setInterval(require('./updatePeers'), 60_000); setInterval(require('./updateStats'), 60_000); setInterval(require('./peersTracking'), 10_000); -class HttpError extends Error { - constructor(message, code) { - super(message); - this.code = code; - } -} - -class EthereumContractError extends Error { - constructor(message, originalError) { - super(message); - this.name = 'EthereumContractError'; - this.originalError = originalError; - } -} - -const generateHash = (data) => - crypto.createHash('sha256').update(`${data}:${process.env.SECRET}`).digest('hex'); - -const encrypt = async (data) => await Gun.SEA.encrypt(data, process.env.SECRET); -const decrypt = async (data) => await Gun.SEA.decrypt(data, process.env.SECRET); - -const getContract = (address, abi) => { - try { - const provider = new JsonRpcProvider('https://sepolia.optimism.io'); - return new Contract(address, abi, provider); - } catch (err) { - throw new EthereumContractError('Failed to get contract', err); - } -}; //const getMulticall3Contract = () => getContract(Multicall3.address, Multicall3.abi); -const getNamespaceContract = () => getContract(Namespace.address, Namespace.abi); -const getWhitelistContract = () => getContract(Whitelist.address, Whitelist.abi); const validateWalletAddress = (req, _res, next) => { if (!req.body.walletAddress) { @@ -102,7 +71,7 @@ const server = app.listen(PORT, () => { require('./updateStats')(); require('./peersTracking')(); }); -const gun = Gun({ web: server, file: process.env.GUNDB_STORAGE_PATH }); +initGun(server); const validateVerificationParameters = (req, _res, next) => { if (!req.body.nonce) { @@ -133,40 +102,14 @@ const verifyMessage = async (req, res, next) => { next(); }; -const createJwtToken = async (walletAddress) => { - return new Promise((resolve, reject) => { - jwt.sign({ walletAddress }, process.env.JWT_SECRET_KEY, {}, (err, token) => { - if (err) reject(err); - resolve(token); - }); - }); -}; - -const saveTokenToGun = (walletAddress, encryptedToken) => { - return new Promise((resolve, reject) => { - gun - .get(generateHash(walletAddress.toLowerCase())) - .get('tokens') - .put(encryptedToken, (ack) => { - if (ack.err) { - reject(new HttpError('Failed to save token to Gun')); - } else { - resolve(); - } - }); - }); -}; +app.post('/api/verify', validateVerificationParameters, verifyMessage, require('./routeApiVerify')); -app.post('/api/verify', validateVerificationParameters, verifyMessage, async (_req, res, next) => { - try { - const token = await createJwtToken(res.locals.address); - const encryptedToken = await encrypt(generateHash(token)); - await saveTokenToGun(res.locals.address, encryptedToken); - res.status(200).send({ token }); - } catch (err) { - next(err); - } -}); +app.post( + '/api/verify-api-token', + validateVerificationParameters, + verifyMessage, + require('./routeApiVerifyApiToken') +); app.use( '/api/v0/cat', @@ -213,11 +156,26 @@ const verifyToken = (req) => { const validateTokenWithGun = (walletAddress, token) => { return new Promise((resolve, reject) => { - gun - .get(generateHash(walletAddress.toLowerCase())) + getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) .get('tokens') .once(async (tokenData) => { - if (!tokenData || (await decrypt(tokenData)) !== generateHash(token)) { + if (!tokenData || (await decrypt(tokenData)) !== require('./generateHash')(token)) { + reject(new HttpError('Unauthorized', 401)); + } else { + resolve(); + } + }); + }); +}; + +const validateApiTokenWithGun = (walletAddress, token) => { + return new Promise((resolve, reject) => { + getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('api-tokens') + .once(async (tokenData) => { + if (!tokenData || (await decrypt(tokenData)) !== require('./generateHash')(token)) { reject(new HttpError('Unauthorized', 401)); } else { resolve(); @@ -234,7 +192,11 @@ const authenticateToken = async (req, _res, next) => { throw new HttpError('Unauthorized', 401); } const token = req.headers.authorization.split(' ')[1]; - await validateTokenWithGun(decoded.walletAddress, token); + if (req.query.api) { + await validateApiTokenWithGun(decoded.walletAddress, token); + } else { + await validateTokenWithGun(decoded.walletAddress, token); + } req.user = decoded; next(); } catch (err) { @@ -266,38 +228,6 @@ const verifyKeyGenNamespace = async (req, _res, next) => { } }; -const validateNamespace = (namespace, next) => { - const errors = []; - - if (!namespace) { - errors.push('Namespace cannot be empty.'); - } - - if (namespace && (namespace.length < 3 || namespace.length > 30)) { - errors.push('Namespace must be between 3 and 30 characters long.'); - } - - if (namespace && !/^[a-z0-9-_]+$/.test(namespace)) { - errors.push( - 'Namespace must be DNS-compatible: lowercase letters, numbers, dashes (-), or underscores (_).' - ); - } - - if (namespace && /^-|-$/.test(namespace)) { - errors.push('Namespace cannot start or end with a dash (-).'); - } - - if (namespace && /^_|_$/.test(namespace)) { - errors.push('Namespace cannot start or end with an underscore (_).'); - } - - if (errors.length > 0) { - return next(new HttpError(errors.join(' '), 400)); - } - - next(); -}; - const verifyNamePublishNamespace = async (req, _res, next) => { try { await validateNamespaceOwnership(req.query.key, req.user.walletAddress); @@ -311,152 +241,25 @@ app.get('/api/protected', authenticateToken, (_req, res) => { res.send('Hello! You are viewing protected content.'); }); -const saveGeneratedKey = ({ walletAddress, key, id }) => { - return new Promise((resolve, reject) => { - gun - .get(generateHash(walletAddress.toLowerCase())) - .get('generated-keys') - .get(key) - .put({ key, id, published: false }, (ack) => { - if (ack.err) { - reject(new HttpError(`Failed to save ipns keys to Gun: ${ack.err}`)); - } else { - resolve(); - } - }); - }); -}; +app.post('/api/generate-api-nonce', authenticateToken, createNonce); + +app.get('/api/check-api-token', authenticateToken, require('./routeApiCheckApiToken')); app.use( '/api/v0/key/gen', authenticateToken, (req, _res, next) => validateNamespace(req.query.arg, next), verifyKeyGenNamespace, - createProxyMiddleware({ - target: `${UPSTREAM_IPFS_URL}/api/v0/key/gen`, - pathRewrite: { - '^/': '', - }, - selfHandleResponse: true, - on: { - proxyReq: function onProxyReq(proxyReq) { - proxyReq.removeHeader('authorization'); - }, - proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { - try { - res.removeHeader('trailer'); - if (proxyRes.statusCode < 400) { - await saveGeneratedKey({ - walletAddress: req.user.walletAddress, - key: req.query.arg, - id: JSON.parse(responseBuffer.toString('utf8')).Id, - }); - } - } catch (err) { - console.error('Error saving to Gun:', err.message); - } - return responseBuffer; - }), - }, - }) + require('./routeApiV0KeyGen') ); -const deleteGeneratedKey = ({ walletAddress, key }) => { - return new Promise((resolve, reject) => { - gun - .get(generateHash(walletAddress.toLowerCase())) - .get('generated-keys') - .get(key) - .put(null, (ack) => { - if (ack.err) { - reject(new HttpError(`Failed to delete key: ${ack.err}`)); - } else { - resolve(); - } - }); - }); -}; - -app.use( - '/api/v0/key/rm', - authenticateToken, - verifyKeyGenNamespace, - createProxyMiddleware({ - target: `${UPSTREAM_IPFS_URL}/api/v0/key/rm`, - pathRewrite: { - '^/': '', - }, - selfHandleResponse: true, - on: { - proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { - try { - res.removeHeader('trailer'); - if (proxyRes.statusCode < 400) { - await Promise.all( - JSON.parse(responseBuffer.toString('utf8')).Keys.map((k) => - deleteGeneratedKey({ - walletAddress: req.user.walletAddress, - key: k.Name, - }) - ) - ); - } - } catch (err) { - console.error('Error processing proxy response:', err); - } - return responseBuffer; - }), - }, - }) -); - -const updateGeneratedKey = ({ walletAddress, key, updates }) => { - return new Promise((resolve, reject) => { - gun - .get(generateHash(walletAddress.toLowerCase())) - .get('generated-keys') - .get(key) - .put(updates, (ack) => { - if (ack.err) { - reject(new HttpError(`Failed to update key: ${ack.err}`)); - } else { - resolve(); - } - }); - }); -}; +app.use('/api/v0/key/rm', authenticateToken, verifyKeyGenNamespace, require('./routeApiV0KeyRm')); app.use( '/api/v0/name/publish', authenticateToken, verifyNamePublishNamespace, - createProxyMiddleware({ - target: `${UPSTREAM_IPFS_URL}/api/v0/name/publish`, - pathRewrite: { - '^/': '', - }, - selfHandleResponse: true, - on: { - proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { - try { - res.removeHeader('trailer'); - if (proxyRes.statusCode < 400) { - await updateGeneratedKey({ - walletAddress: req.user.walletAddress, - key: req.query.key, - updates: { - ipfs: JSON.parse(responseBuffer.toString('utf8')).Value, - published: true, - }, - }); - } - } catch (err) { - console.error('Error saving to Gun:', err.message); - } - return responseBuffer; - }), - }, - }) + require('./routeApiV0NamePublish') ); const authenticateAdmin = async (req, _res, next) => { @@ -530,18 +333,7 @@ app.get('/api/submitted-wallets', authenticateAdmin, async (_req, res, next) => } }); -app.post('/api/refresh-token', validateWalletAddress, authenticateToken, async (req, res, next) => { - try { - const token = req.headers.authorization.split(' ')[1]; - await validateTokenWithGun(req.body.walletAddress, token); - const newToken = await createJwtToken(req.body.walletAddress); - const encryptedNewToken = await encrypt(generateHash(newToken)); - await saveTokenToGun(req.body.walletAddress, encryptedNewToken); - res.status(200).send({ token: newToken }); - } catch (err) { - next(err); - } -}); +app.post('/api/regenerate-api-token', authenticateToken, require('./routeApiRegenerateApiToken')); //const getNamespacesFromContract = async (walletAddress) => { // const NamespaceContract = getNamespaceContract(); @@ -619,64 +411,25 @@ app.post( '/api/unique-namespace', authenticateToken, (req, _res, next) => validateNamespace(req.body.namespace, next), - async (req, res, next) => { - try { - const NamespaceContract = getNamespaceContract(); - - const tokenId = await NamespaceContract.namespaceToTokenId(req.body.namespace); - if (!tokenId) { - res.status(200).json({ unique: true }); - return; - } - const owner = await NamespaceContract.ownerOf(tokenId); - if (owner.toLowerCase() === req.user.walletAddress.toLowerCase()) { - res.status(200).json({ unique: true }); - return; - } - if (owner.toLowerCase() !== req.user.walletAddress.toLowerCase()) { - res.status(200).json({ unique: false }); - return; - } - } catch (err) { - next(err); - } - } + require('./routeApiUniqueNamespace') ); -const checkGeneratedKey = async ({ walletAddress, key }) => { - return gun.get(generateHash(walletAddress.toLowerCase())).get('generated-keys').get(key); -}; - -app.post('/api/unique-generated-key', authenticateToken, async (req, res, next) => { - try { - const unique = await checkGeneratedKey({ - walletAddress: req.user.walletAddress, - key: req.body.key, - }); - - res.status(200).json({ unique: !unique }); - } catch (err) { - next(err); - } -}); - -const removeMetaData = (o) => { - const { _, ...withoutMeta } = o; - return withoutMeta; -}; +app.post('/api/unique-generated-key', authenticateToken, require('./routeApiUniqueGeneratedKey')); const getGeneratedKey = async (walletAddress) => { - const data = await gun.get(generateHash(walletAddress.toLowerCase())).get('generated-keys'); + const data = await getGun() + .get(require('./generateHash')(walletAddress.toLowerCase())) + .get('generated-keys'); if (!data) { return []; } return Promise.all( - Object.values(removeMetaData(data)) + Object.values(require('./removeMetaData')(data)) .filter((ref) => ref) .map(async (ref) => { - return removeMetaData(await gun.get(ref)); + return require('./removeMetaData')(await getGun().get(ref)); }) ); }; @@ -701,18 +454,7 @@ const verifyCidNamespace = async (req, _res, next) => { } }; -app.get('/api/cids', authenticateToken, verifyCidNamespace, async (req, res, next) => { - try { - res.status(200).json({ - cids: await getCidsFromGeneratedKey({ - walletAddress: req.user.walletAddress, - key: req.query.key, - }), - }); - } catch (err) { - next(err); - } -}); +app.get('/api/cids', authenticateToken, verifyCidNamespace, require('./routeApiCids')); const verifyRemoveCidNamespace = async (req, _res, next) => { try { @@ -726,77 +468,12 @@ const verifyRemoveCidNamespace = async (req, _res, next) => { } }; -app.post('/api/remove-cid', authenticateToken, verifyRemoveCidNamespace, async (req, res, next) => { - try { - const { cid, key } = req.body; - - if (!cid) { - return next(new HttpError('CID missed.', 400)); - } - - const response = await fetch(`${UPSTREAM_IPFS_CLUSTER_URL}/api/v0/pin/rm?arg=${cid}`, { - method: 'POST', - }); - if (!response.ok) { - throw new HttpError(`Failed to remove CID ${cid} from IPFS`); - } - - await deleteCidFromGeneratedKey({ - walletAddress: req.user.walletAddress, - key, - cid, - }); - - res.status(200).json({ success: true, cid }); - } catch (err) { - next(err); - } -}); - -const addCidToGeneratedKey = ({ walletAddress, key, cid }) => { - return new Promise((resolve) => { - gun - .get(generateHash(walletAddress.toLowerCase())) - .get('generated-keys') - .get(key) - .get('cids') - .get(cid) - .put({ cid }, resolve); - }); -}; - -const getCidsFromGeneratedKey = ({ walletAddress, key }) => { - return new Promise((resolve) => { - gun - .get(generateHash(walletAddress.toLowerCase())) - .get('generated-keys') - .get(key) - .get('cids') - .once((node) => { - if (!node) { - return resolve([]); - } - - const cids = Object.entries(removeMetaData(node)) - .filter(([_, value]) => value !== null) - .map(([key]) => key); - - resolve(cids); - }); - }); -}; - -const deleteCidFromGeneratedKey = ({ walletAddress, key, cid }) => { - return new Promise((resolve) => { - gun - .get(generateHash(walletAddress.toLowerCase())) - .get('generated-keys') - .get(key) - .get('cids') - .get(cid) - .put(null, resolve); - }); -}; +app.post( + '/api/remove-cid', + authenticateToken, + verifyRemoveCidNamespace, + require('./routeApiRemoveCid') +); app.use( '/api/v0/dag/import', @@ -806,33 +483,11 @@ app.use( pathRewrite: { '^/': '', }, - selfHandleResponse: true, - on: { - proxyRes: responseInterceptor(async (responseBuffer, _proxyRes, req, res) => { - try { - res.removeHeader('trailer'); - if (_proxyRes.statusCode < 400) { - const cid = JSON.parse(responseBuffer.toString('utf8')).Root?.Cid['/']; - if (cid) { - await fetch(`${UPSTREAM_IPFS_CLUSTER_URL}/api/v0/pin/add?arg=${cid}`, { - method: 'POST', - }); - await addCidToGeneratedKey({ - walletAddress: req.user.walletAddress, - key: req.query.key, - cid, - }); - } - } - } catch (e) { - console.error(e); - } - return responseBuffer; - }), - }, }) ); +app.use('/api/v0/pin/add', authenticateToken, require('./routeApiV0PinAdd')); + app.use( '/api/v0/dag/get', authenticateToken, @@ -844,41 +499,7 @@ app.use( }) ); -app.get('/api/screenshot', authenticateToken, async (req, res, next) => { - const { url } = req.query; - - if (!url) { - return next(new HttpError('URL parameter is required', 400)); - } - - const urlPattern = /^(https?:\/\/[a-zA-Z0-9.-]+(:\d+)?\/ipns\/[a-zA-Z0-9\/_-]+)$/; - if (!urlPattern.test(url)) { - return next(new HttpError('Invalid url', 400)); - } - - let browser; - try { - browser = await puppeteer.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - }); - const page = await browser.newPage(); - await page.setViewport({ width: 800, height: 600 }); - await page.goto(url, { waitUntil: 'networkidle2', timeout: 120000 }); - const screenshotBase64 = await page.screenshot({ - encoding: 'base64', - }); - await browser.close(); - - res.json({ image: `data:image/png;base64,${screenshotBase64}` }); - } catch { - next(new HttpError('Failed to generate screenshot', 500)); - } finally { - if (browser) { - await browser.close(); - } - } -}); +app.get('/api/screenshot', authenticateToken, require('./routeApiScreenshot')); app.use((err, _req, res, _next) => { const status = err.code || 500; diff --git a/validateNamespace.js b/validateNamespace.js new file mode 100644 index 0000000..0e5027f --- /dev/null +++ b/validateNamespace.js @@ -0,0 +1,35 @@ +const HttpError = require('./HttpError'); + +const validateNamespace = (namespace, next) => { + const errors = []; + + if (!namespace) { + errors.push('Namespace cannot be empty.'); + } + + if (namespace && (namespace.length < 3 || namespace.length > 30)) { + errors.push('Namespace must be between 3 and 30 characters long.'); + } + + if (namespace && !/^[a-z0-9-_]+$/.test(namespace)) { + errors.push( + 'Namespace must be DNS-compatible: lowercase letters, numbers, dashes (-), or underscores (_).' + ); + } + + if (namespace && /^-|-$/.test(namespace)) { + errors.push('Namespace cannot start or end with a dash (-).'); + } + + if (namespace && /^_|_$/.test(namespace)) { + errors.push('Namespace cannot start or end with an underscore (_).'); + } + + if (errors.length > 0) { + return next(new HttpError(errors.join(' '), 400)); + } + + next(); +}; + +module.exports = validateNamespace;