[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
This commit is contained in:
Pouria Ezzati 2019-10-08 21:56:03 +03:30 committed by GitHub
parent 1249bc99ae
commit 33320f0205
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 5085 additions and 2211 deletions

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 }]]
}

View File

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

View File

@ -1,35 +1,43 @@
{
"extends": [
"airbnb",
"prettier",
"prettier/react"
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"parser": "babel-eslint",
"env": {
"browser": true,
"node": true
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.server.json",
},
"plugins": ["@typescript-eslint"],
"rules": {
"react/jsx-filename-extension": [
1,
{
"extensions": [
".js",
".jsx"
]
}
],
"prettier/prettier": [
"error",
{
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100
}
],
"consistent-return": "off"
"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,
"mocha": true
},
"globals": {
"assert": true
},
"settings": {
"react": {
"version": "detect"
}
},
"plugins": [
"prettier"
]
}

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

3
.gitignore vendored
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
MIGRATION.md Normal file
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
```

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

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);

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'
}
];

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));
}

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>

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';

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}

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;

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>
);

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,
};

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,
})

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,
};

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>
);

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() {

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>

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() {

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

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: {

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);

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: '',
};

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;

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;

View File

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

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'
});
});

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
global.d.ts vendored Normal file
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;
}
}

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
nodemon.json Normal file
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"
}
}

1798
package-lock.json generated

File diff suppressed because it is too large Load Diff

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"
}
}

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
server/configToEnv.ts Normal file
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")
);
}

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();
};

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);
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" });
};
export const generateUserApiKey = async (req, res) => {
const apikey = await generateApiKey(req.user.id);
if (apikey) {
return res.status(201).json({ apikey });
}
return res
.status(400)
.json({ error: "Sorry, an error occured. Please try again later." });
};
export const userSettings: Handler = (req, res) =>
res.status(200).json({
apikey: req.user.apikey || "",
customDomain: req.user.domain || "",
homepage: req.user.homepage || ""
});
export const requestUserPasswordReset: Handler = async (req, res) => {
const user = await requestPasswordReset(req.body.email);
if (!user) {
return res.status(400).json({ error: "Couldn't reset password." });
}
const mail = await transporter.sendMail({
from: process.env.MAIL_USER,
to: user.email,
subject: "Reset your password",
text: resetMailText
.replace(/{{resetpassword}}/gm, user.reset_password_token)
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
html: resetEmailTemplate
.replace(/{{resetpassword}}/gm, user.reset_password_token)
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN)
});
if (mail.accepted.length) {
return res.status(200).json({
email: user.email,
message: "Reset password email has been sent."
});
}
return res.status(400).json({ error: "Couldn't reset password." });
};
export const resetUserPassword: Handler = async (req, _res, next) => {
const user: UserJoined = await resetPassword(req.params.resetPasswordToken);
if (user) {
const token = signToken(user as UserJoined);
req.token = token;
}
return next();
};

View File

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

View File

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

View File

@ -1,202 +0,0 @@
const { promisify } = require('util');
const dns = require('dns');
const axios = require('axios');
const URL = require('url');
const urlRegex = require('url-regex');
const validator = require('express-validator/check');
const { differenceInMinutes, subHours } = require('date-fns/');
const { validationResult } = require('express-validator/check');
const { addCooldown, banUser, getIPCooldown: getIPCooldownCount } = require('../db/user');
const { getBannedDomain, getBannedHost, urlCountFromDate } = require('../db/url');
const subDay = require('date-fns/sub_days');
const { addProtocol } = require('../utils');
const dnsLookup = promisify(dns.lookup);
exports.validationCriterias = [
validator
.body('email')
.exists()
.withMessage('Email must be provided.')
.isEmail()
.withMessage('Email is not valid.')
.trim()
.normalizeEmail(),
validator
.body('password', 'Password must be at least 8 chars long.')
.exists()
.withMessage('Password must be provided.')
.isLength({ min: 8 }),
];
exports.validateBody = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const errorsObj = errors.mapped();
const emailError = errorsObj.email && errorsObj.email.msg;
const passwordError = errorsObj.password && errorsObj.password.msg;
return res.status(400).json({ error: emailError || passwordError });
}
return next();
};
const preservedUrls = [
'login',
'logout',
'signup',
'reset-password',
'resetpassword',
'url-password',
'url-info',
'settings',
'stats',
'verify',
'api',
'404',
'static',
'images',
'banned',
'terms',
'privacy',
'report',
];
exports.preservedUrls = preservedUrls;
exports.validateUrl = async ({ body, user }, res, next) => {
// Validate URL existence
if (!body.target) return res.status(400).json({ error: 'No target has been provided.' });
// validate URL length
if (body.target.length > 3000) {
return res.status(400).json({ error: 'Maximum URL length is 3000.' });
}
// Validate URL
const isValidUrl = urlRegex({ exact: true, strict: false }).test(body.target);
if (!isValidUrl && !/^\w+:\/\//.test(body.target))
return res.status(400).json({ error: 'URL is not valid.' });
// If target is the URL shortener itself
const { host } = URL.parse(addProtocol(body.target));
if (host === process.env.DEFAULT_DOMAIN) {
return res.status(400).json({ error: `${process.env.DEFAULT_DOMAIN} URLs are not allowed.` });
}
// Validate password length
if (body.password && body.password.length > 64) {
return res.status(400).json({ error: 'Maximum password length is 64.' });
}
// Custom URL validations
if (user && body.customurl) {
// Validate custom URL
if (!/^[a-zA-Z0-9-_]+$/g.test(body.customurl.trim())) {
return res.status(400).json({ error: 'Custom URL is not valid.' });
}
// Prevent from using preserved URLs
if (preservedUrls.some(url => url === body.customurl)) {
return res.status(400).json({ error: "You can't use this custom URL name." });
}
// Validate custom URL length
if (body.customurl.length > 64) {
return res.status(400).json({ error: 'Maximum custom URL length is 64.' });
}
}
return next();
};
exports.cooldownCheck = async user => {
if (user && user.cooldowns) {
if (user.cooldowns.length > 4) {
await banUser(user);
throw new Error('Too much malware requests. You are now banned.');
}
const hasCooldownNow = user.cooldowns.some(
cooldown => cooldown > subHours(new Date(), 12).toJSON()
);
if (hasCooldownNow) {
throw new Error('Cooldown because of a malware URL. Wait 12h');
}
}
};
exports.ipCooldownCheck = async (req, res, next) => {
const cooldonwConfig = Number(process.env.NON_USER_COOLDOWN);
if (req.user || !cooldonwConfig) return next();
const cooldownDate = await getIPCooldownCount(req.realIp);
if (cooldownDate) {
const timeToWait = cooldonwConfig - differenceInMinutes(new Date(), cooldownDate);
return res
.status(400)
.json({ error: `Non-logged in users are limited. Wait ${timeToWait} minutes or log in.` });
}
next();
};
exports.malwareCheck = async (user, target) => {
const isMalware = await axios.post(
`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${
process.env.GOOGLE_SAFE_BROWSING_KEY
}`,
{
client: {
clientId: process.env.DEFAULT_DOMAIN.toLowerCase().replace('.', ''),
clientVersion: '1.0.0',
},
threatInfo: {
threatTypes: [
'THREAT_TYPE_UNSPECIFIED',
'MALWARE',
'SOCIAL_ENGINEERING',
'UNWANTED_SOFTWARE',
'POTENTIALLY_HARMFUL_APPLICATION',
],
platformTypes: ['ANY_PLATFORM', 'PLATFORM_TYPE_UNSPECIFIED'],
threatEntryTypes: ['EXECUTABLE', 'URL', 'THREAT_ENTRY_TYPE_UNSPECIFIED'],
threatEntries: [{ url: target }],
},
}
);
if (isMalware.data && isMalware.data.matches) {
if (user) {
await addCooldown(user);
}
throw new Error(user ? 'Malware detected! Cooldown for 12h.' : 'Malware detected!');
}
};
exports.urlCountsCheck = async email => {
const { count } = await urlCountFromDate({
email,
date: subDay(new Date(), 1).toJSON(),
});
if (count > Number(process.env.USER_LIMIT_PER_DAY)) {
throw new Error(
`You have reached your daily limit (${process.env.USER_LIMIT_PER_DAY}). Please wait 24h.`
);
}
};
exports.checkBannedDomain = async domain => {
const isDomainBanned = await getBannedDomain(domain);
if (isDomainBanned) {
throw new Error('URL is containing malware/scam.');
}
};
exports.checkBannedHost = async domain => {
let isHostBanned;
try {
const dnsRes = await dnsLookup(domain);
isHostBanned = await getBannedHost(dnsRes && dnsRes.address);
} catch (error) {
isHostBanned = null;
}
if (isHostBanned) {
throw new Error('URL is containing malware/scam.');
}
};

View File

@ -0,0 +1,224 @@
import { RequestHandler } from "express";
import { promisify } from "util";
import dns from "dns";
import axios from "axios";
import URL from "url";
import urlRegex from "url-regex";
import validator from "express-validator/check";
import { differenceInMinutes, subHours, subDays, isAfter } from "date-fns";
import { validationResult } from "express-validator/check";
import { addCooldown, banUser } from "../db/user";
import { getIP } from "../db/ip";
import { getUserLinksCount } from "../db/link";
import { getDomain } from "../db/domain";
import { getHost } from "../db/host";
import { addProtocol } from "../utils";
const dnsLookup = promisify(dns.lookup);
export const validationCriterias = [
validator
.body("email")
.exists()
.withMessage("Email must be provided.")
.isEmail()
.withMessage("Email is not valid.")
.trim()
.normalizeEmail(),
validator
.body("password", "Password must be at least 8 chars long.")
.exists()
.withMessage("Password must be provided.")
.isLength({ min: 8 })
];
export const validateBody = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const errorsObj = errors.mapped();
const emailError = errorsObj.email && errorsObj.email.msg;
const passwordError = errorsObj.password && errorsObj.password.msg;
return res.status(400).json({ error: emailError || passwordError });
}
return next();
};
export const preservedUrls = [
"login",
"logout",
"signup",
"reset-password",
"resetpassword",
"url-password",
"url-info",
"settings",
"stats",
"verify",
"api",
"404",
"static",
"images",
"banned",
"terms",
"privacy",
"report",
"pricing"
];
export const validateUrl: RequestHandler = async (req, res, next) => {
// Validate URL existence
if (!req.body.target)
return res.status(400).json({ error: "No target has been provided." });
// validate URL length
if (req.body.target.length > 1023) {
return res.status(400).json({ error: "Maximum URL length is 1023." });
}
// Validate URL
const isValidUrl = urlRegex({ exact: true, strict: false }).test(
req.body.target
);
if (!isValidUrl && !/^\w+:\/\//.test(req.body.target))
return res.status(400).json({ error: "URL is not valid." });
// If target is the URL shortener itself
const { host } = URL.parse(addProtocol(req.body.target));
if (host === process.env.DEFAULT_DOMAIN) {
return res
.status(400)
.json({ error: `${process.env.DEFAULT_DOMAIN} URLs are not allowed.` });
}
// Validate password length
if (req.body.password && req.body.password.length > 64) {
return res.status(400).json({ error: "Maximum password length is 64." });
}
// Custom URL validations
if (req.user && req.body.customurl) {
// Validate custom URL
if (!/^[a-zA-Z0-9-_]+$/g.test(req.body.customurl.trim())) {
return res.status(400).json({ error: "Custom URL is not valid." });
}
// Prevent from using preserved URLs
if (preservedUrls.some(url => url === req.body.customurl)) {
return res
.status(400)
.json({ error: "You can't use this custom URL name." });
}
// Validate custom URL length
if (req.body.customurl.length > 64) {
return res
.status(400)
.json({ error: "Maximum custom URL length is 64." });
}
}
return next();
};
export const cooldownCheck = async (user: User) => {
if (user && user.cooldowns) {
if (user.cooldowns.length > 4) {
await banUser(user.id);
throw new Error("Too much malware requests. You are now banned.");
}
const hasCooldownNow = user.cooldowns.some(cooldown =>
isAfter(subHours(new Date(), 12), cooldown)
);
if (hasCooldownNow) {
throw new Error("Cooldown because of a malware URL. Wait 12h");
}
}
};
export const ipCooldownCheck: RequestHandler = async (req, res, next) => {
const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
if (req.user || !cooldownConfig) return next();
const ip = await getIP(req.realIP);
if (ip) {
const timeToWait =
cooldownConfig - differenceInMinutes(new Date(), ip.created_at);
return res.status(400).json({
error:
`Non-logged in users are limited. Wait ${timeToWait} ` +
"minutes or log in."
});
}
next();
};
export const malwareCheck = async (user: User, target: string) => {
const isMalware = await axios.post(
`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${process.env.GOOGLE_SAFE_BROWSING_KEY}`,
{
client: {
clientId: process.env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
clientVersion: "1.0.0"
},
threatInfo: {
threatTypes: [
"THREAT_TYPE_UNSPECIFIED",
"MALWARE",
"SOCIAL_ENGINEERING",
"UNWANTED_SOFTWARE",
"POTENTIALLY_HARMFUL_APPLICATION"
],
platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
threatEntryTypes: [
"EXECUTABLE",
"URL",
"THREAT_ENTRY_TYPE_UNSPECIFIED"
],
threatEntries: [{ url: target }]
}
}
);
if (isMalware.data && isMalware.data.matches) {
if (user) {
await addCooldown(user.id);
}
throw new Error(
user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
);
}
};
export const urlCountsCheck = async (user: User) => {
const count = await getUserLinksCount({
user_id: user.id,
date: subDays(new Date(), 1)
});
if (count > Number(process.env.USER_LIMIT_PER_DAY)) {
throw new Error(
`You have reached your daily limit (${process.env.USER_LIMIT_PER_DAY}). Please wait 24h.`
);
}
};
export const checkBannedDomain = async (domain: string) => {
const bannedDomain = await getDomain({ address: domain, banned: true });
if (bannedDomain) {
throw new Error("URL is containing malware/scam.");
}
};
export const checkBannedHost = async (domain: string) => {
let isHostBanned;
try {
const dnsRes = await dnsLookup(domain);
isHostBanned = await getHost({
address: dnsRes && dnsRes.address,
banned: true
});
} catch (error) {
isHostBanned = null;
}
if (isHostBanned) {
throw new Error("URL is containing malware/scam.");
}
};

View File

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

9
server/cron.ts Normal file
View File

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

114
server/db/domain.ts Normal file
View File

@ -0,0 +1,114 @@
import knex from "../knex";
import * as redis from "../redis";
import { getRedisKey } from "../utils";
export const getDomain = async (data: Partial<Domain>): Promise<Domain> => {
const getData = {
...data,
...(data.address && { address: data.address.toLowerCase() }),
...(data.homepage && { homepage: data.homepage.toLowerCase() })
};
const redisKey = getRedisKey.domain(getData.address);
const cachedDomain = await redis.get(redisKey);
if (cachedDomain) return JSON.parse(cachedDomain);
const domain = await knex<Domain>("domains")
.where(getData)
.first();
if (domain) {
redis.set(redisKey, JSON.stringify(domain), "EX", 60 * 60 * 6);
}
return domain;
};
export const setDomain = async (
data: Partial<Domain>,
user: UserJoined,
matchedDomain: Domain
) => {
// 1. If user has domain, remove it from their possession
await knex<Domain>("domains")
.where({ user_id: user.id })
.update({ user_id: null });
// 2. Create or update the domain with user's ID
let domain;
const updateDate: Partial<Domain> = {
address: data.address.toLowerCase(),
homepage: data.homepage && data.homepage.toLowerCase(),
user_id: user.id,
updated_at: new Date().toISOString()
};
if (matchedDomain) {
const [response]: Domain[] = await knex<Domain>("domains")
.where("id", matchedDomain.id)
.update(updateDate, "*");
domain = response;
} else {
const [response]: Domain[] = await knex<Domain>("domains").insert(
updateDate,
"*"
);
domain = response;
}
redis.del(user.email);
redis.del(user.email);
redis.del(getRedisKey.domain(updateDate.address));
return domain;
};
export const deleteDomain = async (user: UserJoined) => {
// Remove user from domain, do not actually delete the domain
const [domain]: Domain[] = await knex<Domain>("domains")
.where({ user_id: user.id })
.update({ user_id: null, updated_at: new Date().toISOString() }, "*");
if (domain) {
redis.del(getRedisKey.domain(domain.address));
}
redis.del(user.email);
redis.del(user.apikey);
return domain;
};
export const banDomain = async (
addressToban: string,
banned_by_id?: number
): Promise<Domain> => {
const address = addressToban.toLowerCase();
const currentDomain = await getDomain({ address });
let domain;
if (currentDomain) {
const updates: Domain[] = await knex<Domain>("domains")
.where({ address })
.update(
{ banned: true, banned_by_id, updated_at: new Date().toISOString() },
"*"
);
domain = updates[0];
} else {
const inserts: Domain[] = await knex<Domain>("domains").insert(
{ address, banned: true, banned_by_id },
"*"
);
domain = inserts[0];
}
if (domain) {
redis.del(getRedisKey.domain(domain.address));
}
return domain;
};

51
server/db/host.ts Normal file
View File

@ -0,0 +1,51 @@
import knex from "../knex";
import * as redis from "../redis";
import { getRedisKey } from "../utils";
export const getHost = async (data: Partial<Host>) => {
const getData = {
...data,
...(data.address && { address: data.address.toLowerCase() })
};
const redisKey = getRedisKey.host(getData.address);
const cachedHost = await redis.get(redisKey);
if (cachedHost) return JSON.parse(cachedHost);
const host = await knex<Host>("hosts")
.where(getData)
.first();
if (host) {
redis.set(redisKey, JSON.stringify(host), "EX", 60 * 60 * 6);
}
return host;
};
export const banHost = async (addressToBan: string, banned_by_id?: number) => {
const address = addressToBan.toLowerCase();
const currentHost = await knex<Host>("hosts")
.where({ address })
.first();
if (currentHost) {
await knex<Host>("hosts")
.where({ address })
.update({
banned: true,
banned_by_id,
updated_at: new Date().toISOString()
});
} else {
await knex<Host>("hosts").insert({ address, banned: true, banned_by_id });
}
if (currentHost) {
redis.del(getRedisKey.host(currentHost.address));
}
return currentHost;
};

49
server/db/ip.ts Normal file
View File

@ -0,0 +1,49 @@
import subMinutes from "date-fns/sub_minutes";
import knex from "../knex";
export const addIP = async (ipToGet: string) => {
const ip = ipToGet.toLowerCase();
const currentIP = await knex<IP>("ips")
.where({ ip })
.first();
if (currentIP) {
const currentDate = new Date().toISOString();
await knex<IP>("ips")
.where({ ip })
.update({
created_at: currentDate,
updated_at: currentDate
});
} else {
await knex<IP>("ips").insert({ ip });
}
return ip;
};
export const getIP = async (ip: string) => {
const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
const matchedIp = await knex<IP>("ips")
.where({ ip: ip.toLowerCase() })
.andWhere(
"created_at",
">",
subMinutes(new Date(), cooldownConfig).toISOString()
)
.first();
return matchedIp;
};
export const clearIPs = async () =>
knex<IP>("ips")
.where(
"created_at",
"<",
subMinutes(
new Date(),
Number(process.env.NON_USER_COOLDOWN)
).toISOString()
)
.delete();

511
server/db/link.ts Normal file
View File

@ -0,0 +1,511 @@
import bcrypt from "bcryptjs";
import { isAfter, subDays } from "date-fns";
import knex from "../knex";
import * as redis from "../redis";
import {
generateShortLink,
getRedisKey,
getUTCDate,
getDifferenceFunction,
statsObjectToArray
} from "../utils";
import { banDomain } from "./domain";
import { banHost } from "./host";
import { banUser } from "./user";
interface CreateLink extends Link {
reuse?: boolean;
domainName?: string;
}
export const createShortLink = async (data: CreateLink, user: UserJoined) => {
const { id: user_id = null, domain, domain_id = null } =
user || ({} as UserJoined);
let password;
if (data.password) {
const salt = await bcrypt.genSalt(12);
password = await bcrypt.hash(data.password, salt);
}
const [link]: Link[] = await knex<Link>("links").insert(
{
domain_id,
address: data.address,
password,
target: data.target,
user_id
},
"*"
);
return {
...link,
password: !!data.password,
reuse: !!data.reuse,
shortLink: generateShortLink(data.address, domain),
shortUrl: generateShortLink(data.address, domain)
};
};
export const addLinkCount = async (id: number) => {
return knex<Link>("links")
.where({ id })
.increment("visit_count", 1);
};
interface ICreateVisit {
browser: string;
country: string;
domain?: string;
id: number;
limit: number;
os: string;
referrer: string;
}
export const createVisit = async (params: ICreateVisit) => {
const data = {
...params,
country: params.country.toLowerCase(),
referrer: params.referrer.toLowerCase()
};
const visit = await knex<Visit>("visits")
.where({ link_id: params.id })
.andWhere(
knex.raw("date_trunc('hour', created_at) = date_trunc('hour', ?)", [
knex.fn.now()
])
)
.first();
if (visit) {
await knex("visits")
.where({ id: visit.id })
.increment(`br_${data.browser}`, 1)
.increment(`os_${data.os}`, 1)
.increment("total", 1)
.update({
updated_at: new Date().toISOString(),
countries: knex.raw(
"jsonb_set(countries, '{??}', (COALESCE(countries->>?,'0')::int + 1)::text::jsonb)",
[data.country, data.country]
),
referrers: knex.raw(
"jsonb_set(referrers, '{??}', (COALESCE(referrers->>?,'0')::int + 1)::text::jsonb)",
[data.referrer, data.referrer]
)
});
} else {
await knex<Visit>("visits").insert({
[`br_${data.browser}`]: 1,
countries: { [data.country]: 1 },
referrers: { [data.referrer]: 1 },
[`os_${data.os}`]: 1,
total: 1,
link_id: data.id
});
}
return visit;
};
interface IFindLink {
address?: string;
domain_id?: number | null;
user_id?: number | null;
target?: string;
}
export const findLink = async ({
address,
domain_id,
user_id,
target
}: IFindLink): Promise<Link> => {
const redisKey = getRedisKey.link(address, domain_id, user_id);
const cachedLink = await redis.get(redisKey);
if (cachedLink) return JSON.parse(cachedLink);
const link = await knex<Link>("links")
.where({
...(address && { address }),
...(domain_id && { domain_id }),
...(user_id && { user_id }),
...(target && { target })
})
.first();
if (link) {
redis.set(redisKey, JSON.stringify(link), "EX", 60 * 60 * 2);
}
return link;
};
export const getUserLinksCount = async (params: {
user_id: number;
date?: Date;
}) => {
const model = knex<Link>("links").where({ user_id: params.user_id });
// TODO: Test counts;
let res;
if (params.date) {
res = await model
.andWhere("created_at", ">", params.date.toISOString())
.count("id");
} else {
res = await model.count("id");
}
return res[0] && res[0].count;
};
interface IGetLinksOptions {
count?: string;
page?: string;
search?: string;
}
export const getLinks = async (
user_id: number,
options: IGetLinksOptions = {}
) => {
const { count = "5", page = "1", search = "" } = options;
const limit = parseInt(count) < 50 ? parseInt(count) : 50;
const offset = (parseInt(page) - 1) * limit;
const model = knex<LinkJoinedDomain>("links")
.select(
"links.id",
"links.address",
"links.banned",
"links.created_at",
"links.domain_id",
"links.updated_at",
"links.password",
"links.target",
"links.visit_count",
"links.user_id",
"domains.address as domain"
)
.offset(offset)
.limit(limit)
.orderBy("created_at", "desc")
.where("links.user_id", user_id);
if (search) {
model.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
search
]);
}
const matchedLinks = await model.leftJoin(
"domains",
"links.domain_id",
"domains.id"
);
const links = matchedLinks.map(link => ({
...link,
id: link.address,
password: !!link.password,
shortLink: generateShortLink(link.address, link.domain),
shortUrl: generateShortLink(link.address, link.domain)
}));
return links;
};
interface IDeleteLink {
address: string;
user_id: number;
domain?: string;
}
export const deleteLink = async (data: IDeleteLink) => {
const link: LinkJoinedDomain = await knex<LinkJoinedDomain>("links")
.select("links.id", "domains.address as domain")
.where({
"links.address": data.address,
"links.user_id": data.user_id,
...(!data.domain && { domain_id: null })
})
.leftJoin("domains", "links.domain_id", "domains.id")
.first();
if (!link) return;
if (link.domain !== data.domain) {
return;
}
await knex<Visit>("visits")
.where("link_id", link.id)
.delete();
const deletedLink = await knex<Link>("links")
.where("id", link.id)
.delete();
redis.del(getRedisKey.link(link.address, link.domain_id, link.user_id));
return !!deletedLink;
};
/*
** Collecting stats
*/
interface StatsResult {
stats: {
browser: { name: string; value: number }[];
os: { name: string; value: number }[];
country: { name: string; value: number }[];
referrer: { name: string; value: number }[];
};
views: number[];
}
const getInitStats = (): Stats =>
Object.create({
browser: {
chrome: 0,
edge: 0,
firefox: 0,
ie: 0,
opera: 0,
other: 0,
safari: 0
},
os: {
android: 0,
ios: 0,
linux: 0,
macos: 0,
other: 0,
windows: 0
},
country: {},
referrer: {}
});
const STATS_PERIODS: [number, "lastDay" | "lastWeek" | "lastMonth"][] = [
[1, "lastDay"],
[7, "lastWeek"],
[30, "lastMonth"]
];
interface IGetStatsResponse {
allTime: StatsResult;
id: string;
lastDay: StatsResult;
lastMonth: StatsResult;
lastWeek: StatsResult;
shortLink: string;
shortUrl: string;
target: string;
total: number;
updatedAt: string;
}
export const getStats = async (link: Link, domain: Domain) => {
const stats = {
lastDay: {
stats: getInitStats(),
views: new Array(24).fill(0)
},
lastWeek: {
stats: getInitStats(),
views: new Array(7).fill(0)
},
lastMonth: {
stats: getInitStats(),
views: new Array(30).fill(0)
},
allTime: {
stats: getInitStats(),
views: new Array(18).fill(0)
}
};
const visitsStream: any = knex<Visit>("visits")
.where("link_id", link.id)
.stream();
const nowUTC = getUTCDate();
const now = new Date();
for await (const visit of visitsStream as Visit[]) {
STATS_PERIODS.forEach(([days, type]) => {
const isIncluded = isAfter(visit.created_at, subDays(nowUTC, days));
if (isIncluded) {
const diffFunction = getDifferenceFunction(type);
const diff = diffFunction(now, visit.created_at);
const index = stats[type].views.length - diff - 1;
const view = stats[type].views[index];
const period = stats[type].stats;
stats[type].stats = {
browser: {
chrome: period.browser.chrome + visit.br_chrome,
edge: period.browser.edge + visit.br_edge,
firefox: period.browser.firefox + visit.br_firefox,
ie: period.browser.ie + visit.br_ie,
opera: period.browser.opera + visit.br_opera,
other: period.browser.other + visit.br_other,
safari: period.browser.safari + visit.br_safari
},
os: {
android: period.os.android + visit.os_android,
ios: period.os.ios + visit.os_ios,
linux: period.os.linux + visit.os_linux,
macos: period.os.macos + visit.os_macos,
other: period.os.other + visit.os_other,
windows: period.os.windows + visit.os_windows
},
country: {
...period.country,
...Object.entries(visit.countries).reduce(
(obj, [country, count]) => ({
...obj,
[country]: (period.country[country] || 0) + count
}),
{}
)
},
referrer: {
...period.referrer,
...Object.entries(visit.referrers).reduce(
(obj, [referrer, count]) => ({
...obj,
[referrer]: (period.referrer[referrer] || 0) + count
}),
{}
)
}
};
stats[type].views[index] = view + visit.total;
}
});
const allTime = stats.allTime.stats;
const diffFunction = getDifferenceFunction("allTime");
const diff = diffFunction(now, visit.created_at);
const index = stats.allTime.views.length - diff - 1;
const view = stats.allTime.views[index];
stats.allTime.stats = {
browser: {
chrome: allTime.browser.chrome + visit.br_chrome,
edge: allTime.browser.edge + visit.br_edge,
firefox: allTime.browser.firefox + visit.br_firefox,
ie: allTime.browser.ie + visit.br_ie,
opera: allTime.browser.opera + visit.br_opera,
other: allTime.browser.other + visit.br_other,
safari: allTime.browser.safari + visit.br_safari
},
os: {
android: allTime.os.android + visit.os_android,
ios: allTime.os.ios + visit.os_ios,
linux: allTime.os.linux + visit.os_linux,
macos: allTime.os.macos + visit.os_macos,
other: allTime.os.other + visit.os_other,
windows: allTime.os.windows + visit.os_windows
},
country: {
...allTime.country,
...Object.entries(visit.countries).reduce(
(obj, [country, count]) => ({
...obj,
[country]: (allTime.country[country] || 0) + count
}),
{}
)
},
referrer: {
...allTime.referrer,
...Object.entries(visit.referrers).reduce(
(obj, [referrer, count]) => ({
...obj,
[referrer]: (allTime.referrer[referrer] || 0) + count
}),
{}
)
}
};
stats.allTime.views[index] = view + visit.total;
}
const response: IGetStatsResponse = {
allTime: {
stats: statsObjectToArray(stats.allTime.stats),
views: stats.allTime.views
},
id: link.address,
lastDay: {
stats: statsObjectToArray(stats.lastDay.stats),
views: stats.lastDay.views
},
lastMonth: {
stats: statsObjectToArray(stats.lastDay.stats),
views: stats.lastDay.views
},
lastWeek: {
stats: statsObjectToArray(stats.lastWeek.stats),
views: stats.lastWeek.views
},
shortLink: generateShortLink(link.address, domain.address),
shortUrl: generateShortLink(link.address, domain.address),
target: link.target,
total: link.visit_count,
updatedAt: new Date().toISOString()
};
return response;
};
interface IBanLink {
adminId?: number;
banUser?: boolean;
domain?: string;
host?: string;
address: string;
}
export const banLink = async (data: IBanLink) => {
const tasks = [];
const banned_by_id = data.adminId;
// Ban link
const [link]: Link[] = await knex<Link>("links")
.where({ address: data.address, domain_id: null })
.update(
{ banned: true, banned_by_id, updated_at: new Date().toISOString() },
"*"
);
if (!link) throw new Error("No link has been found.");
// If user, ban user and all of their links.
if (data.banUser && link.user_id) {
tasks.push(banUser(link.user_id, banned_by_id));
tasks.push(
knex<Link>("links")
.where({ user_id: link.user_id })
.update(
{ banned: true, banned_by_id, updated_at: new Date().toISOString() },
"*"
)
);
}
// Ban host
if (data.host) tasks.push(banHost(data.host, banned_by_id));
// Ban domain
if (data.domain) tasks.push(banDomain(data.domain, banned_by_id));
redis.del(getRedisKey.link(link.address, link.domain_id, link.user_id));
return Promise.all(tasks);
};

View File

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

View File

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

View File

@ -1,229 +0,0 @@
const bcrypt = require('bcryptjs');
const nanoid = require('nanoid');
const subMinutes = require('date-fns/sub_minutes');
const driver = require('./neo4j');
exports.getUser = async ({ email = '', apikey = '' }) => {
const session = driver.session();
const { records = [] } = await session.readTransaction(tx =>
tx.run(
'MATCH (u:USER) WHERE u.email = $email OR u.apikey = $apikey ' +
'OPTIONAL MATCH (u)-[r:RECEIVED]->(c) WITH u, collect(c.date) as cooldowns ' +
'OPTIONAL MATCH (u)-[:OWNS]->(d) RETURN u, d, cooldowns',
{
apikey,
email,
}
)
);
session.close();
const user = records.length && records[0].get('u').properties;
const cooldowns = records.length && records[0].get('cooldowns');
const domainProps = records.length && records[0].get('d');
const domain = domainProps ? domainProps.properties.name : '';
const homepage = domainProps ? domainProps.properties.homepage : '';
const useHttps = domainProps ? domainProps.properties.useHttps : '';
return user && { ...user, cooldowns, domain, homepage, useHttps };
};
exports.createUser = async ({ email, password }) => {
const session = driver.session();
const salt = await bcrypt.genSalt(12);
const hash = await bcrypt.hash(password, salt);
const verificationToken = nanoid(40);
const { records = [] } = await session.writeTransaction(tx =>
tx.run(
'MERGE (u:USER { email: $email }) ' +
'SET u.password = $hash , u.verified = $verified , ' +
'u.verificationToken = $verificationToken , u.verificationExpires = $verificationExpires, u.createdAt = $createdAt ' +
'RETURN u',
{
email,
hash,
createdAt: new Date().toJSON(),
verified: false,
verificationToken,
verificationExpires: Date.now() + 3600000,
}
)
);
session.close();
const user = records[0].get('u').properties;
return user;
};
exports.verifyUser = async ({ verificationToken }) => {
const session = driver.session();
const { records = [] } = await session.writeTransaction(tx =>
tx.run(
'MATCH (u:USER) ' +
'WHERE u.verificationToken = $verificationToken AND u.verificationExpires > $currentTime ' +
'SET u.verified = true, u.verificationToken = NULL, u.verificationExpires = NULL RETURN u',
{
verificationToken,
currentTime: Date.now(),
}
)
);
session.close();
const user = records.length && records[0].get('u').properties;
return user;
};
exports.changePassword = async ({ email, password }) => {
const session = driver.session();
const salt = await bcrypt.genSalt(12);
const hash = await bcrypt.hash(password, salt);
const { records = [] } = await session.writeTransaction(tx =>
tx.run('MATCH (u:USER { email: $email }) SET u.password = $password RETURN u', {
email,
password: hash,
})
);
session.close();
const user = records.length && records[0].get('u').properties;
return user;
};
exports.generateApiKey = async ({ email }) => {
const session = driver.session();
const apikey = nanoid(40);
const { records = [] } = await session.writeTransaction(tx =>
tx.run('MATCH (u:USER { email: $email }) SET u.apikey = $apikey RETURN u', {
email,
apikey,
})
);
session.close();
const newApikey = records.length && records[0].get('u').properties.apikey;
return { apikey: newApikey };
};
exports.requestPasswordReset = async ({ email }) => {
const session = driver.session();
const resetPasswordToken = nanoid(40);
const { records = [] } = await session.writeTransaction(tx =>
tx.run(
'MATCH (u:USER { email: $email }) ' +
'SET u.resetPasswordToken = $resetPasswordToken ' +
'SET u.resetPasswordExpires = $resetPasswordExpires ' +
'RETURN u',
{
email,
resetPasswordExpires: Date.now() + 3600000,
resetPasswordToken,
}
)
);
session.close();
const user = records.length && records[0].get('u').properties;
return user;
};
exports.resetPassword = async ({ resetPasswordToken }) => {
const session = driver.session();
const { records = [] } = await session.writeTransaction(tx =>
tx.run(
'MATCH (u:USER) ' +
'WHERE u.resetPasswordToken = $resetPasswordToken AND u.resetPasswordExpires > $currentTime ' +
'SET u.resetPasswordExpires = NULL, u.resetPasswordToken = NULL RETURN u',
{
resetPasswordToken,
currentTime: Date.now(),
}
)
);
session.close();
const user = records.length && records[0].get('u').properties;
return user;
};
exports.addCooldown = async ({ email }) => {
const session = driver.session();
const { records = [] } = await session.writeTransaction(tx =>
tx.run(
'MATCH (u:USER { email: $email }) ' +
'MERGE (u)-[r:RECEIVED]->(c:COOLDOWN { date: $date }) ' +
'RETURN COUNT(r) as count',
{
date: new Date().toJSON(),
email,
}
)
);
session.close();
const count = records.length && records[0].get('count').toNumber();
return { count };
};
exports.getCooldowns = async ({ email }) => {
const session = driver.session();
const { records = [] } = await session.writeTransaction(tx =>
tx.run('MATCH (u:USER { email: $email }) MATCH (u)-[r:RECEIVED]->(c) RETURN c.date as date', {
date: new Date().toJSON(),
email,
})
);
session.close();
const cooldowns = records.map(record => record.get('date'));
return { cooldowns };
};
exports.banUser = async ({ email }) => {
const session = driver.session();
const { records = [] } = await session.writeTransaction(tx =>
tx.run('MATCH (u:USER { email: $email }) SET u.banned = true RETURN u', {
email,
})
);
session.close();
const user = records.length && records[0].get('u');
return { user };
};
exports.addIPCooldown = async ip => {
const session = driver.session();
const { records = [] } = await session.writeTransaction(tx =>
tx.run(
'MERGE (i:IP { ip: $ip }) ' +
'MERGE (i)-[r:RECEIVED]->(c:COOLDOWN { date: $date }) ' +
'RETURN COUNT(r) as count',
{
date: new Date().toJSON(),
ip,
}
)
);
session.close();
const count = records.length && records[0].get('count').toNumber();
return count;
};
exports.getIPCooldown = async ip => {
const session = driver.session();
const { records = [] } = await session.readTransaction(tx =>
tx.run(
'MATCH (i:IP { ip: $ip }) ' +
'MATCH (i)-[:RECEIVED]->(c:COOLDOWN) ' +
'WHERE c.date > $date ' +
'RETURN c.date as date',
{
date: subMinutes(new Date(), Number(process.env.NON_USER_COOLDOWN)).toJSON(),
ip,
}
)
);
session.close();
const count = records.length && records[0].get('date');
return count;
};
exports.clearIPs = async () => {
const session = driver.session();
await session.writeTransaction(tx =>
tx.run('MATCH (i:IP)-[:RECEIVED]->(c:COOLDOWN) WHERE c.date < $date DETACH DELETE i, c', {
date: subMinutes(new Date(), Number(process.env.NON_USER_COOLDOWN)).toJSON(),
})
);
session.close();
};

207
server/db/user.ts Normal file
View File

@ -0,0 +1,207 @@
import bcrypt from "bcryptjs";
import nanoid from "nanoid";
import uuid from "uuid/v4";
import addMinutes from "date-fns/add_minutes";
import knex from "../knex";
import * as redis from "../redis";
import { getRedisKey } from "../utils";
export const getUser = async (emailOrKey = ""): Promise<User> => {
const redisKey = getRedisKey.user(emailOrKey);
const cachedUser = await redis.get(redisKey);
if (cachedUser) return JSON.parse(cachedUser);
const user = await knex<UserJoined>("users")
.select(
"users.id",
"users.apikey",
"users.banned",
"users.banned_by_id",
"users.cooldowns",
"users.created_at",
"users.email",
"users.password",
"users.updated_at",
"users.verified",
"domains.id as domain_id",
"domains.homepage as homepage",
"domains.address as domain"
)
.where({ email: emailOrKey.toLowerCase() })
.orWhere({ apikey: emailOrKey })
.leftJoin("domains", "users.id", "domains.user_id")
.first();
if (user) {
redis.set(redisKey, JSON.stringify(user), "EX", 60 * 60 * 1);
}
return user;
};
export const createUser = async (
emailToCreate: string,
password: string,
user?: User
) => {
const email = emailToCreate.toLowerCase();
const salt = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(password, salt);
const data = {
email,
password: hashedPassword,
verification_token: uuid(),
verification_expires: addMinutes(new Date(), 60).toISOString()
};
if (user) {
await knex<User>("users")
.where({ email, updated_at: new Date().toISOString() })
.update(data);
} else {
await knex<User>("users").insert(data);
}
redis.del(getRedisKey.user(email));
return {
...user,
...data
};
};
export const verifyUser = async (verification_token: string) => {
const [user]: User[] = await knex<User>("users")
.where({ verification_token })
.andWhere("verification_expires", ">", new Date().toISOString())
.update(
{
verified: true,
verification_token: undefined,
verification_expires: undefined,
updated_at: new Date().toISOString()
},
"*"
);
if (user) {
redis.del(getRedisKey.user(user.email));
}
return user;
};
export const changePassword = async (id: number, newPassword: string) => {
const salt = await bcrypt.genSalt(12);
const password = await bcrypt.hash(newPassword, salt);
const [user]: User[] = await knex<User>("users")
.where({ id })
.update({ password, updated_at: new Date().toISOString() }, "*");
if (user) {
redis.del(getRedisKey.user(user.email));
redis.del(getRedisKey.user(user.apikey));
}
return user;
};
export const generateApiKey = async (id: number) => {
const apikey = nanoid(40);
const [user]: User[] = await knex<User>("users")
.where({ id })
.update({ apikey, updated_at: new Date().toISOString() }, "*");
if (user) {
redis.del(getRedisKey.user(user.email));
redis.del(getRedisKey.user(user.apikey));
}
return user && apikey;
};
export const requestPasswordReset = async (emailToMatch: string) => {
const email = emailToMatch.toLowerCase();
const reset_password_token = uuid();
const [user]: User[] = await knex<User>("users")
.where({ email })
.update(
{
reset_password_token,
reset_password_expires: addMinutes(new Date(), 30).toISOString(),
updated_at: new Date().toISOString()
},
"*"
);
if (user) {
redis.del(getRedisKey.user(user.email));
redis.del(getRedisKey.user(user.apikey));
}
return user;
};
export const resetPassword = async (reset_password_token: string) => {
const [user]: User[] = await knex<User>("users")
.where({ reset_password_token })
.andWhere("reset_password_expires", ">", new Date().toISOString())
.update(
{
reset_password_expires: null,
reset_password_token: null,
updated_at: new Date().toISOString()
},
"*"
);
if (user) {
redis.del(getRedisKey.user(user.email));
redis.del(getRedisKey.user(user.apikey));
}
return user;
};
export const addCooldown = async (id: number) => {
const [user]: User[] = await knex("users")
.where({ id })
.update(
{
cooldowns: knex.raw("array_append(cooldowns, ?)", [
new Date().toISOString()
]),
updated_at: new Date().toISOString()
},
"*"
);
if (user) {
redis.del(getRedisKey.user(user.email));
redis.del(getRedisKey.user(user.apikey));
}
return user;
};
export const banUser = async (id: number, banned_by_id?: number) => {
const [user]: User[] = await knex<User>("users")
.where({ id })
.update(
{ banned: true, banned_by_id, updated_at: new Date().toISOString() },
"*"
);
if (user) {
redis.del(getRedisKey.user(user.email));
redis.del(getRedisKey.user(user.apikey));
}
return user;
};

28
server/knex.ts Normal file
View File

@ -0,0 +1,28 @@
import knex from "knex";
import { createUserTable } from "./models/user";
import { createDomainTable } from "./models/domain";
import { createLinkTable } from "./models/link";
import { createVisitTable } from "./models/visit";
import { createIPTable } from "./models/ip";
import { createHostTable } from "./models/host";
const db = knex({
client: "postgres",
connection: {
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
}
});
export async function initializeDb() {
await createUserTable(db);
await createIPTable(db);
await createDomainTable(db);
await createHostTable(db);
await createLinkTable(db);
await createVisitTable(db);
}
export default db;

View File

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

15
server/mail/mail.ts Normal file
View File

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

View File

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

View File

@ -0,0 +1,67 @@
require("dotenv").config();
import { v1 as NEO4J } from "neo4j-driver";
import knex from "knex";
import PQueue from "p-queue";
const queue = new PQueue({ concurrency: 10 });
// 1. Connect to Neo4j database
const neo4j = NEO4J.driver(
process.env.NEO4J_DB_URI,
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
);
// 2. Connect to Postgres database
const postgres = knex({
client: "postgres",
connection: {
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
}
});
(async function() {
const startTime = Date.now();
// 3. [NEO4J] Get all hosts
const session = neo4j.session();
session.run("MATCH (h:HOST) RETURN h").subscribe({
onNext(record) {
queue.add(async () => {
// 4. [Postgres] Upsert Hosts
const host = record.get("h").properties;
const address = host.name;
const banned = !!host.banned;
const exists = await postgres<Host>("hosts")
.where({
address
})
.first();
if (exists) {
await postgres<Host>("hosts")
.where("id", exists.id)
.update({ banned });
} else {
await postgres<Host>("hosts").insert({
address,
banned
});
}
});
},
onCompleted() {
session.close();
queue.add(() => {
const endTime = Date.now();
console.log(
`✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
);
});
},
onError(error) {
session.close();
throw error;
}
});
})();

