Explorar o código

Merge pull request #48 from thedevs-network/feature/reuse

Add "reuse" option to the API, minor improvements
Pouria Ezzati %!s(int64=7) %!d(string=hai) anos
pai
achega
bfd2381ab3

+ 3 - 0
README.md

@@ -93,6 +93,9 @@ POST /api/url/submit
 ```
 Body:
   * `target`: Original long URL to be shortened.
+  * `customurl` (optional, users only): Set a custom URL.
+  * `passowrd` (optional, users only): Set a password.
+  * `reuse` (optional, users only): If a URL with the specified target exists returns it, otherwise will send a new shortened URL.
 
 Returns: URL object
 

+ 31 - 58
server/controllers/urlController.js

@@ -1,9 +1,9 @@
 const urlRegex = require('url-regex');
 const URL = require('url');
+const generate = require('nanoid/generate');
 const useragent = require('useragent');
 const geoip = require('geoip-lite');
 const bcrypt = require('bcryptjs');
-const axios = require('axios');
 const {
   createShortUrl,
   createVisit,
@@ -15,48 +15,36 @@ const {
   deleteCustomDomain,
   deleteUrl,
 } = require('../db/url');
+const { addProtocol, generateShortUrl } = require('../utils');
 const config = require('../config');
 
-const preservedUrls = [
-  'login',
-  'logout',
-  'signup',
-  'reset-password',
-  'resetpassword',
-  'url-password',
-  'settings',
-  'stats',
-  'verify',
-  'api',
-  '404',
-  'static',
-  'images',
-];
-
-exports.preservedUrls = preservedUrls;
+const generateId = async () => {
+  const id = generate('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', 6);
+  const urls = await findUrl({ id });
+  if (!urls.length) return id;
+  return generateId();
+};
 
 exports.urlShortener = async ({ body, user }, res) => {
-  if (!body.target) return res.status(400).json({ error: 'No target has been provided.' });
-  if (body.target.length > 1024) {
-    return res.status(400).json({ error: 'Maximum URL length is 1024.' });
-  }
-  const isValidUrl = urlRegex({ exact: true, strict: false }).test(body.target);
-  if (!isValidUrl) return res.status(400).json({ error: 'URL is not valid.' });
-  const hasProtocol = /^https?/.test(URL.parse(body.target).protocol);
-  const target = hasProtocol ? body.target : `http://${body.target}`;
-  if (body.password && body.password.length > 64) {
-    return res.status(400).json({ error: 'Maximum password length is 64.' });
+  // if "reuse" is true, try to return
+  // the existent URL without creating one
+  if (user && body.reuse) {
+    const urls = await findUrl({ target: addProtocol(body.target) });
+    if (urls.length) {
+      urls.sort((a, b) => a.createdAt > b.createdAt);
+      const { domain: d, user: u, ...url } = urls[urls.length - 1];
+      const data = {
+        ...url,
+        password: !!url.password,
+        reuse: true,
+        shortUrl: generateShortUrl(url.id, user.domain),
+      };
+      return res.json(data);
+    }
   }
+
+  // Check if custom URL already exists
   if (user && body.customurl) {
-    if (!/^[a-zA-Z1-9-_]+$/g.test(body.customurl.trim())) {
-      return res.status(400).json({ error: 'Custom URL is not valid.' });
-    }
-    if (preservedUrls.some(url => url === body.customurl)) {
-      return res.status(400).json({ error: "You can't use this custom URL name." });
-    }
-    if (body.customurl.length > 64) {
-      return res.status(400).json({ error: 'Maximum custom URL length is 64.' });
-    }
     const urls = await findUrl({ id: body.customurl || '' });
     if (urls.length) {
       const urlWithNoDomain = !user.domain && urls.some(url => !url.domain);
@@ -66,27 +54,12 @@ exports.urlShortener = async ({ body, user }, res) => {
       }
     }
   }
-  const isMalware = await axios.post(
-    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${
-      config.GOOGLE_SAFE_BROWSING_KEY
-    }`,
-    {
-      client: {
-        clientId: config.DEFAULT_DOMAIN.toLowerCase().replace('.', ''),
-        clientVersion: '1.0.0',
-      },
-      threatInfo: {
-        threatTypes: ['MALWARE', 'SOCIAL_ENGINEERING'],
-        platformTypes: ['WINDOWS'],
-        threatEntryTypes: ['URL'],
-        threatEntries: [{ url: body.target }],
-      },
-    }
-  );
-  if (isMalware.data && isMalware.data.matches) {
-    return res.status(400).json({ error: 'Malware detected!' });
-  }
-  const url = await createShortUrl({ ...body, target, user });
+
+  // Create new URL
+  const id = (user && body.customurl) || (await generateId());
+  const target = addProtocol(body.target);
+  const url = await createShortUrl({ ...body, id, target, user });
+
   return res.json(url);
 };
 

+ 89 - 3
server/controllers/validateBodyController.js

@@ -1,15 +1,20 @@
-const { body } = require('express-validator/check');
+const axios = require('axios');
+const urlRegex = require('url-regex');
+const validator = require('express-validator/check');
 const { validationResult } = require('express-validator/check');
+const config = require('../config');
 
 exports.validationCriterias = [
-  body('email')
+  validator
+    .body('email')
     .exists()
     .withMessage('Email must be provided.')
     .isEmail()
     .withMessage('Email is not valid.')
     .trim()
     .normalizeEmail(),
-  body('password', 'Password must be at least 8 chars long.')
+  validator
+    .body('password', 'Password must be at least 8 chars long.')
     .exists()
     .withMessage('Password must be provided.')
     .isLength({ min: 8 }),
@@ -25,3 +30,84 @@ exports.validateBody = (req, res, next) => {
   }
   return next();
 };
+
+const preservedUrls = [
+  'login',
+  'logout',
+  'signup',
+  'reset-password',
+  'resetpassword',
+  'url-password',
+  'settings',
+  'stats',
+  'verify',
+  'api',
+  '404',
+  'static',
+  'images',
+];
+
+exports.preservedUrls = preservedUrls;
+
+exports.validateUrl = async ({ body, user }, res, next) => {
+  // Validate URL existence
+  if (!body.target) return res.status(400).json({ error: 'No target has been provided.' });
+
+  // validate URL length
+  if (body.target.length > 1024) {
+    return res.status(400).json({ error: 'Maximum URL length is 1024.' });
+  }
+
+  // Validate URL
+  const isValidUrl = urlRegex({ exact: true, strict: false }).test(body.target);
+  if (!isValidUrl) return res.status(400).json({ error: 'URL is not valid.' });
+
+  // Validate password length
+  if (body.password && body.password.length > 64) {
+    return res.status(400).json({ error: 'Maximum password length is 64.' });
+  }
+
+  // Custom URL validations
+  if (user && body.customurl) {
+    // Validate custom URL
+    if (!/^[a-zA-Z1-9-_]+$/g.test(body.customurl.trim())) {
+      return res.status(400).json({ error: 'Custom URL is not valid.' });
+    }
+
+    // Prevent from using preserved URLs
+    if (preservedUrls.some(url => url === body.customurl)) {
+      return res.status(400).json({ error: "You can't use this custom URL name." });
+    }
+
+    // Validate custom URL length
+    if (body.customurl.length > 64) {
+      return res.status(400).json({ error: 'Maximum custom URL length is 64.' });
+    }
+  }
+
+  return next();
+};
+
+exports.malwareCheck = async ({ body }, res, next) => {
+  const isMalware = await axios.post(
+    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${
+      config.GOOGLE_SAFE_BROWSING_KEY
+    }`,
+    {
+      client: {
+        clientId: config.DEFAULT_DOMAIN.toLowerCase().replace('.', ''),
+        clientVersion: '1.0.0',
+      },
+      threatInfo: {
+        threatTypes: ['MALWARE', 'SOCIAL_ENGINEERING'],
+        platformTypes: ['WINDOWS'],
+        threatEntryTypes: ['URL'],
+        threatEntries: [{ url: body.target }],
+      },
+    }
+  );
+  if (isMalware.data && isMalware.data.matches) {
+    return res.status(400).json({ error: 'Malware detected!' });
+  }
+  return next();
+};

