import bcrypt from "bcryptjs";
import { isAfter, subDays, set } 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("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("links")
.where({ id })
.increment("visit_count", 1);
};
interface ICreateVisit {
browser: string;
country: string;
domain?: string;
id: 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("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("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 => {
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("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("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("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",
"links.uuid",
"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("links")
.select("links.id", "domains.address as domain")
.where("links.address", data.address)
.where("links.user_id", data.user_id)
.where({
...(!data.domain && { domain_id: null })
})
.leftJoin("domains", "links.domain_id", "domains.id")
.first();
if (!link) return;
if (link.domain !== data.domain) {
return;
}
await knex("visits")
.where("link_id", link.id)
.delete();
const deletedLink = await knex("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("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(
new Date(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(
set(new Date(), { date: 1 }),
set(new Date(visit.created_at), { date: 1 })
);
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.lastMonth.stats),
views: stats.lastMonth.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("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("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));
redis.del(getRedisKey.link(link.address, link.domain_id));
redis.del(getRedisKey.link(link.address));
return Promise.all(tasks);
};