View File

@ -0,0 +1,85 @@
require("dotenv").config();
import { v1 as NEO4J } from "neo4j-driver";
import knex from "knex";
import PQuque from "p-queue";
const queue = new PQuque({ concurrency: 10 });
// 1. Connect to Neo4j database
const neo4j = NEO4J.driver(
process.env.NEO4J_DB_URI,
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
);
// 2. Connect to Postgres database
const postgres = knex({
client: "postgres",
connection: {
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
}
});
(async function() {
const startTime = Date.now();
// 3. [NEO4J] Get all users
const session = neo4j.session();
session
.run(
"MATCH (u:USER) OPTIONAL MATCH (u)-[r:RECEIVED]->(c) WITH u, collect(c.date) as cooldowns RETURN u, cooldowns"
)
.subscribe({
onNext(record) {
queue.add(async () => {
// 4. [Postgres] Upsert users
const user = record.get("u").properties;
const cooldowns = record.get("cooldowns");
const email = user.email;
const password = user.password;
const verified = !!user.verified;
const banned = !!user.banned;
const apikey = user.apikey;
const created_at = user.createdAt;
const data = {
email,
password,
verified,
banned,
...(apikey && { apikey }),
...(created_at && { created_at }),
...(cooldowns && cooldowns.length && { cooldowns })
};
const exists = await postgres<User>("users")
.where({
email
})
.first();
if (exists) {
await postgres<User>("users")
.where("id", exists.id)
.update(data);
} else {
await postgres<User>("users").insert(data);
}
});
},
onCompleted() {
session.close();
queue.add(() => {
const endTime = Date.now();
console.log(
`✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
);
});
},
onError(error) {
session.close();
throw error;
}
});
})();

View File

@ -0,0 +1,88 @@
require("dotenv").config();
import { v1 as NEO4J } from "neo4j-driver";
import knex from "knex";
import PQueue from "p-queue";
const queue = new PQueue({ concurrency: 1 });
// 1. Connect to Neo4j database
const neo4j = NEO4J.driver(
process.env.NEO4J_DB_URI,
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
);
// 2. Connect to Postgres database
const postgres = knex({
client: "postgres",
connection: {
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
}
});
(async function() {
const startTime = Date.now();
// 3. [NEO4J] Get all domain
const session = neo4j.session();
session
.run(
"MATCH (d:DOMAIN) OPTIONAL MATCH (u)-[:OWNS]->(d) RETURN d as domain, u.email as email"
)
.subscribe({
onNext(record) {
queue.add(async () => {
const domain = record.get("domain").properties;
const email = record.get("email");
// 4. [Postgres] Get user ID
const user =
email &&
(await postgres<User>("users")
.where({ email })
.first());
// 5. [Postgres] Upsert domains
const banned = !!domain.banned;
const address = domain.name;
const homepage = domain.homepage;
const user_id = user ? user.id : null;
const data = {
banned,
address,
...(homepage && { homepage }),
...(user_id && { user_id })
};
const exists = await postgres<Domain>("domains")
.where({
address
})
.first();
if (exists) {
await postgres<Domain>("domains")
.where("id", exists.id)
.update(data);
} else {
await postgres<Domain>("domains").insert(data);
}
});
},
onCompleted() {
session.close();
queue.add(() => {
const endTime = Date.now();
console.log(
`✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
);
});
},
onError(error) {
console.log(error);
session.close();
throw error;
}
});
})();