+ 16 - 18
server/db/url.js

@@ -1,4 +1,3 @@
-const generate = require('nanoid/generate');
 const bcrypt = require('bcryptjs');
 const _ = require('lodash/');
 const {
@@ -12,15 +11,13 @@ const {
 } = require('date-fns');
 const driver = require('./neo4j');
 const config = require('../config');
+const { generateShortUrl } = require('../utils');
 
 const getUTCDate = (dateString = Date.now()) => {
   const date = new Date(dateString);
   return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
 };
 
-const generateId = () =>
-  generate('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', 6);
-
 const queryNewUrl = 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt }) RETURN l';
 
 const queryNewUserUrl = (domain, password) =>
@@ -43,7 +40,7 @@ exports.createShortUrl = params =>
           createdAt: new Date().toJSON(),
           domain: params.user && params.user.domain,
           email: params.user && params.user.email,
-          id: (params.user && params.customurl) || generateId(),
+          id: params.id,
           password: hash || '',
           target: params.target,
         })
@@ -54,11 +51,11 @@ exports.createShortUrl = params =>
         resolve({
           ...data,
           password: !!data.password,
-          shortUrl: `http${!params.user.domain ? 's' : ''}://${params.user.domain ||
-            config.DEFAULT_DOMAIN}/${data.id}`,
+          reuse: !!params.reuse,
+          shortUrl: generateShortUrl(data.id, params.user.domain),
         });
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.createVisit = params =>
@@ -98,16 +95,16 @@ exports.createVisit = params =>
         const url = records.length && records[0].get('l').properties;
         resolve(url);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
