Browse Source

[v2-beta] Move from Neo4j to Postgres, use TypeScript for server. Fixes #217, #197, #190, #75 (#220)

* Disable underscore-dangle rule

* Add mongoose package

* Use mongodb for auth queries

* Move to MongoDB and TypeScript

* Init plan

* Update steps

* Update config

* ๐Ÿ’ฅ Move to Postgres from MongoDB

* Remove unused

* Improve migration scripts

* Decrease concurrent connections

* Add skip and fix query

* Add migration guide

* Increase cahr limit to 1023

* Decrease target limit to 1023

* Update migration guid with important note

* Update example with new env vars

* Update with v2 guides

* Fix migrating visit referrers

* Add script to delete duplicated visit relationship

* Add shortUrl to link response for backward compatibility

* Linting

* Fix creating anonymous links

* Fix IP cooldown not working

* Fix deleting links by deleting visits of links first

* Fix and improve links migration script

* Add fail note to migration

* 2.0.0

* Fix main path

* Fix limit when getting list of links

* Improve table nav buttons clicking and listing

* Return countAll as number instead of string

* Fix proptype warning

* Fix not authenticating user on initial load. Fixes #71
tags/v2.0.2
Pouria Ezzati 1 year ago
committed by GitHub
parent
commit
33320f0205
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 5087 additions and 2213 deletions
  1. +1
    -1
      .babelrc
  2. +2
    -1
      .eslintignore
  3. +35
    -27
      .eslintrc
  4. +14
    -3
      .example.env
  5. +2
    -1
      .gitignore
  6. +44
    -0
      MIGRATION.md
  7. +65
    -42
      README.md
  8. +2
    -7
      client/actions/__test__/settings.js
  9. +3
    -3
      client/actions/__test__/url.js
  10. +4
    -4
      client/actions/settings.js
  11. +1
    -1
      client/components/Footer/Footer.js
  12. +0
    -1
      client/components/Login/LoginInputLabel.js
  13. +1
    -4
      client/components/Settings/Settings.js
  14. +0
    -11
      client/components/Settings/SettingsDomain.js
  15. +4
    -4
      client/components/Shortener/ShortenerResult.js
  16. +5
    -4
      client/components/Stats/Stats.js
  17. +3
    -3
      client/components/Table/TBody/TBody.js
  18. +7
    -7
      client/components/Table/TBody/TBodyCount.js
  19. +2
    -2
      client/components/Table/TBody/TBodyShortUrl.js
  20. +9
    -9
      client/components/Table/Table.js
  21. +6
    -6
      client/components/Table/TableNav.js
  22. +7
    -5
      client/components/Table/TableOptions.js
  23. +1
    -14
      client/pages/_document.js
  24. +1
    -1
      client/pages/report.js
  25. +5
    -4
      client/pages/settings.js
  26. +5
    -3
      client/pages/stats.js
  27. +6
    -4
      client/pages/url-info.js
  28. +5
    -4
      client/pages/url-password.js
  29. +0
    -1
      client/reducers/__test__/settings.js
  30. +6
    -6
      client/reducers/__test__/url.js
  31. +0
    -2
      client/reducers/settings.js
  32. +114
    -0
      global.d.ts
  33. +4
    -3
      next.config.js
  34. +6
    -0
      nodemon.json
  35. +1600
    -204
      package-lock.json
  36. +48
    -17
      package.json
  37. +0
    -52
      server/configToEnv.js
  38. +62
    -0
      server/configToEnv.ts
  39. +0
    -209
      server/controllers/authController.js
  40. +271
    -0
      server/controllers/authController.ts
  41. +425
    -0
      server/controllers/linkController.ts
  42. +0
    -362
      server/controllers/urlController.js
  43. +0
    -202
      server/controllers/validateBodyController.js
  44. +224
    -0
      server/controllers/validateBodyController.ts
  45. +0
    -8
      server/cron.js
  46. +9
    -0
      server/cron.ts
  47. +114
    -0
      server/db/domain.ts
  48. +51
    -0
      server/db/host.ts
  49. +49
    -0
      server/db/ip.ts
  50. +511
    -0
      server/db/link.ts
  51. +0
    -8
      server/db/neo4j.js
  52. +0
    -468
      server/db/url.js
  53. +0
    -229
      server/db/user.js
  54. +207
    -0
      server/db/user.ts
  55. +28
    -0
      server/knex.ts
  56. +0
    -14
      server/mail/mail.js
  57. +15
    -0
      server/mail/mail.ts
  58. +3
    -2
      server/mail/text.ts
  59. +67
    -0
      server/migration/01_host.ts
  60. +85
    -0
      server/migration/02_users.ts
  61. +88
    -0
      server/migration/03_domains.ts
  62. +193
    -0
      server/migration/04_links.ts
  63. +61
    -0
      server/migration/neo4j_delete_duplicated.ts
  64. +29
    -0
      server/models/domain.ts
  65. +23
    -0
      server/models/host.ts
  66. +15
    -0
      server/models/ip.ts
  67. +35
    -0
      server/models/link.ts
  68. +34
    -0
      server/models/user.ts
  69. +77
    -0
      server/models/visit.ts
  70. +15
    -15
      server/passport.ts
  71. +0
    -18
      server/redis.js
  72. +31
    -0
      server/redis.ts
  73. +0
    -135
      server/server.js
  74. +214
    -0
      server/server.ts
  75. +0
    -82
      server/utils/index.js
  76. +94
    -0
      server/utils/index.ts
  77. +23
    -0
      tsconfig.json
  78. +16
    -0
      tsconfig.server.json

+ 1
- 1
.babelrc View File

@@ -1,4 +1,4 @@
{
"presets": ["next/babel"],
"presets": ["next/babel", "@zeit/next-typescript/babel"],
"plugins": [["styled-components", { "ssr": true, "displayName": true, "preprocess": false }]]
}

+ 2
- 1
.eslintignore View File

@@ -1,4 +1,5 @@
.next/
flow-typed/
node_modules/
client/**/__test__/
client/**/__test__/
production-server

+ 35
- 27
.eslintrc View File

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

+ 14
- 3
.example.env View File

@@ -4,11 +4,18 @@ PORT=3000
# The domain that this website is on
DEFAULT_DOMAIN="localhost:3000"

# Neo4j database credential details
DB_URI="bolt://localhost"
DB_USERNAME=
# Postgres database credential details
DB_HOST=localhost
DB_NAME=postgres
DB_USER=
DB_PASSWORD=

# ONLY NEEDED FOR MIGRATION !!1!
# Neo4j database credential details
NEO4J_DB_URI="bolt://localhost"
NEO4J_DB_USERNAME=neo4j
NEO4J_DB_PASSWORD=BjEphmupAf1D5pDD

