Эх сурвалжийг харах

Move to MongoDB and TypeScript

poeti8 6 жил өмнө
parent
commit
080b22eb1c
58 өөрчлөгдсөн 2672 нэмэгдсэн , 1668 устгасан
  1. 33 27
      .eslintrc
  2. 3 0
      .example.env
  3. 2 7
      client/actions/__test__/settings.js
  4. 3 3
      client/actions/__test__/url.js
  5. 4 4
      client/actions/settings.js
  6. 1 1
      client/components/Footer/Footer.js
  7. 0 1
      client/components/Login/LoginInputLabel.js
  8. 1 4
      client/components/Settings/Settings.js
  9. 0 11
      client/components/Settings/SettingsDomain.js
  10. 4 4
      client/components/Shortener/ShortenerResult.js
  11. 2 2
      client/components/Stats/Stats.js
  12. 4 4
      client/components/Table/TBody/TBodyCount.js
  13. 2 2
      client/components/Table/TBody/TBodyShortUrl.js
  14. 0 1
      client/pages/_document.js
  15. 1 1
      client/pages/report.js
  16. 6 4
      client/pages/url-info.js
  17. 5 4
      client/pages/url-password.js
  18. 0 1
      client/reducers/__test__/settings.js
  19. 6 6
      client/reducers/__test__/url.js
  20. 0 2
      client/reducers/settings.js
  21. 6 0
      nodemon.json
  22. 607 174
      package-lock.json
  23. 35 12
      package.json
  24. 17 7
      server/configToEnv.ts
  25. 116 55
      server/controllers/authController.ts
  26. 428 0
      server/controllers/linkController.ts
  27. 0 362
      server/controllers/urlController.js
  28. 79 52
      server/controllers/validateBodyController.ts
  29. 0 8
      server/cron.js
  30. 9 0
      server/cron.ts
  31. 58 0
      server/db/domain.ts
  32. 31 0
      server/db/host.ts
  33. 26 0
      server/db/ip.ts
  34. 465 0
      server/db/link.ts
  35. 0 8
      server/db/neo4j.js
  36. 0 468
      server/db/url.js
  37. 0 154
      server/db/user.js
  38. 149 0
      server/db/user.ts
  39. 6 5
      server/mail/mail.ts
  40. 3 2
      server/mail/text.ts
  41. 25 0
      server/models/domain.ts
  42. 23 0
      server/models/host.ts
  43. 0 8
      server/models/ip.js
  44. 17 0
      server/models/ip.ts
  45. 32 0
      server/models/link.ts
  46. 0 18
      server/models/user.js
  47. 47 0
      server/models/user.ts
  48. 55 0
      server/models/visit.ts
  49. 8 0
      server/module.d.ts
  50. 7 7
      server/passport.ts
  51. 0 18
      server/redis.js
  52. 31 0
      server/redis.ts
  53. 0 139
      server/server.js
  54. 195 0
      server/server.ts
  55. 0 82
      server/utils/index.js
  56. 88 0
      server/utils/index.ts
  57. 21 0
      tsconfig.json
  58. 11 0
      tsconfig.server.json

+ 33 - 27
.eslintrc