-exports.findUrl = ({ id, domain }) =>
+exports.findUrl = ({ id, domain, target }) =>
   new Promise((resolve, reject) => {
     const session = driver.session();
     session
       .readTransaction(tx =>
         tx.run(
-          'MATCH (l:URL { id: $id })' +
+          `MATCH (l:URL { ${id ? 'id: $id' : 'target: $target'} })` +
             `${
               domain
                 ? 'MATCH (l)-[:USES]->(d:DOMAIN { name: $domain })'
@@ -118,6 +115,7 @@ exports.findUrl = ({ id, domain }) =>
           {
             id,
             domain,
+            target,
           }
         )
       )
@@ -132,7 +130,7 @@ exports.findUrl = ({ id, domain }) =>
           }));
         resolve(url);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.getUrls = ({ user, options }) =>
@@ -167,7 +165,7 @@ exports.getUrls = ({ user, options }) =>
         }));
         resolve({ list: urls, countAll });
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.getCustomDomain = ({ customDomain }) =>
@@ -184,7 +182,7 @@ exports.getCustomDomain = ({ customDomain }) =>
         const data = records.length && records[0].get('u').properties;
         resolve(data);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.setCustomDomain = ({ user, customDomain }) =>
@@ -208,7 +206,7 @@ exports.setCustomDomain = ({ user, customDomain }) =>
         const data = records.length && records[0].get('d').properties;
         resolve(data);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.deleteCustomDomain = ({ user }) =>
@@ -225,7 +223,7 @@ exports.deleteCustomDomain = ({ user }) =>
         const data = records.length && records[0].get('u').properties;
         resolve(data);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.deleteUrl = ({ id, domain, user }) =>
@@ -255,7 +253,7 @@ exports.deleteUrl = ({ id, domain, user }) =>
         const data = records.length && records[0].get('u').properties;
         resolve(data);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 /* Collecting stats */
@@ -393,5 +391,5 @@ exports.getStats = ({ id, domain, user }) =>
 
         return resolve(response);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });

+ 3 - 3
server/db/user.js

@@ -94,7 +94,7 @@ exports.changePassword = ({ email, password }) =>
         const user = res.records.length && res.records[0].get('u').properties;
         return resolve(user);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.generateApiKey = ({ email }) =>
@@ -113,7 +113,7 @@ exports.generateApiKey = ({ email }) =>
         const newApikey = res.records.length && res.records[0].get('u').properties.apikey;
         return resolve({ apikey: newApikey });
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.requestPasswordReset = ({ email }) =>
@@ -140,7 +140,7 @@ exports.requestPasswordReset = ({ email }) =>
         const user = res.records.length && res.records[0].get('u').properties;
         return resolve(user);
       })
-      .catch(reject);
+      .catch(() => session.close() && reject);
   });
 
 exports.resetPassword = ({ resetPasswordToken }) =>

+ 10 - 2
server/server.js

@@ -6,7 +6,13 @@ const Raven = require('raven');
 const cookieParser = require('cookie-parser');
 const bodyParser = require('body-parser');
 const passport = require('passport');
-const { validateBody, validationCriterias } = require('./controllers/validateBodyController');
+const {
+  validateBody,
+  validationCriterias,
+  preservedUrls,
+  validateUrl,
+  malwareCheck,
+} = require('./controllers/validateBodyController');
 const auth = require('./controllers/authController');
 const url = require('./controllers/urlController');
 const config = require('./config');
@@ -56,7 +62,7 @@ app.prepare().then(() => {
     const { headers, path } = req;
     if (
       headers.host !== config.DEFAULT_DOMAIN &&
-      (path === '/' || url.preservedUrls.some(item => item === path.replace('/', '')))
+      (path === '/' || preservedUrls.some(item => item === path.replace('/', '')))
     ) {
       return res.redirect(`http://${config.DEFAULT_DOMAIN + path}`);
     }
@@ -93,6 +99,8 @@ app.prepare().then(() => {
     auth.authApikey,
     auth.authJwtLoose,
     catchErrors(auth.recaptcha),
+    catchErrors(validateUrl),
+    catchErrors(malwareCheck),
     catchErrors(url.urlShortener)
   );
   server.post('/api/url/deleteurl', auth.authApikey, auth.authJwt, catchErrors(url.deleteUrl));

+ 10 - 0
server/utils/index.js

@@ -0,0 +1,10 @@
+const URL = require('url');
+const config = require('../config');
+
+exports.addProtocol = url => {
+  const hasProtocol = /^https?/.test(URL.parse(url).protocol);
+  return hasProtocol ? url : `http://${url}`;
+};
+
+exports.generateShortUrl = (id, domain) =>
+  `http${!domain ? 's' : ''}://${domain || config.DEFAULT_DOMAIN}/${id}`;