View File

@ -0,0 +1,193 @@
require("dotenv").config();
import { v1 as NEO4J } from "neo4j-driver";
import knex from "knex";
import PQueue from "p-queue";
import { startOfHour } from "date-fns";
let count = 0;
const queue = new PQueue({ concurrency: 5 });
queue.on("active", () => (count % 1000 === 0 ? console.log(count++) : count++));
// 1. Connect to Neo4j database
const neo4j = NEO4J.driver(
process.env.NEO4J_DB_URI,
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
);
// 2. Connect to Postgres database
const postgres = knex({
client: "postgres",
connection: {
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
}
});
(async function() {
const startTime = Date.now();
// 3. [NEO4J] Get all links
const session = neo4j.session();
const { records } = await session.run(
"MATCH (l:URL) WITH COUNT(l) as count RETURN count"
);
const total = records[0].get("count").toNumber();
const limit = 20000;
function main(index = 0) {
queue.add(
() =>
new Promise((resolve, reject) => {
session
.run(
"MATCH (l:URL) WITH l SKIP $skip LIMIT $limit " +
"OPTIONAL MATCH (l)-[:USES]->(d) " +
"OPTIONAL MATCH (l)<-[:CREATED]-(u) " +
"OPTIONAL MATCH (v)-[:VISITED]->(l) " +
"OPTIONAL MATCH (v)-[:BROWSED_BY]->(b) " +
"OPTIONAL MATCH (v)-[:OS]->(o) " +
"OPTIONAL MATCH (v)-[:LOCATED_IN]->(c) " +
"OPTIONAL MATCH (v)-[:REFERRED_BY]->(r) " +
"OPTIONAL MATCH (v)-[:VISITED_IN]->(dd) " +
"WITH l, u, d, COLLECT([b.browser, o.os, c.country, r.referrer, dd.date]) as stats " +
"RETURN l, u.email as email, d.name as domain, stats",
{ limit: limit, skip: index * limit }
)
.subscribe({
onNext(record) {
queue.add(async () => {
const link = record.get("l").properties;
const email = record.get("email");
const address = record.get("domain");
const stats = record.get("stats");
// 4. Merge and normalize stats based on hour
const visits: Record<
string,
Record<string, number | Record<string, number>>
> = {} as any;
stats.forEach(([b, o, country, referrer, date]) => {
if (b && o && country && referrer && date) {
const dateHour = startOfHour(date).toISOString();
const browser = b.toLowerCase();
const os = o === "Mac Os X" ? "macos" : o.toLowerCase();
visits[dateHour] = {
...visits[dateHour],
total:
(((visits[dateHour] &&
visits[dateHour].total) as number) || 0) + 1,
[`br_${browser}`]:
(((visits[dateHour] &&
visits[dateHour][`br_${browser}`]) as number) ||
0) + 1,
[`os_${os}`]:
(((visits[dateHour] &&
visits[dateHour][`os_${os}`]) as number) || 0) + 1,
countries: {
...((visits[dateHour] || {}).countries as {}),
[country.toLowerCase()]:
((visits[dateHour] &&
visits[dateHour].countries[
country.toLowerCase()
]) ||
0) + 1
},
referrers: {
...((visits[dateHour] || {}).referrers as {}),
[referrer.toLowerCase()]:
((visits[dateHour] &&
visits[dateHour].referrers[
referrer.toLowerCase()
]) ||
0) + 1
}
};
}
});
// 5. [Postgres] Find matching user and or domain
const [user, domain] = await Promise.all([
email &&
postgres<User>("users")
.where({ email })
.first(),
address &&
postgres<Domain>("domains")
.where({ address })
.first()
]);
// 6. [Postgres] Create link
const data = {
address: link.id,
banned: !!link.banned,
domain_id: domain ? domain.id : null,
password: link.password,
target: link.target,
user_id: user ? user.id : null,
...(link.count && { visit_count: link.count.toNumber() }),
...(link.createdAt && { created_at: link.createdAt })
};
const res = await postgres<Link>("links").insert(data, "id");
const link_id = res[0];
// 7. [Postgres] Create visits
const newVisits = Object.entries(visits).map(
([date, details]) => ({
link_id,
created_at: date,
countries: details.countries as Record<string, number>,
referrers: details.referrers as Record<string, number>,
total: details.total as number,
br_chrome: details.br_chrome as number,
br_edge: details.br_edge as number,
br_firefox: details.br_firefox as number,
br_ie: details.br_ie as number,
br_opera: details.br_opera as number,
br_other: details.br_other as number,
br_safari: details.br_safari as number,
os_android: details.os_android as number,
os_ios: details.os_ios as number,
os_linux: details.os_linux as number,
os_macos: details.os_macos as number,
os_other: details.os_other as number,
os_windows: details.os_windows as number
})
);
await postgres<Visit>("visits").insert(newVisits);
});
},
onCompleted() {
session.close();
if ((index + 1) * limit < total) {
queue.add(() => main(index + 1));
} else {
queue.add(() => {
const endTime = Date.now();
console.log(
`✅ Done! It took ${(endTime - startTime) /
1000} seconds.`
);
});
}
resolve();
},
onError(error) {
session.close();
if ((index + 1) * limit < total) {
queue.add(() => main(index + 1));
}
reject(error);
}
});
})
);
}
main();
})();

