Explorar o código

Merge pull request #2 from thedevs-network/develop

Update
Akash Joshi %!s(int64=7) %!d(string=hai) anos
pai
achega
f7eb502e5e

+ 7 - 1
README.md

@@ -17,6 +17,7 @@
 * [Key Features](#key-features)
 * [Key Features](#key-features)
 * [Stack](#stack)
 * [Stack](#stack)
 * [Setup](#setup)
 * [Setup](#setup)
+* [Browser Extensions](#browser-extensions)
 * [API](#api)
 * [API](#api)
 * [Integrate with ShareX](#sharex)
 * [Integrate with ShareX](#sharex)
 * [3rd Party API Packages](#3rd-party-api-packages)
 * [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.
 **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
 ## API
 In addition to the website, you can use these APIs to create, delete and get URLs.
 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    |
 | 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                      |
 | 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          |
 | 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
 ## Contributing
 Pull requests are welcome. You'll probably find lots of improvements to be made.
 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,
           type: SET_DOMAIN,
-          payload: ''
+          payload: {
+            customDomain: '',
+          }
         },
         },
         { type: SHOW_PAGE_LOADING }
         { type: SHOW_PAGE_LOADING }
       ];
       ];
@@ -151,7 +153,9 @@ describe('auth actions', () => {
         },
         },
         {
         {
           type: SET_DOMAIN,
           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 => {
     it('should dispatch SET_APIKEY and SET_DOMAIN when getting user settings have been done', done => {
       const apikey = '123';
       const apikey = '123';
       const customDomain = 'test.com';
       const customDomain = 'test.com';
+      const homepage = '';
 
 
       nock('http://localhost', {
       nock('http://localhost', {
         reqheaders: {
         reqheaders: {
@@ -48,14 +49,17 @@ describe('settings actions', () => {
         }
         }
       })
       })
         .get('/api/auth/usersettings')
         .get('/api/auth/usersettings')
-        .reply(200, { apikey, customDomain });
+        .reply(200, { apikey, customDomain, homepage });
 
 
       const store = mockStore({});
       const store = mockStore({});
 
 
       const expectedActions = [
       const expectedActions = [
         {
         {
           type: SET_DOMAIN,
           type: SET_DOMAIN,
-          payload: customDomain
+          payload: {
+            customDomain,
+            homepage: ''
+          }
         },
         },
         {
         {
           type: SET_APIKEY,
           type: SET_APIKEY,
@@ -76,6 +80,7 @@ describe('settings actions', () => {
   describe('#setCustomDomain()', () => {
   describe('#setCustomDomain()', () => {
     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 = '';
 
 
       nock('http://localhost', {
       nock('http://localhost', {
         reqheaders: {
         reqheaders: {
@@ -83,7 +88,7 @@ describe('settings actions', () => {
         }
         }
       })
       })
         .post('/api/url/customdomain')
         .post('/api/url/customdomain')
-        .reply(200, { customDomain });
+        .reply(200, { customDomain, homepage });
 
 
       const store = mockStore({});
       const store = mockStore({});
 
 
@@ -91,12 +96,18 @@ describe('settings actions', () => {
         { type: DOMAIN_LOADING },
         { type: DOMAIN_LOADING },
         {
         {
           type: SET_DOMAIN,
           type: SET_DOMAIN,
-          payload: customDomain
+          payload: {
+            customDomain,
+            homepage: ''
+          }
         }
         }
       ];
       ];
 
 
       store
       store
-        .dispatch(setCustomDomain(customDomain))
+        .dispatch(setCustomDomain({
+          customDomain,
+          homepage: ''
+        }))
         .then(() => {
         .then(() => {
           expect(store.getActions()).to.deep.equal(expectedActions);
           expect(store.getActions()).to.deep.equal(expectedActions);
           done();
           done();

+ 2 - 2
client/actions/auth.js

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

+ 5 - 5
client/actions/settings.js

@@ -23,11 +23,11 @@ export const showDomainInput = () => ({ type: SHOW_DOMAIN_INPUT });
 
 
 export const getUserSettings = () => async dispatch => {
 export const getUserSettings = () => async dispatch => {
   try {
   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') },
       headers: { Authorization: cookie.get('token') },
     });
     });
-    dispatch(setDomain(data.customDomain));
-    dispatch(setApiKey(data.apikey));
+    dispatch(setDomain({ customDomain, homepage }));
+    dispatch(setApiKey(apikey));
   } catch (error) {
   } catch (error) {
     //
     //
   }
   }
@@ -36,10 +36,10 @@ export const getUserSettings = () => async dispatch => {
 export const setCustomDomain = params => async dispatch => {
 export const setCustomDomain = params => async dispatch => {
   dispatch(showDomainLoading());
   dispatch(showDomainLoading());
   try {
   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') },
       headers: { Authorization: cookie.get('token') },
     });
     });
-    dispatch(setDomain(data.customDomain));
+    dispatch(setDomain({ customDomain, homepage }));
   } catch ({ response }) {
   } catch ({ response }) {
     dispatch(setDomainError(response.data.error));
     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;
   flex-direction: column;
   align-items: center;
   align-items: center;
   margin: 0;
   margin: 0;
-  padding: 102px 0;
+  padding: 102px 0 110px;
   background-color: #eaeaea;
   background-color: #eaeaea;
 
 
   @media only screen and (max-width: 768px) {
   @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`
 const Recaptcha = styled.div`
   display: flex;
   display: flex;
-  margin: 40px 0 16px;
+  margin: 54px 0 16px;
 `;
 `;
 
 
 const ReCaptcha = () => (
 const ReCaptcha = () => (

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

@@ -163,7 +163,8 @@ class Settings extends Component {
     e.preventDefault();
     e.preventDefault();
     if (this.props.domainLoading) return null;
     if (this.props.domainLoading) return null;
     const customDomain = e.currentTarget.elements.customdomain.value;
     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() {
   deleteDomain() {

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

@@ -9,6 +9,9 @@ import { fadeIn } from '../../helpers/animations';
 const Form = styled.form`
 const Form = styled.form`
   position: relative;
   position: relative;
   display: flex;
   display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: flex-start;
   margin: 32px 0;
   margin: 32px 0;
   animation: ${fadeIn} 0.8s ease;
   animation: ${fadeIn} 0.8s ease;
 
 
@@ -25,6 +28,11 @@ const Form = styled.form`
 const DomainWrapper = styled.div`
 const DomainWrapper = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+`;
+
+const ButtonWrapper = styled.div`
+  display: flex;
+  align-items: center;
   margin: 32px 0;
   margin: 32px 0;
   animation: ${fadeIn} 0.8s ease;
   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-size: 20px;
   font-weight: bold;
   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 }) => (
 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:
       Point your domain A record to <b>164.132.153.221</b> then add the domain via form below:
     </p>
     </p>
     {settings.customDomain && !settings.domainInput ? (
     {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}>
       <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' : ''}>
         <Button type="submit" color="purple" icon={loading ? 'loader' : ''}>
           Set domain
           Set domain
         </Button>
         </Button>

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

@@ -141,7 +141,7 @@ class Stats extends Component {
         </TitleWrapper>
         </TitleWrapper>
         <Content>
         <Content>
           <StatsHead total={stats.total} period={period} changePeriod={this.changePeriod} />
           <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>
         </Content>
         <ButtonWrapper>
         <ButtonWrapper>
           <Button icon="arrow-left" onClick={this.goToHomepage}>
           <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')
   const periodText = period.includes('last')
     ? `the last ${period.replace('last', '').toLocaleLowerCase()}`
     ? `the last ${period.replace('last', '').toLocaleLowerCase()}`
     : 'all time';
     : 'all time';
@@ -49,17 +49,17 @@ const StatsCharts = ({ stats, period }) => {
   return (
   return (
     <ChartsWrapper>
     <ChartsWrapper>
       <Row>
       <Row>
-        <Area data={stats.views} period={period} periodText={periodText} />
+        <Area data={stats.views} period={period} updatedAt={updatedAt} periodText={periodText} />
       </Row>
       </Row>
       {hasView.length
       {hasView.length
         ? [
         ? [
             <Row key="second-row">
             <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>,
             <Row key="third-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>,
             </Row>,
           ]
           ]
         : null}
         : null}
@@ -68,6 +68,7 @@ const StatsCharts = ({ stats, period }) => {
 };
 };
 
 
 StatsCharts.propTypes = {
 StatsCharts.propTypes = {
+  updatedAt: PropTypes.string.isRequired,
   period: PropTypes.string.isRequired,
   period: PropTypes.string.isRequired,
   stats: PropTypes.shape({
   stats: PropTypes.shape({
     stats: PropTypes.object.isRequired,
     stats: PropTypes.object.isRequired,

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

@@ -1,6 +1,7 @@
 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';
+import formatDate from 'date-fns/format';
 
 
 const Wrapper = styled.div`
 const Wrapper = styled.div`
   flex: 1 1 50%;
   flex: 1 1 50%;
@@ -14,6 +15,7 @@ const Wrapper = styled.div`
 `;
 `;
 
 
 const Title = styled.h3`
 const Title = styled.h3`
+  margin-bottom: 12px;
   font-size: 24px;
   font-size: 24px;
   font-weight: 300;
   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`
 const Count = styled.span`
   font-weight: bold;
   font-weight: bold;
   border-bottom: 1px dotted #999;
   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 && <Count>{props.data.reduce((sum, view) => sum + view, 0)}</Count>}
           {props.periodText ? ` clicks in ${props.periodText}` : props.title}.
           {props.periodText ? ` clicks in ${props.periodText}` : props.title}.
         </Title>
         </Title>
+        {props.periodText &&
+          props.updatedAt && (
+            <SubTitle>Last update in {formatDate(props.updatedAt, 'dddd, hh:mm aa')}</SubTitle>
+          )}
         <ChartComponent {...props} />
         <ChartComponent {...props} />
       </Wrapper>
       </Wrapper>
     );
     );
@@ -43,6 +60,7 @@ const withTitle = ChartComponent => {
     data: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.object])).isRequired,
     data: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.object])).isRequired,
     periodText: PropTypes.string,
     periodText: PropTypes.string,
     title: PropTypes.string,
     title: PropTypes.string,
+    updatedAt: PropTypes.string.isRequired,
   };
   };
   WithTitle.defaultProps = {
   WithTitle.defaultProps = {
     title: '',
     title: '',

+ 2 - 0
client/pages/index.js

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

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

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

+ 8 - 2
client/reducers/settings.js

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

+ 105 - 3
package-lock.json

@@ -3440,6 +3440,14 @@
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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,
           "dev": true,
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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==",
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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==",
       "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
       "requires": {
       "requires": {
         "ms": "2.0.0"
         "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": {
     "decode-uri-component": {
@@ -5731,6 +5761,14 @@
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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,
           "dev": true,
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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": {
         "find-up": {
@@ -5809,6 +5855,14 @@
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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": {
         "doctrine": {
@@ -5990,6 +6044,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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": {
         "define-property": {
@@ -6111,6 +6172,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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": {
         "setprototypeof": {
@@ -6362,6 +6430,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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": {
         "statuses": {
@@ -8686,6 +8761,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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": {
     "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": {
     "mute-stream": {
       "version": "0.0.7",
       "version": "0.0.7",
@@ -10883,6 +10965,11 @@
             "ms": "2.0.0"
             "ms": "2.0.0"
           }
           }
         },
         },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        },
         "statuses": {
         "statuses": {
           "version": "1.3.1",
           "version": "1.3.1",
           "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
           "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
@@ -11048,6 +11135,13 @@
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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": {
         "define-property": {
@@ -11899,6 +11993,14 @@
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
             "ms": "2.0.0"
             "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",
     "jwt-decode": "^2.2.0",
     "lodash": "^4.17.4",
     "lodash": "^4.17.4",
     "morgan": "^1.9.1",
     "morgan": "^1.9.1",
+    "ms": "^2.1.1",
     "nanoid": "^1.0.1",
     "nanoid": "^1.0.1",
     "natives": "^1.1.6",
     "natives": "^1.1.6",
     "neo4j-driver": "^1.5.2",
     "neo4j-driver": "^1.5.2",

+ 1 - 0
server/config.example.js

@@ -51,6 +51,7 @@ module.exports = {
   MAIL_PORT: 587,
   MAIL_PORT: 587,
   MAIL_SECURE: false,
   MAIL_SECURE: false,
   MAIL_USER: '',
   MAIL_USER: '',
+  MAIL_FROM: '', // Example: "Kutt <support@kutt.it>". Leave empty to use MAIL_USER
   MAIL_PASSWORD: '',
   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.' });
   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: config.MAIL_USER,
+    from: config.MAIL_FROM || config.MAIL_USER,
     to: newUser.email,
     to: newUser.email,
     subject: 'Verify your account',
     subject: 'Verify your account',
     text: verifyMailText.replace('{{verification}}', newUser.verificationToken),
     text: verifyMailText.replace('{{verification}}', newUser.verificationToken),
@@ -170,7 +170,11 @@ exports.generateApiKey = async ({ user }, res) => {
 };
 };
 
 
 exports.userSettings = ({ 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) => {
 exports.requestPasswordReset = async ({ body: { email } }, res) => {
   const user = await requestPasswordReset({ email });
   const user = await requestPasswordReset({ email });

+ 54 - 11
server/controllers/urlController.js

@@ -25,9 +25,10 @@ const {
   getBannedDomain,
   getBannedDomain,
   getBannedHost,
   getBannedHost,
 } = require('../db/url');
 } = require('../db/url');
+const { preservedUrls } = require('./validateBodyController');
 const transporter = require('../mail/mail');
 const transporter = require('../mail/mail');
 const redis = require('../redis');
 const redis = require('../redis');
-const { addProtocol, generateShortUrl } = require('../utils');
+const { addProtocol, generateShortUrl, getStatsCacheTime } = require('../utils');
 const config = require('../config');
 const config = require('../config');
 
 
 const dnsLookup = promisify(dns.lookup);
 const dnsLookup = promisify(dns.lookup);
@@ -134,11 +135,18 @@ exports.goToUrl = async (req, res, next) => {
     url = JSON.parse(cachedUrl);
     url = JSON.parse(cachedUrl);
   } else {
   } else {
     const urls = await findUrl({ id, domain });
     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);
   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 });
   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) {
   if (customDomain.length > 40) {
     return res.status(400).json({ error: 'Maximum custom domain length is 40.' });
     return res.status(400).json({ error: 'Maximum custom domain length is 40.' });
   }
   }
   if (customDomain === config.DEFAULT_DOMAIN) {
   if (customDomain === config.DEFAULT_DOMAIN) {
     return res.status(400).json({ error: "You can't use 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
     return res
       .status(400)
       .status(400)
       .json({ error: 'Domain is already taken. Contact us for multiple users.' });
       .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." });
   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." });
   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) => {
 exports.deleteUrl = async ({ body: { id, domain }, user }, res) => {
   if (!id) return res.status(400).json({ error: 'No id has been provided.' });
   if (!id) return res.status(400).json({ error: 'No id has been provided.' });
   const customDomain = domain !== config.DEFAULT_DOMAIN && domain;
   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) => {
 exports.getStats = async ({ query: { id, domain }, user }, res) => {
   if (!id) return res.status(400).json({ error: 'No id has been provided.' });
   if (!id) return res.status(400).json({ error: 'No id has been provided.' });
   const customDomain = domain !== config.DEFAULT_DOMAIN && domain;
   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 });
   const stats = await getStats({ id, domain: customDomain, user });
   if (!stats) return res.status(400).json({ error: 'Could not get the short URL stats.' });
   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);
   return res.status(200).json(stats);
 };
 };
 
 

+ 5 - 0
server/controllers/validateBodyController.js

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

+ 126 - 127
server/db/url.js

@@ -1,22 +1,14 @@
 const bcrypt = require('bcryptjs');
 const bcrypt = require('bcryptjs');
 const _ = require('lodash/');
 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 driver = require('./neo4j');
 const config = require('../config');
 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';
 const queryNewUrl = 'CREATE (l:URL { id: $id, target: $target, createdAt: $createdAt }) RETURN l';
 
 
@@ -197,19 +189,27 @@ exports.getCustomDomain = ({ customDomain }) =>
     const session = driver.session();
     const session = driver.session();
     session
     session
       .readTransaction(tx =>
       .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 }) => {
       .then(({ records }) => {
         session.close();
         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);
         resolve(data);
       })
       })
       .catch(err => session.close() || reject(err));
       .catch(err => session.close() || reject(err));
   });
   });
 
 
-exports.setCustomDomain = ({ user, customDomain }) =>
+exports.setCustomDomain = ({ user, customDomain, homepage }) =>
   new Promise((resolve, reject) => {
   new Promise((resolve, reject) => {
     const session = driver.session();
     const session = driver.session();
     session
     session
@@ -217,10 +217,11 @@ exports.setCustomDomain = ({ user, customDomain }) =>
         tx.run(
         tx.run(
           'MATCH (u:USER { email: $email }) ' +
           'MATCH (u:USER { email: $email }) ' +
             'OPTIONAL MATCH (u)-[r:OWNS]->() DELETE r ' +
             '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',
             'MERGE (u)-[:OWNS]->(d) RETURN u, d',
           {
           {
             customDomain,
             customDomain,
+            homepage: homepage || '',
             email: user.email,
             email: user.email,
           }
           }
         )
         )
@@ -280,7 +281,10 @@ exports.deleteUrl = ({ id, domain, user }) =>
       .catch(err => session.close() || reject(err));
       .catch(err => session.close() || reject(err));
   });
   });
 
 
-/* Collecting stats */
+/* 
+** Collecting stats 
+*/
+
 const initialStats = {
 const initialStats = {
   browser: {
   browser: {
     IE: 0,
     IE: 0,
@@ -305,117 +309,112 @@ const initialStats = {
   dates: [],
   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 }) =>
 exports.getStats = ({ id, domain, user }) =>
   new Promise((resolve, reject) => {
   new Promise((resolve, reject) => {
     const session = driver.session();
     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,
           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 }) =>
 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 user = res.records.length && res.records[0].get('u').properties;
         const domainProps = res.records.length && res.records[0].get('l');
         const domainProps = res.records.length && res.records[0].get('l');
         const domain = domainProps ? domainProps.properties.name : '';
         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));
       .catch(err => reject(err));
   });
   });

+ 2 - 4
server/offline/sw.js

@@ -1,8 +1,6 @@
 // This is the "Offline copy of pages" service worker
 // 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 => {
 self.addEventListener('install', event => {
   const indexPage = new Request('index.html');
   const indexPage = new Request('index.html');
   event.waitUntil(
   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 => {
 self.addEventListener('fetch', event => {
   const updateCache = request =>
   const updateCache = request =>
     caches
     caches

+ 1 - 11
server/server.js

@@ -10,7 +10,6 @@ const cors = require('cors');
 const {
 const {
   validateBody,
   validateBody,
   validationCriterias,
   validationCriterias,
-  preservedUrls,
   validateUrl,
   validateUrl,
   cooldownCheck,
   cooldownCheck,
   malwareCheck,
   malwareCheck,
@@ -60,16 +59,7 @@ app.prepare().then(() => {
     return next();
     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 */
   /* View routes */
   server.get('/', (req, res) => app.render(req, res, '/'));
   server.get('/', (req, res) => app.render(req, res, '/'));

+ 64 - 0
server/utils/index.js

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/images/mozillafirefox.svg


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio