Browse Source

Merge pull request #2 from thedevs-network/develop

Update
Akash Joshi 7 years ago
parent
commit
f7eb502e5e

+ 7 - 1
README.md

@@ -17,6 +17,7 @@
 * [Key Features](#key-features)
 * [Stack](#stack)
 * [Setup](#setup)
+* [Browser Extensions](#browser-extensions)
 * [API](#api)
 * [Integrate with ShareX](#sharex)
 * [3rd Party API Packages](#3rd-party-api-packages)
@@ -54,6 +55,11 @@ You need to have [Node.js](https://nodejs.org/), [Neo4j](https://neo4j.com/) and
 
 **Docker:** You can use Docker to run the app. Read [docker-examples](/docker-examples) for more info.
 
+## Browser Extensions
+Download Kutt's extension for web browsers via below links. You can also find the source code on [kutt-extension](https://github.com/abhijithvijayan/kutt-extension).
+* [Chrome](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd)
+* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/kutt/)
+
 ## API
 In addition to the website, you can use these APIs to create, delete and get URLs.
 
@@ -120,7 +126,7 @@ You can use Kutt as your default URL shortener in [ShareX](https://getsharex.com
 | Python    | [kutt-cli](https://github.com/univa64/kutt-cli)            | Command-line client for Kutt written in Python    |
 | Ruby      | [kutt.rb](https://github.com/univa64/kutt.rb)              | Kutt library written in Ruby                      |
 | Node.js   | [node-kutt](https://github.com/ardalanamini/node-kutt)     | Node.js client for Kutt.it url shortener          |
-| Bash      | [kutt-bash](https://github.com/caltlgin/kutt-bash)         | Simple command line program for Kutt              |
+| Bash      | [kutt-bash](https://git.nixnet.xyz/caltlgin/kutt-bash)     | Simple command line program for Kutt              |
 
 ## Contributing
 Pull requests are welcome. You'll probably find lots of improvements to be made.

+ 6 - 2
client/actions/__test__/auth.js

@@ -86,7 +86,9 @@ describe('auth actions', () => {
         },
         {
           type: SET_DOMAIN,
-          payload: ''
+          payload: {
+            customDomain: '',
+          }
         },
         { type: SHOW_PAGE_LOADING }
       ];
@@ -151,7 +153,9 @@ describe('auth actions', () => {
         },
         {
           type: SET_DOMAIN,
-          payload: ''
+          payload: {
+            customDomain: '',
+          }
         }
       ];
 

+ 16 - 5
client/actions/__test__/settings.js

@@ -41,6 +41,7 @@ describe('settings actions', () => {
     it('should dispatch SET_APIKEY and SET_DOMAIN when getting user settings have been done', done => {
       const apikey = '123';
       const customDomain = 'test.com';
+      const homepage = '';
 
       nock('http://localhost', {
         reqheaders: {
@@ -48,14 +49,17 @@ describe('settings actions', () => {
         }
       })
         .get('/api/auth/usersettings')
-        .reply(200, { apikey, customDomain });
+        .reply(200, { apikey, customDomain, homepage });
 
       const store = mockStore({});
 
       const expectedActions = [
         {
           type: SET_DOMAIN,
-          payload: customDomain
+          payload: {
+            customDomain,
+            homepage: ''
+          }
         },
         {
           type: SET_APIKEY,
@@ -76,6 +80,7 @@ describe('settings actions', () => {
   describe('#setCustomDomain()', () => {
     it('should dispatch SET_DOMAIN when setting custom domain has been done', done => {
       const customDomain = 'test.com';
+      const homepage = '';
 
       nock('http://localhost', {
         reqheaders: {
@@ -83,7 +88,7 @@ describe('settings actions', () => {
         }
       })
         .post('/api/url/customdomain')
-        .reply(200, { customDomain });
+        .reply(200, { customDomain, homepage });
 
       const store = mockStore({});
 
@@ -91,12 +96,18 @@ describe('settings actions', () => {
         { type: DOMAIN_LOADING },
         {
           type: SET_DOMAIN,
-          payload: customDomain
+          payload: {
+            customDomain,
+            homepage: ''
+          }
         }
       ];
 
       store
-        .dispatch(setCustomDomain(customDomain))
+        .dispatch(setCustomDomain({
+          customDomain,
+          homepage: ''
+        }))
         .then(() => {
           expect(store.getActions()).to.deep.equal(expectedActions);
           done();

+ 2 - 2
client/actions/auth.js

@@ -47,7 +47,7 @@ export const loginUser = payload => async dispatch => {
     cookie.set('token', token, { expires: 7 });
     dispatch(authRenew());
     dispatch(authUser(decodeJwt(token)));
-    dispatch(setDomain(decodeJwt(token).domain));
+    dispatch(setDomain({ customDomain: decodeJwt(token).domain }));
     dispatch(showPageLoading());
     Router.push('/');
   } catch ({ response }) {
@@ -78,7 +78,7 @@ export const renewAuthUser = () => async (dispatch, getState) => {
     cookie.set('token', token, { expires: 7 });
     dispatch(authRenew());
     dispatch(authUser(decodeJwt(token)));
-    dispatch(setDomain(decodeJwt(token).domain));
+    dispatch(setDomain({ customDomain: decodeJwt(token).domain }));
   } catch (error) {
     cookie.remove('token');
     dispatch(unauthUser());

+ 5 - 5
client/actions/settings.js

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

+ 147 - 0
client/components/Extensions/Extensions.js

@@ -0,0 +1,147 @@
+import React from 'react';
+import styled from 'styled-components';
+import SVG from 'react-inlinesvg';
+
+const Section = styled.div`
+  position: relative;
+  width: 100%;
+  flex: 0 0 auto;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 0;
+  padding: 90px 0 100px;
+  background-color: #282828;
+
+  @media only screen and (max-width: 768px) {
+    margin: 0;
+    padding: 48px 0 16px;
+    flex-wrap: wrap;
+  }
+`;
+
+const Wrapper = styled.div`
+  width: 1200px;
+  max-width: 100%;
+  flex: 1 1 auto;
+  display: flex;
+  justify-content: center;
+
+  @media only screen and (max-width: 1200px) {
+    flex-wrap: wrap;
+  }
+`;
+
+const Title = styled.h3`
+  font-size: 28px;
+  font-weight: 300;
+  margin: 0 0 60px;
+  color: #f5f5f5;
+
+  @media only screen and (max-width: 768px) {
+    font-size: 24px;
+    margin-bottom: 56px;
+  }
+
+  @media only screen and (max-width: 448px) {
+    font-size: 20px;
+    margin-bottom: 40px;
+  }
+`;
+
+const Button = styled.button`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin: 0 16px;
+  padding: 12px 28px;
+  font-family: 'Nunito', sans-serif;
+  background-color: #eee;
+  border: 1px solid #aaa;
+  font-size: 14px;
+  font-weight: bold;
+  text-decoration: none;
+  border-radius: 4px;
+  outline: none;
+  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
+  transition: transform 0.3s ease-out;
+  cursor: pointer;
+
+  @media only screen and (max-width: 768px) {
+    margin-bottom: 16px;
+    padding: 8px 16px;
+    font-size: 12px;
+  }
+
+  > * {
+    text-decoration: none;
+  }
+
+  :hover {
+    transform: translateY(-2px);
+  }
+`;
+
+const FirefoxButton = styled(Button)`
+  color: #e0890f;
+`;
+
+const ChromeButton = styled(Button)`
+  color: #4285f4;
+`;
+
+const Link = styled.a`
+  text-decoration: none;
+
+  :visited,
+  :hover,
+  :active,
+  :focus {
+    text-decoration: none;
+  }
+`;
+
+const Icon = styled(SVG)`
+  svg {
+    width: 18px;
+    height: 18px;
+    margin-right: 16px;
+    fill: ${props => props.color || '#333'};
+
+    @media only screen and (max-width: 768px) {
+      width: 13px;
+      height: 13px;
+      margin-right: 10px;
+    }
+  }
+`;
+
+const Extensions = () => (
+  <Section>
+    <Title>Browser extensions.</Title>
+    <Wrapper>
+      <Link
+        href="https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd"
+        target="_blank"
+        rel="noopener noreferrer"
+      >
+        <ChromeButton>
+          <Icon src="/images/googlechrome.svg" color="#4285f4" />
+          <span>Download for Chrome</span>
+        </ChromeButton>
+      </Link>
+      <Link
+        href="https://addons.mozilla.org/en-US/firefox/addon/kutt/"
+        target="_blank"
+        rel="noopener noreferrer"
+      >
+        <FirefoxButton>
+          <Icon src="/images/mozillafirefox.svg" color="#e0890f" />
+          <span>Download for Firefox</span>
+        </FirefoxButton>
+      </Link>
+    </Wrapper>
+  </Section>
+);
+
+export default Extensions;

+ 1 - 0
client/components/Extensions/index.js

@@ -0,0 +1 @@
+export { default } from './Extensions';

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

@@ -10,7 +10,7 @@ const Section = styled.div`
   flex-direction: column;
   align-items: center;
   margin: 0;
-  padding: 102px 0;
+  padding: 102px 0 110px;
   background-color: #eaeaea;
 
   @media only screen and (max-width: 768px) {

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

@@ -4,7 +4,7 @@ import config from '../../config';
 
 const Recaptcha = styled.div`
   display: flex;
-  margin: 40px 0 16px;
+  margin: 54px 0 16px;
 `;
 
 const ReCaptcha = () => (

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

@@ -163,7 +163,8 @@ class Settings extends Component {
     e.preventDefault();
     if (this.props.domainLoading) return null;
     const customDomain = e.currentTarget.elements.customdomain.value;
-    return this.props.setCustomDomain({ customDomain });
+    const homepage = e.currentTarget.elements.homepage.value;
+    return this.props.setCustomDomain({ customDomain, homepage });
   }
 
   deleteDomain() {

+ 85 - 22
client/components/Settings/SettingsDomain.js

@@ -9,6 +9,9 @@ import { fadeIn } from '../../helpers/animations';
 const Form = styled.form`
   position: relative;
   display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: flex-start;
   margin: 32px 0;
   animation: ${fadeIn} 0.8s ease;
 
@@ -25,6 +28,11 @@ const Form = styled.form`
 const DomainWrapper = styled.div`
   display: flex;
   align-items: center;
+`;
+
+const ButtonWrapper = styled.div`
+  display: flex;
+  align-items: center;
   margin: 32px 0;
   animation: ${fadeIn} 0.8s ease;
 
@@ -42,11 +50,40 @@ const DomainWrapper = styled.div`
   }
 `;
 
-const Domain = styled.span`
-  margin-right: 16px;
+const Domain = styled.h4`
+  margin: 0 16px 0 0;
   font-size: 20px;
   font-weight: bold;
-  border-bottom: 2px dotted #999;
+
+  span {
+    border-bottom: 2px dotted #999;
+  }
+`;
+
+const Homepage = styled.h6`
+  margin: 0 16px 0 0;
+  font-size: 14px;
+  font-weight: 300;
+
+  span {
+    border-bottom: 2px dotted #999;
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+`;
+
+const LabelWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+
+  span {
+    font-weight: bold;
+    margin-bottom: 8px;
+  }
 `;
 
 const SettingsDomain = ({ settings, handleCustomDomain, loading, showDomainInput, showModal }) => (
@@ -60,27 +97,53 @@ const SettingsDomain = ({ settings, handleCustomDomain, loading, showDomainInput
       Point your domain A record to <b>164.132.153.221</b> then add the domain via form below:
     </p>
     {settings.customDomain && !settings.domainInput ? (
-      <DomainWrapper>
-        <Domain>{settings.customDomain}</Domain>
-        <Button icon="edit" onClick={showDomainInput}>
-          Change
-        </Button>
-        <Button color="gray" icon="x" onClick={showModal}>
-          Delete
-        </Button>
-      </DomainWrapper>
+      <div>
+        <DomainWrapper>
+          <Domain>
+            <span>{settings.customDomain}</span>
+          </Domain>
+          <Homepage>
+            (Homepage redirects to <span>{settings.homepage || window.location.hostname}</span>)
+          </Homepage>
+        </DomainWrapper>
+        <ButtonWrapper>
+          <Button icon="edit" onClick={showDomainInput}>
+            Change
+          </Button>
+          <Button color="gray" icon="x" onClick={showModal}>
+            Delete
+          </Button>
+        </ButtonWrapper>
+      </div>
     ) : (
       <Form onSubmit={handleCustomDomain}>
-        <Error type="domain" left={0} bottom={-48} />
-        <TextInput
-          id="customdomain"
-          name="customdomain"
-          type="text"
-          placeholder="example.com"
-          defaultValue={settings.customDomain}
-          height={44}
-          small
-        />
+        <Error type="domain" left={0} bottom={-54} />
+        <InputWrapper>
+          <LabelWrapper htmlFor="customdomain">
+            <span>Domain</span>
+            <TextInput
+              id="customdomain"
+              name="customdomain"
+              type="text"
+              placeholder="example.com"
+              defaultValue={settings.customDomain}
+              height={44}
+              small
+            />
+          </LabelWrapper>
+          <LabelWrapper>
+            <span>Homepage (Optional)</span>
+            <TextInput
+              id="homepage"
+              name="homepage"
+              type="text"
+              placeholder="Homepage URL"
+              defaultValue={settings.homepage}
+              height={44}
+              small
+            />
+          </LabelWrapper>
+        </InputWrapper>
         <Button type="submit" color="purple" icon={loading ? 'loader' : ''}>
           Set domain
         </Button>

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

@@ -141,7 +141,7 @@ class Stats extends Component {
         </TitleWrapper>
         <Content>
           <StatsHead total={stats.total} period={period} changePeriod={this.changePeriod} />
-          <StatsCharts stats={stats[period]} period={period} />
+          <StatsCharts stats={stats[period]} updatedAt={stats.updatedAt} period={period} />
         </Content>
         <ButtonWrapper>
           <Button icon="arrow-left" onClick={this.goToHomepage}>

+ 7 - 6
client/components/Stats/StatsCharts/StatsCharts.js

@@ -41,7 +41,7 @@ const Row = styled.div`
   }
 `;
 
-const StatsCharts = ({ stats, period }) => {
+const StatsCharts = ({ stats, period, updatedAt }) => {
   const periodText = period.includes('last')
     ? `the last ${period.replace('last', '').toLocaleLowerCase()}`
     : 'all time';
@@ -49,17 +49,17 @@ const StatsCharts = ({ stats, period }) => {
   return (
     <ChartsWrapper>
       <Row>
-        <Area data={stats.views} period={period} periodText={periodText} />
+        <Area data={stats.views} period={period} updatedAt={updatedAt} periodText={periodText} />
       </Row>
       {hasView.length
         ? [
             <Row key="second-row">
-              <Pie data={stats.stats.referrer} title="Referrals" />
-              <Bar data={stats.stats.browser} title="Browsers" />
+              <Pie data={stats.stats.referrer} updatedAt={updatedAt} title="Referrals" />
+              <Bar data={stats.stats.browser} updatedAt={updatedAt} title="Browsers" />
             </Row>,
             <Row key="third-row">
-              <Pie data={stats.stats.country} title="Country" />
-              <Bar data={stats.stats.os} title="OS" />
+              <Pie data={stats.stats.country} updatedAt={updatedAt} title="Country" />
+              <Bar data={stats.stats.os} updatedAt={updatedAt} title="OS" />
             </Row>,
           ]
         : null}
@@ -68,6 +68,7 @@ const StatsCharts = ({ stats, period }) => {
 };
 
 StatsCharts.propTypes = {
+  updatedAt: PropTypes.string.isRequired,
   period: PropTypes.string.isRequired,
   stats: PropTypes.shape({
     stats: PropTypes.object.isRequired,

+ 18 - 0
client/components/Stats/StatsCharts/withTitle.js

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import styled from 'styled-components';
+import formatDate from 'date-fns/format';
 
 const Wrapper = styled.div`
   flex: 1 1 50%;
@@ -14,6 +15,7 @@ const Wrapper = styled.div`
 `;
 
 const Title = styled.h3`
+  margin-bottom: 12px;
   font-size: 24px;
   font-weight: 300;
 
@@ -22,6 +24,17 @@ const Title = styled.h3`
   }
 `;
 
+const SubTitle = styled.span`
+  margin-bottom: 32px;
+  font-size: 13px;
+  font-weight: 300;
+  color: #aaa;
+
+  @media only screen and (max-width: 768px) {
+    font-size: 11px;
+  }
+`;
+
 const Count = styled.span`
   font-weight: bold;
   border-bottom: 1px dotted #999;
@@ -35,6 +48,10 @@ const withTitle = ChartComponent => {
           {props.periodText && <Count>{props.data.reduce((sum, view) => sum + view, 0)}</Count>}
           {props.periodText ? ` clicks in ${props.periodText}` : props.title}.
         </Title>
+        {props.periodText &&
+          props.updatedAt && (
+            <SubTitle>Last update in {formatDate(props.updatedAt, 'dddd, hh:mm aa')}</SubTitle>
+          )}
         <ChartComponent {...props} />
       </Wrapper>
     );
@@ -43,6 +60,7 @@ const withTitle = ChartComponent => {
     data: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.object])).isRequired,
     periodText: PropTypes.string,
     title: PropTypes.string,
+    updatedAt: PropTypes.string.isRequired,
   };
   WithTitle.defaultProps = {
     title: '',

+ 2 - 0
client/pages/index.js

@@ -5,6 +5,7 @@ import { bindActionCreators } from 'redux';
 import BodyWrapper from '../components/BodyWrapper';
 import Shortener from '../components/Shortener';
 import Features from '../components/Features';
+import Extensions from '../components/Extensions';
 import Table from '../components/Table';
 import NeedToLogin from '../components/NeedToLogin';
 import Footer from '../components/Footer/Footer';
@@ -35,6 +36,7 @@ class Homepage extends Component {
         {needToLogin}
         {table}
         <Features />
+        <Extensions />
         <Footer />
       </BodyWrapper>
     );

+ 5 - 3
client/reducers/__test__/settings.js

@@ -15,6 +15,7 @@ describe('settings reducer', () => {
   const initialState = {
     apikey: '',
     customDomain: '',
+    homepage: '',
     domainInput: true
   };
 
@@ -27,15 +28,16 @@ describe('settings reducer', () => {
   });
 
   it('should handle SET_DOMAIN', () => {
-    const domain = 'example.com';
+    const customDomain = 'example.com';
+    const homepage = '';
 
     const state = reducer(initialState, {
       type: SET_DOMAIN,
-      payload: domain
+      payload: { customDomain, homepage }
     });
 
     expect(state).not.to.be.undefined;
-    expect(state.customDomain).to.be.equal(domain);
+    expect(state.customDomain).to.be.equal(customDomain);
     expect(state.domainInput).to.be.false;
   });
 

+ 8 - 2
client/reducers/settings.js

@@ -9,17 +9,23 @@ import {
 const initialState = {
   apikey: '',
   customDomain: '',
+  homepage: '',
   domainInput: true,
 };
 
 const settings = (state = initialState, action) => {
   switch (action.type) {
     case SET_DOMAIN:
-      return { ...state, customDomain: action.payload, domainInput: false };
+      return {
+        ...state,
+        customDomain: action.payload.customDomain,
+        homepage: action.payload.homepage,
+        domainInput: false,
+      };
     case SET_APIKEY:
       return { ...state, apikey: action.payload };
     case DELETE_DOMAIN:
-      return { ...state, customDomain: '', domainInput: true };
+      return { ...state, customDomain: '', homepage: '', domainInput: true };
     case SHOW_DOMAIN_INPUT:
       return { ...state, domainInput: true };
     case UNAUTH_USER:

+ 105 - 3
package-lock.json

@@ -3440,6 +3440,14 @@
           "dev": true,
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+              "dev": true
+            }
           }
         }
       }
@@ -4151,6 +4159,14 @@
           "dev": true,
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+              "dev": true
+            }
           }
         }
       }
@@ -4308,6 +4324,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
           }
         }
       }
@@ -5251,6 +5274,13 @@
       "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
       "requires": {
         "ms": "2.0.0"
+      },
+      "dependencies": {
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
       }
     },
     "decode-uri-component": {
@@ -5731,6 +5761,14 @@
           "dev": true,
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+              "dev": true
+            }
           }
         }
       }
@@ -5752,6 +5790,14 @@
           "dev": true,
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+              "dev": true
+            }
           }
         },
         "find-up": {
@@ -5809,6 +5855,14 @@
           "dev": true,
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+              "dev": true
+            }
           }
         },
         "doctrine": {
@@ -5990,6 +6044,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
           }
         },
         "define-property": {
@@ -6111,6 +6172,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
           }
         },
         "setprototypeof": {
@@ -6362,6 +6430,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
           }
         },
         "statuses": {
@@ -8686,6 +8761,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
           }
         }
       }
@@ -8704,9 +8786,9 @@
       }
     },
     "ms": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+      "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
     },
     "mute-stream": {
       "version": "0.0.7",
@@ -10883,6 +10965,11 @@
             "ms": "2.0.0"
           }
         },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        },
         "statuses": {
           "version": "1.3.1",
           "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
@@ -11048,6 +11135,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
           }
         },
         "define-property": {
@@ -11899,6 +11993,14 @@
           "dev": true,
           "requires": {
             "ms": "2.0.0"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+              "dev": true
+            }
           }
         }
       }

+ 1 - 0
package.json

@@ -47,6 +47,7 @@
     "jwt-decode": "^2.2.0",
     "lodash": "^4.17.4",
     "morgan": "^1.9.1",
+    "ms": "^2.1.1",
     "nanoid": "^1.0.1",
     "natives": "^1.1.6",
     "neo4j-driver": "^1.5.2",

+ 1 - 0
server/config.example.js

@@ -51,6 +51,7 @@ module.exports = {
   MAIL_PORT: 587,
   MAIL_SECURE: false,
   MAIL_USER: '',
+  MAIL_FROM: '', // Example: "Kutt <support@kutt.it>". Leave empty to use MAIL_USER
   MAIL_PASSWORD: '',
 
   /*

+ 6 - 2
server/controllers/authController.js

@@ -115,7 +115,7 @@ exports.signup = async (req, res) => {
   if (user && user.verified) return res.status(403).json({ error: 'Email is already in use.' });
   const newUser = await createUser({ email, password });
   const mail = await transporter.sendMail({
-    from: config.MAIL_USER,
+    from: config.MAIL_FROM || config.MAIL_USER,
     to: newUser.email,
     subject: 'Verify your account',
     text: verifyMailText.replace('{{verification}}', newUser.verificationToken),
@@ -170,7 +170,11 @@ exports.generateApiKey = async ({ user }, res) => {
 };
 
 exports.userSettings = ({ user }, res) =>
-  res.status(200).json({ apikey: user.apikey || '', customDomain: user.domain || '' });
+  res.status(200).json({
+    apikey: user.apikey || '',
+    customDomain: user.domain || '',
+    homepage: user.homepage || '',
+  });
 
 exports.requestPasswordReset = async ({ body: { email } }, res) => {
   const user = await requestPasswordReset({ email });

+ 54 - 11
server/controllers/urlController.js

@@ -25,9 +25,10 @@ const {
   getBannedDomain,
   getBannedHost,
 } = require('../db/url');
+const { preservedUrls } = require('./validateBodyController');
 const transporter = require('../mail/mail');
 const redis = require('../redis');
-const { addProtocol, generateShortUrl } = require('../utils');
+const { addProtocol, generateShortUrl, getStatsCacheTime } = require('../utils');
 const config = require('../config');
 
 const dnsLookup = promisify(dns.lookup);
@@ -134,11 +135,18 @@ exports.goToUrl = async (req, res, next) => {
     url = JSON.parse(cachedUrl);
   } else {
     const urls = await findUrl({ id, domain });
-    if (!urls && !urls.length) return next();
-    url = urls.find(item => (domain ? item.domain === domain : !item.domain));
+    url =
+      urls && urls.length && urls.find(item => (domain ? item.domain === domain : !item.domain));
   }
 
-  if (!url) return next();
+  if (!url) {
+    if (host !== config.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);
 
@@ -211,23 +219,33 @@ exports.getUrls = async ({ query, user }, res) => {
   return res.json({ list, countAll });
 };
 
-exports.setCustomDomain = async ({ body: { customDomain }, user }, res) => {
+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 === config.DEFAULT_DOMAIN) {
     return res.status(400).json({ error: "You can't use default domain." });
   }
-  const isValidDomain = urlRegex({ exact: true, strict: false }).test(customDomain);
-  if (!isValidDomain) return res.status(400).json({ error: 'Domain is not valid.' });
-  const isOwned = await getCustomDomain({ customDomain });
-  if (isOwned && isOwned.email !== user.email) {
+  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 });
-  if (userCustomDomain) return res.status(201).json({ customDomain: userCustomDomain.name });
+  const userCustomDomain = await setCustomDomain({ user, 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." });
 };
 
@@ -237,6 +255,19 @@ exports.deleteCustomDomain = async ({ user }, res) => {
   return res.status(400).json({ error: "Couldn't delete custom domain." });
 };
 
+exports.customDomainRedirection = async (req, res, next) => {
+  const { headers, path } = req;
+  if (
+    headers.host !== config.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 || `http://${config.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 !== config.DEFAULT_DOMAIN && domain;
@@ -251,8 +282,20 @@ exports.deleteUrl = async ({ body: { id, domain }, user }, res) => {
 exports.getStats = async ({ query: { id, domain }, user }, res) => {
   if (!id) return res.status(400).json({ error: 'No id has been provided.' });
   const customDomain = domain !== config.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.shortUrl = `http${!domain ? 's' : ''}://${domain ? url.domain : config.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);
 };
 

+ 5 - 0
server/controllers/validateBodyController.js

@@ -40,6 +40,7 @@ const preservedUrls = [
   'reset-password',
   'resetpassword',
   'url-password',
+  'url-info',
   'settings',
   'stats',
   'verify',
@@ -47,6 +48,10 @@ const preservedUrls = [
   '404',
   'static',
   'images',
+  'banned',
+  'terms',
+  'privacy',
+  'report',
 ];
 
 exports.preservedUrls = preservedUrls;

+ 126 - 127
server/db/url.js

@@ -1,22 +1,14 @@
 const bcrypt = require('bcryptjs');
 const _ = require('lodash/');
-const {
-  isAfter,
-  isSameHour,
-  isSameDay,
-  isSameMonth,
-  subDays,
-  subHours,
-  subMonths,
-} = require('date-fns');
+const { isAfter, subDays } = require('date-fns');
 const driver = require('./neo4j');
 const config = require('../config');
-const { generateShortUrl } = require('../utils');
-
-const getUTCDate = (dateString = Date.now()) => {
-  const date = new Date(dateString);
-  return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
-};
+const {
+  generateShortUrl,
+  statsObjectToArray,
+  getDifferenceFunction,
+  getUTCDate,
+} = require('../utils');
 
 const queryNewUrl = 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt }) RETURN l';
 
@@ -197,19 +189,27 @@ exports.getCustomDomain = ({ customDomain }) =>
     const session = driver.session();
     session
       .readTransaction(tx =>
-        tx.run('MATCH (d:DOMAIN { name: $customDomain })<-[:OWNS]-(u) RETURN u', {
-          customDomain,
-        })
+        tx.run(
+          'MATCH (d:DOMAIN { name: $customDomain })<-[:OWNS]-(u) RETURN u.email as email, d.homepage as homepage',
+          {
+            customDomain,
+          }
+        )
       )
       .then(({ records }) => {
         session.close();
-        const data = records.length && records[0].get('u').properties;
+        const data = records.length
+          ? {
+              email: records[0].get('email'),
+              homepage: records[0].get('homepage'),
+            }
+          : {};
         resolve(data);
       })
       .catch(err => session.close() || reject(err));
   });
 
-exports.setCustomDomain = ({ user, customDomain }) =>
+exports.setCustomDomain = ({ user, customDomain, homepage }) =>
   new Promise((resolve, reject) => {
     const session = driver.session();
     session
@@ -217,10 +217,11 @@ exports.setCustomDomain = ({ user, customDomain }) =>
         tx.run(
           'MATCH (u:USER { email: $email }) ' +
             'OPTIONAL MATCH (u)-[r:OWNS]->() DELETE r ' +
-            'MERGE (d:DOMAIN { name: $customDomain }) ' +
+            `MERGE (d:DOMAIN { name: $customDomain, homepage: $homepage }) ` +
             'MERGE (u)-[:OWNS]->(d) RETURN u, d',
           {
             customDomain,
+            homepage: homepage || '',
             email: user.email,
           }
         )
@@ -280,7 +281,10 @@ exports.deleteUrl = ({ id, domain, user }) =>
       .catch(err => session.close() || reject(err));
   });
 
-/* Collecting stats */
+/* 
+** Collecting stats 
+*/
+
 const initialStats = {
   browser: {
     IE: 0,
@@ -305,117 +309,112 @@ const initialStats = {
   dates: [],
 };
 
-const filterByDate = days => record => isAfter(record.date, subDays(getUTCDate(), days));
-
-/* eslint-disable no-param-reassign */
-const calcStats = (obj, record) => {
-  obj.browser[record.browser] += 1;
-  obj.os[record.os] += 1;
-  obj.country[record.country] = obj.country[record.country] + 1 || 1;
-  obj.referrer[record.referrer] = obj.referrer[record.referrer] + 1 || 1;
-  obj.dates = [...obj.dates, record.date];
-  return obj;
-};
-/* eslint-enable no-param-reassign */
-
-const objectToArray = 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'),
-  };
-};
-
-const calcViewPerDate = (views, period, sub, compare, lastDate = getUTCDate(), arr = []) => {
-  if (arr.length === period) return arr;
-
-  const matchedStats = views.filter(date => compare(date, lastDate));
-  const viewsPerDate = [matchedStats.length, ...arr];
-
-  return calcViewPerDate(views, period, sub, compare, sub(lastDate, 1), viewsPerDate);
-};
-
-const calcViews = {
-  0: views => calcViewPerDate(views, 24, subHours, isSameHour),
-  1: views => calcViewPerDate(views, 7, subDays, isSameDay),
-  2: views => calcViewPerDate(views, 30, subDays, isSameDay),
-  3: views => calcViewPerDate(views, 18, subMonths, isSameMonth),
-};
-
 exports.getStats = ({ id, domain, user }) =>
   new Promise((resolve, reject) => {
     const session = driver.session();
 
-    session
-      .readTransaction(tx =>
-        tx.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) ' +
-            'RETURN l, b.browser AS browser, c.country AS country,' +
-            `${domain ? 'domain.name AS domain, ' : ''}` +
-            'o.os AS os, r.referrer AS referrer, d.date AS date ' +
-            'ORDER BY d.date DESC',
-          {
-            email: user.email,
-            domain,
-            id,
-          }
-        )
-      )
-      .then(({ records }) => {
-        session.close();
-
-        if (!records.length) resolve([]);
+    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),
+      },
+    };
+
+    let total = 0;
+
+    const statsPeriods = [[1, 'lastDay'], [7, 'lastWeek'], [30, 'lastMonth']];
 
-        const allStats = records.map(record => ({
-          browser: record.get('browser'),
-          os: record.get('os'),
-          country: record.get('country'),
-          referrer: record.get('referrer'),
-          date: record.get('date'),
-        }));
-
-        const statsPeriods = [1, 7, 30, 550];
-
-        const stats = statsPeriods
-          .map(statsPeriod => allStats.filter(filterByDate(statsPeriod)))
-          .map(statsPeriod => statsPeriod.reduce(calcStats, _.cloneDeep(initialStats)))
-          .map((statsPeriod, index) => ({
-            stats: objectToArray(statsPeriod),
-            views: calcViews[index](statsPeriod.dates),
-          }));
-
-        const response = {
-          total: records.length,
+    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) ' +
+          'RETURN l, b.browser AS browser, c.country AS country,' +
+          'o.os AS os, r.referrer AS referrer, d.date AS date ' +
+          'ORDER BY d.date DESC',
+        {
+          email: user.email,
+          domain,
           id,
-          shortUrl: `http${!domain ? 's' : ''}://${
-            domain ? records[0].get('domain') : config.DEFAULT_DOMAIN
-          }/${id}`,
-          target: records[0].get('l').properties.target,
-          lastDay: stats[0],
-          lastWeek: stats[1],
-          lastMonth: stats[2],
-          allTime: stats[3],
-        };
-
-        return resolve(response);
-      })
-      .catch(err => session.close() || reject(err));
+        }
+      )
+      .subscribe({
+        onNext(record) {
+          total += 1;
+          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 = {
+            total,
+            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 = ({ date, email }) =>

+ 2 - 1
server/db/user.js

@@ -21,7 +21,8 @@ exports.getUser = ({ email = '', apikey = '' }) =>
         const user = res.records.length && res.records[0].get('u').properties;
         const domainProps = res.records.length && res.records[0].get('l');
         const domain = domainProps ? domainProps.properties.name : '';
-        return resolve(user && { ...user, domain });
+        const homepage = domainProps ? domainProps.properties.homepage : '';
+        return resolve(user && { ...user, domain, homepage });
       })
       .catch(err => reject(err));
   });

+ 2 - 4
server/offline/sw.js

@@ -1,8 +1,6 @@
 // This is the "Offline copy of pages" service worker
 
-// Install stage sets up the index page (home page) in the cache and opens a new cache
-const { self } = window;
-
+// eslint-disable-next-line no-restricted-globals
 self.addEventListener('install', event => {
   const indexPage = new Request('index.html');
   event.waitUntil(
@@ -12,7 +10,7 @@ self.addEventListener('install', event => {
   );
 });
 
-// If any fetch fails, it will look for the request in the cache and serve it from there first
+// eslint-disable-next-line no-restricted-globals
 self.addEventListener('fetch', event => {
   const updateCache = request =>
     caches

+ 1 - 11
server/server.js

@@ -10,7 +10,6 @@ const cors = require('cors');
 const {
   validateBody,
   validationCriterias,
-  preservedUrls,
   validateUrl,
   cooldownCheck,
   malwareCheck,
@@ -60,16 +59,7 @@ app.prepare().then(() => {
     return next();
   });
 
-  server.use((req, res, next) => {
-    const { headers, path } = req;
-    if (
-      headers.host !== config.DEFAULT_DOMAIN &&
-      (path === '/' || preservedUrls.some(item => item === path.replace('/', '')))
-    ) {
-      return res.redirect(`http://${config.DEFAULT_DOMAIN + path}`);
-    }
-    return next();
-  });
+  server.use(url.customDomainRedirection);
 
   /* View routes */
   server.get('/', (req, res) => app.render(req, res, '/'));

+ 64 - 0
server/utils/index.js

@@ -1,4 +1,6 @@
 const URL = require('url');
+const ms = require('ms');
+const { differenceInDays, differenceInHours, differenceInMonths } = require('date-fns');
 const config = require('../config');
 
 exports.addProtocol = url => {
@@ -10,3 +12,65 @@ exports.generateShortUrl = (id, domain) =>
   `http${!domain ? 's' : ''}://${domain || config.DEFAULT_DOMAIN}/${id}`;
 
 exports.isAdmin = email => config.ADMIN_EMAILS.includes(email);
+
+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;

+ 1 - 0
static/images/googlechrome.svg

@@ -0,0 +1 @@
+<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Chrome icon</title><path d="M16.214 8.69l6.715-1.679A12.027 12.027 0 0 1 24 11.972C24 18.57 18.569 24 11.968 24c-.302 0-.605-.011-.907-.034l4.905-8.347c.356-.376.655-.803.881-1.271a5.451 5.451 0 0 0-.043-4.748 5.156 5.156 0 0 0-.59-.91zm-3.24 8.575l-2.121 6.682C4.738 23.345 0 18.14 0 11.977 0 9.592.709 7.26 2.038 5.279l4.834 8.377c.18.539 1.119 2.581 3.067 3.327.998.382 2.041.481 3.035.282zM11.973 7.62c-2.006.019-3.878 1.544-4.281 3.512a4.478 4.478 0 0 0 1.237 4.032c1.214 1.186 3.14 1.578 4.734.927 1.408-.576 2.47-1.927 2.691-3.431.272-1.856-.788-3.832-2.495-4.629a4.413 4.413 0 0 0-1.886-.411zM7.046 9.962L2.259 4.963A12.043 12.043 0 0 1 11.997 0c4.56 0 8.744 2.592 10.774 6.675H12.558c-1.811-.125-3.288.52-4.265 1.453a5.345 5.345 0 0 0-1.247 1.834z"/></svg>

File diff suppressed because it is too large
+ 0 - 0
static/images/mozillafirefox.svg


Some files were not shown because too many files changed in this diff