# Redis host and port
REDIS_DISABLED=false
REDIS_HOST="127.0.0.1"
@@ -25,6 +32,9 @@ NON_USER_COOLDOWN=0
# Max number of visits for each link to have detailed stats
DEFAULT_MAX_STATS_PER_LINK=5000

# Use HTTPS for links with custom domain
CUSTOM_DOMAIN_USE_HTTPS=false

# A passphrase to encrypt JWT. Use a long and secure key.
JWT_SECRET=securekey

@@ -44,6 +54,7 @@ GOOGLE_SAFE_BROWSING_KEY=
# Google Analytics tracking ID for universal analytics.
# Example: UA-XXXX-XX
GOOGLE_ANALYTICS=
GOOGLE_ANALYTICS_UNIVERSAL=

# Google Analytics tracking ID for universal analytics
# This one is used for links


+ 2
- 1
.gitignore View File

@@ -5,4 +5,5 @@ node_modules/
client/config.js
client/old.config.js
server/config.js
server/old.config.js
server/old.config.js
production-server

+ 44
- 0
MIGRATION.md View File

@@ -0,0 +1,44 @@
# Migrate database from Neo4j to Postgres

As explained in issue #197, Kutt is ditching Neo4j in favor of Postgres in version 2. But what happens to old data? Well, I have created migration scripts that you can use to transfer data from your Neo4j database to your new Postgres database.

### ๐Ÿšง IMPORTANT: v2 is still in beta, proceed carefully!!1!

## General recommendations

- Importing Neo4j data into local Neo4j database and migrate from there would speed things up.
- Use local Postgres database (where app lives), because using a remote database server will be way slower. If you're doing this locally, you can import data from local database to the remote one after migration has finished. I used this command to move data:

## 1. Set up a Postgres database

Set up a Postgres database, either on your own server or using a SaaS service.

## 2. Pull and run Kutt's new version

Right now version 2 is in beta. Therefore, pull from `v2-beta` branch and create and fill the `.env` file based on `.example.env`.

**NOTE**: Run the app at least once and let it create and initialize tables in the database. You just need to do `npm run dev` and wait for it to create tables. Then check your database to make sure tables have been created. (If your production database is separate, you need to initialize it too).

## 3. Migrate data using scripts

First, do `npm run build` to build the files. Now if you check `production-server/migration` folder you will fine 4 files. You can now run these scripts one by one.

**NOTE:** that the order of running the scripts is important.

**NOTE:** Step 4 is going to take a good chunk of time.

**NOTE:** If step 4 fails at any stage, you should delete links and visits data from the database and try again.

```
// 1. Migrate data: Hosts
node production-server/migration/01_hosts.js

// 2. Migrate data: Users
node production-server/migration/02_users.js

// 3. Migrate data: Domains
node production-server/migration/03_domains.js

// 4. Migrate data: Links
node production-server/migration/04_links.js
```

+ 65
- 42
README.md View File

@@ -4,65 +4,77 @@

**Kutt** is a modern URL shortener with support for custom domains. Shorten URLs, manage your links and view the click rate statistics.

*Contributions and bug reports are welcome.*
_Contributions and bug reports are welcome._

[https://kutt.it](https://kutt.it)

[![Build Status](https://travis-ci.org/thedevs-network/kutt.svg?branch=develop)](https://travis-ci.org/thedevs-network/kutt)
[![Build Status](https://travis-ci.org/thedevs-network/kutt.svg?branch=v2-beta)](https://travis-ci.org/thedevs-network/kutt)
[![Contributions](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/thedevs-network/kutt/#contributing)
[![GitHub license](https://img.shields.io/github/license/thedevs-network/kutt.svg)](https://github.com/thedevs-network/kutt/blob/develop/LICENSE)
[![Twitter](https://img.shields.io/twitter/url/https/github.com/thedevs-network/kutt/.svg?style=social)](https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fthedevs-network%2Fkutt%2F)

## Kutt v2 (๐Ÿšง beta)

The new version of Kutt is here. In version 2, we used TypeScript and we moved from Neo4j to PostgreSQL database in favor of performance and we're working on adding new features.

If you're coming from v1, refer to [MIGRATION.md](MIGRATION.md) to migrate data from Neo4j to PostgreSQL.

You can still find the stable version (v1) in the [v1](https://github.com/thedevs-network/kutt/tree/v1) branch.

## Table of Contents
* [Key Features](#key-features)
* [Stack](#stack)
* [Setup](#setup)
* [Browser Extensions](#browser-extensions)
* [API](#api)
* [Integrations](#integrations)
* [3rd Party API Packages](#3rd-party-api-packages)
* [Contributing](#contributing)

- [Key Features](#key-features)
- [Stack](#stack)
- [Setup](#setup)
- [Browser Extensions](#browser-extensions)
- [API](#api)
- [Integrations](#integrations)
- [3rd Party API Packages](#3rd-party-api-packages)
- [Contributing](#contributing)

## Key Features
* Free and open source.
* Custom domain support.
* Custom URLs for shortened links
* Setting password for links.
* Private statistics for shortened URLs.
* View and manage your links.
* RESTful API.

- Free and open source.
- Custom domain support.
- Custom URLs for shortened links
- Setting password for links.
- Private statistics for shortened URLs.
- View and manage your links.
- RESTful API.

## Stack
* Node (Web server)
* Express (Web server framework)
* Passport (Authentication)
* React (UI library)
* Next (Universal/server-side rendered React)
* Redux (State management)
* styled-components (CSS styling solution library)
* Recharts (Chart library)
* Neo4j (Graph database)

- Node (Web server)
- Express (Web server framework)
- Passport (Authentication)
- React (UI library)
- Next (Universal/server-side rendered React)
- Redux (State management)
- styled-components (CSS styling solution library)
- Recharts (Chart library)
- PostgreSQL (database)

## Setup
You need to have [Node.js](https://nodejs.org/), [Neo4j](https://neo4j.com/) and [Redis](https://redis.io/) installed on your machine.

1. Clone this repository or [download zip](https://github.com/thedevs-network/kutt/archive/develop.zip).
2. Copy `.example.env` to `.env` and fill it properly.
You need to have [Node.js](https://nodejs.org/), [PostgreSQL](https://www.postgresql.org/) and [Redis](https://redis.io/) installed.

1. Clone this repository or [download zip](https://github.com/thedevs-network/kutt/archive/v2-beta.zip).
2. Copy `.example.env` to `.env` and fill it properly.
3. Install dependencies: `npm install`.
4. Start Neo4j database.
5. Run for development: `npm run dev`.
6. Run for production: `npm run build` then `npm start`.
4. Run for development: `npm run dev`.
5. Run for production: `npm run build` then `npm start`.

**[Visit our wiki for a more complete setup and development guide.](https://github.com/thedevs-network/kutt/wiki/Setup-and-deployment)**

**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/)

- [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.

### Types
@@ -85,11 +97,13 @@ All API requests and responses are in JSON format.
Include the API key as `X-API-Key` in the header of all below requests. Available API endpoints with body parameters:

**Get shortened URLs list:**

```
GET /api/url/geturls
```

Returns:

```
{
list {Array<URL>} List of URL objects
@@ -98,32 +112,40 @@ Returns:
```

**Submit a link to be shortened**:

```
POST /api/url/submit
```

Body:
* `target`: Original long URL to be shortened.
* `customurl` (optional): Set a custom URL.
* `password` (optional): Set a password.
* `reuse` (optional): If a URL with the specified target exists returns it, otherwise will send a new shortened URL.

- `target`: Original long URL to be shortened.
- `customurl` (optional): Set a custom URL.
- `password` (optional): Set a password.
- `reuse` (optional): If a URL with the specified target exists returns it, otherwise will send a new shortened URL.

Returns: URL object

**Delete a shortened URL** and **Get stats for a shortened URL:**

```
POST /api/url/deleteurl
GET /api/url/stats
```

Body (or query for GET request)
* `id`: ID of the shortened URL.
* `domain` (optional): Required if a custom domain is used for short URL.

- `id`: ID of the shortened URL.
- `domain` (optional): Required if a custom domain is used for short URL.

## Integrations

### ShareX

You can use Kutt as your default URL shortener in [ShareX](https://getsharex.com/). If you host your custom instance of Kutt, refer to [ShareX wiki](https://github.com/thedevs-network/kutt/wiki/ShareX) on how to setup.

### Alfred Workflow

Download Kutt's official workflow for [Alfred](https://www.alfredapp.com/) app from [alfred-kutt](https://github.com/thedevs-network/alfred-kutt) repository.

## 3rd Party API packages
@@ -137,6 +159,7 @@ Download Kutt's official workflow for [Alfred](https://www.alfredapp.com/) app f
| Bash | [kutt-bash](https://git.fossdaily.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.

Open issues for feedback, requesting features, reporting bugs or discussing ideas.


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

@@ -42,7 +42,6 @@ describe('settings actions', () => {
const apikey = '123';
const customDomain = 'test.com';
const homepage = '';
const useHttps = false;

nock('http://localhost', {
reqheaders: {
@@ -50,7 +49,7 @@ describe('settings actions', () => {
}
})
.get('/api/auth/usersettings')
.reply(200, { apikey, customDomain, homepage, useHttps });
.reply(200, { apikey, customDomain, homepage });

const store = mockStore({});

@@ -60,7 +59,6 @@ describe('settings actions', () => {
payload: {
customDomain,
homepage: '',
useHttps: false,
}
},
{
@@ -83,7 +81,6 @@ describe('settings actions', () => {
it('should dispatch SET_DOMAIN when setting custom domain has been done', done => {
const customDomain = 'test.com';
const homepage = '';
const useHttps = false;

nock('http://localhost', {
reqheaders: {
@@ -91,7 +88,7 @@ describe('settings actions', () => {
}
})
.post('/api/url/customdomain')
.reply(200, { customDomain, homepage, useHttps });
.reply(200, { customDomain, homepage });

const store = mockStore({});

@@ -102,7 +99,6 @@ describe('settings actions', () => {
payload: {
customDomain,
homepage: '',
useHttps: false,
}
}
];
@@ -111,7 +107,6 @@ describe('settings actions', () => {
.dispatch(setCustomDomain({
customDomain,
homepage: '',
useHttps: false,
}))
.then(() => {
expect(store.getActions()).to.deep.equal(expectedActions);


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

@@ -35,7 +35,7 @@ describe('url actions', () => {
target: url,
password: false,
reuse: false,
shortUrl: 'http://kutt.it/123'
shortLink: 'http://kutt.it/123'
};

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



+ 4
- 4
client/actions/settings.js View File

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


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

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


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

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


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

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

handleCheckbox({ target: { id, checked } }) {
@@ -257,7 +255,6 @@ class Settings extends Component {
<SettingsDomain
handleCustomDomain={this.handleCustomDomain}
handleCheckbox={this.handleCheckbox}
useHttps={this.state.useHttps}
loading={this.props.domainLoading}
settings={this.props.settings}
showDomainInput={this.props.showDomainInput}


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

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

export default SettingsDomain;

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

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


+ 5
- 4
client/components/Stats/Stats.js View File

@@ -85,10 +85,10 @@ class Stats extends Component {
}

componentDidMount() {
const { id } = this.props;
const { domain, id } = this.props;
if (!id) return null;
return axios
.get(`/api/url/stats?id=${id}`, { headers: { Authorization: cookie.get('token') } })
.get(`/api/url/stats?id=${id}&domain=${domain}`, { headers: { Authorization: cookie.get('token') } })
.then(({ data }) =>
this.setState({
stats: data,
@@ -126,8 +126,8 @@ class Stats extends Component {
<TitleWrapper>
<Title>
Stats for:{' '}
<a href={stats.shortUrl} title="Short URL">
{stats.shortUrl.replace(/https?:\/\//, '')}
<a href={stats.shortLink} title="Short link">
{stats.shortLink.replace(/https?:\/\//, '')}
</a>
</Title>
<TitleTarget>
@@ -155,6 +155,7 @@ class Stats extends Component {

Stats.propTypes = {
isAuthenticated: PropTypes.bool.isRequired,
domain: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
showPageLoading: PropTypes.func.isRequired,
};


+ 3
- 3
client/components/Table/TBody/TBody.js View File

@@ -93,13 +93,13 @@ const TableBody = ({ copiedIndex, handleCopy, tableLoading, showModal, urls }) =
<a href={url.target}>{url.target}</a>
</Td>
<Td flex="1" date>
{`${distanceInWordsToNow(url.createdAt)} ago`}
{`${distanceInWordsToNow(url.created_at)} ago`}
</Td>
<Td flex="1" withFade>
<TBodyShortUrl index={index} copiedIndex={copiedIndex} handleCopy={handleCopy} url={url} />
</Td>
<Td flex="1">
<TBodyCount url={url} showModal={showModal} />
<TBodyCount url={url} showModal={showModal(url)} />
</Td>
</tr>
);
@@ -121,7 +121,7 @@ TableBody.propTypes = {
PropTypes.shape({
id: PropTypes.string.isRequired,
count: PropTypes.number,
createdAt: PropTypes.string.isRequired,
created_at: PropTypes.string.isRequired,
password: PropTypes.bool,
target: PropTypes.string.isRequired,
})


+ 7
- 7
client/components/Table/TBody/TBodyCount.js View File

@@ -50,9 +50,9 @@ class TBodyCount extends Component {

goTo(e) {
e.preventDefault();
const { id, domain } = this.props.url;
this.props.showLoading();
const host = URL.parse(this.props.url.shortUrl).hostname;
Router.push(`/stats?id=${this.props.url.id}${`&domain=${host}`}`);
Router.push(`/stats?id=${id}${domain ? `&domain=${domain}`: ''}`);
}

render() {
@@ -61,10 +61,10 @@ class TBodyCount extends Component {

return (
<Wrapper>
{url.count || 0}
{url.visit_count || 0}
<Actions>
{url.password && <Icon src="/images/lock.svg" lowopacity />}
{url.count > 0 && (
{url.visit_count > 0 && (
<TBodyButton withText onClick={this.goTo}>
<Icon src="/images/chart.svg" />
Stats
@@ -77,14 +77,14 @@ class TBodyCount extends Component {
)}
<TBodyButton
data-id={url.id}
data-host={URL.parse(url.shortUrl).hostname}
data-host={URL.parse(url.shortLink).hostname}
onClick={showModal}
>
<Icon src="/images/trash.svg" />
</TBodyButton>
</Actions>
<Modal show={this.state.showQrCodeModal} close={this.toggleQrCodeModal}>
<QRCode value={url.shortUrl} size={196} />
<QRCode value={url.shortLink} size={196} />
</Modal>
</Wrapper>
);
@@ -98,7 +98,7 @@ TBodyCount.propTypes = {
count: PropTypes.number,
id: PropTypes.string,
password: PropTypes.bool,
shortUrl: PropTypes.string,
shortLink: PropTypes.string,
}).isRequired,
};



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

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



+ 9
- 9
client/components/Table/Table.js View File

@@ -93,15 +93,15 @@ class Table extends Component {
}, 1500);
}

showModal(e) {
e.preventDefault();
const modalUrlId = e.currentTarget.dataset.id;
const modalUrlDomain = e.currentTarget.dataset.host;
this.setState({
modalUrlId,
modalUrlDomain,
showModal: true,
});
showModal(url) {
return e => {
e.preventDefault();
this.setState({
modalUrlId: url.address,
modalUrlDomain: url.domain,
showModal: true,
});
}
}

closeModal() {


+ 6
- 6
client/components/Table/TableNav.js View File

@@ -16,16 +16,16 @@ const Nav = styled.button`
box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
transition: all 0.2s ease-out;

${({ active }) =>
active &&
${({ disabled }) =>
!disabled &&
css`
background-color: white;
cursor: pointer;
`};

:hover {
${({ active }) =>
active &&
${({ disabled }) =>
!disabled &&
css`
transform: translateY(-2px);
box-shadow: 0 5px 25px rgba(50, 50, 50, 0.1);
@@ -49,10 +49,10 @@ const Icon = styled.img`

const TableNav = ({ handleNav, next, prev }) => (
<Wrapper>
<Nav active={prev} data-active={prev} data-type="prev" onClick={handleNav}>
<Nav disabled={!prev} onClick={handleNav(-1)}>
<Icon src="/images/nav-left.svg" />
</Nav>
<Nav active={next} data-active={next} data-type="next" onClick={handleNav}>
<Nav disabled={!next} onClick={handleNav(1)}>
<Icon src="/images/nav-right.svg" />
</Nav>
</Wrapper>


+ 7
- 5
client/components/Table/TableOptions.js View File

@@ -124,11 +124,13 @@ class TableOptions extends Component {
this.props.getUrlsList({ count });
}

handleNav(e) {
const { active, type } = e.target.dataset;
if (active === 'false') return null;
const number = type === 'next' ? 1 : -1;
return this.props.getUrlsList({ page: this.props.url.page + number });
handleNav(num) {
return (e) => {
const { active } = e.target.dataset;
if (active === 'false') return null;
console.log({ page: this.props.url.page, num });
return this.props.getUrlsList({ page: this.props.url.page + num });
}
}

render() {


+ 1
- 14
client/pages/_document.js View File

@@ -1,4 +1,3 @@
/* eslint-disable react/no-danger */
import React from 'react';
import Document, { Head, Main, NextScript } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
@@ -60,20 +59,8 @@ class AppDocument extends Document {
}}
/>

<script
dangerouslySetInnerHTML={{
__html: `
if('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js', {
scope: './'
})
}
`,
}}
/>

<script src="https://www.google.com/recaptcha/api.js?render=explicit" async defer />
<script src="/analytics.js" />
<script src="static/analytics.js" />
</Head>
<body style={style}>
<Main />


+ 1
- 1
client/pages/report.js View File

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


+ 5
- 4
client/pages/settings.js View File

@@ -1,20 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { decode } from 'jsonwebtoken';
import BodyWrapper from '../components/BodyWrapper';
import Footer from '../components/Footer';
import { authUser } from '../actions';
import Settings from '../components/Settings';

const SettingsPage = ({ isAuthenticated }) => (
const SettingsPage = ({ auth, isAuthenticated }) => console.log({auth}) || (
<BodyWrapper>
{isAuthenticated ? <Settings /> : null}
{isAuthenticated ? <Settings /> : <PageLoading />}
<Footer />
</BodyWrapper>
);

SettingsPage.getInitialProps = ({ req, reduxStore }) => {
const token = req && req.cookies && req.cookies.token;
const token = decode(req && req.cookies && req.cookies.token);
if (token && reduxStore) reduxStore.dispatch(authUser(token));
return {};
};
@@ -23,6 +24,6 @@ SettingsPage.propTypes = {
isAuthenticated: PropTypes.bool.isRequired,
};

const mapStateToProps = ({ auth: { isAuthenticated } }) => ({ isAuthenticated });
const mapStateToProps = ({ auth }) => ({ isAuthenticated: auth.isAuthenticated, auth });

export default connect(mapStateToProps)(SettingsPage);

+ 5
- 3
client/pages/stats.js View File

@@ -4,23 +4,25 @@ import BodyWrapper from '../components/BodyWrapper';
import Stats from '../components/Stats';
import { authUser } from '../actions';

const StatsPage = ({ id }) => (
const StatsPage = ({ domain, id }) => (
<BodyWrapper>
<Stats id={id} />
<Stats domain={domain} id={id} />
</BodyWrapper>
);

StatsPage.getInitialProps = ({ req, reduxStore, query }) => {
const token = req && req.cookies && req.cookies.token;
if (token && reduxStore) reduxStore.dispatch(authUser(token));
return { id: query && query.id };
return query;
};

StatsPage.propTypes = {
domain: PropTypes.string,
id: PropTypes.string,
};

StatsPage.defaultProps = {
domain: '',
id: '',
};



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

@@ -40,7 +40,7 @@ class UrlInfoPage extends Component {
}

render() {
if (!this.props.query) {
if (!this.props.query.linkTarget) {
return (
<BodyWrapper>
<Title>404 | Not found.</Title>
@@ -52,7 +52,7 @@ class UrlInfoPage extends Component {
<BodyWrapper>
<Wrapper>
<Title>Target:</Title>
<Target>{this.props.query}</Target>
<Target>{this.props.query.linkTarget}</Target>
</Wrapper>
<Footer />
</BodyWrapper>
@@ -61,11 +61,13 @@ class UrlInfoPage extends Component {
}

UrlInfoPage.propTypes = {
query: PropTypes.string,
query: PropTypes.shape({
linkTarget: PropTypes.string,
}),
};

UrlInfoPage.defaultProps = {
query: null,
query: {},
};

export default UrlInfoPage;

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

@@ -64,6 +64,7 @@ class UrlPasswordPage extends Component {
requestUrl(e) {
e.preventDefault();
const { password } = this.state;
const { protectedLink } = this.props.query;
if (!password) {
return this.setState({
error: 'Password must not be empty',
@@ -72,7 +73,7 @@ class UrlPasswordPage extends Component {
this.setState({ error: '' });
this.setState({ loading: true });
return axios
.post('/api/url/requesturl', { id: this.props.query, password })
.post('/api/url/requesturl', { id: protectedLink, password })
.then(({ data }) => window.location.replace(data.target))
.catch(({ response }) =>
this.setState({
@@ -83,7 +84,7 @@ class UrlPasswordPage extends Component {
}

render() {
if (!this.props.query) {
if (!this.props.query.protectedLink) {
return (
<BodyWrapper>
<Title>404 | Not found.</Title>
@@ -107,12 +108,12 @@ class UrlPasswordPage extends Component {

UrlPasswordPage.propTypes = {
query: PropTypes.shape({
id: PropTypes.string,
protectedLink: PropTypes.string,
}),
};

UrlPasswordPage.defaultProps = {
query: null,
query: {},
};

export default UrlPasswordPage;

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

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

beforeEach(() => {


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

@@ -36,7 +36,7 @@ describe('url reducer', () => {
target: 'https://kutt.it/',
password: false,
reuse: false,
shortUrl: 'https://kutt.it/YufjdS'
shortLink: 'https://kutt.it/YufjdS'
};

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

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



+ 0
- 2
client/reducers/settings.js View File

@@ -11,7 +11,6 @@ const initialState = {
customDomain: '',
homepage: '',
domainInput: true,
useHttps: false,
};

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


+ 114
- 0
global.d.ts View File

@@ -0,0 +1,114 @@
interface User {
id: number;
apikey?: string;
banned: boolean;
banned_by_id?: number;
cooldowns?: string[];
created_at: string;
email: string;
password: string;
reset_password_expires?: string;
reset_password_token?: string;
updated_at: string;
verification_expires?: string;
verification_token?: string;
verified?: boolean;
}

interface UserJoined extends User {
admin?: boolean;
homepage?: string;
domain?: string;
domain_id?: number;
}

interface Domain {
id: number;
address: string;
banned: boolean;
banned_by_id?: number;
created_at: string;
homepage?: string;
updated_at: string;
user_id?: number;
}

interface Host {
id: number;
address: string;
banned: boolean;
banned_by_id?: number;
created_at: string;
updated_at: string;
}

interface IP {
id: number;
created_at: string;
updated_at: string;
ip: string;
}

interface Link {
id: number;
address: string;
banned: boolean;
banned_by_id?: number;
created_at: string;
domain_id?: number;
password?: string;
target: string;
updated_at: string;
user_id?: number;
visit_count: number;
}

interface LinkJoinedDomain extends Link {
domain?: string;
}

interface Visit {
id: number;
countries: Record<string, number>;
created_at: string;
link_id: number;
referrers: Record<string, number>;
total: number;
br_chrome: number;
br_edge: number;
br_firefox: number;
br_ie: number;
br_opera: number;
br_other: number;
br_safari: number;
os_android: number;
os_ios: number;
os_linux: number;
os_macos: number;
os_other: number;
os_windows: number;
}

interface Stats {
browser: Record<
'chrome' | 'edge' | 'firefox' | 'ie' | 'opera' | 'other' | 'safari',
number
>;
os: Record<
'android' | 'ios' | 'linux' | 'macos' | 'other' | 'windows',
number
>;
country: Record<string, number>;
referrer: Record<string, number>;
}

declare namespace Express {
export interface Request {
realIP?: string;
pageType?: string;
linkTarget?: string;
protectedLink?: string;
token?: string;
user: UserJoined;
}
}

+ 4
- 3
next.config.js View File

@@ -1,10 +1,11 @@
const withTypescript = require('@zeit/next-typescript');
const { parsed: localEnv } = require('dotenv').config();
const webpack = require('webpack'); // eslint-disable-line

module.exports = {
module.exports = withTypescript({
webpack(config) {
config.plugins.push(new webpack.EnvironmentPlugin(localEnv));

return config;
},
};
}
});

+ 6
- 0
nodemon.json View File

@@ -0,0 +1,6 @@
{
"watch": ["server/**/*.ts"],
"execMap": {
"ts": "rimraf production-server && tsc --project tsconfig.server.json && copyfiles -f \"server/mail/*.html\" production-server/mail && node production-server/server.js"
}
}

+ 1600
- 204
package-lock.json
File diff suppressed because it is too large
View File


+ 48
- 17
package.json View File

@@ -1,17 +1,17 @@
{
"name": "kutt",
"version": "1.2.1",
"version": "2.0.0",
"description": "Modern URL shortener.",
"main": "./server/server.js",
"main": "./production-server/server.js",
"scripts": {
"test": "mocha --compilers js:@babel/register ./client/**/__test__/*.js",
"dev": "nodemon ./server/server.js",
"docker:build": "docker build -t kutt .",
"docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
"build": "next build ./client",
"start": "NODE_ENV=production node ./server/server.js",
"lint": "./node_modules/.bin/eslint . --fix",
"lint:nofix": "./node_modules/.bin/eslint ."
"dev": "nodemon server/server.ts",
"build": "next build client/ && rimraf production-server && tsc --project tsconfig.server.json && copyfiles -f \"server/mail/*.html\" production-server/mail",
"start": "NODE_ENV=production node production-server/server.js",
"lint": "eslint server/ --ext .js,.ts --fix",
"lint:nofix": "eslint server/ --ext .js,.ts"
},
"husky": {
"hooks": {
@@ -34,13 +34,12 @@
"dependencies": {
"axios": "^0.19.0",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.3",
"cookie-parser": "^1.4.4",
"cors": "^2.8.5",
"date-fns": "^1.30.1",
"dotenv": "^8.0.0",
"email-validator": "^1.2.3",
"express": "^4.16.4",
"express": "^4.17.1",
"express-validator": "^4.3.0",
"geoip-lite": "^1.3.6",
"helmet": "^3.15.1",
@@ -48,7 +47,9 @@
"js-cookie": "^2.2.0",
"jsonwebtoken": "^8.4.0",
"jwt-decode": "^2.2.0",
"knex": "^0.19.2",
"lodash": "^4.17.11",
"mongoose": "^5.6.4",
"morgan": "^1.9.1",
"ms": "^2.1.1",
"nanoid": "^1.3.4",
@@ -57,11 +58,14 @@
"next": "^7.0.3",
"next-redux-wrapper": "^2.1.0",
"node-cron": "^2.0.3",
"nodemailer": "^4.7.0",
"nodemailer": "^6.3.0",
"p-queue": "^6.1.1",
"passport": "^0.4.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"passport-localapikey-update": "^0.6.0",
"pg": "^7.12.1",
"pg-query-stream": "^2.0.0",
"prop-types": "^15.7.2",
"qrcode.react": "^0.8.0",
"raven": "^2.6.4",
@@ -79,7 +83,8 @@
"styled-components": "^4.1.3",
"universal-analytics": "^0.4.20",
"url-regex": "^4.1.1",
"useragent": "^2.2.1"
"useragent": "^2.2.1",
"uuid": "^3.3.2"
},
"devDependencies": {
"@babel/cli": "^7.2.3",
@@ -87,6 +92,29 @@
"@babel/node": "^7.2.2",
"@babel/preset-env": "^7.3.1",
"@babel/register": "^7.0.0",
"@types/bcryptjs": "^2.4.2",
"@types/body-parser": "^1.17.0",
"@types/cookie-parser": "^1.4.1",
"@types/cors": "^2.8.5",
"@types/date-fns": "^2.6.0",
"@types/dotenv": "^4.0.3",
"@types/express": "^4.16.0",
"@types/helmet": "0.0.38",
"@types/jsonwebtoken": "^7.2.8",
"@types/jwt-decode": "^2.2.1",
"@types/mongodb": "^3.1.17",
"@types/mongoose": "^5.3.5",
"@types/morgan": "^1.7.36",
"@types/ms": "^0.7.30",
"@types/next": "^7.0.5",
"@types/node-cron": "^2.0.2",
"@types/nodemailer": "^6.2.1",
"@types/pg": "^7.11.0",
"@types/pg-query-stream": "^1.0.3",
"@types/redis": "^2.8.10",
"@typescript-eslint/eslint-plugin": "^2.0.0",
"@typescript-eslint/parser": "^2.0.0",
"@zeit/next-typescript": "^1.1.1",
"babel": "^6.23.0",
"babel-cli": "^6.26.0",
"babel-core": "^6.26.3",
@@ -94,20 +122,23 @@
"babel-plugin-styled-components": "^1.10.0",
"babel-preset-env": "^1.7.0",
"chai": "^4.1.2",
"copyfiles": "^2.1.1",
"deep-freeze": "^0.0.1",
"eslint": "^4.19.1",
"eslint": "^5.4.0",
"eslint-config-airbnb": "^16.1.0",
"eslint-config-prettier": "^2.10.0",
"eslint-config-prettier": "^6.1.0",
"eslint-plugin-import": "^2.16.0",
"eslint-plugin-jsx-a11y": "^6.2.1",
"eslint-plugin-prettier": "^2.7.0",
"eslint-plugin-react": "^7.12.4",
"eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-react": "^7.14.3",
"husky": "^0.15.0-rc.13",
"mocha": "^5.2.0",
"nock": "^9.3.3",
"nodemon": "^1.18.10",
"prettier": "^1.16.4",
"prettier": "^1.18.2",
"redux-mock-store": "^1.5.3",
"sinon": "^6.0.0"
"rimraf": "^3.0.0",
"sinon": "^6.0.0",
"typescript": "^3.5.3"
}
}

+ 0
- 52
server/configToEnv.js View File

@@ -1,52 +0,0 @@
/* eslint-disable global-require */
/* eslint-disable import/no-unresolved */
const fs = require('fs');
const path = require('path');

const hasServerConfig = fs.existsSync(path.resolve(__dirname, 'config.js'));
const hasClientConfig = fs.existsSync(path.resolve(__dirname, '../client/config.js'));

if (hasServerConfig && hasClientConfig) {
const serverConfig = require('./config.js');
const clientConfig = require('../client/config.js');
let envTemplate = fs.readFileSync(path.resolve(__dirname, '../.template.env'), 'utf-8');

const configs = {
PORT: serverConfig.PORT || 3000,
DEFAULT_DOMAIN: serverConfig.DEFAULT_DOMAIN || 'localhost:3000',
DB_URI: serverConfig.DB_URI || 'bolt://localhost',
DB_USERNAME: serverConfig.DB_USERNAME,
DB_PASSWORD: serverConfig.DB_PASSWORD,
REDIS_DISABLED: serverConfig.REDIS_DISABLED || false,
REDIS_HOST: serverConfig.REDIS_HOST || '127.0.0.1',
REDIS_PORT: serverConfig.REDIS_PORT || 6379,
REDIS_PASSWORD: serverConfig.REDIS_PASSWORD,
USER_LIMIT_PER_DAY: serverConfig.USER_LIMIT_PER_DAY || 50,
JWT_SECRET: serverConfig.JWT_SECRET || 'securekey',
ADMIN_EMAILS: serverConfig.ADMIN_EMAILS.join(','),
RECAPTCHA_SITE_KEY: clientConfig.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: serverConfig.RECAPTCHA_SECRET_KEY,
GOOGLE_SAFE_BROWSING_KEY: serverConfig.GOOGLE_SAFE_BROWSING_KEY,
GOOGLE_ANALYTICS: clientConfig.GOOGLE_ANALYTICS_ID,
GOOGLE_ANALYTICS_UNIVERSAL: serverConfig.GOOGLE_ANALYTICS,
MAIL_HOST: serverConfig.MAIL_HOST,
MAIL_PORT: serverConfig.MAIL_PORT,
MAIL_SECURE: serverConfig.MAIL_SECURE,
MAIL_USER: serverConfig.MAIL_USER,
MAIL_FROM: serverConfig.MAIL_FROM,
MAIL_PASSWORD: serverConfig.MAIL_PASSWORD,
REPORT_MAIL: serverConfig.REPORT_MAIL,
CONTACT_EMAIL: clientConfig.CONTACT_EMAIL,
};

Object.keys(configs).forEach(c => {
envTemplate = envTemplate.replace(new RegExp(`{{${c}}}`, 'gm'), configs[c] || '');
});

fs.writeFileSync(path.resolve(__dirname, '../.env'), envTemplate);
fs.renameSync(path.resolve(__dirname, 'config.js'), path.resolve(__dirname, 'old.config.js'));
fs.renameSync(
path.resolve(__dirname, '../client/config.js'),
path.resolve(__dirname, '../client/old.config.js')
);
}

+ 62
- 0
server/configToEnv.ts View File

@@ -0,0 +1,62 @@
/* eslint-disable global-require */
import fs from "fs";
import path from "path";

const hasServerConfig = fs.existsSync(path.resolve(__dirname, "config.js"));
const hasClientConfig = fs.existsSync(
path.resolve(__dirname, "../client/config.js")
);

if (hasServerConfig && hasClientConfig) {
const serverConfig = require("./config.js");
const clientConfig = require("../client/config.js");
let envTemplate = fs.readFileSync(
path.resolve(__dirname, "../.template.env"),
"utf-8"
);

const configs = {
PORT: serverConfig.PORT || 3000,
DEFAULT_DOMAIN: serverConfig.DEFAULT_DOMAIN || "localhost:3000",
DB_URI: serverConfig.DB_URI || "bolt://localhost",
DB_USERNAME: serverConfig.DB_USERNAME,
DB_PASSWORD: serverConfig.DB_PASSWORD,
REDIS_DISABLED: serverConfig.REDIS_DISABLED || false,
REDIS_HOST: serverConfig.REDIS_HOST || "127.0.0.1",
REDIS_PORT: serverConfig.REDIS_PORT || 6379,
REDIS_PASSWORD: serverConfig.REDIS_PASSWORD,
USER_LIMIT_PER_DAY: serverConfig.USER_LIMIT_PER_DAY || 50,
JWT_SECRET: serverConfig.JWT_SECRET || "securekey",
ADMIN_EMAILS: serverConfig.ADMIN_EMAILS.join(","),
RECAPTCHA_SITE_KEY: clientConfig.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: serverConfig.RECAPTCHA_SECRET_KEY,
GOOGLE_SAFE_BROWSING_KEY: serverConfig.GOOGLE_SAFE_BROWSING_KEY,
GOOGLE_ANALYTICS: clientConfig.GOOGLE_ANALYTICS_ID,
GOOGLE_ANALYTICS_UNIVERSAL: serverConfig.GOOGLE_ANALYTICS,
MAIL_HOST: serverConfig.MAIL_HOST,
MAIL_PORT: serverConfig.MAIL_PORT,
MAIL_SECURE: serverConfig.MAIL_SECURE,
MAIL_USER: serverConfig.MAIL_USER,
MAIL_FROM: serverConfig.MAIL_FROM,
MAIL_PASSWORD: serverConfig.MAIL_PASSWORD,
REPORT_MAIL: serverConfig.REPORT_MAIL,
CONTACT_EMAIL: clientConfig.CONTACT_EMAIL
};

Object.keys(configs).forEach(c => {
envTemplate = envTemplate.replace(
new RegExp(`{{${c}}}`, "gm"),
configs[c] || ""
);
});

fs.writeFileSync(path.resolve(__dirname, "../.env"), envTemplate);
fs.renameSync(
path.resolve(__dirname, "config.js"),
path.resolve(__dirname, "old.config.js")
);
fs.renameSync(
path.resolve(__dirname, "../client/config.js"),
path.resolve(__dirname, "../client/old.config.js")
);
}

+ 0
- 209
server/controllers/authController.js View File

@@ -1,209 +0,0 @@
const fs = require('fs');
const path = require('path');
const passport = require('passport');
const JWT = require('jsonwebtoken');
const axios = require('axios');
const { isAdmin } = require('../utils');
const transporter = require('../mail/mail');
const { resetMailText, verifyMailText } = require('../mail/text');
const {
createUser,
changePassword,
generateApiKey,
getUser,
verifyUser,
requestPasswordReset,
resetPassword,
} = require('../db/user');

/* Read email template */
const resetEmailTemplatePath = path.join(__dirname, '../mail/template-reset.html');
const verifyEmailTemplatePath = path.join(__dirname, '../mail/template-verify.html');
const resetEmailTemplate = fs
.readFileSync(resetEmailTemplatePath, { encoding: 'utf-8' })
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
const verifyEmailTemplate = fs
.readFileSync(verifyEmailTemplatePath, { encoding: 'utf-8' })
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);

/* Function to generate JWT */
const signToken = user =>
JWT.sign(
{
iss: 'ApiAuth',
sub: user.email,
domain: user.domain || '',
admin: isAdmin(user.email),
iat: new Date().getTime(),
exp: new Date().setDate(new Date().getDate() + 7),
},
process.env.JWT_SECRET
);

/* Passport.js authentication controller */
const authenticate = (type, error, isStrict = true) =>
function auth(req, res, next) {
if (req.user) return next();
return passport.authenticate(type, (err, user) => {
if (err) return res.status(400);
if (!user && isStrict) return res.status(401).json({ error });
if (user && isStrict && !user.verified) {
return res.status(400).json({
error:
'Your email address is not verified.' +
'Click on signup to get the verification link again.',
});
}
if (user && user.banned) {
return res.status(400).json({ error: 'Your are banned from using this website.' });
}
if (user) {
req.user = {
...user,
admin: isAdmin(user.email),
};
return next();
}
return next();
})(req, res, next);
};

exports.authLocal = authenticate('local', 'Login email and/or password are wrong.');
exports.authJwt = authenticate('jwt', 'Unauthorized.');
exports.authJwtLoose = authenticate('jwt', 'Unauthorized.', false);
exports.authApikey = authenticate('localapikey', 'API key is not correct.', false);

/* reCaptcha controller */
exports.recaptcha = async (req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.user) {
const isReCaptchaValid = await axios({
method: 'post',
url: 'https://www.google.com/recaptcha/api/siteverify',
headers: {
'Content-type': 'application/x-www-form-urlencoded',
},
params: {
secret: process.env.RECAPTCHA_SECRET_KEY,
response: req.body.reCaptchaToken,
remoteip: req.realIp,
},
});
if (!isReCaptchaValid.data.success) {
return res.status(401).json({ error: 'reCAPTCHA is not valid. Try again.' });
}
}
return next();
};

exports.authAdmin = async (req, res, next) => {
if (!req.user.admin) {
return res.status(401).json({ error: 'Unauthorized.' });
}
return next();
};

exports.signup = async (req, res) => {
const { email, password } = req.body;
if (password.length > 64) {
return res.status(400).json({ error: 'Maximum password length is 64.' });
}
if (email.length > 64) {
return res.status(400).json({ error: 'Maximum email length is 64.' });
}
const user = await getUser({ email });
if (user && user.verified) return res.status(403).json({ error: 'Email is already in use.' });
const newUser = await createUser({ email, password });
const mail = await transporter.sendMail({
from: process.env.MAIL_FROM || process.env.MAIL_USER,
to: newUser.email,
subject: 'Verify your account',
text: verifyMailText.replace(/{{verification}}/gim, newUser.verificationToken),
html: verifyEmailTemplate.replace(/{{verification}}/gim, newUser.verificationToken),
});
if (mail.accepted.length) {
return res.status(201).json({ email, message: 'Verification email has been sent.' });
}
return res.status(400).json({ error: "Couldn't send verification email. Try again." });
};

exports.login = ({ user }, res) => {
const token = signToken(user);
return res.status(200).json({ token });
};

exports.renew = ({ user }, res) => {
const token = signToken(user);
return res.status(200).json({ token });
};

exports.verify = async (req, res, next) => {
const { verificationToken = '' } = req.params;
const user = await verifyUser({ verificationToken });
if (user) {
const token = signToken(user);
req.user = { token };
}
return next();
};

exports.changePassword = async ({ body: { password }, user }, res) => {
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 chars long.' });
}
if (password.length > 64) {
return res.status(400).json({ error: 'Maximum password length is 64.' });
}
const changedUser = await changePassword({ email: user.email, password });
if (changedUser) {
return res.status(200).json({ message: 'Your password has been changed successfully.' });
}
return res.status(400).json({ error: "Couldn't change the password. Try again later" });
};

exports.generateApiKey = async ({ user }, res) => {
const { apikey } = await generateApiKey({ email: user.email });
if (apikey) {
return res.status(201).json({ apikey });
}
return res.status(400).json({ error: 'Sorry, an error occured. Please try again later.' });
};

exports.userSettings = ({ user }, res) =>
res.status(200).json({
apikey: user.apikey || '',
customDomain: user.domain || '',
homepage: user.homepage || '',
useHttps: user.useHttps || false,
});

exports.requestPasswordReset = async ({ body: { email } }, res) => {
const user = await requestPasswordReset({ email });
if (!user) {
return res.status(400).json({ error: "Couldn't reset password." });
}
const mail = await transporter.sendMail({
from: process.env.MAIL_FROM || process.env.MAIL_USER,
to: user.email,
subject: 'Reset your password',
text: resetMailText
.replace(/{{resetpassword}}/gm, user.resetPasswordToken)
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
html: resetEmailTemplate
.replace(/{{resetpassword}}/gm, user.resetPasswordToken)
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
});
if (mail.accepted.length) {
return res.status(200).json({ email, message: 'Reset password email has been sent.' });
}
return res.status(400).json({ error: "Couldn't reset password." });
};

exports.resetPassword = async (req, res, next) => {
const { resetPasswordToken = '' } = req.params;
const user = await resetPassword({ resetPasswordToken });
if (user) {
const token = signToken(user);
req.user = { token };
}
return next();
};

+ 271
- 0
server/controllers/authController.ts View File

@@ -0,0 +1,271 @@
import { Handler } from "express";
import fs from "fs";
import path from "path";
import passport from "passport";
import JWT from "jsonwebtoken";
import axios from "axios";
import { addDays } from "date-fns";

import { isAdmin } from "../utils";
import transporter from "../mail/mail";
import { resetMailText, verifyMailText } from "../mail/text";
import {
createUser,
changePassword,
generateApiKey,
getUser,
verifyUser,
requestPasswordReset,
resetPassword
} from "../db/user";

/* Read email template */
const resetEmailTemplatePath = path.join(
__dirname,
"../mail/template-reset.html"
);
const verifyEmailTemplatePath = path.join(
__dirname,
"../mail/template-verify.html"
);
const resetEmailTemplate = fs
.readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
const verifyEmailTemplate = fs
.readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);

/* Function to generate JWT */
const signToken = (user: UserJoined) =>
JWT.sign(
{
iss: "ApiAuth",
sub: user.email,
domain: user.domain || "",
admin: isAdmin(user.email),
iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
} as Record<string, any>,
process.env.JWT_SECRET
);

/* Passport.js authentication controller */
const authenticate = (
type: "jwt" | "local" | "localapikey",
error: string,
isStrict = true
) =>
function auth(req, res, next) {
if (req.user) return next();
return passport.authenticate(type, (err, user) => {
if (err) return res.status(400);
if (!user && isStrict) return res.status(401).json({ error });
if (user && isStrict && !user.verified) {
return res.status(400).json({
error:
"Your email address is not verified. " +
"Click on signup to get the verification link again."
});
}
if (user && user.banned) {
return res
.status(400)
.json({ error: "Your are banned from using this website." });
}
if (user) {
req.user = {
...user,
admin: isAdmin(user.email)
};
return next();
}
return next();
})(req, res, next);
};

export const authLocal = authenticate(
"local",
"Login email and/or password are wrong."
);
export const authJwt = authenticate("jwt", "Unauthorized.");
export const authJwtLoose = authenticate("jwt", "Unauthorized.", false);
export const authApikey = authenticate(
"localapikey",
"API key is not correct.",
false
);

/* reCaptcha controller */
export const recaptcha: Handler = async (req, res, next) => {
if (process.env.NODE_ENV === "production" && !req.user) {
const isReCaptchaValid = await axios({
method: "post",
url: "https://www.google.com/recaptcha/api/siteverify",
headers: {
"Content-type": "application/x-www-form-urlencoded"
},
params: {
secret: process.env.RECAPTCHA_SECRET_KEY,
response: req.body.reCaptchaToken,
remoteip: req.realIP
}
});
if (!isReCaptchaValid.data.success) {
return res
.status(401)
.json({ error: "reCAPTCHA is not valid. Try again." });
}
}
return next();
};

export const authAdmin: Handler = async (req, res, next) => {
if (!req.user.admin) {
return res.status(401).json({ error: "Unauthorized." });
}
return next();
};

export const signup: Handler = async (req, res) => {
const { email, password } = req.body;

if (password.length > 64) {
return res.status(400).json({ error: "Maximum password length is 64." });
}

if (email.length > 255) {
return res.status(400).json({ error: "Maximum email length is 255." });
}

const user = await getUser(email);

if (user && user.verified) {
return res.status(403).json({ error: "Email is already in use." });
}

const newUser = await createUser(email, password, user);

const mail = await transporter.sendMail({
from: process.env.MAIL_FROM || process.env.MAIL_USER,
to: newUser.email,
subject: "Verify your account",
text: verifyMailText.replace(
/{{verification}}/gim,
newUser.verification_token
),
html: verifyEmailTemplate.replace(
/{{verification}}/gim,
newUser.verification_token
)
});

if (mail.accepted.length) {
return res
.status(201)
.json({ email, message: "Verification email has been sent." });
}

return res
.status(400)
.json({ error: "Couldn't send verification email. Try again." });
};

export const login: Handler = (req, res) => {
const token = signToken(req.user);
return res.status(200).json({ token });
};

export const renew: Handler = (req, res) => {
const token = signToken(req.user);
return res.status(200).json({ token });
};

export const verify: Handler = async (req, _res, next) => {
const user = await verifyUser(req.params.verificationToken);
if (user) {
const token = signToken(user);
req.token = token;
}
return next();
};

export const changeUserPassword: Handler = async (req, res) => {
if (req.body.password.length < 8) {
return res
.status(400)
.json({ error: "Password must be at least 8 chars long." });
}

if (req.body.password.length > 64) {
return res.status(400).json({ error: "Maximum password length is 64." });
}

const changedUser = await changePassword(req.user.id, req.body.password);