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

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": [
   "extends": [
-    "airbnb",
+    "eslint:recommended",
+    "plugin:@typescript-eslint/eslint-recommended",
+    "plugin:@typescript-eslint/recommended",
     "prettier",
     "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": {
   "env": {
     "browser": true,
     "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
 # Max number of visits for each link to have detailed stats
 DEFAULT_MAX_STATS_PER_LINK=5000
 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.
 # A passphrase to encrypt JWT. Use a long and secure key.
 JWT_SECRET=securekey
 JWT_SECRET=securekey
 
 

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

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

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

@@ -35,7 +35,7 @@ describe('url actions', () => {
         target: url,
         target: url,
         password: false,
         password: false,
         reuse: false,
         reuse: false,
-        shortUrl: 'http://kutt.it/123'
+        shortLink: 'http://kutt.it/123'
       };
       };
 
 
       nock('http://localhost', {
       nock('http://localhost', {
@@ -83,7 +83,7 @@ describe('url actions', () => {
             target: 'https://kutt.it/',
             target: 'https://kutt.it/',
             password: false,
             password: false,
             count: 0,
             count: 0,
-            shortUrl: 'http://test.com/UkEs33'
+            shortLink: 'http://test.com/UkEs33'
           }
           }
         ],
         ],
         countAll: 1
         countAll: 1
@@ -128,7 +128,7 @@ describe('url actions', () => {
           target: 'test.com',
           target: 'test.com',
           password: false,
           password: false,
           reuse: 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 => {
 export const getUserSettings = () => async dispatch => {
   try {
   try {
     const {
     const {
-      data: { apikey, customDomain, homepage, useHttps },
+      data: { apikey, customDomain, homepage },
     } = await axios.get('/api/auth/usersettings', {
     } = await axios.get('/api/auth/usersettings', {
       headers: { Authorization: cookie.get('token') },
       headers: { Authorization: cookie.get('token') },
     });
     });
-    dispatch(setDomain({ customDomain, homepage, useHttps }));
+    dispatch(setDomain({ customDomain, homepage }));
     dispatch(setApiKey(apikey));
     dispatch(setApiKey(apikey));
   } catch (error) {
   } catch (error) {
     //
     //
@@ -39,11 +39,11 @@ export const setCustomDomain = params => async dispatch => {
   dispatch(showDomainLoading());
   dispatch(showDomainLoading());
   try {
   try {
     const {
     const {
-      data: { customDomain, homepage, useHttps },
+      data: { customDomain, homepage },
     } = await axios.post('/api/url/customdomain', params, {
     } = await axios.post('/api/url/customdomain', params, {
       headers: { Authorization: cookie.get('token') },
       headers: { Authorization: cookie.get('token') },
     });
     });
-    dispatch(setDomain({ customDomain, homepage, useHttps }));
+    dispatch(setDomain({ customDomain, homepage }));
   } catch ({ response }) {
   } catch ({ response }) {
     dispatch(setDomainError(response.data.error));
     dispatch(setDomainError(response.data.error));
   }
   }

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

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

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

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

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

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

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

@@ -92,7 +92,6 @@ const SettingsDomain = ({
   loading,
   loading,
   showDomainInput,
   showDomainInput,
   showModal,
   showModal,
-  useHttps,
   handleCheckbox,
   handleCheckbox,
 }) => (
 }) => (
   <div>
   <div>
@@ -110,7 +109,6 @@ const SettingsDomain = ({
           <Domain>
           <Domain>
             <span>{settings.customDomain}</span>
             <span>{settings.customDomain}</span>
           </Domain>
           </Domain>
-          {settings.useHttps && <Homepage>(With HTTPS)</Homepage>}
           <Homepage>
           <Homepage>
             (Homepage redirects to <span>{settings.homepage || window.location.hostname}</span>)
             (Homepage redirects to <span>{settings.homepage || window.location.hostname}</span>)
           </Homepage>
           </Homepage>
@@ -153,14 +151,6 @@ const SettingsDomain = ({
             />
             />
           </LabelWrapper>
           </LabelWrapper>
         </InputWrapper>
         </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' : ''}>
         <Button type="submit" color="purple" icon={loading ? 'loader' : ''}>
           Set domain
           Set domain
         </Button>
         </Button>
@@ -179,7 +169,6 @@ SettingsDomain.propTypes = {
   showDomainInput: PropTypes.func.isRequired,
   showDomainInput: PropTypes.func.isRequired,
   showModal: PropTypes.func.isRequired,
   showModal: PropTypes.func.isRequired,
   handleCheckbox: PropTypes.func.isRequired,
   handleCheckbox: PropTypes.func.isRequired,
-  useHttps: PropTypes.bool.isRequired,
 };
 };
 
 
 export default SettingsDomain;
 export default SettingsDomain;

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

@@ -96,10 +96,10 @@ class ShortenerResult extends Component {
     return (
     return (
       <Wrapper>
       <Wrapper>
         {isCopied && <CopyMessage>Copied to clipboard.</CopyMessage>}
         {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>
-        <CopyToClipboard text={url.list[0].shortUrl} onCopy={copyHandler}>
+        <CopyToClipboard text={url.list[0].shortLink} onCopy={copyHandler}>
           <Button icon="copy">Copy</Button>
           <Button icon="copy">Copy</Button>
         </CopyToClipboard>
         </CopyToClipboard>
         {showQrCode && (
         {showQrCode && (
@@ -108,7 +108,7 @@ class ShortenerResult extends Component {
           </QRButton>
           </QRButton>
         )}
         )}
         <Modal show={this.state.showQrCodeModal} close={this.toggleQrCodeModal}>
         <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>
         </Modal>
       </Wrapper>
       </Wrapper>
     );
     );

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

@@ -126,8 +126,8 @@ class Stats extends Component {
         <TitleWrapper>
         <TitleWrapper>
           <Title>
           <Title>
             Stats for:{' '}
             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>
             </a>
           </Title>
           </Title>
           <TitleTarget>
           <TitleTarget>

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

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

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

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

+ 0 - 1
client/pages/_document.js

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

+ 1 - 1
client/pages/report.js

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

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

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

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

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

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

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

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

@@ -36,7 +36,7 @@ describe('url reducer', () => {
       target: 'https://kutt.it/',
       target: 'https://kutt.it/',
       password: false,
       password: false,
       reuse: false,
       reuse: false,
-      shortUrl: 'https://kutt.it/YufjdS'
+      shortLink: 'https://kutt.it/YufjdS'
     };
     };
 
 
     const state = reducer(initialState, {
     const state = reducer(initialState, {
@@ -105,7 +105,7 @@ describe('url reducer', () => {
         target: 'https://kutt.it/',
         target: 'https://kutt.it/',
         password: false,
         password: false,
         reuse: false,
         reuse: false,
-        shortUrl: 'https://kutt.it/YufjdS'
+        shortLink: 'https://kutt.it/YufjdS'
       },
       },
       {
       {
         createdAt: '2018-06-12T19:51:56.435Z',
         createdAt: '2018-06-12T19:51:56.435Z',
@@ -113,7 +113,7 @@ describe('url reducer', () => {
         target: 'https://kutt.it/',
         target: 'https://kutt.it/',
         password: false,
         password: false,
         reuse: false,
         reuse: false,
-        shortUrl: 'https://kutt.it/1gCdbC'
+        shortLink: 'https://kutt.it/1gCdbC'
       }
       }
     ];
     ];
 
 
@@ -140,7 +140,7 @@ describe('url reducer', () => {
           target: 'https://kutt.it/',
           target: 'https://kutt.it/',
           password: false,
           password: false,
           reuse: false,
           reuse: false,
-          shortUrl: 'https://kutt.it/YufjdS'
+          shortLink: 'https://kutt.it/YufjdS'
         },
         },
         {
         {
           createdAt: '2018-06-12T19:51:56.435Z',
           createdAt: '2018-06-12T19:51:56.435Z',
@@ -148,7 +148,7 @@ describe('url reducer', () => {
           target: 'https://kutt.it/',
           target: 'https://kutt.it/',
           password: false,
           password: false,
           reuse: false,
           reuse: false,
-          shortUrl: 'https://kutt.it/1gCdbC'
+          shortLink: 'https://kutt.it/1gCdbC'
         }
         }
       ],
       ],
       isShortened: true,
       isShortened: true,
@@ -173,7 +173,7 @@ describe('url reducer', () => {
       target: 'https://kutt.it/',
       target: 'https://kutt.it/',
       password: false,
       password: false,
       reuse: 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: '',
   customDomain: '',
   homepage: '',
   homepage: '',
   domainInput: true,
   domainInput: true,
-  useHttps: false,
 };
 };
 
 
 const settings = (state = initialState, action) => {
 const settings = (state = initialState, action) => {
@@ -22,7 +21,6 @@ const settings = (state = initialState, action) => {
         customDomain: action.payload.customDomain,
         customDomain: action.payload.customDomain,
         homepage: action.payload.homepage,
         homepage: action.payload.homepage,
         domainInput: false,
         domainInput: false,
-        useHttps: action.payload.useHttps,
       };
       };
     case SET_APIKEY:
     case SET_APIKEY:
       return { ...state, apikey: action.payload };
       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",
   "main": "./server/server.js",
   "scripts": {
   "scripts": {
     "test": "mocha --compilers js:@babel/register ./client/**/__test__/*.js",
     "test": "mocha --compilers js:@babel/register ./client/**/__test__/*.js",
-    "dev": "nodemon ./server/server.js",
     "docker:build": "docker build -t kutt .",
     "docker:build": "docker build -t kutt .",
     "docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
     "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": {
   "husky": {
     "hooks": {
     "hooks": {
@@ -32,15 +32,28 @@
   },
   },
   "homepage": "https://github.com/TheDevs-Network/kutt#readme",
   "homepage": "https://github.com/TheDevs-Network/kutt#readme",
   "dependencies": {
   "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",
     "axios": "^0.19.0",
     "bcryptjs": "^2.4.3",
     "bcryptjs": "^2.4.3",
-    "body-parser": "^1.18.3",
     "cookie-parser": "^1.4.4",
     "cookie-parser": "^1.4.4",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
     "date-fns": "^1.30.1",
     "date-fns": "^1.30.1",
     "dotenv": "^8.0.0",
     "dotenv": "^8.0.0",
     "email-validator": "^1.2.3",
     "email-validator": "^1.2.3",
-    "express": "^4.16.4",
+    "express": "^4.17.1",
     "express-validator": "^4.3.0",
     "express-validator": "^4.3.0",
     "geoip-lite": "^1.3.6",
     "geoip-lite": "^1.3.6",
     "helmet": "^3.15.1",
     "helmet": "^3.15.1",
@@ -58,7 +71,7 @@
     "next": "^7.0.3",
     "next": "^7.0.3",
     "next-redux-wrapper": "^2.1.0",
     "next-redux-wrapper": "^2.1.0",
     "node-cron": "^2.0.3",
     "node-cron": "^2.0.3",
-    "nodemailer": "^4.7.0",
+    "nodemailer": "^6.3.0",
     "passport": "^0.4.0",
     "passport": "^0.4.0",
     "passport-jwt": "^4.0.0",
     "passport-jwt": "^4.0.0",
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
@@ -89,6 +102,15 @@
     "@babel/node": "^7.2.2",
     "@babel/node": "^7.2.2",
     "@babel/preset-env": "^7.3.1",
     "@babel/preset-env": "^7.3.1",
     "@babel/register": "^7.0.0",
     "@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": "^6.23.0",
     "babel-cli": "^6.26.0",
     "babel-cli": "^6.26.0",
     "babel-core": "^6.26.3",
     "babel-core": "^6.26.3",
@@ -97,19 +119,20 @@
     "babel-preset-env": "^1.7.0",
     "babel-preset-env": "^1.7.0",
     "chai": "^4.1.2",
     "chai": "^4.1.2",
     "deep-freeze": "^0.0.1",
     "deep-freeze": "^0.0.1",
-    "eslint": "^4.19.1",
+    "eslint": "^6.1.0",
     "eslint-config-airbnb": "^16.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-import": "^2.16.0",
     "eslint-plugin-jsx-a11y": "^6.2.1",
     "eslint-plugin-jsx-a11y": "^6.2.1",
     "eslint-plugin-prettier": "^2.7.0",
     "eslint-plugin-prettier": "^2.7.0",
-    "eslint-plugin-react": "^7.12.4",
+    "eslint-plugin-react": "^7.14.3",
     "husky": "^0.15.0-rc.13",
     "husky": "^0.15.0-rc.13",
     "mocha": "^5.2.0",
     "mocha": "^5.2.0",
     "nock": "^9.3.3",
     "nock": "^9.3.3",
     "nodemon": "^1.18.10",
     "nodemon": "^1.18.10",
     "prettier": "^1.16.4",
     "prettier": "^1.16.4",
     "redux-mock-store": "^1.5.3",
     "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 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 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) {
 if (hasServerConfig && hasClientConfig) {
   const serverConfig = require('./config.js');
   const serverConfig = require('./config.js');
   const clientConfig = require('../client/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 = {
   const configs = {
     PORT: serverConfig.PORT || 3000,
     PORT: serverConfig.PORT || 3000,
@@ -40,11 +44,17 @@ if (hasServerConfig && hasClientConfig) {
   };
   };
 
 
   Object.keys(configs).forEach(c => {
   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.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(
   fs.renameSync(
     path.resolve(__dirname, '../client/config.js'),
     path.resolve(__dirname, '../client/config.js'),
     path.resolve(__dirname, '../client/old.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,
   createUser,
   changePassword,
   changePassword,
   generateApiKey,
   generateApiKey,
@@ -14,11 +16,18 @@ const {
   verifyUser,
   verifyUser,
   requestPasswordReset,
   requestPasswordReset,
   resetPassword,
   resetPassword,
-} = require('../db/user');
+} from '../db/user';
+import { IUser } from '../models/user';
 
 
 /* Read email template */
 /* 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
 const resetEmailTemplate = fs
   .readFileSync(resetEmailTemplatePath, { encoding: 'utf-8' })
   .readFileSync(resetEmailTemplatePath, { encoding: 'utf-8' })
   .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
   .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
@@ -27,12 +36,12 @@ const verifyEmailTemplate = fs
   .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
   .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
 
 
 /* Function to generate JWT */
 /* Function to generate JWT */
-const signToken = user =>
+const signToken = (user: IUser) =>
   JWT.sign(
   JWT.sign(
     {
     {
       iss: 'ApiAuth',
       iss: 'ApiAuth',
-      sub: user.email,
-      domain: user.domain || '',
+      sub: () => user.email,
+      domain: (user.domain && user.domain.name) || '',
       admin: isAdmin(user.email),
       admin: isAdmin(user.email),
       iat: new Date().getTime(),
       iat: new Date().getTime(),
       exp: new Date().setDate(new Date().getDate() + 7),
       exp: new Date().setDate(new Date().getDate() + 7),
@@ -41,7 +50,11 @@ const signToken = user =>
   );
   );
 
 
 /* Passport.js authentication controller */
 /* 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) {
   function auth(req, res, next) {
     if (req.user) return next();
     if (req.user) return next();
     return passport.authenticate(type, (err, user) => {
     return passport.authenticate(type, (err, user) => {
@@ -55,7 +68,9 @@ const authenticate = (type, error, isStrict = true) =>
         });
         });
       }
       }
       if (user && user.banned) {
       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) {
       if (user) {
         req.user = {
         req.user = {
@@ -68,13 +83,20 @@ const authenticate = (type, error, isStrict = true) =>
     })(req, res, next);
     })(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 */
 /* reCaptcha controller */
-exports.recaptcha = async (req, res, next) => {
+export const recaptcha: RequestHandler = async (req, res, next) => {
   if (process.env.NODE_ENV === 'production' && !req.user) {
   if (process.env.NODE_ENV === 'production' && !req.user) {
     const isReCaptchaValid = await axios({
     const isReCaptchaValid = await axios({
       method: 'post',
       method: 'post',
@@ -85,58 +107,79 @@ exports.recaptcha = async (req, res, next) => {
       params: {
       params: {
         secret: process.env.RECAPTCHA_SECRET_KEY,
         secret: process.env.RECAPTCHA_SECRET_KEY,
         response: req.body.reCaptchaToken,
         response: req.body.reCaptchaToken,
-        remoteip: req.realIp,
+        remoteip: req.realIP,
       },
       },
     });
     });
     if (!isReCaptchaValid.data.success) {
     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();
   return next();
 };
 };
 
 
-exports.authAdmin = async (req, res, next) => {
+export const authAdmin: RequestHandler = async (req, res, next) => {
   if (!req.user.admin) {
   if (!req.user.admin) {
     return res.status(401).json({ error: 'Unauthorized.' });
     return res.status(401).json({ error: 'Unauthorized.' });
   }
   }
   return next();
   return next();
 };
 };
 
 
-exports.signup = async (req, res) => {
+export const signup: RequestHandler = async (req, res) => {
   const { email, password } = req.body;
   const { email, password } = req.body;
+
   if (password.length > 64) {
   if (password.length > 64) {
     return res.status(400).json({ error: 'Maximum password length is 64.' });
     return res.status(400).json({ error: 'Maximum password length is 64.' });
   }
   }
+
   if (email.length > 64) {
   if (email.length > 64) {
     return res.status(400).json({ error: 'Maximum email length is 64.' });
     return res.status(400).json({ error: 'Maximum email length is 64.' });
   }
   }
+
   const user = await getUser(email);
   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 newUser = await createUser(email, password);
+
   const mail = await transporter.sendMail({
   const mail = await transporter.sendMail({
     from: process.env.MAIL_FROM || process.env.MAIL_USER,
     from: process.env.MAIL_FROM || process.env.MAIL_USER,
     to: newUser.email,
     to: newUser.email,
     subject: 'Verify your account',
     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) {
   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 });
   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 });
   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);
   const user = await verifyUser(req.params.verificationToken);
   if (user) {
   if (user) {
     const token = signToken(user);
     const token = signToken(user);
@@ -145,41 +188,56 @@ exports.verify = async (req, res, next) => {
   return 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.' });
     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) {
   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);
   const user = await generateApiKey(req.user._id);
+
   if (user.apikey) {
   if (user.apikey) {
     return res.status(201).json({ apikey: 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({
   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);
   const user = await requestPasswordReset(req.body.email);
+
   if (!user) {
   if (!user) {
     return res.status(400).json({ error: "Couldn't reset password." });
     return res.status(400).json({ error: "Couldn't reset password." });
   }
   }
+
   const mail = await transporter.sendMail({
   const mail = await transporter.sendMail({
     from: process.env.MAIL_USER,
     from: process.env.MAIL_USER,
     to: user.email,
     to: user.email,
@@ -191,15 +249,18 @@ exports.requestPasswordReset = async (req, res) => {
       .replace(/{{resetpassword}}/gm, user.resetPasswordToken)
       .replace(/{{resetpassword}}/gm, user.resetPasswordToken)
       .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
       .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
   });
   });
+
   if (mail.accepted.length) {
   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." });
   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);
   const user = await resetPassword(req.params.resetPasswordToken);
   if (user) {
   if (user) {
     const token = signToken(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);
 const dnsLookup = promisify(dns.lookup);
 
 
-exports.validationCriterias = [
+export const validationCriterias = [
   validator
   validator
     .body('email')
     .body('email')
     .exists()
     .exists()
@@ -29,7 +34,7 @@ exports.validationCriterias = [
     .isLength({ min: 8 }),
     .isLength({ min: 8 }),
 ];
 ];
 
 
-exports.validateBody = (req, res, next) => {
+export const validateBody = (req, res, next) => {
   const errors = validationResult(req);
   const errors = validationResult(req);
   if (!errors.isEmpty()) {
   if (!errors.isEmpty()) {
     const errorsObj = errors.mapped();
     const errorsObj = errors.mapped();
@@ -40,7 +45,7 @@ exports.validateBody = (req, res, next) => {
   return next();
   return next();
 };
 };
 
 
-const preservedUrls = [
+export const preservedUrls = [
   'login',
   'login',
   'logout',
   'logout',
   'signup',
   'signup',
@@ -59,64 +64,72 @@ const preservedUrls = [
   'terms',
   'terms',
   'privacy',
   'privacy',
   'report',
   'report',
+  'pricing',
 ];
 ];
 
 
-exports.preservedUrls = preservedUrls;
-
-exports.validateUrl = async ({ body, user }, res, next) => {
+export const validateUrl: RequestHandler = async (req, res, next) => {
   // Validate URL existence
   // 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
   // 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.' });
     return res.status(400).json({ error: 'Maximum URL length is 3000.' });
   }
   }
 
 
   // Validate URL
   // 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.' });
     return res.status(400).json({ error: 'URL is not valid.' });
 
 
   // If target is the URL shortener itself
   // 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) {
   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
   // 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.' });
     return res.status(400).json({ error: 'Maximum password length is 64.' });
   }
   }
 
 
   // Custom URL validations
   // Custom URL validations
-  if (user && body.customurl) {
+  if (req.user && req.body.customurl) {
     // Validate custom URL
     // 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.' });
       return res.status(400).json({ error: 'Custom URL is not valid.' });
     }
     }
 
 
     // Prevent from using preserved URLs
     // 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
     // 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();
   return next();
 };
 };
 
 
-exports.cooldownCheck = async user => {
+export const cooldownCheck = async (user: IUser) => {
   if (user && user.cooldowns) {
   if (user && user.cooldowns) {
     if (user.cooldowns.length > 4) {
     if (user.cooldowns.length > 4) {
       await banUser(user._id);
       await banUser(user._id);
       throw new Error('Too much malware requests. You are now banned.');
       throw new Error('Too much malware requests. You are now banned.');
     }
     }
     const hasCooldownNow = user.cooldowns.some(
     const hasCooldownNow = user.cooldowns.some(
-      cooldown => cooldown > subHours(new Date(), 12).toJSON()
+      cooldown => cooldown.toJSON() > subHours(new Date(), 12).toJSON()
     );
     );
     if (hasCooldownNow) {
     if (hasCooldownNow) {
       throw new Error('Cooldown because of a malware URL. Wait 12h');
       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);
   const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
   if (req.user || !cooldownConfig) return next();
   if (req.user || !cooldownConfig) return next();
-  const ip = await getIp(req.realIp);
+  const ip = await getIP(req.realIP);
   if (ip) {
   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();
   next();
 };
 };
 
 
-exports.malwareCheck = async (user, target) => {
+export const malwareCheck = async (user: IUser, target: string) => {
   const isMalware = await axios.post(
   const isMalware = await axios.post(
     `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${
     `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${
       process.env.GOOGLE_SAFE_BROWSING_KEY
       process.env.GOOGLE_SAFE_BROWSING_KEY
@@ -156,7 +172,11 @@ exports.malwareCheck = async (user, target) => {
           'POTENTIALLY_HARMFUL_APPLICATION',
           'POTENTIALLY_HARMFUL_APPLICATION',
         ],
         ],
         platformTypes: ['ANY_PLATFORM', 'PLATFORM_TYPE_UNSPECIFIED'],
         platformTypes: ['ANY_PLATFORM', 'PLATFORM_TYPE_UNSPECIFIED'],
-        threatEntryTypes: ['EXECUTABLE', 'URL', 'THREAT_ENTRY_TYPE_UNSPECIFIED'],
+        threatEntryTypes: [
+          'EXECUTABLE',
+          'URL',
+          'THREAT_ENTRY_TYPE_UNSPECIFIED',
+        ],
         threatEntries: [{ url: target }],
         threatEntries: [{ url: target }],
       },
       },
     }
     }
@@ -165,34 +185,41 @@ exports.malwareCheck = async (user, target) => {
     if (user) {
     if (user) {
       await addCooldown(user._id);
       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)) {
   if (count > Number(process.env.USER_LIMIT_PER_DAY)) {
     throw new Error(
     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.');
     throw new Error('URL is containing malware/scam.');
   }
   }
 };
 };
 
 
-exports.checkBannedHost = async domain => {
+export const checkBannedHost = async (domain: string) => {
   let isHostBanned;
   let isHostBanned;
   try {
   try {
     const dnsRes = await dnsLookup(domain);
     const dnsRes = await dnsLookup(domain);
-    isHostBanned = await getBannedHost(dnsRes && dnsRes.address);
+    isHostBanned = await getHost({
+      address: dnsRes && dnsRes.address,
+      banned: true,
+    });
   } catch (error) {
   } catch (error) {
     isHostBanned = null;
     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,
   host: process.env.MAIL_HOST,
-  port: process.env.MAIL_PORT,
+  port: Number(process.env.MAIL_PORT),
   secure: process.env.MAIL_SECURE === 'true',
   secure: process.env.MAIL_SECURE === 'true',
   auth: {
   auth: {
     user: process.env.MAIL_USER,
     user: process.env.MAIL_USER,
     pass: process.env.MAIL_PASSWORD,
     pass: process.env.MAIL_PASSWORD,
   },
   },
-};
+});
 
 
 const transporter = nodemailer.createTransport(mailConfig);
 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.
 Please verify your email address using the link below.
 
 
 https://{{domain}}/{{verification}}`;
 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.
 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 = {
 const jwtOptions = {
   jwtFromRequest: ExtractJwt.fromHeader('authorization'),
   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"]
+}

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