Permalink
Please
sign in to comment.
Browse files
Create the server, web, and test files.
Create the backend server files, the frontend files, and tests for the backend. Include `package.json` and `.babelrc` configuration files. Also include an ESLint configuration file. Use Express.js for frontend hosting, and use Mocha.js for testing. Firebase is used as the database for the service.Use Handlebars for templating. The server also exposes documentation files and a public API.
- Loading branch information
Showing
with
11,062 additions
and 0 deletions.
- +14 −0 .babelrc
- +7,389 −0 package-lock.json
- +40 −0 package.json
- +30 −0 server/.eslintrc
- +12 −0 server/config/firebaseServiceKey.json
- +45 −0 server/config/settings.json
- +24 −0 server/database.js
- +227 −0 server/link-statistics.js
- +36 −0 server/long-links.js
- +214 −0 server/main.js
- +256 −0 server/short-links.js
- +143 −0 server/templates.js
- +187 −0 server/total-statistics.js
- +73 −0 server/type-definitions.js
- +115 −0 server/util.js
- +121 −0 tests/link-statistics.test.js
- +53 −0 tests/long-links.test.js
- +230 −0 tests/short-links.test.js
- +92 −0 tests/test-util.js
- +68 −0 tests/total-statistics.test.js
- +51 −0 tests/util.test.js
- +428 −0 web/static/css/index.css
- +4 −0 web/static/css/mobile.css
- +38 −0 web/static/css/statistic.css
- +3 −0 web/static/js/src/config.js
- +110 −0 web/static/js/src/index.js
- +83 −0 web/static/js/src/statistic.js
- BIN web/static/res/drawings/android-drawing.png
- BIN web/static/res/drawings/discord-drawing.png
- BIN web/static/res/drawings/github-drawing.png
- BIN web/static/res/drawings/ios-drawing.png
- BIN web/static/res/drawings/js-library-drawing.png
- BIN web/static/res/drawings/telegram-drawing.png
- BIN web/static/res/drawings/web-api-drawing.png
- BIN web/static/res/hero-photo.jpeg
- BIN web/static/res/koala-favicon.ico
- BIN web/static/res/koala-logo-white.png
- BIN web/static/res/koala-logo.png
- BIN web/static/res/mont.otf
- BIN web/static/res/readme-header.png
- BIN web/static/res/spacemono.ttf
- BIN web/static/res/young.ttf
- +33 −0 web/views/404.hbs
- +60 −0 web/views/discord-bot.hbs
- +231 −0 web/views/index.hbs
- +182 −0 web/views/js-library.hbs
- +126 −0 web/views/mobile.hbs
- +91 −0 web/views/statistic.hbs
- +64 −0 web/views/telegram-bot.hbs
- +189 −0 web/views/web-api.hbs
@@ -0,0 +1,14 @@ | ||
{ | ||
"presets": [ | ||
[ | ||
"@babel/preset-env", | ||
{ | ||
"targets": "cover 95%, last 2 versions" | ||
} | ||
], | ||
[ | ||
"minify" | ||
] | ||
], | ||
"comments": false | ||
} |
@@ -0,0 +1,40 @@ | ||
{ | ||
"name": "qwala-server", | ||
"version": "1.0.0", | ||
"description": "an open-source link shortener", | ||
"main": "app.js", | ||
"scripts": { | ||
"start": "npm run build && node ./server/main.js", | ||
"build": "babel ./web/static/js/src/ -d ./web/static/js/dist/", | ||
"test": "mocha tests/" | ||
}, | ||
"author": "Marco Burstein <marco@marco.how>", | ||
"license": "GPL-3.0-only", | ||
"dependencies": { | ||
"body-parser": "^1.18.3", | ||
"express": "^4.16.4", | ||
"firebase-admin": "^6.4.0", | ||
"handlebars": "^4.0.12", | ||
"iplocation": "^6.0.4", | ||
"lodash": "^4.17.11", | ||
"request-ip": "^2.1.3", | ||
"serve-favicon": "^2.5.0", | ||
"url-regex": "^4.1.1" | ||
}, | ||
"devDependencies": { | ||
"@babel/cli": "^7.2.3", | ||
"@babel/core": "^7.2.2", | ||
"@babel/preset-env": "^7.2.3", | ||
"babel-preset-minify": "^0.5.0", | ||
"eslint": "^5.11.1", | ||
"eslint-config-airbnb": "^17.1.0", | ||
"eslint-plugin-import": "^2.14.0", | ||
"eslint-plugin-jsx-a11y": "^6.1.2", | ||
"eslint-plugin-react": "^7.12.2", | ||
"mocha": "^5.2.0", | ||
"mock-require": "^3.0.2" | ||
}, | ||
"engines": { | ||
"node": "10" | ||
} | ||
} |
@@ -0,0 +1,30 @@ | ||
{ | ||
"extends": "airbnb", | ||
"rules": { | ||
"indent": ["error", 4], | ||
"prefer-const": "off", | ||
"max-len": ["error", { | ||
"code": 79, | ||
"ignoreUrls": true, | ||
"ignoreComments": false, | ||
"ignoreTrailingComments": false, | ||
"ignoreStrings": false, | ||
"ignoreRegExpLiterals": true | ||
}], | ||
"semi": ["error", "always"], | ||
"arrow-body-style": ["error", "always"], | ||
"yoda": ["error", "never"], | ||
"function-paren-newline": "off", | ||
"newline-per-chained-call": "off", | ||
"prefer-template": "off", | ||
"no-plusplus": "off", | ||
"no-throw-literal": "off", | ||
"prefer-destructuring": "off", | ||
"no-use-before-define": "off" | ||
}, | ||
"env": { | ||
"browser": true, | ||
"node": true, | ||
"jquery": true | ||
} | ||
} |
@@ -0,0 +1,12 @@ | ||
{ | ||
"type": "service_account", | ||
"project_id": "<put your own Project ID here>", | ||
"private_key_id": "<put your own Private Key ID here>", | ||
"private_key": "<put your own Private Key here>", | ||
"client_email": "<put your own Client Email Address here>", | ||
"client_id": "<put your own Client ID here>", | ||
"auth_uri": "https://accounts.google.com/o/oauth2/auth", | ||
"token_uri": "https://oauth2.googleapis.com/token", | ||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", | ||
"client_x509_cert_url": "<put your own Certificate URL here>" | ||
} |
@@ -0,0 +1,45 @@ | ||
{ | ||
"shortLinkAdjectives": [ | ||
"red", "orange", "yellow", "green", "blue", "purple", | ||
"friendly", "smart", "nice", "old", "young", "strong", "big", "small", | ||
"tall", "short", "thin", "kind" | ||
], | ||
"shortLinkNouns": [ | ||
"dog", "cat", "fish", "bison", "dolphin", "eagle", "pony", "ape", | ||
"monkey", "cow", "duck", "rabbit", "spider", "wolf", "turkey", | ||
"lion", "pig", "snake", "shark", "bird", "bear", "fish", "chicken", | ||
"horse", "cat", "dog", "koala" | ||
], | ||
"shortLinkVerbs": [ | ||
"walking", "running", "having", "doing", "saying", "going", "getting", | ||
"making", "knowing", "thinking", "taking", "seeing", "wanting", | ||
"looking", "using", "finding", "giving", "telling", "working", | ||
"calling", "trying", "asking", "needing", "feeling", "keeping", | ||
"beginning", "talking", "helping", "turning" | ||
], | ||
"shortLinkUnambiguousDigits": [ | ||
"2", "3", "4", "5", "6", "7", "8", "9" | ||
], | ||
"shortLinkUnambiguousLetters": [ | ||
"a", "b", "c", "d", "e", "f", "g", "h", "j", "k", "m", "n", "p", "q", | ||
"r", "t", "w", "x", "y", "z" | ||
], | ||
"reservedCustomShortLinkIDRegExps": [ | ||
"^pages$", "^info$", "^information$", "^terms$", "^about$", "^settings$", | ||
"^qwala$", "^qwa.la$", "^faq$", "^report$", "^contact$", "^news$", "^static$", | ||
"^mobile$", "^ios$", "^android$", "^api$", "^telegram-bot$", | ||
"^discord-bot$", "^telegram$", "^discord$", "^repository$", "^github$", | ||
"^web-api$", "^js-library$", "^js$", "^javascript$", "^contact$", | ||
"^settings$", "^developer$", "^developers$" | ||
], | ||
"reservedLongLinkRegExps": [ | ||
"^http://qwa.la($|/)", "^https://qwa.la($|/)" | ||
], | ||
"customShortLinkIDRegExp": "^[a-zA-Z\\-_0-9]+$", | ||
"shortLinkCharactersLength": 6, | ||
"protocolRegExp": "^(https://|http://)", | ||
"defaultTotalViewCount": 500, | ||
"defaultTotalLinksCount": 200, | ||
"defaultTotalCustomLinksCount": 100, | ||
"analyticsKey": "<put your own Google Analytics Key here>" | ||
} |
@@ -0,0 +1,24 @@ | ||
/** | ||
* A module that contains database-related functions. | ||
* | ||
* @module database | ||
*/ | ||
|
||
let firebase = require('firebase-admin'); | ||
let serviceKey = require('./config/firebaseServiceKey.json'); | ||
|
||
firebase.initializeApp({ | ||
credential: firebase.credential.cert(serviceKey), | ||
databaseURL: `https://${serviceKey.project_id}.firebaseio.com`, | ||
}); | ||
|
||
firebase.firestore().settings({ timestampsInSnapshots: true }); | ||
|
||
/** | ||
* Returns a configured Firebase Firestore reference. | ||
* | ||
* @return {Object} The Firestore. | ||
*/ | ||
module.exports.get = function getDatabase() { | ||
return firebase.firestore(); | ||
}; |
@@ -0,0 +1,227 @@ | ||
/** | ||
* A module that contains functions for managing link statistics. | ||
* | ||
* @module link-statistics | ||
*/ | ||
|
||
let ipLocation = require('iplocation').default; | ||
let shortLinks = require('./short-links.js'); | ||
let util = require('./util.js'); | ||
|
||
/** | ||
* Returns statistics for this week (the last 7 days) for a short link ID. | ||
* | ||
* @param {string} shortLinkID The short link ID to find statistics for. | ||
* @return {LabelDataArrayPair} The statistics for weekly views. | ||
*/ | ||
let getWeekly = async function getWeeklyStatistics(shortLinkID) { | ||
let weekAgo = new Date(); | ||
weekAgo.setHours(0, 0, 0, 0); | ||
weekAgo.setDate(weekAgo.getDate() - 6); | ||
|
||
let views = await shortLinks.getViews(shortLinkID); | ||
let weeklyViews = views.filter((view) => { | ||
return weekAgo <= view.viewed; | ||
}); | ||
|
||
let labels = getWeekLabels(weekAgo); | ||
let data = getDataArray(weeklyViews, weekAgo); | ||
|
||
return { labels, data }; | ||
}; | ||
|
||
/** | ||
* Returns statistics for this month (the last 31 days) for a short link ID. | ||
* | ||
* @param {string} shortLinkID The short link ID to find statistics for. | ||
* @return {LabelDataArrayPair} The statistics for monthly views. | ||
*/ | ||
let getMonthly = async function getMonthlyStatistics(shortLinkID) { | ||
let monthAgo = new Date(); | ||
monthAgo.setHours(0, 0, 0, 0); | ||
monthAgo.setDate(monthAgo.getDate() - 30); | ||
|
||
let views = await shortLinks.getViews(shortLinkID); | ||
let monthlyViews = views.filter((view) => { | ||
return monthAgo <= view.viewed; | ||
}); | ||
|
||
let labels = getLabelsSinceDate(monthAgo); | ||
let data = getDataArray(monthlyViews, monthAgo); | ||
|
||
return { labels, data }; | ||
}; | ||
|
||
/** | ||
* Returns statistics for all time for a short link ID. | ||
* | ||
* @param {string} shortLinkID The short link ID to find statistics for. | ||
* @return {LabelDataArrayPair} The statistics for all time. | ||
*/ | ||
let getAll = async function getAllStatistics(shortLinkID) { | ||
let views = await shortLinks.getViews(shortLinkID); | ||
let creationDate = await shortLinks.getCreationDate(shortLinkID); | ||
|
||
let labels = getLabelsSinceDate(creationDate); | ||
let data = getDataArray(views, creationDate); | ||
|
||
return { labels, data }; | ||
}; | ||
|
||
/** | ||
* Returns a map of views for all time for a short link ID. | ||
* | ||
* @param {string} shortLinkID The short link ID to find the map for. | ||
* @return {Array<MapDataPoint>} An array of map points to use for the map. | ||
*/ | ||
let getMap = async function getAllStatistics(shortLinkID) { | ||
let views = await shortLinks.getViews(shortLinkID); | ||
return getMapArray(views); | ||
}; | ||
|
||
/** | ||
* Returns an array containing values for the functions `getWeekly`, | ||
* `getMonthly`, `getAll`, and `getMap`. | ||
* | ||
* @param {string} shortLinkID The short link ID to find the statistics and map | ||
* for. | ||
* @return {LinkStatisticsSummary} The array containing the values. | ||
*/ | ||
module.exports.getEach = async function getEachStatisticArray(shortLinkID) { | ||
return Promise.all([ | ||
getWeekly(shortLinkID), | ||
getMonthly(shortLinkID), | ||
getAll(shortLinkID), | ||
getMap(shortLinkID), | ||
]); | ||
}; | ||
|
||
/** | ||
* Returns a list of labels for weekly statistics. | ||
* | ||
* @param {Date} startDate The date that the week starts on. | ||
* @return {Array<string>} An array of strings representing labels for each day | ||
* of the week. | ||
*/ | ||
let getWeekLabels = function getWeekLabelsForIndex(startDate) { | ||
let days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; | ||
let startSlice = days.slice(startDate.getDay()); | ||
let endSlice = days.slice(0, startDate.getDay()); | ||
return startSlice.concat(endSlice); | ||
}; | ||
|
||
/** | ||
* Returns a list of labels for the days since a specified day. | ||
* | ||
* @param {Date} startDate The date to use as the starting label. | ||
* @return {Array<string>} An array of labels for each day until today, where | ||
* index 0 corresponds with `startDate`. | ||
*/ | ||
let getLabelsSinceDate = function getDateLabelsSinceDate(startDate) { | ||
let currentDates = []; | ||
let todayDate = new Date(); | ||
todayDate.setHours(0, 0, 0, 0); | ||
|
||
for (let i = 0; true; i++) { | ||
let currentDate = new Date(startDate.getTime()); | ||
currentDate.setDate(currentDate.getDate() + i); | ||
currentDate.setHours(0, 0, 0, 0); | ||
|
||
currentDates.push(getDateString(currentDate)); | ||
|
||
if (todayDate <= currentDate) { | ||
return currentDates; | ||
} | ||
} | ||
}; | ||
|
||
/** | ||
* Returns a map of views for all time for an array of views. | ||
* | ||
* @param {Array<View>} views The views to use to construct the map. | ||
* @return {Array<MapDataPoint>} An array of map points to use for the map. | ||
*/ | ||
let getMapArray = async function getLatitudeLongitudeArray(views) { | ||
let points = await Promise.all( | ||
views.map(async (view) => { | ||
// `iplocation` seems to have a bug where return data is empty, but | ||
// this can be solved by using only one of its default providers. | ||
// See https://git.io/fh3hm. | ||
let ipProviders = [ | ||
'https://ipinfo.io/*', | ||
]; | ||
|
||
let lookup = await new Promise((resolve) => { | ||
ipLocation(view.ipAddress, ipProviders, (error, response) => { | ||
if (error) { | ||
resolve({}); | ||
return; | ||
} | ||
|
||
resolve(response); | ||
}); | ||
}); | ||
|
||
if ( | ||
!lookup | ||
|| lookup.latitude === null | ||
|| lookup.longitude === null | ||
) { | ||
return null; | ||
} | ||
|
||
return { | ||
latitude: lookup.latitude, | ||
longitude: lookup.longitude, | ||
title: getDateString(view.viewed), | ||
}; | ||
}), | ||
); | ||
|
||
return points.filter((point) => { | ||
return point !== null; | ||
}); | ||
}; | ||
|
||
/** | ||
* Returns a data array for an array of views given a start date. | ||
* | ||
* @param {Array<View>} views An array of views for a link. | ||
* @param {Date} startDate The date to start with in the data array. All views | ||
* before this date are ignored. | ||
* @return {Array<number>} An array containing numbers representing the number | ||
* of views for each day, where index 0 corresponds | ||
* with `startDate`. | ||
*/ | ||
let getDataArray = function getDataArrayForViews(views, startDate) { | ||
// `dataLength` is the number of data points (i.e. days) to be shown. | ||
// E.g., for a month this would be 31, and for a week this would be 7. | ||
let dataLength = util.getDateDifference(startDate, new Date()) + 1; | ||
let currentData = new Array(dataLength).fill(0); | ||
|
||
for (let i = 0; i < views.length; i++) { | ||
// The data index is the location in the data array, which is | ||
// essentially the difference between the day index for this one view | ||
// and the starting day. | ||
let dataDayIndex = util.getDateDifference(startDate, views[i].viewed); | ||
|
||
currentData[dataDayIndex]++; | ||
} | ||
|
||
return currentData; | ||
}; | ||
|
||
/** | ||
* Returns a string representation of a date, in the form "<month>_<date>", for | ||
* a `LabelDataArrayPair`. | ||
* | ||
* @param {Date} date The date to return a string for. | ||
* @return {string} The string representation. | ||
*/ | ||
let getDateString = function getDateAndMonthString(date) { | ||
let months = [ | ||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', | ||
'Oct', 'Nov', 'Dec', | ||
]; | ||
return months[date.getMonth()] + '_' + date.getDate(); | ||
}; |

Oops, something went wrong.
0 comments on commit
82be5c3