View File

@ -0,0 +1,61 @@
require("dotenv").config();
import { v1 as NEO4J } from "neo4j-driver";
import PQueue from "p-queue";
let count = 0;
const queue = new PQueue({ concurrency: 1 });
queue.on("active", () => console.log(count++));
// 1. Connect to Neo4j database
const neo4j = NEO4J.driver(
process.env.NEO4J_DB_URI,
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
);
(async function() {
const startTime = Date.now();
const nodes = [
["VISITED_IN", "DATE"]
// ['BROWSED_BY', 'BROWSER'],
// ['OS', 'OS'],
// ['LOCATED_IN', 'COUNTRY'],
// ['REFERRED_BY', 'REFERRER'],
];
// 3. [NEO4J] Get all hosts
const session = neo4j.session();
const { records } = await session.run(
"MATCH (v:VISIT) WITH COUNT(v) as count RETURN count;"
);
const total = records[0].get("count").toNumber();
const limit = 100000;
function main(index = 0) {
nodes.forEach(([r, n]) => {
queue.add(() => {
return session.run(`
MATCH (a:VISIT)-[r:${r}]->(b:${n})
WITH a, r, b SKIP ${index * limit} LIMIT ${limit}
WITH a, b, TYPE(r) AS t, COLLECT(r) AS rr
WHERE SIZE(rr) > 1
WITH rr
FOREACH (r IN TAIL(rr) | DELETE r);
`);
});
});
if ((index + 1) * limit < total) {
main(index + 1);
} else {
queue.add(() => {
const endTime = Date.now();
console.log(
`✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
);
});
}
}
main();
})();

29
server/models/domain.ts Normal file
View File

@ -0,0 +1,29 @@
import * as Knex from "knex";
export async function createDomainTable(knex: Knex) {
const hasTable = await knex.schema.hasTable("domains");
if (!hasTable) {
await knex.schema.createTable("domains", table => {
table.increments("id").primary();
table
.boolean("banned")
.notNullable()
.defaultTo(false);
table
.integer("banned_by_id")
.references("id")
.inTable("users");
table
.string("address")
.unique()
.notNullable();
table.string("homepage").nullable();
table
.integer("user_id")
.references("id")
.inTable("users")
.unique();
table.timestamps(false, true);
});
}
}

23
server/models/host.ts Normal file
View File

@ -0,0 +1,23 @@
import * as Knex from "knex";
export async function createHostTable(knex: Knex) {
const hasTable = await knex.schema.hasTable("hosts");
if (!hasTable) {
await knex.schema.createTable("hosts", table => {
table.increments("id").primary();
table
.string("address")
.unique()
.notNullable();
table
.boolean("banned")
.notNullable()
.defaultTo(false);
table
.integer("banned_by_id")
.references("id")
.inTable("users");
table.timestamps(false, true);
});
}
}

15
server/models/ip.ts Normal file
View File

@ -0,0 +1,15 @@
import * as Knex from "knex";
export async function createIPTable(knex: Knex) {
const hasTable = await knex.schema.hasTable("ips");
if (!hasTable) {
await knex.schema.createTable("ips", table => {
table.increments("id").primary();
table
.string("ip")
.unique()
.notNullable();
table.timestamps(false, true);
});
}
}

35
server/models/link.ts Normal file
View File

@ -0,0 +1,35 @@
import * as Knex from "knex";
export async function createLinkTable(knex: Knex) {
const hasTable = await knex.schema.hasTable("links");
if (!hasTable) {
await knex.schema.createTable("links", table => {
table.increments("id").primary();
table.string("address").notNullable();
table
.boolean("banned")
.notNullable()
.defaultTo(false);
table
.integer("banned_by_id")
.references("id")
.inTable("users");
table
.integer("domain_id")
.references("id")
.inTable("domains");
table.string("password");
table.string("target", 1023).notNullable();
table
.integer("user_id")
.references("id")
.inTable("users");
table
.integer("visit_count")
.notNullable()
.defaultTo(0);
table.timestamps(false, true);
});
}
}

34
server/models/user.ts Normal file
View File

@ -0,0 +1,34 @@
import * as Knex from "knex";
export async function createUserTable(knex: Knex) {
const hasTable = await knex.schema.hasTable("users");
if (!hasTable) {
await knex.schema.createTable("users", table => {
table.increments("id").primary();
table.string("apikey");
table
.boolean("banned")
.notNullable()
.defaultTo(false);
table
.integer("banned_by_id")
.references("id")
.inTable("users");
table.specificType("cooldowns", "timestamptz[]");
table
.string("email")
.unique()
.notNullable();
table.string("password").notNullable();
table.dateTime("reset_password_expires");
table.string("reset_password_token");
table.dateTime("verification_expires");
table.string("verification_token");
table
.boolean("verified")
.notNullable()
.defaultTo(false);
table.timestamps(false, true);
});
}
}

77
server/models/visit.ts Normal file
View File

@ -0,0 +1,77 @@
import * as Knex from "knex";
export async function createVisitTable(knex: Knex) {
const hasTable = await knex.schema.hasTable("visits");
if (!hasTable) {
await knex.schema.createTable("visits", table => {
table.increments("id").primary();
table.jsonb("countries").defaultTo("{}");
table
.dateTime("created_at")
.notNullable()
.defaultTo(knex.fn.now());
table
.integer("link_id")
.references("id")
.inTable("links")
.notNullable();
table.jsonb("referrers").defaultTo("{}");
table
.integer("total")
.notNullable()
.defaultTo(0);
table
.integer("br_chrome")
.notNullable()
.defaultTo(0);
table
.integer("br_edge")
.notNullable()
.defaultTo(0);
table
.integer("br_firefox")
.notNullable()
.defaultTo(0);
table
.integer("br_ie")
.notNullable()
.defaultTo(0);
table
.integer("br_opera")
.notNullable()
.defaultTo(0);
table
.integer("br_other")
.notNullable()
.defaultTo(0);
table
.integer("br_safari")
.notNullable()
.defaultTo(0);
table
.integer("os_android")
.notNullable()
.defaultTo(0);
table
.integer("os_ios")
.notNullable()
.defaultTo(0);
table
.integer("os_linux")
.notNullable()
.defaultTo(0);
table
.integer("os_macos")
.notNullable()
.defaultTo(0);
table
.integer("os_other")
.notNullable()
.defaultTo(0);
table
.integer("os_windows")
.notNullable()
.defaultTo(0);
});
}
}

View File

@ -1,20 +1,20 @@
const passport = require('passport');
const JwtStrategy = require('passport-jwt').Strategy;
const { ExtractJwt } = require('passport-jwt');
const LocalStratergy = require('passport-local').Strategy;
const LocalAPIKeyStrategy = require('passport-localapikey-update').Strategy;
const bcrypt = require('bcryptjs');
const { getUser } = require('./db/user');
import passport from "passport";
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
import { Strategy as LocalStratergy } from "passport-local";
import { Strategy as LocalAPIKeyStrategy } from "passport-localapikey-update";
import bcrypt from "bcryptjs";
import { getUser } from "./db/user";
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromHeader('authorization'),
secretOrKey: process.env.JWT_SECRET,
jwtFromRequest: ExtractJwt.fromHeader("authorization"),
secretOrKey: process.env.JWT_SECRET
};
passport.use(
new JwtStrategy(jwtOptions, async (payload, done) => {
try {
const user = await getUser({ email: payload.sub });
const user = await getUser(payload.sub);
if (!user) return done(null, false);
return done(null, user);
} catch (err) {
@ -24,13 +24,13 @@ passport.use(
);
const localOptions = {
usernameField: 'email',
usernameField: "email"
};
passport.use(
new LocalStratergy(localOptions, async (email, password, done) => {
try {
const user = await getUser({ email });
const user = await getUser(email);
if (!user) {
return done(null, false);
}
@ -46,14 +46,14 @@ passport.use(
);
const localAPIKeyOptions = {
apiKeyField: 'apikey',
apiKeyHeader: 'x-api-key',
apiKeyField: "apikey",
apiKeyHeader: "x-api-key"
};
passport.use(
new LocalAPIKeyStrategy(localAPIKeyOptions, async (apikey, done) => {
try {
const user = await getUser({ apikey });
const user = await getUser(apikey);
if (!user) {
return done(null, false);
}

View File

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

31
server/redis.ts Normal file
View File

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

View File

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

214
server/server.ts Normal file
View File

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

View File

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

94
server/utils/index.ts Normal file
View File

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

23
tsconfig.json Normal file
View File

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

16
tsconfig.server.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"sourceMap": true,
"outDir": "production-server",
"noUnusedLocals": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strict": false
},
"include": ["global.d.ts", "server"]
}