@@ -1,36 +1,42 @@
 {
   "extends": [
-    "airbnb",
+    "eslint:recommended",
+    "plugin:@typescript-eslint/eslint-recommended",
+    "plugin:@typescript-eslint/recommended",
     "prettier",
-    "prettier/react"
+    "prettier/@typescript-eslint"
   ],
-  "parser": "babel-eslint",
+  "parser": "@typescript-eslint/parser",
+  "parserOptions": {
+    "project": "./tsconfig.server.json",
+  },
+  "plugins": ["@typescript-eslint"],
+  "rules": {
+    "eqeqeq": ["warn", "always", { "null": "ignore" }],
+    "no-useless-return": "warn",
+    "no-var": "warn",
+    "max-len": ["warn", { "comments": 80 }],
+    "no-param-reassign": ["warn", { "props": false }],
+    "require-atomic-updates": 0,
+    "@typescript-eslint/interface-name-prefix": "off",
+    "@typescript-eslint/no-unused-vars": "off", // "warn" for production
+    "@typescript-eslint/no-explicit-any": "off", // "warn" for production
+    "@typescript-eslint/no-var-requires": "off",
+    "@typescript-eslint/no-object-literal-type-assertion": "off",
+    "@typescript-eslint/no-parameter-properties": "off",
+    "@typescript-eslint/explicit-function-return-type": "off"
+  },
   "env": {
     "browser": true,
-    "node": true
+    "node": true,
+    "mocha": true
   },
-  "rules": {
-    "react/jsx-filename-extension": [
-      1,
-      {
-        "extensions": [
-          ".js",
-          ".jsx"
-        ]
-      }
-    ],
-    "no-underscore-dangle": 0,
-    "prettier/prettier": [
-      "error",
-      {
-        "trailingComma": "es5",
-        "singleQuote": true,
-        "printWidth": 100
-      }
-    ],
-    "consistent-return": "off"
+  "globals": {
+    "assert": true
+  },
+  "settings": {
+    "react": {
+      "version": "detect"
+    }
   },
-  "plugins": [
-    "prettier"
-  ]
 }

+ 3 - 0
.example.env

@@ -25,6 +25,9 @@ NON_USER_COOLDOWN=0
 # Max number of visits for each link to have detailed stats
 DEFAULT_MAX_STATS_PER_LINK=5000
 
+# Use HTTPS for links with custom domain
+CUSTOM_DOMAIN_USE_HTTPS=false 
+
 # A passphrase to encrypt JWT. Use a long and secure key.
 JWT_SECRET=securekey
 

+ 2 - 7
client/actions/__test__/settings.js

@@ -42,7 +42,6 @@ describe('settings actions', () => {
       const apikey = '123';
       const customDomain = 'test.com';
       const homepage = '';
-      const useHttps = false;
 
       nock('http://localhost', {
         reqheaders: {
@@ -50,7 +49,7 @@ describe('settings actions', () => {
         }
       })
         .get('/api/auth/usersettings')
-        .reply(200, { apikey, customDomain, homepage, useHttps });
+        .reply(200, { apikey, customDomain, homepage });
 
       const store = mockStore({});
 
@@ -60,7 +59,6 @@ describe('settings actions', () => {
           payload: {
             customDomain,
             homepage: '',
-            useHttps: false,
           }
         },
         {
@@ -83,7 +81,6 @@ describe('settings actions', () => {
     it('should dispatch SET_DOMAIN when setting custom domain has been done', done => {
       const customDomain = 'test.com';
       const homepage = '';
-      const useHttps = false;
 
       nock('http://localhost', {
         reqheaders: {
@@ -91,7 +88,7 @@ describe('settings actions', () => {
         }
       })
         .post('/api/url/customdomain')
-        .reply(200, { customDomain, homepage, useHttps });
+        .reply(200, { customDomain, homepage });
 
       const store = mockStore({});
 
@@ -102,7 +99,6 @@ describe('settings actions', () => {
           payload: {
             customDomain,
             homepage: '',
-            useHttps: false,
           }
         }
       ];
@@ -111,7 +107,6 @@ describe('settings actions', () => {
         .dispatch(setCustomDomain({
           customDomain,
           homepage: '',
-          useHttps: false,
         }))
         .then(() => {
           expect(store.getActions()).to.deep.equal(expectedActions);

+ 3 - 3
client/actions/__test__/url.js

@@ -35,7 +35,7 @@ describe('url actions', () => {
         target: url,
         password: false,
         reuse: false,
-        shortUrl: 'http://kutt.it/123'
+        shortLink: 'http://kutt.it/123'
       };
 
       nock('http://localhost', {
@@ -83,7 +83,7 @@ describe('url actions', () => {
             target: 'https://kutt.it/',
             password: false,
             count: 0,
-            shortUrl: 'http://test.com/UkEs33'
+            shortLink: 'http://test.com/UkEs33'
           }
         ],
         countAll: 1
@@ -128,7 +128,7 @@ describe('url actions', () => {
           target: 'test.com',
           password: false,
           reuse: false,
-          shortUrl: 'http://kutt.it/123'
+          shortLink: 'http://kutt.it/123'
         }
       ];
 

+ 4 - 4
client/actions/settings.js

@@ -24,11 +24,11 @@ export const showDomainInput = () => ({ type: SHOW_DOMAIN_INPUT });
 export const getUserSettings = () => async dispatch => {
   try {
     const {
-      data: { apikey, customDomain, homepage, useHttps },
+      data: { apikey, customDomain, homepage },
     } = await axios.get('/api/auth/usersettings', {
       headers: { Authorization: cookie.get('token') },
     });
-    dispatch(setDomain({ customDomain, homepage, useHttps }));
+    dispatch(setDomain({ customDomain, homepage }));
     dispatch(setApiKey(apikey));
   } catch (error) {
     //
@@ -39,11 +39,11 @@ export const setCustomDomain = params => async dispatch => {
   dispatch(showDomainLoading());
   try {
     const {
-      data: { customDomain, homepage, useHttps },
+      data: { customDomain, homepage },
     } = await axios.post('/api/url/customdomain', params, {
       headers: { Authorization: cookie.get('token') },
     });
-    dispatch(setDomain({ customDomain, homepage, useHttps }));
+    dispatch(setDomain({ customDomain, homepage }));
   } catch ({ response }) {
     dispatch(setDomainError(response.data.error));
   }

+ 1 - 1
client/components/Footer/Footer.js

@@ -48,7 +48,7 @@ class Footer extends Component {
           <a
             href="https://github.com/thedevs-network/kutt"
             title="GitHub"
-            target="_blank" // eslint-disable-line react/jsx-no-target-blank
+            target="_blank"
           >
             GitHub
           </a>

+ 0 - 1
client/components/Login/LoginInputLabel.js

@@ -1,4 +1,3 @@
-/* eslint-disable jsx-a11y/label-has-for */
 import React from 'react';
 import PropTypes from 'prop-types';
 import styled from 'styled-components';

+ 1 - 4
client/components/Settings/Settings.js

@@ -78,7 +78,6 @@ class Settings extends Component {
       showModal: false,
       passwordMessage: '',
       passwordError: '',
-      useHttps: null,
       isCopied: false,
       ban: {
         domain: false,
@@ -175,10 +174,9 @@ class Settings extends Component {
   handleCustomDomain(e) {
     e.preventDefault();
     if (this.props.domainLoading) return null;
-    const { useHttps } = this.state;
     const customDomain = e.currentTarget.elements.customdomain.value;
     const homepage = e.currentTarget.elements.homepage.value;
-    return this.props.setCustomDomain({ customDomain, homepage, useHttps });
+    return this.props.setCustomDomain({ customDomain, homepage });
   }
 
   handleCheckbox({ target: { id, checked } }) {
@@ -257,7 +255,6 @@ class Settings extends Component {
         <SettingsDomain
           handleCustomDomain={this.handleCustomDomain}
           handleCheckbox={this.handleCheckbox}
-          useHttps={this.state.useHttps}
           loading={this.props.domainLoading}
           settings={this.props.settings}
           showDomainInput={this.props.showDomainInput}

+ 0 - 11
client/components/Settings/SettingsDomain.js

@@ -92,7 +92,6 @@ const SettingsDomain = ({
   loading,
   showDomainInput,
   showModal,
-  useHttps,
   handleCheckbox,
 }) => (
   <div>
@@ -110,7 +109,6 @@ const SettingsDomain = ({
           <Domain>
             <span>{settings.customDomain}</span>
           </Domain>
-          {settings.useHttps && <Homepage>(With HTTPS)</Homepage>}
           <Homepage>
             (Homepage redirects to <span>{settings.homepage || window.location.hostname}</span>)
           </Homepage>
@@ -153,14 +151,6 @@ const SettingsDomain = ({
             />
           </LabelWrapper>
         </InputWrapper>
-        <Checkbox
-          checked={useHttps === null ? settings.useHttps : useHttps}
-          id="useHttps"
-          name="useHttps"
-          onClick={handleCheckbox}
-          withMargin={false}
-          label="Use HTTPS (We don't handle the SSL, you should take care of it)"
-        />
         <Button type="submit" color="purple" icon={loading ? 'loader' : ''}>
           Set domain
         </Button>
@@ -179,7 +169,6 @@ SettingsDomain.propTypes = {
   showDomainInput: PropTypes.func.isRequired,
   showModal: PropTypes.func.isRequired,
   handleCheckbox: PropTypes.func.isRequired,
-  useHttps: PropTypes.bool.isRequired,
 };
 
 export default SettingsDomain;

+ 4 - 4
client/components/Shortener/ShortenerResult.js

@@ -96,10 +96,10 @@ class ShortenerResult extends Component {
     return (
       <Wrapper>
         {isCopied && <CopyMessage>Copied to clipboard.</CopyMessage>}
-        <CopyToClipboard text={url.list[0].shortUrl} onCopy={copyHandler}>
-          <Url>{url.list[0].shortUrl.replace(/^https?:\/\//, '')}</Url>
+        <CopyToClipboard text={url.list[0].shortLink} onCopy={copyHandler}>
+          <Url>{url.list[0].shortLink.replace(/^https?:\/\//, '')}</Url>
         </CopyToClipboard>
-        <CopyToClipboard text={url.list[0].shortUrl} onCopy={copyHandler}>
+        <CopyToClipboard text={url.list[0].shortLink} onCopy={copyHandler}>
           <Button icon="copy">Copy</Button>
         </CopyToClipboard>
         {showQrCode && (
@@ -108,7 +108,7 @@ class ShortenerResult extends Component {
           </QRButton>
         )}
         <Modal show={this.state.showQrCodeModal} close={this.toggleQrCodeModal}>
-          <QRCode value={url.list[0].shortUrl} size={196} />
+          <QRCode value={url.list[0].shortLink} size={196} />
         </Modal>
       </Wrapper>
     );

+ 2 - 2
client/components/Stats/Stats.js

@@ -126,8 +126,8 @@ class Stats extends Component {
         <TitleWrapper>
           <Title>
             Stats for:{' '}
-            <a href={stats.shortUrl} title="Short URL">
-              {stats.shortUrl.replace(/https?:\/\//, '')}
+            <a href={stats.shortLink} title="Short link">
+              {stats.shortLink.replace(/https?:\/\//, '')}
             </a>
           </Title>
           <TitleTarget>

+ 4 - 4
client/components/Table/TBody/TBodyCount.js

@@ -51,7 +51,7 @@ class TBodyCount extends Component {
   goTo(e) {
     e.preventDefault();
     this.props.showLoading();
-    const host = URL.parse(this.props.url.shortUrl).hostname;
+    const host = URL.parse(this.props.url.shortLink).hostname;
     Router.push(`/stats?id=${this.props.url.id}${`&domain=${host}`}`);
   }
 
@@ -77,14 +77,14 @@ class TBodyCount extends Component {
           )}
           <TBodyButton
             data-id={url.id}
-            data-host={URL.parse(url.shortUrl).hostname}
+            data-host={URL.parse(url.shortLink).hostname}
             onClick={showModal}
           >
             <Icon src="/images/trash.svg" />
           </TBodyButton>
         </Actions>
         <Modal show={this.state.showQrCodeModal} close={this.toggleQrCodeModal}>
-          <QRCode value={url.shortUrl} size={196} />
+          <QRCode value={url.shortLink} size={196} />
         </Modal>
       </Wrapper>
     );
@@ -98,7 +98,7 @@ TBodyCount.propTypes = {
     count: PropTypes.number,
     id: PropTypes.string,
     password: PropTypes.bool,
-    shortUrl: PropTypes.string,
+    shortLink: PropTypes.string,
   }).isRequired,
 };
 

+ 2 - 2
client/components/Table/TBody/TBodyShortUrl.js

@@ -25,12 +25,12 @@ const Icon = styled.img`
 const TBodyShortUrl = ({ index, copiedIndex, handleCopy, url }) => (
   <Wrapper>
     {copiedIndex === index && <CopyText>Copied to clipboard!</CopyText>}
-    <CopyToClipboard onCopy={() => handleCopy(index)} text={`${url.shortUrl}`}>
+    <CopyToClipboard onCopy={() => handleCopy(index)} text={`${url.shortLink}`}>
       <TBodyButton>
         <Icon src="/images/copy.svg" />
       </TBodyButton>
     </CopyToClipboard>
-    <a href={`${url.shortUrl}`}>{`${url.shortUrl.replace(/^https?:\/\//, '')}`}</a>
+    <a href={`${url.shortLink}`}>{`${url.shortLink.replace(/^https?:\/\//, '')}`}</a>
   </Wrapper>
 );
 

+ 0 - 1
client/pages/_document.js

@@ -1,4 +1,3 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 import Document, { Head, Main, NextScript } from 'next/document';
 import { ServerStyleSheet } from 'styled-components';

+ 1 - 1
client/pages/report.js

@@ -56,7 +56,7 @@ class ReportPage extends Component {
     e.preventDefault();
     this.setState({ loading: true });
     try {
-      await axios.post('/api/url/report', { url: this.state.url });
+      await axios.post('/api/url/report', { link: this.state.url });
       this.setState({
         loading: false,
         message: {

+ 6 - 4
client/pages/url-info.js

@@ -40,7 +40,7 @@ class UrlInfoPage extends Component {
   }
 
   render() {
-    if (!this.props.query) {
+    if (!this.props.query.linkTarget) {
       return (
         <BodyWrapper>
           <Title>404 | Not found.</Title>
@@ -52,7 +52,7 @@ class UrlInfoPage extends Component {
       <BodyWrapper>
         <Wrapper>
           <Title>Target:</Title>
-          <Target>{this.props.query}</Target>
+          <Target>{this.props.query.linkTarget}</Target>
         </Wrapper>
         <Footer />
       </BodyWrapper>
@@ -61,11 +61,13 @@ class UrlInfoPage extends Component {
 }
 
 UrlInfoPage.propTypes = {
-  query: PropTypes.string,
+  query: PropTypes.shape({
+    linkTarget: PropTypes.string,
+  }),
 };
 
 UrlInfoPage.defaultProps = {
-  query: null,
+  query: {},
 };
 
 export default UrlInfoPage;

+ 5 - 4
client/pages/url-password.js

@@ -64,6 +64,7 @@ class UrlPasswordPage extends Component {
   requestUrl(e) {
     e.preventDefault();
     const { password } = this.state;
+    const { protectedLink } = this.props.query;
     if (!password) {
       return this.setState({
         error: 'Password must not be empty',
@@ -72,7 +73,7 @@ class UrlPasswordPage extends Component {
     this.setState({ error: '' });
     this.setState({ loading: true });
     return axios
-      .post('/api/url/requesturl', { id: this.props.query, password })
+      .post('/api/url/requesturl', { id: protectedLink, password })
       .then(({ data }) => window.location.replace(data.target))
       .catch(({ response }) =>
         this.setState({
@@ -83,7 +84,7 @@ class UrlPasswordPage extends Component {
   }
 
   render() {
-    if (!this.props.query) {
+    if (!this.props.query.protectedLink) {
       return (
         <BodyWrapper>
           <Title>404 | Not found.</Title>
@@ -107,12 +108,12 @@ class UrlPasswordPage extends Component {
 
 UrlPasswordPage.propTypes = {
   query: PropTypes.shape({
-    id: PropTypes.string,
+    protectedLink: PropTypes.string,
   }),
 };
 
 UrlPasswordPage.defaultProps = {
-  query: null,
+  query: {},
 };
 
 export default UrlPasswordPage;

+ 0 - 1
client/reducers/__test__/settings.js

@@ -17,7 +17,6 @@ describe('settings reducer', () => {
     customDomain: '',
     homepage: '',
     domainInput: true,
-    useHttps: false,
   };
 
   beforeEach(() => {

+ 6 - 6
client/reducers/__test__/url.js

@@ -36,7 +36,7 @@ describe('url reducer', () => {
       target: 'https://kutt.it/',
       password: false,
       reuse: false,
-      shortUrl: 'https://kutt.it/YufjdS'
+      shortLink: 'https://kutt.it/YufjdS'
     };
 
     const state = reducer(initialState, {
@@ -105,7 +105,7 @@ describe('url reducer', () => {
         target: 'https://kutt.it/',
         password: false,
         reuse: false,
-        shortUrl: 'https://kutt.it/YufjdS'
+        shortLink: 'https://kutt.it/YufjdS'
       },
       {
         createdAt: '2018-06-12T19:51:56.435Z',
@@ -113,7 +113,7 @@ describe('url reducer', () => {
         target: 'https://kutt.it/',
         password: false,
         reuse: false,
-        shortUrl: 'https://kutt.it/1gCdbC'
+        shortLink: 'https://kutt.it/1gCdbC'
       }
     ];
 
@@ -140,7 +140,7 @@ describe('url reducer', () => {
           target: 'https://kutt.it/',
           password: false,
           reuse: false,
-          shortUrl: 'https://kutt.it/YufjdS'
+          shortLink: 'https://kutt.it/YufjdS'
         },
         {
           createdAt: '2018-06-12T19:51:56.435Z',
@@ -148,7 +148,7 @@ describe('url reducer', () => {
           target: 'https://kutt.it/',
           password: false,
           reuse: false,
-          shortUrl: 'https://kutt.it/1gCdbC'
+          shortLink: 'https://kutt.it/1gCdbC'
         }
       ],
       isShortened: true,
@@ -173,7 +173,7 @@ describe('url reducer', () => {
       target: 'https://kutt.it/',
       password: false,
       reuse: false,
-      shortUrl: 'https://kutt.it/YufjdS'
+      shortLink: 'https://kutt.it/YufjdS'
     });
   });
 

+ 0 - 2
client/reducers/settings.js

@@ -11,7 +11,6 @@ const initialState = {
   customDomain: '',
   homepage: '',
   domainInput: true,
-  useHttps: false,
 };
 
 const settings = (state = initialState, action) => {
@@ -22,7 +21,6 @@ const settings = (state = initialState, action) => {
         customDomain: action.payload.customDomain,
         homepage: action.payload.homepage,
         domainInput: false,
-        useHttps: action.payload.useHttps,
       };
     case SET_APIKEY:
       return { ...state, apikey: action.payload };

+ 6 - 0
nodemon.json

@@ -0,0 +1,6 @@
+{
+  "watch": ["server/**/*.ts"],
+  "execMap": {
+    "ts": "ts-node --compilerOptions \"{\\\"module\\\":\\\"commonjs\\\"}\""
+  }
+}

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 607 - 174
package-lock.json


+ 35 - 12
package.json

@@ -5,13 +5,13 @@
   "main": "./server/server.js",
   "scripts": {
     "test": "mocha --compilers js:@babel/register ./client/**/__test__/*.js",
-    "dev": "nodemon ./server/server.js",
     "docker:build": "docker build -t kutt .",
     "docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
-    "build": "next build ./client",
-    "start": "NODE_ENV=production node ./server/server.js",
-    "lint": "./node_modules/.bin/eslint . --fix",
-    "lint:nofix": "./node_modules/.bin/eslint ."
+    "dev": "nodemon server/server.ts",
+    "build": "next build && tsc --project tsconfig.server.json",
+    "start": "NODE_ENV=production node production-server/server.js",
+    "lint": "eslint server/ --ext .js,.ts --fix",
+    "lint:nofix": "eslint server/ --ext .js,.ts"
   },
   "husky": {
     "hooks": {
@@ -32,15 +32,28 @@
   },
   "homepage": "https://github.com/TheDevs-Network/kutt#readme",
   "dependencies": {
+    "@types/body-parser": "^1.17.0",
+    "@types/cookie-parser": "^1.4.1",
+    "@types/date-fns": "^2.6.0",
+    "@types/dotenv": "^4.0.3",
+    "@types/express": "^4.16.0",
+    "@types/helmet": "0.0.38",
+    "@types/jsonwebtoken": "^7.2.8",
+    "@types/jwt-decode": "^2.2.1",
+    "@types/mongodb": "^3.1.17",
+    "@types/mongoose": "^5.3.5",
+    "@types/next": "^7.0.5",
+    "@types/passport": "^0.4.7",
+    "@types/passport-jwt": "^3.0.1",
+    "@types/redis": "^2.8.10",
     "axios": "^0.19.0",
     "bcryptjs": "^2.4.3",
-    "body-parser": "^1.18.3",
     "cookie-parser": "^1.4.4",
     "cors": "^2.8.5",
     "date-fns": "^1.30.1",
     "dotenv": "^8.0.0",
     "email-validator": "^1.2.3",
-    "express": "^4.16.4",
+    "express": "^4.17.1",
     "express-validator": "^4.3.0",
     "geoip-lite": "^1.3.6",
     "helmet": "^3.15.1",
@@ -58,7 +71,7 @@
     "next": "^7.0.3",
     "next-redux-wrapper": "^2.1.0",
     "node-cron": "^2.0.3",
-    "nodemailer": "^4.7.0",
+    "nodemailer": "^6.3.0",
     "passport": "^0.4.0",
     "passport-jwt": "^4.0.0",
     "passport-local": "^1.0.0",
@@ -89,6 +102,15 @@
     "@babel/node": "^7.2.2",
     "@babel/preset-env": "^7.3.1",
     "@babel/register": "^7.0.0",
+    "@types/bcryptjs": "^2.4.2",
+    "@types/cors": "^2.8.5",
+    "@types/morgan": "^1.7.36",
+    "@types/ms": "^0.7.30",
+    "@types/node-cron": "^2.0.2",
+    "@types/nodemailer": "^6.2.1",
+    "@types/passport-local": "^1.0.33",
+    "@typescript-eslint/eslint-plugin": "^1.13.0",
+    "@typescript-eslint/parser": "^1.13.0",
     "babel": "^6.23.0",
     "babel-cli": "^6.26.0",
     "babel-core": "^6.26.3",
@@ -97,19 +119,20 @@
     "babel-preset-env": "^1.7.0",
     "chai": "^4.1.2",
     "deep-freeze": "^0.0.1",
-    "eslint": "^4.19.1",
+    "eslint": "^6.1.0",
     "eslint-config-airbnb": "^16.1.0",
-    "eslint-config-prettier": "^2.10.0",
+    "eslint-config-prettier": "^6.0.0",
     "eslint-plugin-import": "^2.16.0",
     "eslint-plugin-jsx-a11y": "^6.2.1",
     "eslint-plugin-prettier": "^2.7.0",
-    "eslint-plugin-react": "^7.12.4",
+    "eslint-plugin-react": "^7.14.3",
     "husky": "^0.15.0-rc.13",
     "mocha": "^5.2.0",
     "nock": "^9.3.3",
     "nodemon": "^1.18.10",
     "prettier": "^1.16.4",
     "redux-mock-store": "^1.5.3",
-    "sinon": "^6.0.0"
+    "sinon": "^6.0.0",
+    "typescript": "^3.5.3"
   }
 }

+ 17 - 7
server/configToEnv.js → server/configToEnv.ts

@@ -1,15 +1,19 @@
 /* eslint-disable global-require */
-/* eslint-disable import/no-unresolved */
-const fs = require('fs');
-const path = require('path');
+import fs from 'fs';
+import path from 'path';
 
 const hasServerConfig = fs.existsSync(path.resolve(__dirname, 'config.js'));
-const hasClientConfig = fs.existsSync(path.resolve(__dirname, '../client/config.js'));
+const hasClientConfig = fs.existsSync(
+  path.resolve(__dirname, '../client/config.js')
+);
 
 if (hasServerConfig && hasClientConfig) {
   const serverConfig = require('./config.js');
   const clientConfig = require('../client/config.js');
-  let envTemplate = fs.readFileSync(path.resolve(__dirname, '../.template.env'), 'utf-8');
+  let envTemplate = fs.readFileSync(
+    path.resolve(__dirname, '../.template.env'),
+    'utf-8'
+  );
 
   const configs = {
     PORT: serverConfig.PORT || 3000,
@@ -40,11 +44,17 @@ if (hasServerConfig && hasClientConfig) {
   };
 
   Object.keys(configs).forEach(c => {
-    envTemplate = envTemplate.replace(new RegExp(`{{${c}}}`, 'gm'), configs[c] || '');
+    envTemplate = envTemplate.replace(
+      new RegExp(`{{${c}}}`, 'gm'),
+      configs[c] || ''
+    );
   });
 
   fs.writeFileSync(path.resolve(__dirname, '../.env'), envTemplate);
-  fs.renameSync(path.resolve(__dirname, 'config.js'), path.resolve(__dirname, 'old.config.js'));
+  fs.renameSync(
+    path.resolve(__dirname, 'config.js'),
+    path.resolve(__dirname, 'old.config.js')
+  );
   fs.renameSync(
     path.resolve(__dirname, '../client/config.js'),
     path.resolve(__dirname, '../client/old.config.js')

+ 116 - 55
server/controllers/authController.js → server/controllers/authController.ts

@@ -1,12 +1,14 @@
-const fs = require('fs');
-const path = require('path');
-const passport = require('passport');
-const JWT = require('jsonwebtoken');
-const axios = require('axios');
-const { isAdmin } = require('../utils');
-const transporter = require('../mail/mail');
-const { resetMailText, verifyMailText } = require('../mail/text');
-const {
+import { RequestHandler } from 'express';
+import fs from 'fs';
+import path from 'path';
+import passport from 'passport';
+import JWT from 'jsonwebtoken';
+import axios from 'axios';
+
+import { isAdmin } from '../utils';
+import transporter from '../mail/mail';
+import { resetMailText, verifyMailText } from '../mail/text';
+import {
   createUser,
   changePassword,
   generateApiKey,
@@ -14,11 +16,18 @@ const {
   verifyUser,
   requestPasswordReset,
   resetPassword,
-} = require('../db/user');
+} from '../db/user';
+import { IUser } from '../models/user';
 
 /* Read email template */
-const resetEmailTemplatePath = path.join(__dirname, '../mail/template-reset.html');
-const verifyEmailTemplatePath = path.join(__dirname, '../mail/template-verify.html');
+const resetEmailTemplatePath = path.join(
+  __dirname,
+  '../mail/template-reset.html'
+);
+const verifyEmailTemplatePath = path.join(
+  __dirname,
+  '../mail/template-verify.html'
+);
 const resetEmailTemplate = fs
   .readFileSync(resetEmailTemplatePath, { encoding: 'utf-8' })
   .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
@@ -27,12 +36,12 @@ const verifyEmailTemplate = fs
   .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
 
 /* Function to generate JWT */
-const signToken = user =>
+const signToken = (user: IUser) =>
   JWT.sign(
     {
       iss: 'ApiAuth',
-      sub: user.email,
-      domain: user.domain || '',
+      sub: () => user.email,
+      domain: (user.domain && user.domain.name) || '',
       admin: isAdmin(user.email),
       iat: new Date().getTime(),
       exp: new Date().setDate(new Date().getDate() + 7),
@@ -41,7 +50,11 @@ const signToken = user =>
   );
 
 /* Passport.js authentication controller */
-const authenticate = (type, error, isStrict = true) =>
+const authenticate = (
+  type: 'jwt' | 'local' | 'localapikey',
+  error: string,
+  isStrict: boolean = true
+) =>
   function auth(req, res, next) {
     if (req.user) return next();
     return passport.authenticate(type, (err, user) => {
@@ -55,7 +68,9 @@ const authenticate = (type, error, isStrict = true) =>
         });
       }
       if (user && user.banned) {
-        return res.status(400).json({ error: 'Your are banned from using this website.' });
+        return res
+          .status(400)
+          .json({ error: 'Your are banned from using this website.' });
       }
       if (user) {
         req.user = {
@@ -68,13 +83,20 @@ const authenticate = (type, error, isStrict = true) =>
     })(req, res, next);
   };
 
-exports.authLocal = authenticate('local', 'Login email and/or password are wrong.');
-exports.authJwt = authenticate('jwt', 'Unauthorized.');
-exports.authJwtLoose = authenticate('jwt', 'Unauthorized.', false);
-exports.authApikey = authenticate('localapikey', 'API key is not correct.', false);
+export const authLocal = authenticate(
+  'local',
+  'Login email and/or password are wrong.'
+);
+export const authJwt = authenticate('jwt', 'Unauthorized.');
+export const authJwtLoose = authenticate('jwt', 'Unauthorized.', false);
+export const authApikey = authenticate(
+  'localapikey',
+  'API key is not correct.',
+  false
+);
 
 /* reCaptcha controller */
-exports.recaptcha = async (req, res, next) => {
+export const recaptcha: RequestHandler = async (req, res, next) => {
   if (process.env.NODE_ENV === 'production' && !req.user) {
     const isReCaptchaValid = await axios({
       method: 'post',
@@ -85,58 +107,79 @@ exports.recaptcha = async (req, res, next) => {
       params: {
         secret: process.env.RECAPTCHA_SECRET_KEY,
         response: req.body.reCaptchaToken,
-        remoteip: req.realIp,
+        remoteip: req.realIP,
       },
     });
     if (!isReCaptchaValid.data.success) {
-      return res.status(401).json({ error: 'reCAPTCHA is not valid. Try again.' });
+      return res
+        .status(401)
+        .json({ error: 'reCAPTCHA is not valid. Try again.' });
     }
   }
   return next();
 };
 
-exports.authAdmin = async (req, res, next) => {
+export const authAdmin: RequestHandler = async (req, res, next) => {
   if (!req.user.admin) {
     return res.status(401).json({ error: 'Unauthorized.' });
   }
   return next();
 };
 
-exports.signup = async (req, res) => {
+export const signup: RequestHandler = async (req, res) => {
   const { email, password } = req.body;
+
   if (password.length > 64) {
     return res.status(400).json({ error: 'Maximum password length is 64.' });
   }
+
   if (email.length > 64) {
     return res.status(400).json({ error: 'Maximum email length is 64.' });
   }
+
   const user = await getUser(email);
-  if (user && user.verified) return res.status(403).json({ error: 'Email is already in use.' });
+
+  if (user && user.verified)
+    return res.status(403).json({ error: 'Email is already in use.' });
+
   const newUser = await createUser(email, password);
+
   const mail = await transporter.sendMail({
     from: process.env.MAIL_FROM || process.env.MAIL_USER,
     to: newUser.email,
     subject: 'Verify your account',
-    text: verifyMailText.replace(/{{verification}}/gim, newUser.verificationToken),
-    html: verifyEmailTemplate.replace(/{{verification}}/gim, newUser.verificationToken),
+    text: verifyMailText.replace(
+      /{{verification}}/gim,
+      newUser.verificationToken
+    ),
+    html: verifyEmailTemplate.replace(
+      /{{verification}}/gim,
+      newUser.verificationToken
+    ),
   });
+
   if (mail.accepted.length) {
-    return res.status(201).json({ email, message: 'Verification email has been sent.' });
+    return res
+      .status(201)
+      .json({ email, message: 'Verification email has been sent.' });
   }
-  return res.status(400).json({ error: "Couldn't send verification email. Try again." });
+
+  return res
+    .status(400)
+    .json({ error: "Couldn't send verification email. Try again." });
 };
 
-exports.login = ({ user }, res) => {
-  const token = signToken(user);
+export const login: RequestHandler = (req, res) => {
+  const token = signToken(req.user);
   return res.status(200).json({ token });
 };
 
-exports.renew = ({ user }, res) => {
-  const token = signToken(user);
+export const renew: RequestHandler = (req, res) => {
+  const token = signToken(req.user);
   return res.status(200).json({ token });
 };
 
-exports.verify = async (req, res, next) => {
+export const verify: RequestHandler = async (req, _res, next) => {
   const user = await verifyUser(req.params.verificationToken);
   if (user) {
     const token = signToken(user);
@@ -145,41 +188,56 @@ exports.verify = async (req, res, next) => {
   return next();
 };
 
-exports.changePassword = async ({ body: { password }, user }, res) => {
-  if (password.length < 8) {
-    return res.status(400).json({ error: 'Password must be at least 8 chars long.' });
+export const changeUserPassword: RequestHandler = async (req, res) => {
+  if (req.body.password.length < 8) {
+    return res
+      .status(400)
+      .json({ error: 'Password must be at least 8 chars long.' });
   }
-  if (password.length > 64) {
+
+  if (req.body.password.length > 64) {
     return res.status(400).json({ error: 'Maximum password length is 64.' });
   }
-  const changedUser = await changePassword(user._id, password);
+
+  const changedUser = await changePassword(req.user._id, req.body.password);
+
   if (changedUser) {
-    return res.status(200).json({ message: 'Your password has been changed successfully.' });
+    return res
+      .status(200)
+      .json({ message: 'Your password has been changed successfully.' });
   }
-  return res.status(400).json({ error: "Couldn't change the password. Try again later" });
+
+  return res
+    .status(400)
+    .json({ error: "Couldn't change the password. Try again later" });
 };
 
-exports.generateApiKey = async (req, res) => {
+export const generateUserApiKey: RequestHandler = async (req, res) => {
   const user = await generateApiKey(req.user._id);
+
   if (user.apikey) {
     return res.status(201).json({ apikey: user.apikey });
   }
-  return res.status(400).json({ error: 'Sorry, an error occured. Please try again later.' });
+
+  return res
+    .status(400)
+    .json({ error: 'Sorry, an error occured. Please try again later.' });
 };
 
-exports.userSettings = ({ user }, res) =>
+export const userSettings: RequestHandler = (req, res) =>
   res.status(200).json({
-    apikey: user.apikey || '',
-    customDomain: user.domain || '',
-    homepage: user.homepage || '',
-    useHttps: user.useHttps || false,
+    apikey: req.user.apikey || '',
+    customDomain: req.user.domain || '',
+    homepage: req.user.homepage || '',
   });
 
-exports.requestPasswordReset = async (req, res) => {
+export const requestUserPasswordReset: RequestHandler = async (req, res) => {
   const user = await requestPasswordReset(req.body.email);
+
   if (!user) {
     return res.status(400).json({ error: "Couldn't reset password." });
   }
+
   const mail = await transporter.sendMail({
     from: process.env.MAIL_USER,
     to: user.email,
@@ -191,15 +249,18 @@ exports.requestPasswordReset = async (req, res) => {
       .replace(/{{resetpassword}}/gm, user.resetPasswordToken)
       .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
   });
+
   if (mail.accepted.length) {
-    return res
-      .status(200)
-      .json({ email: user.email, message: 'Reset password email has been sent.' });
+    return res.status(200).json({
+      email: user.email,
+      message: 'Reset password email has been sent.',
+    });
   }
+
   return res.status(400).json({ error: "Couldn't reset password." });
 };
 
-exports.resetPassword = async (req, res, next) => {
+export const resetUserPassword: RequestHandler = async (req, _res, next) => {
   const user = await resetPassword(req.params.resetPasswordToken);
   if (user) {
     const token = signToken(user);

+ 428 - 0
server/controllers/linkController.ts

@@ -0,0 +1,428 @@
+import { Handler } from 'express';
+import { promisify } from 'util';
+import urlRegex from 'url-regex';
+import dns from 'dns';
+import URL from 'url';
+import generate from 'nanoid/generate';
+import useragent from 'useragent';
+import geoip from 'geoip-lite';
+import bcrypt from 'bcryptjs';
+import ua from 'universal-analytics';
+import isbot from 'isbot';
+
+import { addIP } from '../db/ip';
+import {
+  addLinkCount,
+  createShortLink,
+  createVisit,
+  deleteLink,
+  findLink,
+  getUserLinksCount,
+  getStats,
+  getLinks,
+  banLink,
+} from '../db/link';
+import {
+  checkBannedDomain,
+  checkBannedHost,
+  cooldownCheck,
+  malwareCheck,
+  preservedUrls,
+  urlCountsCheck,
+} from './validateBodyController';
+import { getDomain, setDomain, deleteDomain } from '../db/domain';
+import transporter from '../mail/mail';
+import * as redis from '../redis';
+import {
+  addProtocol,
+  getStatsLimit,
+  generateShortLink,
+  getStatsCacheTime,
+} from '../utils';
+import { IDomain } from '../models/domain';
+
+const dnsLookup = promisify(dns.lookup);
+
+const generateId = async () => {
+  const id = generate(
+    'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
+    6
+  );
+  const link = await findLink({ id });
+  if (!link) return id;
+  return generateId();
+};
+
+export const shortener: Handler = async (req, res) => {
+  try {
+    const targetDomain = URL.parse(req.body.target).hostname;
+
+    const queries = await Promise.all([
+      process.env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
+      process.env.GOOGLE_SAFE_BROWSING_KEY &&
+        malwareCheck(req.user, req.body.target),
+      req.user && urlCountsCheck(req.user._id),
+      req.user &&
+        req.body.reuse &&
+        findLink({ target: addProtocol(req.body.target), user: req.user._id }),
+      req.user &&
+        req.body.customurl &&
+        findLink(
+          {
+            id: req.body.customurl,
+            domain: req.user.domain && req.user.domain._id,
+          },
+          { forceDomainCheck: true }
+        ),
+      (!req.user || !req.body.customurl) && generateId(),
+      checkBannedDomain(targetDomain),
+      checkBannedHost(targetDomain),
+    ]);
+
+    // if "reuse" is true, try to return
+    // the existent URL without creating one
+    if (queries[3]) {
+      const { domain: d, user: u, ...link } = queries[3];
+      const data = {
+        ...link,
+        password: !!link.password,
+        reuse: true,
+        shortLink: generateShortLink(link.id, req.user.domain),
+      };
+      return res.json(data);
+    }
+
+    // Check if custom link already exists
+    if (queries[4]) {
+      throw new Error('Custom URL is already in use.');
+    }
+
+    // Create new link
+    const id = (req.user && req.body.customurl) || queries[5];
+    const target = addProtocol(req.body.target);
+    const link = await createShortLink({
+      ...req.body,
+      id,
+      target,
+      user: req.user,
+    });
+    if (!req.user && Number(process.env.NON_USER_COOLDOWN)) {
+      addIP(req.realIP);
+    }
+
+    return res.json(link);
+  } catch (error) {
+    return res.status(400).json({ error: error.message });
+  }
+};
+
+const browsersList = ['IE', 'Firefox', 'Chrome', 'Opera', 'Safari', 'Edge'];
+const osList = ['Windows', 'Mac Os', 'Linux', 'Android', 'iOS'];
+const filterInBrowser = agent => item =>
+  agent.family.toLowerCase().includes(item.toLocaleLowerCase());
+const filterInOs = agent => item =>
+  agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
+
+export const goToLink: Handler = async (req, res, next) => {
+  const { host } = req.headers;
+  const reqestedId = req.params.id || req.body.id;
+  const id = reqestedId.replace('+', '');
+  const customDomain = host !== process.env.DEFAULT_DOMAIN && host;
+  const agent = useragent.parse(req.headers['user-agent']);
+  const [browser = 'Other'] = browsersList.filter(filterInBrowser(agent));
+  const [os = 'Other'] = osList.filter(filterInOs(agent));
+  const referrer =
+    req.header('Referer') && URL.parse(req.header('Referer')).hostname;
+  const location = geoip.lookup(req.realIP);
+  const country = location && location.country;
+  const isBot = isbot(req.headers['user-agent']);
+
+  const domain = await (customDomain && getDomain({ name: customDomain }));
+
+  const link = await findLink({ id, domain: domain && domain._id });
+
+  if (!link) {
+    if (host !== process.env.DEFAULT_DOMAIN) {
+      if (!domain || !domain.homepage) return next();
+      return res.redirect(301, domain.homepage);
+    }
+    return next();
+  }
+
+  if (link.banned) {
+    return res.redirect('/banned');
+  }
+
+  const doesRequestInfo = /.*\+$/gi.test(reqestedId);
+  if (doesRequestInfo && !link.password) {
+    req.linkTarget = link.target;
+    req.pageType = 'info';
+    return next();
+  }
+
+  if (link.password && !req.body.password) {
+    req.protectedLink = id;
+    req.pageType = 'password';
+    return next();
+  }
+
+  if (link.password) {
+    const isMatch = await bcrypt.compare(req.body.password, link.password);
+    if (!isMatch) {
+      return res.status(401).json({ error: 'Password is not correct' });
+    }
+    if (link.user && !isBot) {
+      addLinkCount(link.id, customDomain);
+      createVisit({
+        browser: browser.toLowerCase(),
+        country: country || 'Unknown',
+        domain: customDomain,
+        id: link.id,
+        os: os.toLowerCase().replace(/\s/gi, ''),
+        referrer: referrer.replace(/\./gi, '[dot]') || 'Direct',
+        limit: getStatsLimit(),
+      });
+    }
+    return res.status(200).json({ target: link.target });
+  }
+  if (link.user && !isBot) {
+    addLinkCount(link.id, customDomain);
+    createVisit({
+      browser,
+      country: country || 'Unknown',
+      domain: customDomain,
+      id: link.id,
+      os,
+      referrer: referrer || 'Direct',
+      limit: getStatsLimit(),
+    });
+  }
+
+  if (process.env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
+    const visitor = ua(process.env.GOOGLE_ANALYTICS_UNIVERSAL);
+    visitor
+      .pageview({
+        dp: `/${id}`,
+        ua: req.headers['user-agent'],
+        uip: req.realIP,
+        aip: 1,
+      })
+      .send();
+  }
+
+  return res.redirect(link.target);
+};
+
+export const getUserLinks: Handler = async (req, res) => {
+  // TODO: Use aggregation
+  const [countAll, list] = await Promise.all([
+    getUserLinksCount({ user: req.user }),
+    getLinks(req.user, req.query),
+  ]);
+  return res.json({ list, countAll });
+};
+
+export const setCustomDomain: Handler = async (req, res) => {
+  const parsed = URL.parse(req.body.customDomain);
+  const customDomain = parsed.hostname || parsed.href;
+  if (!customDomain)
+    return res.status(400).json({ error: 'Domain is not valid.' });
+  if (customDomain.length > 40) {
+    return res
+      .status(400)
+      .json({ error: 'Maximum custom domain length is 40.' });
+  }
+  if (customDomain === process.env.DEFAULT_DOMAIN) {
+    return res.status(400).json({ error: "You can't use default domain." });
+  }
+  const isValidHomepage =
+    !req.body.homepage ||
+    urlRegex({ exact: true, strict: false }).test(req.body.homepage);
+  if (!isValidHomepage)
+    return res.status(400).json({ error: 'Homepage is not valid.' });
+  const homepage =
+    req.body.homepage &&
+    (URL.parse(req.body.homepage).protocol
+      ? req.body.homepage
+      : `http://${req.body.homepage}`);
+  const matchedDomain = await getDomain({ name: customDomain });
+  if (
+    matchedDomain &&
+    matchedDomain.user.toString() !== req.user._id.toString()
+  ) {
+    return res.status(400).json({
+      error: 'Domain is already taken. Contact us for multiple users.',
+    });
+  }
+  const userCustomDomain = await setDomain({
+    user: req.user,
+    name: customDomain,
+    homepage,
+  });
+  if (userCustomDomain) {
+    return res.status(201).json({
+      customDomain: userCustomDomain.name,
+      homepage: userCustomDomain.homepage,
+    });
+  }
+  return res.status(400).json({ error: "Couldn't set custom domain." });
+};
+
+export const deleteCustomDomain: Handler = async (req, res) => {
+  const response = await deleteDomain(req.user);
+  if (response)
+    return res.status(200).json({ message: 'Domain deleted successfully' });
+  return res.status(400).json({ error: "Couldn't delete custom domain." });
+};
+
+export const customDomainRedirection: Handler = async (req, res, next) => {
+  const { headers, path } = req;
+  if (
+    headers.host !== process.env.DEFAULT_DOMAIN &&
+    (path === '/' ||
+      preservedUrls
+        .filter(l => l !== 'url-password')
+        .some(item => item === path.replace('/', '')))
+  ) {
+    const domain = await getDomain({ name: headers.host });
+    return res.redirect(
+      301,
+      (domain && domain.homepage) ||
+        `https://${process.env.DEFAULT_DOMAIN + path}`
+    );
+  }
+  return next();
+};
+
+export const deleteUserLink: Handler = async (req, res) => {
+  if (!req.body.id)
+    return res.status(400).json({ error: 'No id has been provided.' });
+  const customDomain =
+    req.body.domain !== process.env.DEFAULT_DOMAIN
+      ? await getDomain({ name: req.body.domain })
+      : ({} as IDomain);
+  const link = await findLink(
+    {
+      id: req.body.id,
+      domain: customDomain._id,
+      user: req.user && req.user._id,
+    },
+    { forceDomainCheck: true }
+  );
+  if (!link) {
+    return res.status(400).json({ error: "Couldn't find the short link." });
+  }
+  const response = await deleteLink({
+    id: req.body,
+    domain: customDomain._id,
+    user: req.user,
+  });
+
+  if (response) {
+    return res.status(200).json({ message: 'Short link deleted successfully' });
+  }
+
+  return res.status(400).json({ error: "Couldn't delete the short link." });
+};
+
+export const getLinkStats: Handler = async (req, res) => {
+  if (!req.query.id)
+    return res.status(400).json({ error: 'No id has been provided.' });
+  const customDomain =
+    req.query.domain !== process.env.DEFAULT_DOMAIN
+      ? await getDomain({ name: req.query.domain })
+      : ({} as IDomain);
+  const redisKey = req.query.id + (customDomain.name || '') + req.user.email;
+  const cached = await redis.get(redisKey);
+  if (cached) return res.status(200).json(JSON.parse(cached));
+  const link = await findLink(
+    {
+      id: req.query.id,
+      domain: customDomain._id,
+      user: req.user && req.user._id,
+    },
+    { forceDomainCheck: true }
+  );
+  if (!link)
+    return res.status(400).json({ error: "Couldn't find the short link." });
+
+  const stats = await getStats({
+    id: req.query.id,
+    domain: customDomain._id,
+    user: req.user,
+  });
+
+  if (!stats) {
+    return res
+      .status(400)
+      .json({ error: 'Could not get the short link stats.' });
+  }
+
+  const cacheTime = getStatsCacheTime(stats.total);
+  redis.set(redisKey, JSON.stringify(stats), 'EX', cacheTime);
+  return res.status(200).json(stats);
+};
+
+export const reportLink: Handler = async (req, res) => {
+  // TODO: Change from url to link in front-end
+  if (!req.body.link)
+    return res.status(400).json({ error: 'No URL has been provided.' });
+
+  const { hostname } = URL.parse(req.body.link);
+  if (hostname !== process.env.DEFAULT_DOMAIN) {
+    return res.status(400).json({
+      error: `You can only report a ${process.env.DEFAULT_DOMAIN} link`,
+    });
+  }
+
+  const mail = await transporter.sendMail({
+    from: process.env.MAIL_USER,
+    to: process.env.REPORT_MAIL,
+    subject: '[REPORT]',
+    text: req.body.url,
+    html: req.body.url,
+  });
+  if (mail.accepted.length) {
+    return res
+      .status(200)
+      .json({ message: "Thanks for the report, we'll take actions shortly." });
+  }
+  return res
+    .status(400)
+    .json({ error: "Couldn't submit the report. Try again later." });
+};
+
+export const ban: Handler = async (req, res) => {
+  if (!req.body.id)
+    return res.status(400).json({ error: 'No id has been provided.' });
+
+  const link = await findLink({ id: req.body.id }, { forceDomainCheck: true });
+
+  if (!link) return res.status(400).json({ error: "Couldn't find the link." });
+
+  if (link.banned) {
+    return res.status(200).json({ message: 'Link was banned already' });
+  }
+
+  const domain = URL.parse(link.target).hostname;
+
+  let host;
+  if (req.body.host) {
+    try {
+      const dnsRes = await dnsLookup(domain);
+      host = dnsRes && dnsRes.address;
+    } catch (error) {
+      host = null;
+    }
+  }
+
+  await banLink({
+    adminId: req.user,
+    domain,
+    host,
+    id: req.body.id,
+    banUser: !!req.body.user,
+  });
+
+  return res.status(200).json({ message: 'Link has been banned successfully' });
+};

+ 0 - 362
server/controllers/urlController.js

@@ -1,362 +0,0 @@
-const { promisify } = require('util');
-const urlRegex = require('url-regex');
-const dns = require('dns');
-const URL = require('url');
-const generate = require('nanoid/generate');
-const useragent = require('useragent');
-const geoip = require('geoip-lite');
-const bcrypt = require('bcryptjs');
-const ua = require('universal-analytics');
-const isbot = require('isbot');
-const { addIp } = require('../db/user');
-const {
-  addUrlCount,
-  createShortUrl,
-  createVisit,
-  deleteCustomDomain,
-  deleteUrl,
-  findUrl,
-  getCountUrls,
-  getCustomDomain,
-  getStats,
-  getUrls,
-  setCustomDomain,
-  banUrl,
-} = require('../db/url');
-const {
-  checkBannedDomain,
-  checkBannedHost,
-  cooldownCheck,
-  malwareCheck,
-  preservedUrls,
-  urlCountsCheck,
-} = require('./validateBodyController');
-const transporter = require('../mail/mail');
-const redis = require('../redis');
-const { addProtocol, getStatsLimit, generateShortUrl, getStatsCacheTime } = require('../utils');
-
-const dnsLookup = promisify(dns.lookup);
-
-const generateId = async () => {
-  const id = generate('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', 6);
-  const urls = await findUrl({ id });
-  if (!urls.length) return id;
-  return generateId();
-};
-
-exports.urlShortener = async ({ body, realIp, user }, res) => {
-  try {
-    const domain = URL.parse(body.target).hostname;
-
-    const queries = await Promise.all([
-      process.env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(user),
-      process.env.GOOGLE_SAFE_BROWSING_KEY && malwareCheck(user, body.target),
-      user && urlCountsCheck(user.email),
-      user && body.reuse && findUrl({ target: addProtocol(body.target) }),
-      user && body.customurl && findUrl({ id: body.customurl || '' }),
-      (!user || !body.customurl) && generateId(),
-      checkBannedDomain(domain),
-      checkBannedHost(domain),
-    ]);
-
-    // if "reuse" is true, try to return
-    // the existent URL without creating one
-    if (user && body.reuse) {
-      const urls = queries[3];
-      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, user.useHttps),
-        };
-        return res.json(data);
-      }
-    }
-
-    // Check if custom URL already exists
-    if (user && body.customurl) {
-      const urls = queries[4];
-      if (urls.length) {
-        const urlWithNoDomain = !user.domain && urls.some(url => !url.domain);
-        const urlWithDmoain = user.domain && urls.some(url => url.domain === user.domain);
-        if (urlWithNoDomain || urlWithDmoain) {
-          throw new Error('Custom URL is already in use.');
-        }
-      }
-    }
-
-    // Create new URL
-    const id = (user && body.customurl) || queries[5];
-    const target = addProtocol(body.target);
-    const url = await createShortUrl({ ...body, id, target, user });
-    if (!user && Number(process.env.NON_USER_COOLDOWN)) {
-      addIp(realIp);
-    }
-
-    return res.json(url);
-  } catch (error) {
-    return res.status(400).json({ error: error.message });
-  }
-};
-
-const browsersList = ['IE', 'Firefox', 'Chrome', 'Opera', 'Safari', 'Edge'];
-const osList = ['Windows', 'Mac Os X', 'Linux', 'Chrome OS', 'Android', 'iOS'];
-const filterInBrowser = agent => item =>
-  agent.family.toLowerCase().includes(item.toLocaleLowerCase());
-const filterInOs = agent => item =>
-  agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
-
-exports.goToUrl = async (req, res, next) => {
-  const { host } = req.headers;
-  const reqestedId = req.params.id || req.body.id;
-  const id = reqestedId.replace('+', '');
-  const domain = host !== process.env.DEFAULT_DOMAIN && host;
-  const agent = useragent.parse(req.headers['user-agent']);
-  const [browser = 'Other'] = browsersList.filter(filterInBrowser(agent));
-  const [os = 'Other'] = osList.filter(filterInOs(agent));
-  const referrer = req.header('Referer') && URL.parse(req.header('Referer')).hostname;
-  const location = geoip.lookup(req.realIp);
-  const country = location && location.country;
-  const isBot = isbot(req.headers['user-agent']);
-
-  let url;
-
-  const cachedUrl = await redis.get(id + (domain || ''));
-
-  if (cachedUrl) {
-    url = JSON.parse(cachedUrl);
-  } else {
-    const urls = await findUrl({ id, domain });
-    url =
-      urls && urls.length && urls.find(item => (domain ? item.domain === domain : !item.domain));
-  }
-
-  if (!url) {
-    if (host !== process.env.DEFAULT_DOMAIN) {
-      const { homepage } = await getCustomDomain({ customDomain: domain });
-      if (!homepage) return next();
-      return res.redirect(301, homepage);
-    }
-    return next();
-  }
-
-  redis.set(id + (domain || ''), JSON.stringify(url), 'EX', 60 * 60 * 1);
-
-  if (url.banned) {
-    return res.redirect('/banned');
-  }
-
-  const doesRequestInfo = /.*\+$/gi.test(reqestedId);
-  if (doesRequestInfo && !url.password) {
-    req.urlTarget = url.target;
-    req.pageType = 'info';
-    return next();
-  }
-
-  if (url.password && !req.body.password) {
-    req.protectedUrl = id;
-    req.pageType = 'password';
-    return next();
-  }
-
-  if (url.password) {
-    const isMatch = await bcrypt.compare(req.body.password, url.password);
-    if (!isMatch) {
-      return res.status(401).json({ error: 'Password is not correct' });
-    }
-    if (url.user && !isBot) {
-      addUrlCount(url.id, domain);
-      createVisit({
-        browser,
-        country: country || 'Unknown',
-        domain,
-        id: url.id,
-        os,
-        referrer: referrer || 'Direct',
-        limit: getStatsLimit(url),
-      });
-    }
-    return res.status(200).json({ target: url.target });
-  }
-  if (url.user && !isBot) {
-    addUrlCount(url.id, domain);
-    createVisit({
-      browser,
-      country: country || 'Unknown',
-      domain,
-      id: url.id,
-      os,
-      referrer: referrer || 'Direct',
-      limit: getStatsLimit(url),
-    });
-  }
-
-  if (process.env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
-    const visitor = ua(process.env.GOOGLE_ANALYTICS_UNIVERSAL);
-    visitor
-      .pageview({
-        dp: `/${id}`,
-        ua: req.headers['user-agent'],
-        uip: req.realIp,
-        aip: 1,
-      })
-      .send();
-  }
-
-  return res.redirect(url.target);
-};
-
-exports.getUrls = async ({ query, user }, res) => {
-  const { countAll } = await getCountUrls({ user });
-  const urlsList = await getUrls({ options: query, user });
-  const isCountMissing = urlsList.list.some(url => typeof url.count === 'undefined');
-  const { list } = isCountMissing
-    ? await getUrls({ options: query, user, setCount: true })
-    : urlsList;
-  return res.json({ list, countAll });
-};
-
-exports.setCustomDomain = async ({ body, user }, res) => {
-  const parsed = URL.parse(body.customDomain);
-  const customDomain = parsed.hostname || parsed.href;
-  if (!customDomain) return res.status(400).json({ error: 'Domain is not valid.' });
-  if (customDomain.length > 40) {
-    return res.status(400).json({ error: 'Maximum custom domain length is 40.' });
-  }
-  if (customDomain === process.env.DEFAULT_DOMAIN) {
-    return res.status(400).json({ error: "You can't use default domain." });
-  }
-  const isValidHomepage =
-    !body.homepage || urlRegex({ exact: true, strict: false }).test(body.homepage);
-  if (!isValidHomepage) return res.status(400).json({ error: 'Homepage is not valid.' });
-  const homepage =
-    body.homepage &&
-    (URL.parse(body.homepage).protocol ? body.homepage : `http://${body.homepage}`);
-  const { email } = await getCustomDomain({ customDomain });
-  if (email && email !== user.email) {
-    return res
-      .status(400)
-      .json({ error: 'Domain is already taken. Contact us for multiple users.' });
-  }
-  const userCustomDomain = await setCustomDomain({
-    user,
-    customDomain,
-    homepage,
-    useHttps: body.useHttps,
-  });
-  if (userCustomDomain)
-    return res.status(201).json({
-      customDomain: userCustomDomain.name,
-      homepage: userCustomDomain.homepage,
-      useHttps: userCustomDomain.useHttps,
-    });
-  return res.status(400).json({ error: "Couldn't set custom domain." });
-};
-
-exports.deleteCustomDomain = async ({ user }, res) => {
-  const response = await deleteCustomDomain({ user });
-  if (response) return res.status(200).json({ message: 'Domain deleted successfully' });
-  return res.status(400).json({ error: "Couldn't delete custom domain." });
-};
-
-exports.customDomainRedirection = async (req, res, next) => {
-  const { headers, path } = req;
-  if (
-    headers.host !== process.env.DEFAULT_DOMAIN &&
-    (path === '/' ||
-      preservedUrls.filter(u => u !== 'url-password').some(item => item === path.replace('/', '')))
-  ) {
-    const { homepage } = await getCustomDomain({ customDomain: headers.host });
-    return res.redirect(301, homepage || `https://${process.env.DEFAULT_DOMAIN + path}`);
-  }
-  return next();
-};
-
-exports.deleteUrl = async ({ body: { id, domain }, user }, res) => {
-  if (!id) return res.status(400).json({ error: 'No id has been provided.' });
-  const customDomain = domain !== process.env.DEFAULT_DOMAIN && domain;
-  const urls = await findUrl({ id, domain: customDomain });
-  if (!urls && !urls.length) return res.status(400).json({ error: "Couldn't find the short URL." });
-  redis.del(id + (customDomain || ''));
-  const response = await deleteUrl({ id, domain: customDomain, user });
-  if (response) return res.status(200).json({ message: 'Short URL deleted successfully' });
-  return res.status(400).json({ error: "Couldn't delete short URL." });
-};
-
-exports.getStats = async ({ query: { id, domain }, user }, res) => {
-  if (!id) return res.status(400).json({ error: 'No id has been provided.' });
-  const customDomain = domain !== process.env.DEFAULT_DOMAIN && domain;
-  const redisKey = id + (customDomain || '') + user.email;
-  const cached = await redis.get(redisKey);
-  if (cached) return res.status(200).json(JSON.parse(cached));
-  const urls = await findUrl({ id, domain: customDomain });
-  if (!urls && !urls.length) return res.status(400).json({ error: "Couldn't find the short URL." });
-  const [url] = urls;
-  const stats = await getStats({ id, domain: customDomain, user });
-  if (!stats) return res.status(400).json({ error: 'Could not get the short URL stats.' });
-  stats.total = url.count && url.count.toNumber ? url.count.toNumber() : 0;
-  stats.shortUrl = `http${!domain ? 's' : ''}://${
-    domain ? url.domain : process.env.DEFAULT_DOMAIN
-  }/${url.id}`;
-  stats.target = url.target;
-  const cacheTime = getStatsCacheTime(stats.total);
-  redis.set(redisKey, JSON.stringify(stats), 'EX', cacheTime);
-  return res.status(200).json(stats);
-};
-
-exports.reportUrl = async ({ body: { url } }, res) => {
-  if (!url) return res.status(400).json({ error: 'No URL has been provided.' });
-
-  const isValidUrl = urlRegex({ exact: true, strict: false }).test(url);
-  if (!isValidUrl) return res.status(400).json({ error: 'URL is not valid.' });
-
-  const mail = await transporter.sendMail({
-    from: process.env.MAIL_USER,
-    to: process.env.REPORT_MAIL,
-    subject: '[REPORT]',
-    text: url,
-    html: url,
-  });
-  if (mail.accepted.length) {
-    return res.status(200).json({ message: "Thanks for the report, we'll take actions shortly." });
-  }
-  return res.status(400).json({ error: "Couldn't submit the report. Try again later." });
-};
-
-exports.ban = async ({ body, user }, res) => {
-  if (!body.id) return res.status(400).json({ error: 'No id has been provided.' });
-
-  const urls = await findUrl({ id: body.id });
-  const [url] = urls.filter(item => !item.domain);
-
-  if (!url) return res.status(400).json({ error: "Couldn't find the URL." });
-
-  if (url.banned) return res.status(200).json({ message: 'URL was banned already' });
-
-  redis.del(body.id);
-
-  const domain = URL.parse(url.target).hostname;
-
-  let host;
-  if (body.host) {
-    try {
-      const dnsRes = await dnsLookup(domain);
-      host = dnsRes && dnsRes.address;
-    } catch (error) {
-      host = null;
-    }
-  }
-
-  await banUrl({
-    adminEmail: user.email,
-    domain: body.domain && domain,
-    host,
-    id: body.id,
-    user: body.user,
-  });
-
-  return res.status(200).json({ message: 'URL has been banned successfully' });
-};

+ 79 - 52
server/controllers/validateBodyController.js → server/controllers/validateBodyController.ts

@@ -1,19 +1,24 @@
-const { promisify } = require('util');
-const dns = require('dns');
-const axios = require('axios');
-const URL = require('url');
-const urlRegex = require('url-regex');
-const validator = require('express-validator/check');
-const { differenceInMinutes, subHours } = require('date-fns/');
-const { validationResult } = require('express-validator/check');
-const { addCooldown, banUser, getIp } = require('../db/user');
-const { getBannedDomain, getBannedHost, urlCountFromDate } = require('../db/url');
-const subDay = require('date-fns/sub_days');
-const { addProtocol } = require('../utils');
+import { RequestHandler } from 'express';
+import { promisify } from 'util';
+import dns from 'dns';
+import axios from 'axios';
+import URL from 'url';
+import urlRegex from 'url-regex';
+import validator from 'express-validator/check';
+import { differenceInMinutes, subHours, subDays } from 'date-fns';
+import { validationResult } from 'express-validator/check';
+
+import { IUser } from '../models/user';
+import { addCooldown, banUser } from '../db/user';
+import { getIP } from '../db/ip';
+import { getUserLinksCount } from '../db/link';
+import { getDomain } from '../db/domain';
+import { getHost } from '../db/host';
+import { addProtocol } from '../utils';
 
 const dnsLookup = promisify(dns.lookup);
 
-exports.validationCriterias = [
+export const validationCriterias = [
   validator
     .body('email')
     .exists()
@@ -29,7 +34,7 @@ exports.validationCriterias = [
     .isLength({ min: 8 }),
 ];
 
-exports.validateBody = (req, res, next) => {
+export const validateBody = (req, res, next) => {
   const errors = validationResult(req);
   if (!errors.isEmpty()) {
     const errorsObj = errors.mapped();
@@ -40,7 +45,7 @@ exports.validateBody = (req, res, next) => {
   return next();
 };
 
-const preservedUrls = [
+export const preservedUrls = [
   'login',
   'logout',
   'signup',
@@ -59,64 +64,72 @@ const preservedUrls = [
   'terms',
   'privacy',
   'report',
+  'pricing',
 ];
 
-exports.preservedUrls = preservedUrls;
-
-exports.validateUrl = async ({ body, user }, res, next) => {
+export const validateUrl: RequestHandler = async (req, res, next) => {
   // Validate URL existence
-  if (!body.target) return res.status(400).json({ error: 'No target has been provided.' });
+  if (!req.body.target)
+    return res.status(400).json({ error: 'No target has been provided.' });
 
   // validate URL length
-  if (body.target.length > 3000) {
+  if (req.body.target.length > 3000) {
     return res.status(400).json({ error: 'Maximum URL length is 3000.' });
   }
 
   // Validate URL
-  const isValidUrl = urlRegex({ exact: true, strict: false }).test(body.target);
-  if (!isValidUrl && !/^\w+:\/\//.test(body.target))
+  const isValidUrl = urlRegex({ exact: true, strict: false }).test(
+    req.body.target
+  );
+  if (!isValidUrl && !/^\w+:\/\//.test(req.body.target))
     return res.status(400).json({ error: 'URL is not valid.' });
 
   // If target is the URL shortener itself
-  const { host } = URL.parse(addProtocol(body.target));
+  const { host } = URL.parse(addProtocol(req.body.target));
   if (host === process.env.DEFAULT_DOMAIN) {
-    return res.status(400).json({ error: `${process.env.DEFAULT_DOMAIN} URLs are not allowed.` });
+    return res
+      .status(400)
+      .json({ error: `${process.env.DEFAULT_DOMAIN} URLs are not allowed.` });
   }
 
   // Validate password length
-  if (body.password && body.password.length > 64) {
+  if (req.body.password && req.body.password.length > 64) {
     return res.status(400).json({ error: 'Maximum password length is 64.' });
   }
 
   // Custom URL validations
-  if (user && body.customurl) {
+  if (req.user && req.body.customurl) {
     // Validate custom URL
-    if (!/^[a-zA-Z0-9-_]+$/g.test(body.customurl.trim())) {
+    if (!/^[a-zA-Z0-9-_]+$/g.test(req.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." });
+    if (preservedUrls.some(url => url === req.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.' });
+    if (req.body.customurl.length > 64) {
+      return res
+        .status(400)
+        .json({ error: 'Maximum custom URL length is 64.' });
     }
   }
 
   return next();
 };
 
-exports.cooldownCheck = async user => {
+export const cooldownCheck = async (user: IUser) => {
   if (user && user.cooldowns) {
     if (user.cooldowns.length > 4) {
       await banUser(user._id);
       throw new Error('Too much malware requests. You are now banned.');
     }
     const hasCooldownNow = user.cooldowns.some(
-      cooldown => cooldown > subHours(new Date(), 12).toJSON()
+      cooldown => cooldown.toJSON() > subHours(new Date(), 12).toJSON()
     );
     if (hasCooldownNow) {
       throw new Error('Cooldown because of a malware URL. Wait 12h');
@@ -124,20 +137,23 @@ exports.cooldownCheck = async user => {
   }
 };
 
-exports.ipCooldownCheck = async (req, res, next) => {
+export const ipCooldownCheck: RequestHandler = async (req, res, next) => {
   const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
   if (req.user || !cooldownConfig) return next();
-  const ip = await getIp(req.realIp);
+  const ip = await getIP(req.realIP);
   if (ip) {
-    const timeToWait = cooldownConfig - differenceInMinutes(new Date(), ip.createdAt);
-    return res
-      .status(400)
-      .json({ error: `Non-logged in users are limited. Wait ${timeToWait} minutes or log in.` });
+    const timeToWait =
+      cooldownConfig - differenceInMinutes(new Date(), ip.createdAt);
+    return res.status(400).json({
+      error:
+        `Non-logged in users are limited. Wait ${timeToWait} ` +
+        'minutes or log in.',
+    });
   }
   next();
 };
 
-exports.malwareCheck = async (user, target) => {
+export const malwareCheck = async (user: IUser, target: string) => {
   const isMalware = await axios.post(
     `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${
       process.env.GOOGLE_SAFE_BROWSING_KEY
@@ -156,7 +172,11 @@ exports.malwareCheck = async (user, target) => {
           'POTENTIALLY_HARMFUL_APPLICATION',
         ],
         platformTypes: ['ANY_PLATFORM', 'PLATFORM_TYPE_UNSPECIFIED'],
-        threatEntryTypes: ['EXECUTABLE', 'URL', 'THREAT_ENTRY_TYPE_UNSPECIFIED'],
+        threatEntryTypes: [
+          'EXECUTABLE',
+          'URL',
+          'THREAT_ENTRY_TYPE_UNSPECIFIED',
+        ],
         threatEntries: [{ url: target }],
       },
     }
@@ -165,34 +185,41 @@ exports.malwareCheck = async (user, target) => {
     if (user) {
       await addCooldown(user._id);
     }
-    throw new Error(user ? 'Malware detected! Cooldown for 12h.' : 'Malware detected!');
+    throw new Error(
+      user ? 'Malware detected! Cooldown for 12h.' : 'Malware detected!'
+    );
   }
 };
 
-exports.urlCountsCheck = async email => {
-  const { count } = await urlCountFromDate({
-    email,
-    date: subDay(new Date(), 1).toJSON(),
+export const urlCountsCheck = async (user: IUser) => {
+  const count = await getUserLinksCount({
+    user: user._id,
+    date: subDays(new Date(), 1),
   });
   if (count > Number(process.env.USER_LIMIT_PER_DAY)) {
     throw new Error(
-      `You have reached your daily limit (${process.env.USER_LIMIT_PER_DAY}). Please wait 24h.`
+      `You have reached your daily limit (${
+        process.env.USER_LIMIT_PER_DAY
+      }). Please wait 24h.`
     );
   }
 };
 
-exports.checkBannedDomain = async domain => {
-  const isDomainBanned = await getBannedDomain(domain);
-  if (isDomainBanned) {
+export const checkBannedDomain = async (domain: string) => {
+  const bannedDomain = await getDomain({ name: domain, banned: true });
+  if (bannedDomain) {
     throw new Error('URL is containing malware/scam.');
   }
 };
 
-exports.checkBannedHost = async domain => {
+export const checkBannedHost = async (domain: string) => {
   let isHostBanned;
   try {
     const dnsRes = await dnsLookup(domain);
-    isHostBanned = await getBannedHost(dnsRes && dnsRes.address);
+    isHostBanned = await getHost({
+      address: dnsRes && dnsRes.address,
+      banned: true,
+    });
   } catch (error) {
     isHostBanned = null;
   }

+ 0 - 8
server/cron.js

@@ -1,8 +0,0 @@
-const cron = require('node-cron');
-const { clearIps } = require('./db/user');
-
-if (Number(process.env.NON_USER_COOLDOWN)) {
-  cron.schedule('* */24 * * *', () => {
-    clearIps().catch();
-  });
-}

+ 9 - 0
server/cron.ts

@@ -0,0 +1,9 @@
+import cron from 'node-cron';
+
+import { clearIPs } from "./db/ip";
+
+if (Number(process.env.NON_USER_COOLDOWN)) {
+  cron.schedule('* */24 * * *', () => {
+    clearIPs().catch();
+  });
+}

+ 58 - 0
server/db/domain.ts

@@ -0,0 +1,58 @@
+import { Types } from 'mongoose';
+
+import Domain, { IDomain } from '../models/domain';
+import User from '../models/user';
+import * as redis from '../redis';
+
+export const getDomain = async (data: Partial<IDomain>) => {
+  const redisKey = `${data.name}-${data.banned ? 'y' : 'n'}`;
+  const cachedDomain = await redis.get(redisKey);
+
+  if (cachedDomain) return JSON.parse(cachedDomain);
+
+  const domain = await Domain.findOne(data);
+
+  redis.set(redisKey, JSON.stringify(domain), 'EX', 60 * 60 * 6);
+
+  return domain;
+};
+
+export const setDomain = async (data: Partial<IDomain>) => {
+  const [domain] = await Promise.all([
+    Domain.create({
+      name: data.name,
+      homepage: data.homepage,
+      user: data.user,
+    }),
+    Domain.findOneAndUpdate({ user: data.user }, { user: undefined }),
+  ]);
+  await User.findByIdAndUpdate(data.user, { domain });
+  return domain;
+};
+
+export const deleteDomain = async (user: Types.ObjectId) => {
+  const [domain] = await Promise.all([
+    Domain.findOneAndUpdate({ user }, { user: undefined }),
+    User.findByIdAndUpdate(user, { domain: undefined }),
+  ]);
+
+  if (domain) {
+    redis.del(`${domain.name}-${domain.banned ? 'y' : 'n'}`);
+  }
+
+  return domain;
+};
+
+export const banDomain = async (name: string, bannedBy?: Types.ObjectId) => {
+  const domain = await Domain.findOneAndUpdate(
+    { name },
+    { banned: true, bannedBy },
+    { upsert: true }
+  );
+
+  if (domain) {
+    redis.del(`${domain.name}-${domain.banned ? 'y' : 'n'}`);
+  }
+
+  return domain;
+};

+ 31 - 0
server/db/host.ts

@@ -0,0 +1,31 @@
+import { Types } from 'mongoose';
+
+import Host, { IHost } from '../models/host';
+import * as redis from '../redis';
+
+export const getHost = async (data: Partial<IHost>) => {
+  const redisKey = `${data.address}-${data.banned ? 'y' : 'n'}`;
+  const cachedHost = await redis.get(redisKey);
+
+  if (cachedHost) return JSON.parse(cachedHost);
+
+  const host = await Host.findOne(data);
+
+  redis.set(redisKey, JSON.stringify(host), 'EX', 60 * 60 * 6);
+
+  return host;
+};
+
+export const banHost = async (address: string, bannedBy?: Types.ObjectId) => {
+  const host = await Host.findOneAndUpdate(
+    { address },
+    { banned: true, bannedBy },
+    { upsert: true }
+  );
+
+  if (host) {
+    redis.del(`${host.address}-${host.banned ? 'y' : 'n'}`);
+  }
+
+  return host;
+};

+ 26 - 0
server/db/ip.ts

@@ -0,0 +1,26 @@
+import subMinutes from 'date-fns/sub_minutes';
+import IP from '../models/ip';
+
+export const addIP = async (newIP: string) => {
+  const ip = await IP.findOneAndUpdate(
+    { ip: newIP },
+    { ip: newIP, createdAt: new Date() },
+    { new: true, upsert: true, runValidators: true }
+  );
+  return ip;
+};
+export const getIP = async (ip: string) => {
+  const matchedIp = await IP.findOne({
+    ip,
+    createdAt: {
+      $gt: subMinutes(new Date(), Number(process.env.NON_USER_COOLDOWN)),
+    },
+  });
+  return matchedIp;
+};
+export const clearIPs = async () =>
+  IP.deleteMany({
+    createdAt: {
+      $lt: subMinutes(new Date(), Number(process.env.NON_USER_COOLDOWN)),
+    },
+  });

+ 465 - 0
server/db/link.ts

@@ -0,0 +1,465 @@
+import bcrypt from 'bcryptjs';
+import _ from 'lodash';
+import { isAfter, subDays } from 'date-fns';
+import { Types } from 'mongoose';
+
+import Link, { ILink } from '../models/link';
+import Visit from '../models/visit';
+import Domain, { IDomain } from '../models/domain';
+import {
+  generateShortLink,
+  statsObjectToArray,
+  getDifferenceFunction,
+  getUTCDate,
+} from '../utils';
+import { getDomain, banDomain } from './domain';
+import * as redis from '../redis';
+import { banHost } from './host';
+import { banUser } from './user';
+
+interface ICreateLink extends ILink {
+  reuse?: boolean;
+}
+
+export const createShortLink = async (data: ICreateLink) => {
+  let password;
+  if (data.password) {
+    const salt = await bcrypt.genSalt(12);
+    password = await bcrypt.hash(data.password, salt);
+  }
+
+  const link = await Link.create({
+    id: data.id,
+    password,
+    target: data.target,
+    user: data.user,
+    domain: data.domain,
+  });
+
+  return {
+    ...link,
+    password: !!data.password,
+    reuse: !!data.reuse,
+    shortLink: generateShortLink(
+      data.id,
+      data.domain && (data.domain as IDomain).name
+    ),
+  };
+};
+
+export const addLinkCount = async (
+  id: Types.ObjectId,
+  customDomain?: string
+) => {
+  const domain = await (customDomain && getDomain({ name: customDomain }));
+  const url = await Link.findOneAndUpdate(
+    { id, domain: domain || { $exists: false } },
+    { $inc: { count: 1 } }
+  );
+  return url;
+};
+
+interface ICreateVisit {
+  browser: string;
+  country: string;
+  domain?: string;
+  id: string;
+  limit: number;
+  os: string;
+  referrer: string;
+}
+
+export const createVisit = async (params: ICreateVisit) => {
+  const domain = await (params.domain && getDomain({ name: params.domain }));
+  const link = await Link.findOne({
+    id: params.id,
+    domain: domain || { $exists: false },
+  });
+
+  if (link.count > params.limit) return null;
+
+  const visit = await Visit.findOneAndUpdate(
+    {
+      data: getUTCDate().toJSON(),
+      link,
+    },
+    {
+      $inc: {
+        [`browser.${params.browser}`]: 1,
+        [`country.${params.country}`]: 1,
+        [`os.${params.os}`]: 1,
+        [`referrer.${params.referrer}`]: 1,
+        total: 1,
+      },
+    },
+    { upsert: true }
+  );
+  return visit;
+};
+
+interface IFindLink {
+  id?: string;
+  domain?: Types.ObjectId | string;
+  user?: Types.ObjectId | string;
+  target?: string;
+}
+
+export const findLink = async (
+  { id = '', domain = '', user = '', target }: IFindLink,
+  options?: { forceDomainCheck?: boolean }
+) => {
+  const redisKey = id + domain.toString() + user.toString();
+  const cachedLink = await redis.get(redisKey);
+
+  if (cachedLink) return JSON.parse(cachedLink);
+
+  const link = await Link.findOne({
+    ...(id && { id }),
+    ...(domain && { domain }),
+    ...(options.forceDomainCheck && { domain: domain || { $exists: false } }),
+    ...(user && { user }),
+    ...(target && { target }),
+  }).populate('domain');
+
+  redis.set(redisKey, JSON.stringify(link), 'EX', 60 * 60 * 2);
+
+  // TODO: Get user?
+  return link;
+};
+
+export const getUserLinksCount = async (params: {
+  user: Types.ObjectId;
+  date?: Date;
+}) => {
+  const count = await Link.find({
+    user: params.user,
+    ...(params.date && { createdAt: { $gt: params.date } }),
+  }).count();
+  return count;
+};
+
+interface IGetLinksOptions {
+  count?: string;
+  page?: string;
+  search?: string;
+}
+
+export const getLinks = async (
+  user: Types.ObjectId,
+  options: IGetLinksOptions = {}
+) => {
+  const { count = '5', page = '1', search = '' } = options;
+  const limit = parseInt(count, 10);
+  const skip = parseInt(page, 10);
+  const $regex = new RegExp(`.*${search}.*`, 'i');
+
+  const matchedLinks = await Link.find({
+    user,
+    $or: [{ id: { $regex } }, { target: { $regex } }],
+  })
+    .sort({ createdAt: -1 })
+    .skip(skip)
+    .limit(limit)
+    .populate('domain');
+
+  const links = matchedLinks.map(link => ({
+    ...link,
+    password: !!link.password,
+    shortLink: generateShortLink(
+      link.id,
+      link.domain && (link.domain as IDomain).name
+    ),
+  }));
+
+  return links;
+};
+
+interface IDeleteLink {
+  id: string;
+  user: Types.ObjectId;
+  domain?: Types.ObjectId;
+}
+
+export const deleteLink = async (data: IDeleteLink) => {
+  const link = await Link.findOneAndDelete({
+    id: data.id,
+    user: data.user,
+    domain: data.domain || { $exists: false },
+  });
+  await Visit.deleteMany({ link });
+
+  const domainKey = link.domain ? link.domain.toString() : '';
+  const userKey = link.user ? link.user.toString() : '';
+  redis.del(link.id + domainKey);
+  redis.del(link.id + domainKey + userKey);
+
+  return link;
+};
+
+/*
+ ** Collecting stats
+ */
+
+interface IStats {
+  browser: Record<
+    'chrome' | 'edge' | 'firefox' | 'ie' | 'opera' | 'other' | 'safari',
+    number
+  >;
+  os: Record<
+    'android' | 'ios' | 'linux' | 'macos' | 'other' | 'windows',
+    number
+  >;
+  country: Record<string, number>;
+  referrer: Record<string, number>;
+  dates: Date[];
+}
+
+interface Stats {
+  stats: IStats;
+  views: number[];
+}
+
+const INIT_STATS: IStats = {
+  browser: {
+    chrome: 0,
+    edge: 0,
+    firefox: 0,
+    ie: 0,
+    opera: 0,
+    other: 0,
+    safari: 0,
+  },
+  os: {
+    android: 0,
+    ios: 0,
+    linux: 0,
+    macos: 0,
+    other: 0,
+    windows: 0,
+  },
+  country: {},
+  referrer: {},
+  dates: [],
+};
+
+const STATS_PERIODS: [number, 'lastDay' | 'lastWeek' | 'lastMonth'][] = [
+  [1, 'lastDay'],
+  [7, 'lastWeek'],
+  [30, 'lastMonth'],
+];
+
+interface IGetStats {
+  domain: Types.ObjectId;
+  id: string;
+  user: Types.ObjectId;
+}
+
+interface IGetStatsResponse {
+  allTime: Stats;
+  id: string;
+  lastDay: Stats;
+  lastMonth: Stats;
+  lastWeek: Stats;
+  shortLink: string;
+  target: string;
+  total: number;
+  updatedAt: string;
+}
+
+export const getStats = async (data: IGetStats) => {
+  const stats = {
+    lastDay: {
+      stats: _.cloneDeep(INIT_STATS),
+      views: new Array(24).fill(0),
+    },
+    lastWeek: {
+      stats: _.cloneDeep(INIT_STATS),
+      views: new Array(7).fill(0),
+    },
+    lastMonth: {
+      stats: _.cloneDeep(INIT_STATS),
+      views: new Array(30).fill(0),
+    },
+    allTime: {
+      stats: _.cloneDeep(INIT_STATS),
+      views: new Array(18).fill(0),
+    },
+  };
+
+  const domain = await (data.domain && Domain.findOne({ name: data.domain }));
+  const link = await Link.findOne({
+    id: data.id,
+    user: data.user,
+    ...(domain && { domain }),
+  });
+
+  if (!link) throw new Error("Couldn't get stats for this link.");
+
+  const visits = await Visit.find({
+    link: link.id,
+  });
+
+  visits.forEach(visit => {
+    STATS_PERIODS.forEach(([days, type]) => {
+      const isIncluded = isAfter(visit.date, subDays(getUTCDate(), days));
+      if (isIncluded) {
+        const diffFunction = getDifferenceFunction(type);
+        const now = new Date();
+        const diff = diffFunction(now, visit.date);
+        const index = stats[type].views.length - diff - 1;
+        const view = stats[type].views[index];
+        const period = stats[type].stats;
+        stats[type].stats = {
+          browser: {
+            chrome: period.chrome + visit.browser.chrome,
+            edge: period.edge + visit.browser.edge,
+            firefox: period.firefox + visit.browser.firefox,
+            ie: period.ie + visit.browser.ie,
+            opera: period.opera + visit.browser.opera,
+            other: period.other + visit.browser.other,
+            safari: period.safari + visit.browser.safari,
+          },
+          os: {
+            android: period.android + visit.os.android,
+            ios: period.ios + visit.os.ios,
+            linux: period.linux + visit.os.linux,
+            macos: period.macos + visit.os.macos,
+            other: period.other + visit.os.other,
+            windows: period.windows + visit.os.windows,
+          },
+          country: {
+            ...period.country,
+            ...Object.keys(visit.country).reduce(
+              (obj, key) => ({
+                ...obj,
+                [key]: period.country[key] + visit.country[key],
+              }),
+              {}
+            ),
+          },
+          referrer: {
+            ...period.referrer,
+            ...Object.keys(visit.referrer).reduce(
+              (obj, key) => ({
+                ...obj,
+                [key]: period.referrer[key] + visit.referrer[key],
+              }),
+              {}
+            ),
+          },
+        };
+        stats[type].views[index] = view + 1 || 1;
+      }
+    });
+
+    const allTime = stats.allTime.stats;
+    const diffFunction = getDifferenceFunction('allTime');
+    const now = new Date();
+    const diff = diffFunction(now, visit.date);
+    const index = stats.allTime.views.length - diff - 1;
+    const view = stats.allTime.views[index];
+    stats.allTime.stats = {
+      browser: {
+        chrome: allTime.chrome + visit.browser.chrome,
+        edge: allTime.edge + visit.browser.edge,
+        firefox: allTime.firefox + visit.browser.firefox,
+        ie: allTime.ie + visit.browser.ie,
+        opera: allTime.opera + visit.browser.opera,
+        other: allTime.other + visit.browser.other,
+        safari: allTime.safari + visit.browser.safari,
+      },
+      os: {
+        android: allTime.android + visit.os.android,
+        ios: allTime.ios + visit.os.ios,
+        linux: allTime.linux + visit.os.linux,
+        macos: allTime.macos + visit.os.macos,
+        other: allTime.other + visit.os.other,
+        windows: allTime.windows + visit.os.windows,
+      },
+      country: {
+        ...allTime.country,
+        ...Object.keys(visit.country).reduce(
+          (obj, key) => ({
+            ...obj,
+            [key]: allTime.country[key] + visit.country[key],
+          }),
+          {}
+        ),
+      },
+      referrer: {
+        ...allTime.referrer,
+        ...Object.keys(visit.referrer).reduce(
+          (obj, key) => ({
+            ...obj,
+            [key]: allTime.referrer[key] + visit.referrer[key],
+          }),
+          {}
+        ),
+      },
+    };
+    stats.allTime.views[index] = view + 1 || 1;
+  });
+
+  stats.lastDay.stats = statsObjectToArray(stats.lastDay.stats);
+  stats.lastWeek.stats = statsObjectToArray(stats.lastWeek.stats);
+  stats.lastMonth.stats = statsObjectToArray(stats.lastMonth.stats);
+  stats.allTime.stats = statsObjectToArray(stats.allTime.stats);
+  const response: IGetStatsResponse = {
+    allTime: stats.allTime,
+    id: link.id,
+    lastDay: stats.lastDay,
+    lastMonth: stats.lastMonth,
+    lastWeek: stats.lastWeek,
+    shortLink: generateShortLink(
+      link.id,
+      link.domain && (link.domain as IDomain).name
+    ),
+    target: link.target,
+    total: link.count,
+    updatedAt: new Date().toISOString(),
+  };
+  return response;
+};
+
+interface IBanLink {
+  adminId?: Types.ObjectId;
+  banUser?: boolean;
+  domain?: string;
+  host?: string;
+  id: string;
+}
+
+export const banLink = async (data: IBanLink) => {
+  const tasks = [];
+  const bannedBy = data.adminId;
+
+  // Ban link
+  const link = await Link.findOneAndUpdate(
+    { id: data.id },
+    { banned: true, bannedBy },
+    { new: true }
+  );
+
+  if (!link) throw new Error('No link has been found.');
+
+  // If user, ban user and all of their links.
+  if (data.banUser && link.user) {
+    tasks.push(banUser(link.user, bannedBy));
+    tasks.push(
+      Link.updateMany({ user: link.user }, { banned: true, bannedBy })
+    );
+  }
+
+  // Ban host
+  if (data.host) tasks.push(banHost(data.host, bannedBy));
+
+  // Ban domain
+  if (data.domain) tasks.push(banDomain(data.domain, bannedBy));
+
+  const domainKey = link.domain ? link.domain.toString() : '';
+  const userKey = link.user ? link.user.toString() : '';
+  redis.del(link.id + domainKey);
+  redis.del(link.id + domainKey + userKey);
+
+  return Promise.all(tasks);
+};

+ 0 - 8
server/db/neo4j.js

@@ -1,8 +0,0 @@
-const neo4j = require('neo4j-driver').v1;
-
-const driver = neo4j.driver(
-  process.env.DB_URI,
-  neo4j.auth.basic(process.env.DB_USERNAME, process.env.DB_PASSWORD)
-);
-
-module.exports = driver;

+ 0 - 468
server/db/url.js

@@ -1,468 +0,0 @@
-const bcrypt = require('bcryptjs');
-const _ = require('lodash/');
-const { isAfter, subDays } = require('date-fns');
-const driver = require('./neo4j');
-const {
-  generateShortUrl,
-  statsObjectToArray,
-  getDifferenceFunction,
-  getUTCDate,
-} = require('../utils');
-
-const queryNewUrl = 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt }) RETURN l';
-
-const queryNewUserUrl = (domain, password) =>
-  'MATCH (u:USER { email: $email })' +
-  'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt, count: 0 ' +
-  `${password ? ', password: $password' : ''} })` +
-  'CREATE (u)-[:CREATED]->(l)' +
-  `${domain ? 'MERGE (l)-[:USES]->(:DOMAIN { name: $domain })' : ''}` +
-  'RETURN l';
-
-exports.createShortUrl = async params => {
-  const session = driver.session();
-  const query = params.user ? queryNewUserUrl(params.user.domain, params.password) : queryNewUrl;
-  const salt = params.password && (await bcrypt.genSalt(12));
-  const hash = params.password && (await bcrypt.hash(params.password, salt));
-  const { records = [] } = await session.writeTransaction(tx =>
-    tx.run(query, {
-      createdAt: new Date().toJSON(),
-      domain: params.user && params.user.domain,
-      email: params.user && params.user.email,
-      id: params.id,
-      password: hash || '',
-      target: params.target,
-    })
-  );
-  session.close();
-  const data = records[0].get('l').properties;
-  return {
-    ...data,
-    password: !!data.password,
-    reuse: !!params.reuse,
-    count: 0,
-    shortUrl: generateShortUrl(
-      data.id,
-      params.user && params.user.domain,
-      params.user && params.user.useHttps
-    ),
-  };
-};
-
-exports.addUrlCount = async (id, domain) => {
-  const session = driver.session();
-  const { records = [] } = await session.writeTransaction(tx =>
-    tx.run(
-      'MATCH (l:URL { id: $id }) ' +
-        `${domain ? 'MATCH (l)-[:USES]->({ name: $domain })' : ''} ` +
-        'SET l.count = l.count + 1 ' +
-        'RETURN l',
-      {
-        id,
-        domain,
-      }
-    )
-  );
-  session.close();
-  const url = records.length && records[0].get('l').properties;
-  return url;
-};
-
-exports.createVisit = async params => {
-  const session = driver.session();
-  const { records = [] } = await session.writeTransaction(tx =>
-    tx.run(
-      'MATCH (l:URL { id: $id }) WHERE l.count < $limit' +
-        `${params.domain ? 'MATCH (l)-[:USES]->({ name: $domain })' : ''} ` +
-        'CREATE (v:VISIT)' +
-        'MERGE (b:BROWSER { browser: $browser })' +
-        'MERGE (c:COUNTRY { country: $country })' +
-        'MERGE (o:OS { os: $os })' +
-        'MERGE (r:REFERRER { referrer: $referrer })' +
-        'MERGE (d:DATE { date: $date })' +
-        'MERGE (v)-[:VISITED]->(l)' +
-        'MERGE (v)-[:BROWSED_BY]->(b)' +
-        'MERGE (v)-[:LOCATED_IN]->(c)' +
-        'MERGE (v)-[:OS]->(o)' +
-        'MERGE (v)-[:REFERRED_BY]->(r)' +
-        'MERGE (v)-[:VISITED_IN]->(d)' +
-        'RETURN l',
-      {
-        id: params.id,
-        browser: params.browser,
-        domain: params.domain,
-        country: params.country,
-        os: params.os,
-        referrer: params.referrer,
-        date: getUTCDate().toJSON(),
-        limit: params.limit,
-      }
-    )
-  );
-  session.close();
-  const url = records.length && records[0].get('l').properties;
-  return url;
-};
-
-exports.findUrl = async ({ id, domain, target }) => {
-  const session = driver.session();
-  const { records = [] } = await session.readTransaction(tx =>
-    tx.run(
-      `MATCH (l:URL { ${id ? 'id: $id' : 'target: $target'} })` +
-        `${
-          domain
-            ? 'MATCH (l)-[:USES]->(d:DOMAIN { name: $domain })'
-            : 'OPTIONAL MATCH (l)-[:USES]->(d)'
-        }` +
-        'OPTIONAL MATCH (u)-[:CREATED]->(l)' +
-        'RETURN l, d.name AS domain, u AS user',
-      {
-        id,
-        domain,
-        target,
-      }
-    )
-  );
-  session.close();
-  const url =
-    records.length &&
-    records.map(record => ({
-      ...record.get('l').properties,
-      domain: record.get('domain'),
-      user: (record.get('user') || {}).properties,
-    }));
-  return url;
-};
-
-exports.getCountUrls = async ({ user }) => {
-  const session = driver.session();
-  const { records = [] } = await session.readTransaction(tx =>
-    tx.run('MATCH (u:USER {email: $email}) RETURN size((u)-[:CREATED]->()) as count', {
-      email: user.email,
-    })
-  );
-  session.close();
-  const countAll = records.length ? records[0].get('count').toNumber() : 0;
-  return { countAll };
-};
-
-exports.getUrls = async ({ user, options, setCount }) => {
-  const session = driver.session();
-  const { count = 5, page = 1, search = '' } = options;
-  const limit = parseInt(count, 10);
-  const skip = parseInt(page, 10);
-  const searchQuery = search ? 'WHERE l.id =~ $search OR l.target =~ $search' : '';
-  const setVisitsCount = setCount ? 'SET l.count = size((l)<-[:VISITED]-())' : '';
-  const { records = [] } = await session.readTransaction(tx =>
-    tx.run(
-      `MATCH (u:USER { email: $email })-[:CREATED]->(l) ${searchQuery} ` +
-        'WITH l ORDER BY l.createdAt DESC ' +
-        'WITH l SKIP $skip LIMIT $limit ' +
-        `OPTIONAL MATCH (l)-[:USES]->(d) ${setVisitsCount} ` +
-        'RETURN l, d.name AS domain, d.useHttps as useHttps',
-      {
-        email: user.email,
-        limit,
-        skip: limit * (skip - 1),
-        search: `(?i).*${search}.*`,
-      }
-    )
-  );
-  session.close();
-  const urls = records.map(record => {
-    const visitCount = record.get('l').properties.count;
-    const domain = record.get('domain');
-    const protocol = record.get('useHttps') || !domain ? 'https://' : 'http://';
-    return {
-      ...record.get('l').properties,
-      count: typeof visitCount === 'object' ? visitCount.toNumber() : visitCount,
-      password: !!record.get('l').properties.password,
-      shortUrl: `${protocol}${domain || process.env.DEFAULT_DOMAIN}/${
-        record.get('l').properties.id
-      }`,
-    };
-  });
-  return { list: urls };
-};
-
-exports.getCustomDomain = async ({ customDomain }) => {
-  const session = driver.session();
-  const { records = [] } = await session.readTransaction(tx =>
-    tx.run(
-      'MATCH (d:DOMAIN { name: $customDomain })<-[:OWNS]-(u) RETURN u.email as email, d.homepage as homepage',
-      {
-        customDomain,
-      }
-    )
-  );
-  session.close();
-  const data = records.length
-    ? {
-        email: records[0].get('email'),
-        homepage: records[0].get('homepage'),
-      }
-    : {};
-  return data;
-};
-
-exports.setCustomDomain = async ({ user, customDomain, homepage, useHttps }) => {
-  const session = driver.session();
-  const { records = [] } = await session.writeTransaction(tx =>
-    tx.run(
-      'MATCH (u:USER { email: $email }) ' +
-        'OPTIONAL MATCH (u)-[r:OWNS]->() DELETE r ' +
-        `MERGE (d:DOMAIN { name: $customDomain, homepage: $homepage, useHttps: $useHttps }) ` +
-        'MERGE (u)-[:OWNS]->(d) RETURN u, d',
-      {
-        customDomain,
-        homepage: homepage || '',
-        email: user.email,
-        useHttps: !!useHttps,
-      }
-    )
-  );
-  session.close();
-  const data = records.length && records[0].get('d').properties;
-  return data;
-};
-
-exports.deleteCustomDomain = async ({ user }) => {
-  const session = driver.session();
-  const { records = [] } = await session.writeTransaction(tx =>
-    tx.run('MATCH (u:USER { email: $email }) MATCH (u)-[r:OWNS]->() DELETE r RETURN u', {
-      email: user.email,
-    })
-  );
-  session.close();
-  const data = records.length && records[0].get('u').properties;
-  return data;
-};
-
-exports.deleteUrl = async ({ id, domain, user }) => {
-  const session = driver.session();
-  const { records = [] } = await session.writeTransaction(tx =>
-    tx.run(
-      'MATCH (u:USER { email: $email }) ' +
-        'MATCH (u)-[:CREATED]->(l { id: $id }) ' +
-        `${
-          domain
-            ? 'MATCH (l)-[:USES]->(:DOMAIN { name: $domain })'
-            : 'MATCH (l) WHERE NOT (l)-[:USES]->()'
-        }` +
-        'OPTIONAL MATCH (l)-[:MATCHES]->(v) ' +
-        'DETACH DELETE l, v RETURN u',
-      {
-        email: user.email,
-        domain,
-        id,
-      }
-    )
-  );
-  session.close();
-  const data = records.length && records[0].get('u').properties;
-  return data;
-};
-
-/*
- ** Collecting stats
- */
-
-const initialStats = {
-  browser: {
-    IE: 0,
-    Firefox: 0,
-    Chrome: 0,
-    Opera: 0,
-    Safari: 0,
-    Edge: 0,
-    Other: 0,
-  },
-  os: {
-    Windows: 0,
-    'Mac Os X': 0,
-    Linux: 0,
-    'Chrome OS': 0,
-    Android: 0,
-    iOS: 0,
-    Other: 0,
-  },
-  country: {},
-  referrer: {},
-  dates: [],
-};
-
-exports.getStats = ({ id, domain, user }) =>
-  new Promise((resolve, reject) => {
-    const session = driver.session();
-
-    const stats = {
-      lastDay: {
-        stats: _.cloneDeep(initialStats),
-        views: new Array(24).fill(0),
-      },
-      lastWeek: {
-        stats: _.cloneDeep(initialStats),
-        views: new Array(7).fill(0),
-      },
-      lastMonth: {
-        stats: _.cloneDeep(initialStats),
-        views: new Array(30).fill(0),
-      },
-      allTime: {
-        stats: _.cloneDeep(initialStats),
-        views: new Array(18).fill(0),
-      },
-    };
-
-    const statsPeriods = [[1, 'lastDay'], [7, 'lastWeek'], [30, 'lastMonth']];
-
-    session
-      .run(
-        'MATCH (l:URL { id: $id })<-[:CREATED]-(u:USER { email: $email }) ' +
-          `${domain ? 'MATCH (l)-[:USES]->(domain { name: $domain })' : ''}` +
-          'MATCH (v)-[:VISITED]->(l) ' +
-          'MATCH (v)-[:BROWSED_BY]->(b) ' +
-          'MATCH (v)-[:LOCATED_IN]->(c) ' +
-          'MATCH (v)-[:OS]->(o) ' +
-          'MATCH (v)-[:REFERRED_BY]->(r) ' +
-          'MATCH (v)-[:VISITED_IN]->(d) ' +
-          'WITH l, b.browser AS browser, c.country AS country, ' +
-          'o.os AS os, r.referrer AS referrer, d.date AS date ' +
-          'RETURN l, browser, country, os, referrer, date ' +
-          'ORDER BY date DESC',
-        {
-          email: user.email,
-          domain,
-          id,
-        }
-      )
-      .subscribe({
-        onNext(record) {
-          const browser = record.get('browser');
-          const os = record.get('os');
-          const country = record.get('country');
-          const referrer = record.get('referrer');
-          const date = record.get('date');
-
-          statsPeriods.forEach(([days, type]) => {
-            const isIncluded = isAfter(date, subDays(getUTCDate(), days));
-            if (isIncluded) {
-              const period = stats[type].stats;
-              const diffFunction = getDifferenceFunction(type);
-              const now = new Date();
-              const diff = diffFunction(now, date);
-              const index = stats[type].views.length - diff - 1;
-              const view = stats[type].views[index];
-              period.browser[browser] += 1;
-              period.os[os] += 1;
-              period.country[country] = period.country[country] + 1 || 1;
-              period.referrer[referrer] = period.referrer[referrer] + 1 || 1;
-              stats[type].views[index] = view + 1 || 1;
-            }
-          });
-
-          const allTime = stats.allTime.stats;
-          const diffFunction = getDifferenceFunction('allTime');
-          const now = new Date();
-          const diff = diffFunction(now, date);
-          const index = stats.allTime.views.length - diff - 1;
-          const view = stats.allTime.views[index];
-          allTime.browser[browser] += 1;
-          allTime.os[os] += 1;
-          allTime.country[country] = allTime.country[country] + 1 || 1;
-          allTime.referrer[referrer] = allTime.referrer[referrer] + 1 || 1;
-          allTime.dates = [...allTime.dates, date];
-          stats.allTime.views[index] = view + 1 || 1;
-        },
-        onCompleted() {
-          stats.lastDay.stats = statsObjectToArray(stats.lastDay.stats);
-          stats.lastWeek.stats = statsObjectToArray(stats.lastWeek.stats);
-          stats.lastMonth.stats = statsObjectToArray(stats.lastMonth.stats);
-          stats.allTime.stats = statsObjectToArray(stats.allTime.stats);
-          const response = {
-            id,
-            updatedAt: new Date().toISOString(),
-            lastDay: stats.lastDay,
-            lastWeek: stats.lastWeek,
-            lastMonth: stats.lastMonth,
-            allTime: stats.allTime,
-          };
-          return resolve(response);
-        },
-        onError(error) {
-          session.close();
-          return reject(error);
-        },
-      });
-  });
-
-exports.urlCountFromDate = async ({ date, email }) => {
-  const session = driver.session();
-  const { records = [] } = await session.readTransaction(tx =>
-    tx.run(
-      'MATCH (u:USER { email: $email })-[:CREATED]->(l) WHERE l.createdAt > $date ' +
-        'WITH COUNT(l) as count RETURN count',
-      {
-        date,
-        email,
-      }
-    )
-  );
-  session.close();
-  const count = records.length && records[0].get('count').toNumber();
-  return { count };
-};
-
-exports.banUrl = async ({ adminEmail, id, domain, host, user }) => {
-  const session = driver.session();
-  const userQuery = user
-    ? 'OPTIONAL MATCH (u:USER)-[:CREATED]->(l) SET u.banned = true WITH a, u ' +
-      'OPTIONAL MATCH (u)-[:CREATED]->(ls:URL) SET ls.banned = true WITH a, u, ls ' +
-      'WHERE u.email IS NOT NULL MERGE (a)-[:BANNED]->(u) MERGE (a)-[:BANNED]->(ls) '
-    : '';
-  const domainQuery = domain
-    ? 'MERGE (d:DOMAIN { name: $domain }) ON CREATE SET d.banned = true WITH a, d ' +
-      'WHERE d.name IS NOT NULL MERGE (a)-[:BANNED]->(d)'
-    : '';
-  const hostQuery = host
-    ? 'MERGE (h:HOST { name: $host }) ON CREATE SET h.banned = true WITH a, h ' +
-      'WHERE h.name IS NOT NULL MERGE (a)-[:BANNED]->(h)'
-    : '';
-  await session.writeTransaction(tx =>
-    tx.run(
-      'MATCH (l:URL { id: $id }) WHERE NOT (l)-[:USES]->(:DOMAIN) ' +
-        'MATCH (a:USER) WHERE a.email = $adminEmail ' +
-        'SET l.banned = true WITH l, a MERGE (a)-[:BANNED]->(l) WITH l, a ' +
-        `${userQuery} ${domainQuery} ${hostQuery}`,
-      {
-        adminEmail,
-        id,
-        domain,
-        host,
-      }
-    )
-  );
-  session.close();
-};
-
-exports.getBannedDomain = async (domain = '') => {
-  const session = driver.session();
-  const { records } = await session.readTransaction(tx =>
-    tx.run('MATCH (d:DOMAIN { name: $domain, banned: true }) RETURN d', {
-      domain,
-    })
-  );
-  session.close();
-  return records.length > 0;
-};
-
-exports.getBannedHost = async (host = '') => {
-  const session = driver.session();
-  const { records } = await session.readTransaction(tx =>
-    tx.run('MATCH (h:HOST { name: $host, banned: true }) RETURN h', {
-      host,
-    })
-  );
-  session.close();
-  return records.length > 0;
-};

+ 0 - 154
server/db/user.js

@@ -1,154 +0,0 @@
-const bcrypt = require('bcryptjs');
-const nanoid = require('nanoid');
-const uuid = require('uuid/v4');
-const subMinutes = require('date-fns/sub_minutes');
-const addMinutes = require('date-fns/add_minutes');
-const User = require('../models/user');
-const Ip = require('../models/ip');
-
-exports.getUser = async (emailOrKey = '') => {
-  const user = await User.findOne({
-    $or: [{ email: emailOrKey }, { apikey: emailOrKey }],
-  }).lean();
-  // TODO: Get domains
-
-  // const session = driver.session();
-  // const { records = [] } = await session.readTransaction(tx =>
-  //   tx.run(
-  //     'MATCH (u:USER) WHERE u.email = $email OR u.apikey = $apikey ' +
-  //       'OPTIONAL MATCH (u)-[r:RECEIVED]->(c) WITH u, collect(c.date) as cooldowns ' +
-  //       'OPTIONAL MATCH (u)-[:OWNS]->(d) RETURN u, d, cooldowns',
-  //     {
-  //       apikey,
-  //       email,
-  //     }
-  //   )
-  // );
-  // session.close();
-  // const user = records.length && records[0].get('u').properties;
-  // const cooldowns = records.length && records[0].get('cooldowns');
-  // const domainProps = records.length && records[0].get('d');
-  // const domain = domainProps ? domainProps.properties.name : '';
-  // const homepage = domainProps ? domainProps.properties.homepage : '';
-  // const useHttps = domainProps ? domainProps.properties.useHttps : '';
-  // return user && { ...user, cooldowns, domain, homepage, useHttps };
-  return user;
-};
-
-exports.createUser = async (email, password) => {
-  const salt = await bcrypt.genSalt(12);
-  const hashedPassword = await bcrypt.hash(password, salt);
-
-  const user = await User.findOneAndUpdate(
-    { email },
-    {
-      email,
-      password: hashedPassword,
-      verificationToken: uuid(),
-      verificationExpires: addMinutes(new Date(), 60),
-    },
-    { new: true, upsert: true, runValidators: true, setDefaultsOnInsert: true }
-  );
-
-  return user;
-};
-
-exports.verifyUser = async verificationToken => {
-  const user = await User.findOneAndUpdate(
-    { verificationToken, verificationExpires: { $gt: new Date() } },
-    {
-      verified: true,
-      verificationToken: undefined,
-      verificationExpires: undefined,
-    },
-    { new: true }
-  );
-
-  return user;
-};
-
-exports.changePassword = async (id, newPassword) => {
-  const salt = await bcrypt.genSalt(12);
-  const password = await bcrypt.hash(newPassword, salt);
-
-  const user = await User.findByIdAndUpdate(id, { password }, { new: true });
-
-  return user;
-};
-
-exports.generateApiKey = async id => {
-  const apikey = nanoid(40);
-
-  const user = await User.findByIdAndUpdate(id, { apikey }, { new: true });
-
-  return user;
-};
-
-exports.requestPasswordReset = async email => {
-  const resetPasswordToken = uuid();
-
-  const user = await User.findOneAndUpdate(
-    { email },
-    {
-      resetPasswordToken,
-      resetPasswordExpires: addMinutes(new Date(), 30),
-    },
-    { new: true }
-  );
-
-  return user;
-};
-
-exports.resetPassword = async resetPasswordToken => {
-  const user = await User.findOneAndUpdate(
-    { resetPasswordToken, resetPasswordExpires: { $gt: new Date() } },
-    { resetPasswordExpires: undefined, resetPasswordToken: undefined },
-    { new: true }
-  );
-
-  return user;
-};
-
-exports.addCooldown = async id => {
-  const user = await User.findByIdAndUpdate(
-    id,
-    { $push: { cooldowns: new Date() } },
-    { new: true }
-  );
-
-  return user;
-};
-
-exports.banUser = async id => {
-  const user = await User.findByIdAndUpdate(
-    id,
-    {
-      banned: true,
-    },
-    { new: true }
-  );
-
-  return user;
-};
-
-exports.addIp = async newIp => {
-  const ip = await Ip.findOneAndUpdate(
-    { ip: newIp },
-    { ip: newIp, createdAt: new Date() },
-    { new: true, upsert: true, runValidators: true }
-  );
-  return ip;
-};
-
-exports.getIp = async ip => {
-  const matchedIp = await Ip.findOne({
-    ip,
-    createdAt: { $gt: subMinutes(new Date(), Number(process.env.NON_USER_COOLDOWN)) },
-  });
-  return matchedIp;
-};
-
-exports.clearIps = async () =>
-  Ip.deleteMany({
-    createdAt: { $lt: subMinutes(new Date(), Number(process.env.NON_USER_COOLDOWN)) },
-  });

+ 149 - 0
server/db/user.ts

@@ -0,0 +1,149 @@
+import bcrypt from 'bcryptjs';
+import { Types } from 'mongoose';
+import nanoid from 'nanoid';
+import uuid from 'uuid/v4';
+import addMinutes from 'date-fns/add_minutes';
+
+import User from '../models/user';
+import * as redis from '../redis';
+
+export const getUser = async (emailOrKey: string = '') => {
+  const cachedUser = await redis.get(emailOrKey);
+
+  if (cachedUser) return JSON.parse(cachedUser);
+
+  const user = await User.findOne({
+    $or: [{ email: emailOrKey }, { apikey: emailOrKey }],
+  })
+    .populate('domain')
+    .lean();
+
+  redis.set(emailOrKey, JSON.parse(user), 'EX', 60 * 60 * 1);
+
+  return user;
+};
+
+export const createUser = async (email: string, password: string) => {
+  const salt = await bcrypt.genSalt(12);
+  const hashedPassword = await bcrypt.hash(password, salt);
+
+  const user = await User.findOneAndUpdate(
+    { email },
+    {
+      email,
+      password: hashedPassword,
+      verificationToken: uuid(),
+      verificationExpires: addMinutes(new Date(), 60),
+    },
+    { new: true, upsert: true, runValidators: true, setDefaultsOnInsert: true }
+  );
+
+  redis.del(user.email);
+
+  return user;
+};
+
+export const verifyUser = async (verificationToken: string) => {
+  const user = await User.findOneAndUpdate(
+    { verificationToken, verificationExpires: { $gt: new Date() } },
+    {
+      verified: true,
+      verificationToken: undefined,
+      verificationExpires: undefined,
+    },
+    { new: true }
+  );
+
+  redis.del(user.email);
+
+  return user;
+};
+
+export const changePassword = async (
+  id: Types.ObjectId,
+  newPassword: string
+) => {
+  const salt = await bcrypt.genSalt(12);
+  const password = await bcrypt.hash(newPassword, salt);
+
+  const user = await User.findByIdAndUpdate(id, { password }, { new: true });
+
+  redis.del(user.email);
+  redis.del(user.apikey);
+
+  return user;
+};
+
+export const generateApiKey = async (id: Types.ObjectId) => {
+  const apikey = nanoid(40);
+
+  const user = await User.findByIdAndUpdate(id, { apikey });
+
+  redis.del(user.email);
+  redis.del(user.apikey);
+
+  return { ...user, apikey };
+};
+
+export const requestPasswordReset = async (email: string) => {
+  const resetPasswordToken = uuid();
+
+  const user = await User.findOneAndUpdate(
+    { email },
+    {
+      resetPasswordToken,
+      resetPasswordExpires: addMinutes(new Date(), 30),
+    },
+    { new: true }
+  );
+
+  redis.del(user.email);
+  redis.del(user.apikey);
+
+  return user;
+};
+
+export const resetPassword = async (resetPasswordToken: string) => {
+  const user = await User.findOneAndUpdate(
+    { resetPasswordToken, resetPasswordExpires: { $gt: new Date() } },
+    { resetPasswordExpires: undefined, resetPasswordToken: undefined },
+    { new: true }
+  );
+
+  redis.del(user.email);
+  redis.del(user.apikey);
+
+  return user;
+};
+
+export const addCooldown = async (id: Types.ObjectId) => {
+  const user = await User.findByIdAndUpdate(
+    id,
+    { $push: { cooldowns: new Date() } },
+    { new: true }
+  );
+
+  redis.del(user.email);
+  redis.del(user.apikey);
+
+  return user;
+};
+
+export const banUser = async (
+  id: Types.ObjectId,
+  bannedBy?: Types.ObjectId
+) => {
+  const user = await User.findByIdAndUpdate(
+    id,
+    {
+      banned: true,
+      bannedBy,
+    },
+    { new: true }
+  );
+
+  redis.del(user.email);
+  redis.del(user.apikey);
+
+  return user;
+};

+ 6 - 5
server/mail/mail.js → server/mail/mail.ts

@@ -1,14 +1,15 @@
-const nodemailer = require('nodemailer');
+import nodemailer from 'nodemailer';
 
-const mailConfig = {
+const mailConfig = nodemailer.createTransport({
   host: process.env.MAIL_HOST,
-  port: process.env.MAIL_PORT,
+  port: Number(process.env.MAIL_PORT),
   secure: process.env.MAIL_SECURE === 'true',
   auth: {
     user: process.env.MAIL_USER,
     pass: process.env.MAIL_PASSWORD,
   },
-};
+});
 
 const transporter = nodemailer.createTransport(mailConfig);
-module.exports = transporter;
+
+export default transporter;

+ 3 - 2
server/mail/text.js → server/mail/text.ts

@@ -1,10 +1,11 @@
-exports.verifyMailText = `Thanks for creating an account on Kutt.it.
+/* eslint-disable max-len */
+export const verifyMailText = `Thanks for creating an account on Kutt.it.
 
 Please verify your email address using the link below.
 
 https://{{domain}}/{{verification}}`;
 
-exports.resetMailText = `A password reset has been requested for your account.
+export const resetMailText = `A password reset has been requested for your account.
 
 Please click on the button below to reset your password. There's no need to take any action if you didn't request this.
 

+ 25 - 0
server/models/domain.ts

@@ -0,0 +1,25 @@
+import { Document, model, Schema, Types } from 'mongoose';
+
+export interface IDomain extends Document {
+  banned?: boolean;
+  bannedBy?: Types.ObjectId;
+  createdAt: Date;
+  name: string;
+  homepage?: string;
+  updatedAt?: Date;
+  user?: Types.ObjectId;
+}
+
+const DomainSchema: Schema = new Schema({
+  banned: { type: Boolean, default: false },
+  bannedBy: { type: Schema.Types.ObjectId, ref: 'user' },
+  createdAt: { type: Date, default: Date.now },
+  name: { type: String, unique: true, trim: true, required: true },
+  homepage: { type: String, trim: true },
+  updatedAt: { type: Date, default: Date.now },
+  user: { type: Schema.Types.ObjectId, ref: 'user' },
+});
+
+const Domain = model<IDomain>('domain', DomainSchema);
+
+export default Domain;

+ 23 - 0
server/models/host.ts

@@ -0,0 +1,23 @@
+import { Document, model, Schema, Types } from 'mongoose';
+
+export interface IHost extends Document {
+  address: string;
+  banned?: boolean;
+  bannedBy?: Types.ObjectId;
+  createdAt?: Date;
+  updatedAt?: Date;
+  user?: Types.ObjectId;
+}
+
+const HostSchema: Schema = new Schema({
+  address: { type: String, unique: true, trim: true, required: true },
+  banned: { type: Boolean, default: false },
+  bannedBy: { type: Schema.Types.ObjectId, ref: 'user' },
+  createdAt: { type: Date, default: Date.now },
+  updatedAt: { type: Date, default: Date.now },
+  user: { type: Schema.Types.ObjectId, ref: 'user' },
+});
+
+const Host = model<IHost>('host', HostSchema);
+
+export default Host;

+ 0 - 8
server/models/ip.js

@@ -1,8 +0,0 @@
-const mongoose = require('mongoose');
-
-const IpSchema = new mongoose.Schema({
-  ip: { type: String, required: true, trim: true },
-  createdAt: { type: Date, default: Date.now },
-});
-
-module.exports = mongoose.model('ip', IpSchema);

+ 17 - 0
server/models/ip.ts

@@ -0,0 +1,17 @@
+import { Document, model, Schema } from 'mongoose';
+
+export interface IIP extends Document {
+  createdAt?: Date;
+  updatedAt?: Date;
+  ip: string;
+}
+
+const IpSchema: Schema = new Schema({
+  createdAt: { type: Date, default: Date.now },
+  updatedAt: { type: Date, default: Date.now },
+  ip: { type: String, required: true, trim: true },
+});
+
+const IP = model<IIP>('ip', IpSchema);
+
+export default IP;

+ 32 - 0
server/models/link.ts

@@ -0,0 +1,32 @@
+import { Document, model, Schema, Types } from 'mongoose';
+import { IDomain } from './domain';
+
+export interface ILink extends Document {
+  banned?: boolean;
+  bannedBy?: Types.ObjectId;
+  count?: number;
+  createdAt?: Date;
+  domain?: Types.ObjectId | IDomain;
+  id: string;
+  password?: string;
+  target: string;
+  updatedAt?: Date;
+  user?: Types.ObjectId;
+}
+
+const LinkSchema: Schema = new Schema({
+  banned: { type: Boolean, default: false },
+  bannedBy: { type: Schema.Types.ObjectId, ref: 'user' },
+  count: { type: Number, default: 0 },
+  createdAt: { type: Date, default: Date.now },
+  domain: { type: Schema.Types.ObjectId, ref: 'domain' },
+  id: { type: String, required: true, trim: true },
+  password: { type: String, trim: true },
+  target: { type: String, required: true },
+  updatedAt: { type: Date, default: Date.now },
+  user: { type: Schema.Types.ObjectId, ref: 'user' },
+});
+
+const Link = model<ILink>('link', LinkSchema);
+
+export default Link;

+ 0 - 18
server/models/user.js

@@ -1,18 +0,0 @@
-const mongoose = require('mongoose');
-
-const UserSchema = new mongoose.Schema({
-  apikey: { type: String, unique: true },
-  banned: { type: Boolean, default: false },
-  cooldowns: [Date],
-  createdAt: { type: Date, required: true, default: Date.now },
-  email: { type: String, required: true, trim: true, lowercase: true, unique: true },
-  password: { type: String, required: true },
-  resetPasswordExpires: { type: Date },
-  resetPasswordToken: { type: String },
-  verificationExpires: { type: Date },
-  verificationToken: { type: String },
-  verified: { type: Boolean, required: true, default: false },
-  // TODO: domains
-});
-
-module.exports = mongoose.model('user', UserSchema);

+ 47 - 0
server/models/user.ts

@@ -0,0 +1,47 @@
+import { Document, model, Schema, Types } from 'mongoose';
+
+import { IDomain } from './domain';
+
+export interface IUser extends Document {
+  apikey?: string;
+  banned?: boolean;
+  bannedBy?: Types.ObjectId;
+  cooldowns?: Date[];
+  createdAt?: Date;
+  domain?: Types.ObjectId | IDomain;
+  email: string;
+  password: string;
+  resetPasswordExpires?: Date;
+  resetPasswordToken?: string;
+  updatedAt?: Date;
+  verificationExpires?: Date;
+  verificationToken?: string;
+  verified?: boolean;
+}
+
+const UserSchema: Schema = new Schema({
+  apikey: { type: String, unique: true },
+  banned: { type: Boolean, default: false },
+  bannedBy: { type: Schema.Types.ObjectId, ref: 'user' },
+  cooldowns: [Date],
+  createdAt: { type: Date, default: Date.now },
+  domain: { type: Schema.Types.ObjectId, ref: 'domain' },
+  email: {
+    type: String,
+    required: true,
+    trim: true,
+    lowercase: true,
+    unique: true,
+  },
+  password: { type: String, required: true },
+  resetPasswordExpires: { type: Date },
+  resetPasswordToken: { type: String },
+  updatedAt: { type: Date, default: Date.now },
+  verificationExpires: { type: Date },
+  verificationToken: { type: String },
+  verified: { type: Boolean, default: false },
+});
+
+const User = model<IUser>('user', UserSchema);
+
+export default User;

+ 55 - 0
server/models/visit.ts

@@ -0,0 +1,55 @@
+import { Document, model, Schema, Types } from 'mongoose';
+
+export interface IVisit extends Document {
+  browser: {
+    chrome: number;
+    edge: number;
+    firefox: number;
+    ie: number;
+    opera: number;
+    other: number;
+    safari: number;
+  };
+  country: Record<string, number>;
+  date: Date;
+  link: Types.ObjectId;
+  os: {
+    android: number;
+    ios: number;
+    linux: number;
+    macos: number;
+    other: number;
+    windows: number;
+  };
+  referrer: Record<string, number>;
+  total: number;
+}
+
+const VisitSchema: Schema = new Schema({
+  browser: {
+    chrome: { type: Number, default: 0 },
+    edge: { type: Number, default: 0 },
+    firefox: { type: Number, default: 0 },
+    ie: { type: Number, default: 0 },
+    opera: { type: Number, default: 0 },
+    other: { type: Number, default: 0 },
+    safari: { type: Number, default: 0 },
+  },
+  country: Schema.Types.Mixed,
+  date: { type: Date },
+  link: { type: Schema.Types.ObjectId, ref: 'link' },
+  os: {
+    android: { type: Number, default: 0 },
+    ios: { type: Number, default: 0 },
+    linux: { type: Number, default: 0 },
+    macos: { type: Number, default: 0 },
+    other: { type: Number, default: 0 },
+    windows: { type: Number, default: 0 },
+  },
+  referrer: Schema.Types.Mixed,
+  total: { type: Number, default: 0 },
+});
+
+const Visit = model<IVisit>('visit', VisitSchema);
+
+export default Visit;

+ 8 - 0
server/module.d.ts

@@ -0,0 +1,8 @@
+declare namespace Express {
+  interface Request {
+    realIP?: string;
+    pageType?: string;
+    linkTarget?: string;
+    protectedLink?: string;
+  }
+}

+ 7 - 7
server/passport.js → server/passport.ts

@@ -1,10 +1,10 @@
-const passport = require('passport');
-const JwtStrategy = require('passport-jwt').Strategy;
-const { ExtractJwt } = require('passport-jwt');
-const LocalStratergy = require('passport-local').Strategy;
-const LocalAPIKeyStrategy = require('passport-localapikey-update').Strategy;
-const bcrypt = require('bcryptjs');
-const { getUser } = require('./db/user');
+import passport  from 'passport';
+import { Strategy as JwtStrategy, ExtractJwt }  from 'passport-jwt';
+import { Strategy as LocalStratergy }  from 'passport-local';
+import { Strategy as LocalAPIKeyStrategy }  from 'passport-localapikey-update';
+import bcrypt  from 'bcryptjs';
+
+import { getUser }  from './db/user';
 
 const jwtOptions = {
   jwtFromRequest: ExtractJwt.fromHeader('authorization'),

+ 0 - 18
server/redis.js

@@ -1,18 +0,0 @@
-const { promisify } = require('util');
-const redis = require('redis');
-
-if (process.env.REDIS_DISABLED === 'true') {
-  exports.get = () => Promise.resolve(null);
-  exports.set = () => Promise.resolve(null);
-  exports.del = () => Promise.resolve(null);
-} else {
-  const client = redis.createClient({
-    host: process.env.REDIS_HOST || '127.0.0.1',
-    port: Number(process.env.REDIS_PORT) || 6379,
-    ...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD }),
-  });
-
-  exports.get = promisify(client.get).bind(client);
-  exports.set = promisify(client.set).bind(client);
-  exports.del = promisify(client.del).bind(client);
-}

+ 31 - 0
server/redis.ts

@@ -0,0 +1,31 @@
+import { promisify } from 'util';
+import redis from 'redis';
+
+const disabled = process.env.REDIS_DISABLED === 'true';
+
+const client =
+  !disabled &&
+  redis.createClient({
+    host: process.env.REDIS_HOST || '127.0.0.1',
+    port: Number(process.env.REDIS_PORT) || 6379,
+    ...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD }),
+  });
+
+const defaultResolver: () => Promise<null> = () => Promise.resolve(null);
+
+export const get: (key: string) => Promise<any> = disabled
+  ? defaultResolver
+  : promisify(client.get).bind(client);
+
+export const set: (
+  key: string,
+  value: string,
+  ex?: string,
+  exValue?: number
+) => Promise<any> = disabled
+  ? defaultResolver
+  : promisify(client.set).bind(client);
+
+export const del: (key: string) => Promise<any> = disabled
+  ? defaultResolver
+  : promisify(client.del).bind(client);

+ 0 - 139
server/server.js

@@ -1,139 +0,0 @@
-require('./configToEnv');
-require('dotenv').config();
-const nextApp = require('next');
-const express = require('express');
-const mongoose = require('mongoose');
-const helmet = require('helmet');
-const morgan = require('morgan');
-const Raven = require('raven');
-const cookieParser = require('cookie-parser');
-const bodyParser = require('body-parser');
-const passport = require('passport');
-const cors = require('cors');
-const {
-  validateBody,
-  validationCriterias,
-  validateUrl,
-  ipCooldownCheck,
-} = require('./controllers/validateBodyController');
-const auth = require('./controllers/authController');
-const url = require('./controllers/urlController');
-const neo4j = require('./db/neo4j');
-
-require('./cron');
-require('./passport');
-
-mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true });
-
-if (process.env.RAVEN_DSN) {
-  Raven.config(process.env.RAVEN_DSN).install();
-}
-
-const catchErrors = fn => (req, res, next) =>
-  fn(req, res, next).catch(err => {
-    res.status(500).json({ error: 'Sorry an error ocurred. Please try again later.' });
-    neo4j.close();
-    if (process.env.RAVEN_DSN) {
-      Raven.captureException(err, {
-        user: { email: req.user && req.user.email },
-      });
-      throw new Error(err);
-    } else {
-      throw new Error(err);
-    }
-  });
-
-const port = Number(process.env.PORT) || 3000;
-const dev = process.env.NODE_ENV !== 'production';
-const app = nextApp({ dir: './client', dev });
-const handle = app.getRequestHandler();
-
-app.prepare().then(() => {
-  const server = express();
-
-  server.set('trust proxy', true);
-  server.use(helmet());
-  if (process.env.NODE_ENV !== 'production') {
-    server.use(morgan('dev'));
-  }
-  server.use(cookieParser());
-  server.use(bodyParser.json());
-  server.use(bodyParser.urlencoded({ extended: true }));
-  server.use(passport.initialize());
-  server.use(express.static('static'));
-
-  server.use((req, res, next) => {
-    req.realIp = req.headers['x-real-ip'] || req.connection.remoteAddress || '';
-    return next();
-  });
-
-  server.use(url.customDomainRedirection);
-
-  /* View routes */
-  server.get('/', (req, res) => app.render(req, res, '/'));
-  server.get('/login', (req, res) => app.render(req, res, '/login'));
-  server.get('/logout', (req, res) => app.render(req, res, '/logout'));
-  server.get('/settings', (req, res) => app.render(req, res, '/settings'));
-  server.get('/stats', (req, res) => app.render(req, res, '/stats', req.query));
-  server.get('/terms', (req, res) => app.render(req, res, '/terms'));
-  server.get('/report', (req, res) => app.render(req, res, '/report'));
-  server.get('/banned', (req, res) => app.render(req, res, '/banned'));
-  server.get('/offline', (req, res) => app.render(req, res, '/offline'));
-  server.get('/reset-password/:resetPasswordToken?', catchErrors(auth.resetPassword), (req, res) =>
-    app.render(req, res, '/reset-password', req.user)
-  );
-  server.get('/verify/:verificationToken?', catchErrors(auth.verify), (req, res) =>
-    app.render(req, res, '/verify', req.user)
-  );
-
-  /* User and authentication */
-  server.post('/api/auth/signup', validationCriterias, validateBody, catchErrors(auth.signup));
-  server.post('/api/auth/login', validationCriterias, validateBody, auth.authLocal, auth.login);
-  server.post('/api/auth/renew', auth.authJwt, auth.renew);
-  server.post('/api/auth/changepassword', auth.authJwt, catchErrors(auth.changePassword));
-  server.post('/api/auth/generateapikey', auth.authJwt, catchErrors(auth.generateApiKey));
-  server.post('/api/auth/resetpassword', catchErrors(auth.requestPasswordReset));
-  server.get('/api/auth/usersettings', auth.authJwt, auth.userSettings);
-
-  /* URL shortener */
-  server.post(
-    '/api/url/submit',
-    cors(),
-    auth.authApikey,
-    auth.authJwtLoose,
-    catchErrors(auth.recaptcha),
-    catchErrors(validateUrl),
-    catchErrors(ipCooldownCheck),
-    catchErrors(url.urlShortener)
-  );
-  server.post('/api/url/deleteurl', auth.authApikey, auth.authJwt, catchErrors(url.deleteUrl));
-  server.get('/api/url/geturls', auth.authApikey, auth.authJwt, catchErrors(url.getUrls));
-  server.post('/api/url/customdomain', auth.authJwt, catchErrors(url.setCustomDomain));
-  server.delete('/api/url/customdomain', auth.authJwt, catchErrors(url.deleteCustomDomain));
-  server.get('/api/url/stats', auth.authApikey, auth.authJwt, catchErrors(url.getStats));
-  server.post('/api/url/requesturl', catchErrors(url.goToUrl));
-  server.post('/api/url/report', catchErrors(url.reportUrl));
-  server.post(
-    '/api/url/admin/ban',
-    auth.authApikey,
-    auth.authJwt,
-    auth.authAdmin,
-    catchErrors(url.ban)
-  );
-  server.get('/:id', catchErrors(url.goToUrl), (req, res) => {
-    switch (req.pageType) {
-      case 'password':
-        return app.render(req, res, '/url-password', req.protectedUrl);
-      case 'info':
-      default:
-        return app.render(req, res, '/url-info', req.urlTarget);
-    }
-  });
-
-  server.get('*', (req, res) => handle(req, res));
-
-  server.listen(port, err => {
-    if (err) throw err;
-    console.log(`> Ready on http://localhost:${port}`); // eslint-disable-line no-console
-  });
-});

+ 195 - 0
server/server.ts

@@ -0,0 +1,195 @@
+import './configToEnv';
+
+import dotenv from 'dotenv';
+dotenv.config();
+
+import nextApp from 'next';
+import express, { Request, Response } from 'express';
+import mongoose from 'mongoose';
+import helmet from 'helmet';
+import morgan from 'morgan';
+import Raven from 'raven';
+import cookieParser from 'cookie-parser';
+import passport from 'passport';
+import cors from 'cors';
+
+import {
+  validateBody,
+  validationCriterias,
+  validateUrl,
+  ipCooldownCheck,
+} from './controllers/validateBodyController';
+import * as auth from './controllers/authController';
+import * as link from './controllers/linkController';
+
+import './cron';
+import './passport';
+
+mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true });
+
+if (process.env.RAVEN_DSN) {
+  Raven.config(process.env.RAVEN_DSN).install();
+}
+
+const catchErrors = fn => (req, res, next) =>
+  fn(req, res, next).catch(err => {
+    res
+      .status(500)
+      .json({ error: 'Sorry an error ocurred. Please try again later.' });
+    if (process.env.RAVEN_DSN) {
+      Raven.captureException(err, {
+        user: { email: req.user && req.user.email },
+      });
+      throw new Error(err);
+    } else {
+      throw new Error(err);
+    }
+  });
+
+const port = Number(process.env.PORT) || 3000;
+const dev = process.env.NODE_ENV !== 'production';
+const app = nextApp({ dir: './client', dev });
+const handle = app.getRequestHandler();
+
+app.prepare().then(() => {
+  const server = express();
+
+  server.set('trust proxy', true);
+
+  if (process.env.NODE_ENV !== 'production') {
+    server.use(morgan('dev'));
+  }
+
+  server.use(helmet());
+  server.use(cookieParser());
+  server.use(express.json());
+  server.use(express.urlencoded({ extended: true }));
+  server.use(passport.initialize());
+  server.use(express.static('static'));
+
+  server.use((req, _res, next) => {
+    req.realIP =
+      (req.headers['x-real-ip'] as string) ||
+      req.connection.remoteAddress ||
+      '';
+    return next();
+  });
+
+  server.use(link.customDomainRedirection);
+
+  /* View routes */
+  server.get(
+    '/reset-password/:resetPasswordToken?',
+    catchErrors(auth.resetUserPassword),
+    (req, res) => app.render(req, res, '/reset-password', req.user)
+  );
+  server.get(
+    '/verify/:verificationToken?',
+    catchErrors(auth.verify),
+    (req, res) => app.render(req, res, '/verify', req.user)
+  );
+
+  /* User and authentication */
+  server.post(
+    '/api/auth/signup',
+    validationCriterias,
+    validateBody,
+    catchErrors(auth.signup)
+  );
+  server.post(
+    '/api/auth/login',
+    validationCriterias,
+    validateBody,
+    auth.authLocal,
+    auth.login
+  );
+  server.post('/api/auth/renew', auth.authJwt, auth.renew);
+  server.post(
+    '/api/auth/changepassword',
+    auth.authJwt,
+    catchErrors(auth.changeUserPassword)
+  );
+  server.post(
+    '/api/auth/generateapikey',
+    auth.authJwt,
+    catchErrors(auth.generateUserApiKey)
+  );
+  server.post(
+    '/api/auth/resetpassword',
+    catchErrors(auth.requestUserPasswordReset)
+  );
+  server.get('/api/auth/usersettings', auth.authJwt, auth.userSettings);
+
+  /* URL shortener */
+  server.post(
+    '/api/url/submit',
+    cors(),
+    auth.authApikey,
+    auth.authJwtLoose,
+    catchErrors(auth.recaptcha),
+    catchErrors(validateUrl),
+    catchErrors(ipCooldownCheck),
+    catchErrors(link.shortener)
+  );
+  server.post(
+    '/api/url/deleteurl',
+    auth.authApikey,
+    auth.authJwt,
+    catchErrors(link.deleteUserLink)
+  );
+  server.get(
+    '/api/url/geturls',
+    auth.authApikey,
+    auth.authJwt,
+    catchErrors(link.getUserLinks)
+  );
+  server.post(
+    '/api/url/customdomain',
+    auth.authJwt,
+    catchErrors(link.setCustomDomain)
+  );
+  server.delete(
+    '/api/url/customdomain',
+    auth.authJwt,
+    catchErrors(link.deleteCustomDomain)
+  );
+  server.get(
+    '/api/url/stats',
+    auth.authApikey,
+    auth.authJwt,
+    catchErrors(link.getLinkStats)
+  );
+  server.post('/api/url/requesturl', catchErrors(link.goToLink));
+  server.post('/api/url/report', catchErrors(link.reportLink));
+  server.post(
+    '/api/url/admin/ban',
+    auth.authApikey,
+    auth.authJwt,
+    auth.authAdmin,
+    catchErrors(link.ban)
+  );
+  server.get(
+    '/:id',
+    catchErrors(link.goToLink),
+    (req: Request, res: Response) => {
+      switch (req.pageType) {
+        case 'password':
+          return app.render(req, res, '/url-password', {
+            protectedLink: req.protectedLink,
+          });
+        case 'info':
+        default:
+          return app.render(req, res, '/url-info', {
+            linkTarget: req.linkTarget,
+          });
+      }
+    }
+  );
+
+  server.get('*', (req, res) => handle(req, res));
+
+  server.listen(port, err => {
+    if (err) throw err;
+    console.log(`> Ready on http://localhost:${port}`);
+  });
+});

+ 0 - 82
server/utils/index.js

@@ -1,82 +0,0 @@
-const ms = require('ms');
-const { differenceInDays, differenceInHours, differenceInMonths } = require('date-fns');
-
-exports.addProtocol = url => {
-  const hasProtocol = /^\w+:\/\//.test(url);
-  return hasProtocol ? url : `http://${url}`;
-};
-
-exports.generateShortUrl = (id, domain, useHttps) => {
-  const protocol = useHttps || !domain ? 'https://' : 'http://';
-  return `${protocol}${domain || process.env.DEFAULT_DOMAIN}/${id}`;
-};
-
-exports.isAdmin = email =>
-  process.env.ADMIN_EMAILS.split(',')
-    .map(e => e.trim())
-    .includes(email);
-
-exports.getStatsLimit = url =>
-  url.user.statsLimit || Number(process.env.DEFAULT_MAX_STATS_PER_LINK) || 10000000;
-
-exports.getStatsCacheTime = total => {
-  switch (true) {
-    case total <= 5000:
-      return ms('5 minutes') / 1000;
-
-    case total > 5000 && total < 20000:
-      return ms('10 minutes') / 1000;
-
-    case total < 40000:
-      return ms('15 minutes') / 1000;
-
-    case total > 40000:
-      return ms('30 minutes') / 1000;
-
-    default:
-      return ms('5 minutes') / 1000;
-  }
-};
-
-exports.statsObjectToArray = item => {
-  const objToArr = key =>
-    Array.from(Object.keys(item[key]))
-      .map(name => ({
-        name,
-        value: item[key][name],
-      }))
-      .sort((a, b) => b.value - a.value);
-
-  return {
-    browser: objToArr('browser'),
-    os: objToArr('os'),
-    country: objToArr('country'),
-    referrer: objToArr('referrer'),
-  };
-};
-
-exports.getDifferenceFunction = type => {
-  switch (type) {
-    case 'lastDay':
-      return differenceInHours;
-
-    case 'lastWeek':
-      return differenceInDays;
-
-    case 'lastMonth':
-      return differenceInDays;
-
-    case 'allTime':
-      return differenceInMonths;
-
-    default:
-      return null;
-  }
-};
-
-const getUTCDate = (dateString = Date.now()) => {
-  const date = new Date(dateString);
-  return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
-};
-
-exports.getUTCDate = getUTCDate;

+ 88 - 0
server/utils/index.ts

@@ -0,0 +1,88 @@
+import ms from 'ms';
+import {
+  differenceInDays,
+  differenceInHours,
+  differenceInMonths,
+} from 'date-fns';
+
+export const addProtocol = (url: string): string => {
+  const hasProtocol = /^\w+:\/\//.test(url);
+  return hasProtocol ? url : `http://${url}`;
+};
+
+export const generateShortLink = (id: string, domain?: string): string => {
+  const protocol =
+    process.env.CUSTOM_DOMAIN_USE_HTTPS === 'true' || !domain
+      ? 'https://'
+      : 'http://';
+  return `${protocol}${domain || process.env.DEFAULT_DOMAIN}/${id}`;
+};
+
+export const isAdmin = (email: string): boolean =>
+  process.env.ADMIN_EMAILS.split(',')
+    .map(e => e.trim())
+    .includes(email);
+
+// TODO: Add statsLimit
+export const getStatsLimit = (): number =>
+  Number(process.env.DEFAULT_MAX_STATS_PER_LINK) || 100000000;
+
+export const getStatsCacheTime = (total?: number): number => {
+  let durationInMs;
+  switch (true) {
+    case total <= 5000:
+      durationInMs = ms('5 minutes');
+      break;
+    case total > 5000 && total < 20000:
+      durationInMs = ms('10 minutes');
+      break;
+    case total < 40000:
+      durationInMs = ms('15 minutes');
+      break;
+    case total > 40000:
+      durationInMs = ms('30 minutes');
+      break;
+    default:
+      durationInMs = ms('5 minutes');
+  }
+  return durationInMs / 1000;
+};
+
+export const statsObjectToArray = (
+  obj: Record<string, Record<string, number>>
+) => {
+  const objToArr = key =>
+    Array.from(Object.keys(obj[key]))
+      .map(name => ({
+        name,
+        value: obj[key][name],
+      }))
+      .sort((a, b) => b.value - a.value);
+
+  return {
+    browser: objToArr('browser'),
+    os: objToArr('os'),
+    country: objToArr('country'),
+    referrer: objToArr('referrer'),
+  };
+};
+
+export const getDifferenceFunction = (
+  type: 'lastDay' | 'lastWeek' | 'lastMonth' | 'allTime'
+): Function => {
+  if (type === 'lastDay') return differenceInHours;
+  if (type === 'lastWeek') return differenceInDays;
+  if (type === 'lastMonth') return differenceInDays;
+  if (type === 'allTime') return differenceInMonths;
+  throw new Error('Unknown type.');
+};
+
+export const getUTCDate = (dateString?: Date) => {
+  const date = new Date(dateString || Date.now());
+  return new Date(
+    date.getUTCFullYear(),
+    date.getUTCMonth(),
+    date.getUTCDate(),
+    date.getUTCHours()
+  );
+};

+ 21 - 0
tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "compileOnSave": false,
+  "compilerOptions": {
+    "allowJs": true,
+    "allowSyntheticDefaultImports": true,
+    "baseUrl": ".",
+    "jsx": "preserve",
+    "lib": ["dom", "es2017"],
+    "module": "esnext",
+    "moduleResolution": "node",
+    "noEmit": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "preserveConstEnums": true,
+    "removeComments": false,
+    "skipLibCheck": true,
+    "sourceMap": true,
+    "target": "esnext",
+    "typeRoots": ["./node_modules/@types"]
+  }
+}

+ 11 - 0
tsconfig.server.json

@@ -0,0 +1,11 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "allowSyntheticDefaultImports": true,
+    "module": "commonjs",
+    "noEmit": false,
+    "outDir": "production-server/",
+    "sourceMap": false
+  },
+  "include": ["server/**/*.ts", "index.d.ts"]
+}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно