htmx almost done
@ -444,9 +444,6 @@ export default {
|
|||||||
Stats: {
|
Stats: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
allTime: {
|
|
||||||
$ref: "#/components/schemas/StatsItem"
|
|
||||||
},
|
|
||||||
lastDay: {
|
lastDay: {
|
||||||
$ref: "#/components/schemas/StatsItem"
|
$ref: "#/components/schemas/StatsItem"
|
||||||
},
|
},
|
||||||
@ -456,6 +453,9 @@ export default {
|
|||||||
lastWeek: {
|
lastWeek: {
|
||||||
$ref: "#/components/schemas/StatsItem"
|
$ref: "#/components/schemas/StatsItem"
|
||||||
},
|
},
|
||||||
|
lastYear: {
|
||||||
|
$ref: "#/components/schemas/StatsItem"
|
||||||
|
},
|
||||||
updatedAt: {
|
updatedAt: {
|
||||||
type: "string"
|
type: "string"
|
||||||
},
|
},
|
||||||
|
69
package-lock.json
generated
@ -12,7 +12,8 @@
|
|||||||
"app-root-path": "^3.1.0",
|
"app-root-path": "^3.1.0",
|
||||||
"axios": "^1.1.3",
|
"axios": "^1.1.3",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bull": "^4.10.1",
|
"bull": "^4.16.2",
|
||||||
|
"compression": "^1.7.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
@ -3027,9 +3028,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bull": {
|
"node_modules/bull": {
|
||||||
"version": "4.16.0",
|
"version": "4.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.2.tgz",
|
||||||
"integrity": "sha512-dgHRLULPexLkpm9wP/7F7Vlf2fdvmffdwhv3Bqu5lFhO+XDDJ4yGqlTPE61Jj1zM8CgchLmJEgIfe7y69jtuOg==",
|
"integrity": "sha512-VCy33UdPGiIoZHDTrslGXKXWxcIUHNH5Z82pihr8HicbIfAH4SHug1HxlwKEbibVv85hq8rJ9tKAW/cuxv2T0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cron-parser": "^4.2.1",
|
"cron-parser": "^4.2.1",
|
||||||
@ -3410,6 +3411,66 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compressible": {
|
||||||
|
"version": "2.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
|
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": ">= 1.43.0 < 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression": {
|
||||||
|
"version": "1.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
|
||||||
|
"integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.5",
|
||||||
|
"bytes": "3.0.0",
|
||||||
|
"compressible": "~2.0.16",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"on-headers": "~1.0.2",
|
||||||
|
"safe-buffer": "5.1.2",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/bytes": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
@ -32,7 +32,8 @@
|
|||||||
"app-root-path": "^3.1.0",
|
"app-root-path": "^3.1.0",
|
||||||
"axios": "^1.1.3",
|
"axios": "^1.1.3",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bull": "^4.10.1",
|
"bull": "^4.16.2",
|
||||||
|
"compression": "^1.7.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
@ -54,9 +54,6 @@ const jwt = authenticate("jwt", "Unauthorized.", true);
|
|||||||
const jwtLoose = authenticate("jwt", "Unauthorized.", false);
|
const jwtLoose = authenticate("jwt", "Unauthorized.", false);
|
||||||
const apikey = authenticate("localapikey", "API key is not correct.", false);
|
const apikey = authenticate("localapikey", "API key is not correct.", false);
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function cooldown(req, res, next) {
|
async function cooldown(req, res, next) {
|
||||||
if (env.DISALLOW_ANONYMOUS_LINKS) return next();
|
if (env.DISALLOW_ANONYMOUS_LINKS) return next();
|
||||||
const cooldownConfig = env.NON_USER_COOLDOWN;
|
const cooldownConfig = env.NON_USER_COOLDOWN;
|
||||||
@ -78,18 +75,12 @@ async function cooldown(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function admin(req, res, next) {
|
function admin(req, res, next) {
|
||||||
// FIXME: attaching to req is risky, find another way
|
// FIXME: attaching to req is risky, find another way
|
||||||
if (req.user.admin) return next();
|
if (req.user.admin) return next();
|
||||||
throw new CustomError("Unauthorized", 401);
|
throw new CustomError("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function signup(req, res) {
|
async function signup(req, res) {
|
||||||
const salt = await bcrypt.genSalt(12);
|
const salt = await bcrypt.genSalt(12);
|
||||||
const password = await bcrypt.hash(req.body.password, salt);
|
const password = await bcrypt.hash(req.body.password, salt);
|
||||||
@ -109,9 +100,6 @@ async function signup(req, res) {
|
|||||||
return res.status(201).send({ message: "A verification email has been sent." });
|
return res.status(201).send({ message: "A verification email has been sent." });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function login(req, res) {
|
function login(req, res) {
|
||||||
const token = utils.signToken(req.user);
|
const token = utils.signToken(req.user);
|
||||||
|
|
||||||
@ -128,9 +116,6 @@ function login(req, res) {
|
|||||||
return res.status(200).send({ token });
|
return res.status(200).send({ token });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function verify(req, res, next) {
|
async function verify(req, res, next) {
|
||||||
if (!req.params.verificationToken) return next();
|
if (!req.params.verificationToken) return next();
|
||||||
|
|
||||||
@ -148,15 +133,19 @@ async function verify(req, res, next) {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const token = utils.signToken(user);
|
const token = utils.signToken(user);
|
||||||
req.token = token;
|
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
||||||
|
res.cookie("token", token, {
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.isProd
|
||||||
|
});
|
||||||
|
res.locals.token_verified = true;
|
||||||
|
req.cookies.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function changePassword(req, res) {
|
async function changePassword(req, res) {
|
||||||
const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password);
|
const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password);
|
||||||
if (!isMatch) {
|
if (!isMatch) {
|
||||||
@ -174,8 +163,6 @@ async function changePassword(req, res) {
|
|||||||
throw new CustomError("Couldn't change the password. Try again later.");
|
throw new CustomError("Couldn't change the password. Try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await utils.sleep(1000);
|
|
||||||
|
|
||||||
if (req.isHTML) {
|
if (req.isHTML) {
|
||||||
res.setHeader("HX-Trigger-After-Swap", "resetChangePasswordForm");
|
res.setHeader("HX-Trigger-After-Swap", "resetChangePasswordForm");
|
||||||
res.render("partials/settings/change_password", {
|
res.render("partials/settings/change_password", {
|
||||||
@ -189,9 +176,6 @@ async function changePassword(req, res) {
|
|||||||
.send({ message: "Your password has been changed successfully." });
|
.send({ message: "Your password has been changed successfully." });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function generateApiKey(req, res) {
|
async function generateApiKey(req, res) {
|
||||||
const apikey = nanoid(40);
|
const apikey = nanoid(40);
|
||||||
|
|
||||||
@ -203,8 +187,6 @@ async function generateApiKey(req, res) {
|
|||||||
throw new CustomError("Couldn't generate API key. Please try again later.");
|
throw new CustomError("Couldn't generate API key. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await utils.sleep(1000);
|
|
||||||
|
|
||||||
if (req.isHTML) {
|
if (req.isHTML) {
|
||||||
res.render("partials/settings/apikey", {
|
res.render("partials/settings/apikey", {
|
||||||
user: { apikey },
|
user: { apikey },
|
||||||
@ -215,9 +197,6 @@ async function generateApiKey(req, res) {
|
|||||||
return res.status(201).send({ apikey });
|
return res.status(201).send({ apikey });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function resetPasswordRequest(req, res) {
|
async function resetPasswordRequest(req, res) {
|
||||||
const [user] = await query.user.update(
|
const [user] = await query.user.update(
|
||||||
{ email: req.body.email },
|
{ email: req.body.email },
|
||||||
@ -228,7 +207,15 @@ async function resetPasswordRequest(req, res) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await mail.resetPasswordToken(user);
|
// TODO: handle error
|
||||||
|
await mail.resetPasswordToken(user).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.isHTML) {
|
||||||
|
res.render("partials/reset_password/form", {
|
||||||
|
message: "If the email address exists, a reset password email will be sent to it."
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
@ -236,12 +223,9 @@ async function resetPasswordRequest(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function resetPassword(req, res, next) {
|
async function resetPassword(req, res, next) {
|
||||||
const { resetPasswordToken } = req.params;
|
const resetPasswordToken = req.params.resetPasswordToken;
|
||||||
|
|
||||||
if (resetPasswordToken) {
|
if (resetPasswordToken) {
|
||||||
const [user] = await query.user.update(
|
const [user] = await query.user.update(
|
||||||
{
|
{
|
||||||
@ -253,23 +237,25 @@ async function resetPassword(req, res, next) {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const token = utils.signToken(user);
|
const token = utils.signToken(user);
|
||||||
req.token = token;
|
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
||||||
|
res.cookie("token", token, {
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.isProd
|
||||||
|
});
|
||||||
|
res.locals.token_verified = true;
|
||||||
|
req.cookies.token = token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next();
|
|
||||||
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function signupAccess(req, res, next) {
|
function signupAccess(req, res, next) {
|
||||||
if (!env.DISALLOW_REGISTRATION) return next();
|
if (!env.DISALLOW_REGISTRATION) return next();
|
||||||
return res.status(403).send({ message: "Registration is not allowed." });
|
return res.status(403).send({ message: "Registration is not allowed." });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function changeEmailRequest(req, res) {
|
async function changeEmailRequest(req, res) {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
|
|
||||||
@ -317,11 +303,10 @@ async function changeEmailRequest(req, res) {
|
|||||||
return res.status(200).send({ message });
|
return res.status(200).send({ message });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function changeEmail(req, res, next) {
|
async function changeEmail(req, res, next) {
|
||||||
const { changeEmailToken } = req.params;
|
const changeEmailToken = req.params.changeEmailToken;
|
||||||
|
|
||||||
|
console.log("-", changeEmailToken, "-");
|
||||||
|
|
||||||
if (changeEmailToken) {
|
if (changeEmailToken) {
|
||||||
const foundUser = await query.user.find({
|
const foundUser = await query.user.find({
|
||||||
@ -347,7 +332,14 @@ async function changeEmail(req, res, next) {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const token = utils.signToken(user);
|
const token = utils.signToken(user);
|
||||||
req.token = token;
|
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
||||||
|
res.cookie("token", token, {
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.isProd
|
||||||
|
});
|
||||||
|
res.locals.token_verified = true;
|
||||||
|
req.cookies.token = token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
|
@ -13,8 +13,6 @@ async function add(req, res) {
|
|||||||
user_id: req.user.id
|
user_id: req.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
if (req.isHTML) {
|
if (req.isHTML) {
|
||||||
const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain);
|
const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain);
|
||||||
res.setHeader("HX-Reswap", "none");
|
res.setHeader("HX-Reswap", "none");
|
||||||
@ -38,8 +36,6 @@ async function remove(req, res) {
|
|||||||
|
|
||||||
redis.remove.domain(domain);
|
redis.remove.domain(domain);
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
throw new CustomError("Could not delete the domain.", 500);
|
throw new CustomError("Could not delete the domain.", 500);
|
||||||
}
|
}
|
||||||
|
@ -12,9 +12,6 @@ const env = require("../env");
|
|||||||
// return next();
|
// return next();
|
||||||
// };
|
// };
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function isHTML(req, res, next) {
|
function isHTML(req, res, next) {
|
||||||
const accepts = req.accepts(["json", "html"]);
|
const accepts = req.accepts(["json", "html"]);
|
||||||
req.isHTML = accepts === "html";
|
req.isHTML = accepts === "html";
|
||||||
@ -22,9 +19,6 @@ function isHTML(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addNoLayoutLocals(req, res, next) {
|
function addNoLayoutLocals(req, res, next) {
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
res.locals.layout = null;
|
res.locals.layout = null;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
@ -36,17 +30,12 @@ function viewTemplate(template) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function addConfigLocals(req, res, next) {
|
function addConfigLocals(req, res, next) {
|
||||||
res.locals.default_domain = env.DEFAULT_DOMAIN;
|
res.locals.default_domain = env.DEFAULT_DOMAIN;
|
||||||
|
res.locals.site_name = env.SITE_NAME;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function addUserLocals(req, res, next) {
|
async function addUserLocals(req, res, next) {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
res.locals.user = user;
|
res.locals.user = user;
|
||||||
@ -54,9 +43,6 @@ async function addUserLocals(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").ErrorRequestHandler}
|
|
||||||
*/
|
|
||||||
function error(error, req, res, _next) {
|
function error(error, req, res, _next) {
|
||||||
if (env.isDev) {
|
if (env.isDev) {
|
||||||
signale.fatal(error);
|
signale.fatal(error);
|
||||||
@ -74,9 +60,6 @@ function error(error, req, res, _next) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function verify(req, res, next) {
|
function verify(req, res, next) {
|
||||||
const result = validationResult(req);
|
const result = validationResult(req);
|
||||||
if (result.isEmpty()) return next();
|
if (result.isEmpty()) return next();
|
||||||
@ -124,7 +107,7 @@ function parseQuery(req, res, next) {
|
|||||||
req.context = {
|
req.context = {
|
||||||
limit: limit > 50 ? 50 : limit,
|
limit: limit > 50 ? 50 : limit,
|
||||||
skip,
|
skip,
|
||||||
all: admin ? req.query.all === "true" : false
|
all: admin ? req.query.all === "true" || req.query.all === "on" : false
|
||||||
};
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
@ -5,9 +5,10 @@ const URL = require("url");
|
|||||||
const dns = require("dns");
|
const dns = require("dns");
|
||||||
|
|
||||||
const validators = require("./validators.handler");
|
const validators = require("./validators.handler");
|
||||||
// const transporter = require("../mail");
|
const map = require("../utils/map.json");
|
||||||
|
const transporter = require("../mail");
|
||||||
const query = require("../queries");
|
const query = require("../queries");
|
||||||
// const queue = require("../queues");
|
const queue = require("../queues");
|
||||||
const utils = require("../utils");
|
const utils = require("../utils");
|
||||||
const env = require("../env");
|
const env = require("../env");
|
||||||
const { differenceInSeconds } = require("date-fns");
|
const { differenceInSeconds } = require("date-fns");
|
||||||
@ -15,9 +16,6 @@ const { differenceInSeconds } = require("date-fns");
|
|||||||
const CustomError = utils.CustomError;
|
const CustomError = utils.CustomError;
|
||||||
const dnsLookup = promisify(dns.lookup);
|
const dnsLookup = promisify(dns.lookup);
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function get(req, res) {
|
async function get(req, res) {
|
||||||
const { limit, skip, all } = req.context;
|
const { limit, skip, all } = req.context;
|
||||||
const search = req.query.search;
|
const search = req.query.search;
|
||||||
@ -34,8 +32,6 @@ async function get(req, res) {
|
|||||||
|
|
||||||
const links = data.map(utils.sanitize.link);
|
const links = data.map(utils.sanitize.link);
|
||||||
|
|
||||||
await utils.sleep(1000);
|
|
||||||
|
|
||||||
if (req.isHTML) {
|
if (req.isHTML) {
|
||||||
res.render("partials/links/table", {
|
res.render("partials/links/table", {
|
||||||
total,
|
total,
|
||||||
@ -54,9 +50,6 @@ async function get(req, res) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function create(req, res) {
|
async function create(req, res) {
|
||||||
const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
|
const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
|
||||||
const domain_id = fetched_domain ? fetched_domain.id : null;
|
const domain_id = fetched_domain ? fetched_domain.id : null;
|
||||||
@ -78,7 +71,7 @@ async function create(req, res) {
|
|||||||
address: customurl,
|
address: customurl,
|
||||||
domain_id
|
domain_id
|
||||||
}),
|
}),
|
||||||
!customurl && utils.generateId(domain_id),
|
!customurl && utils.generateId(query, domain_id),
|
||||||
validators.bannedDomain(targetDomain),
|
validators.bannedDomain(targetDomain),
|
||||||
validators.bannedHost(targetDomain)
|
validators.bannedHost(targetDomain)
|
||||||
]);
|
]);
|
||||||
@ -161,8 +154,6 @@ async function edit(req, res) {
|
|||||||
isChanged = true;
|
isChanged = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.sleep(1000);
|
|
||||||
|
|
||||||
if (!isChanged) {
|
if (!isChanged) {
|
||||||
throw new CustomError("Should at least update one field.");
|
throw new CustomError("Should at least update one field.");
|
||||||
}
|
}
|
||||||
@ -215,9 +206,6 @@ async function edit(req, res) {
|
|||||||
return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
|
return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function remove(req, res) {
|
async function remove(req, res) {
|
||||||
const { error, isRemoved, link } = await query.link.remove({
|
const { error, isRemoved, link } = await query.link.remove({
|
||||||
uuid: req.params.id,
|
uuid: req.params.id,
|
||||||
@ -229,8 +217,6 @@ async function remove(req, res) {
|
|||||||
throw new CustomError(messsage);
|
throw new CustomError(messsage);
|
||||||
}
|
}
|
||||||
|
|
||||||
await utils.sleep(1000);
|
|
||||||
|
|
||||||
if (req.isHTML) {
|
if (req.isHTML) {
|
||||||
res.setHeader("HX-Reswap", "outerHTML");
|
res.setHeader("HX-Reswap", "outerHTML");
|
||||||
res.setHeader("HX-Trigger", "reloadLinks");
|
res.setHeader("HX-Trigger", "reloadLinks");
|
||||||
@ -245,24 +231,22 @@ async function remove(req, res) {
|
|||||||
.send({ message: "Link has been deleted successfully." });
|
.send({ message: "Link has been deleted successfully." });
|
||||||
};
|
};
|
||||||
|
|
||||||
// export const report: Handler = async (req, res) => {
|
async function report(req, res) {
|
||||||
// const { link } = req.body;
|
const { link } = req.body;
|
||||||
|
|
||||||
// const mail = await transporter.sendMail({
|
await transporter.sendReportEmail(link);
|
||||||
// from: env.MAIL_FROM || env.MAIL_USER,
|
|
||||||
// to: env.REPORT_EMAIL,
|
|
||||||
// subject: "[REPORT]",
|
|
||||||
// text: link,
|
|
||||||
// html: link
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (!mail.accepted.length) {
|
if (req.isHTML) {
|
||||||
// throw new CustomError("Couldn't submit the report. Try again later.");
|
res.render("partials/report/form", {
|
||||||
// }
|
message: "Report was received. We'll take actions shortly."
|
||||||
// return res
|
});
|
||||||
// .status(200)
|
return;
|
||||||
// .send({ message: "Thanks for the report, we'll take actions shortly." });
|
}
|
||||||
// };
|
|
||||||
|
return res
|
||||||
|
.status(200)
|
||||||
|
.send({ message: "Thanks for the report, we'll take actions shortly." });
|
||||||
|
};
|
||||||
|
|
||||||
async function ban(req, res) {
|
async function ban(req, res) {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@ -320,8 +304,6 @@ async function ban(req, res) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 8. Send response
|
// 8. Send response
|
||||||
await utils.sleep(1000);
|
|
||||||
|
|
||||||
if (req.isHTML) {
|
if (req.isHTML) {
|
||||||
res.setHeader("HX-Reswap", "outerHTML");
|
res.setHeader("HX-Reswap", "outerHTML");
|
||||||
res.setHeader("HX-Trigger", "reloadLinks");
|
res.setHeader("HX-Trigger", "reloadLinks");
|
||||||
@ -334,148 +316,178 @@ async function ban(req, res) {
|
|||||||
return res.status(200).send({ message: "Banned link successfully." });
|
return res.status(200).send({ message: "Banned link successfully." });
|
||||||
};
|
};
|
||||||
|
|
||||||
// export const redirect = (app) => async (
|
async function redirect(req, res, next) {
|
||||||
// req,
|
const isPreservedUrl = utils.preservedURLs.some(
|
||||||
// res,
|
item => item === req.path.replace("/", "")
|
||||||
// next
|
);
|
||||||
// ) => {
|
|
||||||
// const isBot = isbot(req.headers["user-agent"]);
|
|
||||||
// const isPreservedUrl = validators.preservedUrls.some(
|
|
||||||
// item => item === req.path.replace("/", "")
|
|
||||||
// );
|
|
||||||
|
|
||||||
// if (isPreservedUrl) return next();
|
if (isPreservedUrl) return next();
|
||||||
|
|
||||||
// // 1. If custom domain, get domain info
|
// 1. If custom domain, get domain info
|
||||||
// const host = utils.removeWww(req.headers.host);
|
const host = utils.removeWww(req.headers.host);
|
||||||
// const domain =
|
const domain =
|
||||||
// host !== env.DEFAULT_DOMAIN
|
host !== env.DEFAULT_DOMAIN
|
||||||
// ? await query.domain.find({ address: host })
|
? await query.domain.find({ address: host })
|
||||||
// : null;
|
: null;
|
||||||
|
|
||||||
// // 2. Get link
|
// 2. Get link
|
||||||
// const address = req.params.id.replace("+", "");
|
const address = req.params.id.replace("+", "");
|
||||||
// const link = await query.link.find({
|
const link = await query.link.find({
|
||||||
// address,
|
address,
|
||||||
// domain_id: domain ? domain.id : null
|
domain_id: domain ? domain.id : null
|
||||||
// });
|
});
|
||||||
|
|
||||||
// // 3. When no link, if has domain redirect to domain's homepage
|
// 3. When no link, if has domain redirect to domain's homepage
|
||||||
// // otherwise redirect to 404
|
// otherwise redirect to 404
|
||||||
// if (!link) {
|
if (!link) {
|
||||||
// return res.redirect(302, domain ? domain.homepage : "/404");
|
return res.redirect(domain.homepage || "/404");
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // 4. If link is banned, redirect to banned page.
|
// 4. If link is banned, redirect to banned page.
|
||||||
// if (link.banned) {
|
if (link.banned) {
|
||||||
// return res.redirect("/banned");
|
return res.redirect("/banned");
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // 5. If wants to see link info, then redirect
|
// 5. If wants to see link info, then redirect
|
||||||
// const doesRequestInfo = /.*\+$/gi.test(req.params.id);
|
const isRequestingInfo = /.*\+$/gi.test(req.params.id);
|
||||||
// if (doesRequestInfo && !link.password) {
|
if (isRequestingInfo && !link.password) {
|
||||||
// return app.render(req, res, "/url-info", { target: link.target });
|
if (req.isHTML) {
|
||||||
// }
|
res.render("url_info", {
|
||||||
|
title: "Short link information",
|
||||||
|
target: link.target,
|
||||||
|
link: utils.getShortURL(link.address, link.domain).link
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return res.send({ target: link.target });
|
||||||
|
}
|
||||||
|
|
||||||
// // 6. If link is protected, redirect to password page
|
// 6. If link is protected, redirect to password page
|
||||||
// if (link.password) {
|
if (link.password) {
|
||||||
// return res.redirect(`/protected/${link.uuid}`);
|
res.render("protected", {
|
||||||
// }
|
title: "Protected short link",
|
||||||
|
id: link.uuid
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// // 7. Create link visit
|
// 7. Create link visit
|
||||||
// if (link.user_id && !isBot) {
|
const isBot = isbot(req.headers["user-agent"]);
|
||||||
// queue.visit.add({
|
if (link.user_id && !isBot) {
|
||||||
// headers: req.headers,
|
queue.visit.add({
|
||||||
// realIP: req.realIP,
|
headers: req.headers,
|
||||||
// referrer: req.get("Referrer"),
|
realIP: req.realIP,
|
||||||
// link
|
referrer: req.get("Referrer"),
|
||||||
// });
|
link
|
||||||
// }
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// // 8. Redirect to target
|
// 8. Redirect to target
|
||||||
// return res.redirect(link.target);
|
return res.redirect(link.target);
|
||||||
// };
|
};
|
||||||
|
|
||||||
// export const redirectProtected: Handler = async (req, res) => {
|
async function redirectProtected(req, res) {
|
||||||
// // 1. Get link
|
// 1. Get link
|
||||||
// const uuid = req.params.id;
|
const uuid = req.params.id;
|
||||||
// const link = await query.link.find({ uuid });
|
const link = await query.link.find({ uuid });
|
||||||
|
|
||||||
// // 2. Throw error if no link
|
// 2. Throw error if no link
|
||||||
// if (!link || !link.password) {
|
if (!link || !link.password) {
|
||||||
// throw new CustomError("Couldn't find the link.", 400);
|
throw new CustomError("Couldn't find the link.", 400);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // 3. Check if password matches
|
// 3. Check if password matches
|
||||||
// const matches = await bcrypt.compare(req.body.password, link.password);
|
const matches = await bcrypt.compare(req.body.password, link.password);
|
||||||
|
|
||||||
// if (!matches) {
|
if (!matches) {
|
||||||
// throw new CustomError("Password is not correct.", 401);
|
throw new CustomError("Password is not correct.", 401);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // 4. Create visit
|
// 4. Create visit
|
||||||
// if (link.user_id) {
|
if (link.user_id) {
|
||||||
// queue.visit.add({
|
queue.visit.add({
|
||||||
// headers: req.headers,
|
headers: req.headers,
|
||||||
// realIP: req.realIP,
|
realIP: req.realIP,
|
||||||
// referrer: req.get("Referrer"),
|
referrer: req.get("Referrer"),
|
||||||
// link
|
link
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // 5. Send target
|
// 5. Send target
|
||||||
// return res.status(200).send({ target: link.target });
|
if (req.isHTML) {
|
||||||
// };
|
res.setHeader("HX-Redirect", link.target);
|
||||||
|
res.render("partials/protected/form", {
|
||||||
|
id: link.uuid,
|
||||||
|
message: "Redirecting...",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return res.status(200).send({ target: link.target });
|
||||||
|
};
|
||||||
|
|
||||||
// export const redirectCustomDomain: Handler = async (req, res, next) => {
|
async function redirectCustomDomainHomepage(req, res, next) {
|
||||||
// const { path } = req;
|
const path = req.path;
|
||||||
// const host = utils.removeWww(req.headers.host);
|
const host = utils.removeWww(req.headers.host);
|
||||||
|
|
||||||
// if (host === env.DEFAULT_DOMAIN) {
|
if (host === env.DEFAULT_DOMAIN) {
|
||||||
// return next();
|
return next();
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (
|
if (
|
||||||
// path === "/" ||
|
path === "/" ||
|
||||||
// validators.preservedUrls
|
utils.preservedURLs
|
||||||
// .filter(l => l !== "url-password")
|
.filter(l => l !== "url-password")
|
||||||
// .some(item => item === path.replace("/", ""))
|
.some(item => item === path.replace("/", ""))
|
||||||
// ) {
|
) {
|
||||||
// const domain = await query.domain.find({ address: host });
|
const domain = await query.domain.find({ address: host });
|
||||||
// const redirectURL = domain
|
const redirectURL = domain
|
||||||
// ? domain.homepage
|
? domain.homepage
|
||||||
// : `https://${env.DEFAULT_DOMAIN + path}`;
|
: `https://${env.DEFAULT_DOMAIN + path}`;
|
||||||
|
|
||||||
// return res.redirect(302, redirectURL);
|
return res.redirect(302, redirectURL);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// return next();
|
return next();
|
||||||
// };
|
};
|
||||||
|
|
||||||
// export const stats: Handler = async (req, res) => {
|
async function stats(req, res) {
|
||||||
// const { user } = req;
|
const { user } = req;
|
||||||
// const uuid = req.params.id;
|
const uuid = req.params.id;
|
||||||
|
|
||||||
// const link = await query.link.find({
|
const link = await query.link.find({
|
||||||
// ...(!user.admin && { user_id: user.id }),
|
...(!user.admin && { user_id: user.id }),
|
||||||
// uuid
|
uuid
|
||||||
// });
|
});
|
||||||
|
|
||||||
// if (!link) {
|
if (!link) {
|
||||||
// throw new CustomError("Link could not be found.");
|
if (req.isHTML) {
|
||||||
// }
|
res.setHeader("HX-Redirect", "/404");
|
||||||
|
res.status(200).send("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new CustomError("Link could not be found.");
|
||||||
|
}
|
||||||
|
|
||||||
// const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
|
const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
|
||||||
|
|
||||||
// if (!stats) {
|
if (!stats) {
|
||||||
// throw new CustomError("Could not get the short link stats.");
|
throw new CustomError("Could not get the short link stats. Try again later.");
|
||||||
// }
|
}
|
||||||
|
|
||||||
// return res.status(200).send({
|
if (req.isHTML) {
|
||||||
// ...stats,
|
res.render("partials/stats", {
|
||||||
// ...utils.sanitize.link(link)
|
link: utils.sanitize.link(link),
|
||||||
// });
|
stats,
|
||||||
// };
|
map,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
...stats,
|
||||||
|
...utils.sanitize.link(link)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ban,
|
ban,
|
||||||
@ -483,4 +495,9 @@ module.exports = {
|
|||||||
edit,
|
edit,
|
||||||
get,
|
get,
|
||||||
remove,
|
remove,
|
||||||
|
report,
|
||||||
|
stats,
|
||||||
|
redirect,
|
||||||
|
redirectProtected,
|
||||||
|
redirectCustomDomainHomepage,
|
||||||
}
|
}
|
@ -1,21 +1,21 @@
|
|||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function createLink(req, res, next) {
|
function createLink(req, res, next) {
|
||||||
res.locals.show_advanced = !!req.body.show_advanced;
|
res.locals.show_advanced = !!req.body.show_advanced;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function editLink(req, res, next) {
|
function editLink(req, res, next) {
|
||||||
res.locals.id = req.params.id;
|
res.locals.id = req.params.id;
|
||||||
res.locals.class = "no-animation";
|
res.locals.class = "no-animation";
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function protected(req, res, next) {
|
||||||
|
res.locals.id = req.params.id;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createLink,
|
createLink,
|
||||||
editLink,
|
editLink,
|
||||||
|
protected,
|
||||||
}
|
}
|
@ -2,18 +2,12 @@ const utils = require("../utils");
|
|||||||
const query = require("../queries")
|
const query = require("../queries")
|
||||||
const env = require("../env");
|
const env = require("../env");
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function homepage(req, res) {
|
async function homepage(req, res) {
|
||||||
res.render("homepage", {
|
res.render("homepage", {
|
||||||
title: "Modern open source URL shortener",
|
title: "Modern open source URL shortener",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function login(req, res) {
|
function login(req, res) {
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
return res.redirect("/");
|
return res.redirect("/");
|
||||||
@ -23,9 +17,6 @@ function login(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function logout(req, res) {
|
function logout(req, res) {
|
||||||
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
||||||
res.render("logout", {
|
res.render("logout", {
|
||||||
@ -33,9 +24,12 @@ function logout(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function notFound(req, res) {
|
||||||
* @type {import("express").Handler}
|
res.render("404", {
|
||||||
*/
|
title: "404 - Not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function settings(req, res) {
|
function settings(req, res) {
|
||||||
// TODO: make this a middelware function, apply it to where it's necessary
|
// TODO: make this a middelware function, apply it to where it's necessary
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
@ -46,16 +40,64 @@ function settings(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stats(req, res) {
|
||||||
|
// TODO: make this a middelware function, apply it to where it's necessary
|
||||||
|
if (!req.user) {
|
||||||
|
return res.redirect("/");
|
||||||
|
}
|
||||||
|
const id = req.query.id;
|
||||||
|
res.render("stats", {
|
||||||
|
title: "Stats"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function banned(req, res) {
|
||||||
|
res.render("banned", {
|
||||||
|
title: "Banned link",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function report(req, res) {
|
||||||
|
res.render("report", {
|
||||||
|
title: "Report abuse",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(req, res) {
|
||||||
|
res.render("reset_password", {
|
||||||
|
title: "Reset password",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPasswordResult(req, res) {
|
||||||
|
res.render("reset_password_result", {
|
||||||
|
title: "Reset password",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyChangeEmail(req, res) {
|
||||||
|
res.render("verify_change_email", {
|
||||||
|
title: "Verifying email",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify(req, res) {
|
||||||
|
res.render("verify", {
|
||||||
|
title: "Verify",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terms(req, res) {
|
||||||
|
res.render("terms", {
|
||||||
|
title: "Terms of Service",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function confirmLinkDelete(req, res) {
|
async function confirmLinkDelete(req, res) {
|
||||||
const link = await query.link.find({
|
const link = await query.link.find({
|
||||||
uuid: req.query.id,
|
uuid: req.query.id,
|
||||||
...(!req.user.admin && { user_id: req.user.id })
|
...(!req.user.admin && { user_id: req.user.id })
|
||||||
});
|
});
|
||||||
await utils.sleep(500);
|
|
||||||
if (!link) {
|
if (!link) {
|
||||||
return res.render("partials/links/dialog/message", {
|
return res.render("partials/links/dialog/message", {
|
||||||
layout: false,
|
layout: false,
|
||||||
@ -69,15 +111,11 @@ async function confirmLinkDelete(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function confirmLinkBan(req, res) {
|
async function confirmLinkBan(req, res) {
|
||||||
const link = await query.link.find({
|
const link = await query.link.find({
|
||||||
uuid: req.query.id,
|
uuid: req.query.id,
|
||||||
...(!req.user.admin && { user_id: req.user.id })
|
...(!req.user.admin && { user_id: req.user.id })
|
||||||
});
|
});
|
||||||
await utils.sleep(500);
|
|
||||||
if (!link) {
|
if (!link) {
|
||||||
return res.render("partials/links/dialog/message", {
|
return res.render("partials/links/dialog/message", {
|
||||||
message: "Could not find the link."
|
message: "Could not find the link."
|
||||||
@ -89,23 +127,15 @@ async function confirmLinkBan(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function addDomainForm(req, res) {
|
async function addDomainForm(req, res) {
|
||||||
await utils.sleep(1000);
|
|
||||||
res.render("partials/settings/domain/add_form");
|
res.render("partials/settings/domain/add_form");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
async function confirmDomainDelete(req, res) {
|
async function confirmDomainDelete(req, res) {
|
||||||
const domain = await query.domain.find({
|
const domain = await query.domain.find({
|
||||||
uuid: req.query.id,
|
uuid: req.query.id,
|
||||||
user_id: req.user.id
|
user_id: req.user.id
|
||||||
});
|
});
|
||||||
await utils.sleep(500);
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
throw new utils.CustomError("Could not find the link", 400);
|
throw new utils.CustomError("Could not find the link", 400);
|
||||||
}
|
}
|
||||||
@ -115,15 +145,21 @@ async function confirmDomainDelete(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
async function getReportEmail(req, res) {
|
||||||
* @type {import("express").Handler}
|
if (!env.REPORT_EMAIL) {
|
||||||
*/
|
throw new utils.CustomError("No report email is available.", 400);
|
||||||
|
}
|
||||||
|
res.render("partials/report/email", {
|
||||||
|
report_email: env.REPORT_EMAIL.replace("@", "[at]")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function linkEdit(req, res) {
|
async function linkEdit(req, res) {
|
||||||
const link = await query.link.find({
|
const link = await query.link.find({
|
||||||
uuid: req.params.id,
|
uuid: req.params.id,
|
||||||
...(!req.user.admin && { user_id: req.user.id })
|
...(!req.user.admin && { user_id: req.user.id })
|
||||||
});
|
});
|
||||||
await utils.sleep(500);
|
|
||||||
// TODO: handle when no link
|
// TODO: handle when no link
|
||||||
// if (!link) {
|
// if (!link) {
|
||||||
// return res.render("partials/links/dialog/message", {
|
// return res.render("partials/links/dialog/message", {
|
||||||
@ -138,12 +174,22 @@ async function linkEdit(req, res) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
addDomainForm,
|
addDomainForm,
|
||||||
|
banned,
|
||||||
|
confirmDomainDelete,
|
||||||
|
confirmLinkBan,
|
||||||
|
confirmLinkDelete,
|
||||||
|
getReportEmail,
|
||||||
homepage,
|
homepage,
|
||||||
linkEdit,
|
linkEdit,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
confirmDomainDelete,
|
notFound,
|
||||||
confirmLinkBan,
|
report,
|
||||||
confirmLinkDelete,
|
resetPassword,
|
||||||
|
resetPasswordResult,
|
||||||
settings,
|
settings,
|
||||||
|
stats,
|
||||||
|
terms,
|
||||||
|
verifyChangeEmail,
|
||||||
|
verify,
|
||||||
}
|
}
|
@ -17,8 +17,6 @@ async function get(req, res) {
|
|||||||
async function remove(req, res) {
|
async function remove(req, res) {
|
||||||
await query.user.remove(req.user);
|
await query.user.remove(req.user);
|
||||||
|
|
||||||
await utils.sleep(1000);
|
|
||||||
|
|
||||||
if (req.isHTML) {
|
if (req.isHTML) {
|
||||||
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
||||||
res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");
|
res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");
|
||||||
|
@ -167,16 +167,16 @@ const editLink = [
|
|||||||
.isLength({ min: 36, max: 36 })
|
.isLength({ min: 36, max: 36 })
|
||||||
];
|
];
|
||||||
|
|
||||||
// export const redirectProtected = [
|
const redirectProtected = [
|
||||||
// body("password", "Password is invalid.")
|
body("password", "Password is invalid.")
|
||||||
// .exists({ checkFalsy: true, checkNull: true })
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
// .isString()
|
.isString()
|
||||||
// .isLength({ min: 3, max: 64 })
|
.isLength({ min: 3, max: 64 })
|
||||||
// .withMessage("Password length must be between 3 and 64."),
|
.withMessage("Password length must be between 3 and 64."),
|
||||||
// param("id", "ID is invalid.")
|
param("id", "ID is invalid.")
|
||||||
// .exists({ checkFalsy: true, checkNull: true })
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
// .isLength({ min: 36, max: 36 })
|
.isLength({ min: 36, max: 36 })
|
||||||
// ];
|
];
|
||||||
|
|
||||||
const addDomain = [
|
const addDomain = [
|
||||||
body("address", "Domain is not valid.")
|
body("address", "Domain is not valid.")
|
||||||
@ -221,18 +221,18 @@ const deleteLink = [
|
|||||||
.isLength({ min: 36, max: 36 })
|
.isLength({ min: 36, max: 36 })
|
||||||
];
|
];
|
||||||
|
|
||||||
// export const reportLink = [
|
const reportLink = [
|
||||||
// body("link", "No link has been provided.")
|
body("link", "No link has been provided.")
|
||||||
// .exists({
|
.exists({
|
||||||
// checkFalsy: true,
|
checkFalsy: true,
|
||||||
// checkNull: true
|
checkNull: true
|
||||||
// })
|
})
|
||||||
// .customSanitizer(addProtocol)
|
.customSanitizer(addProtocol)
|
||||||
// .custom(
|
.custom(
|
||||||
// value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN
|
value => removeWww(URL.parse(value).host) === env.DEFAULT_DOMAIN
|
||||||
// )
|
)
|
||||||
// .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
|
.withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
|
||||||
// ];
|
];
|
||||||
|
|
||||||
const banLink = [
|
const banLink = [
|
||||||
param("id", "ID is invalid.")
|
param("id", "ID is invalid.")
|
||||||
@ -267,14 +267,14 @@ const banLink = [
|
|||||||
.isBoolean()
|
.isBoolean()
|
||||||
];
|
];
|
||||||
|
|
||||||
// export const getStats = [
|
const getStats = [
|
||||||
// param("id", "ID is invalid.")
|
param("id", "ID is invalid.")
|
||||||
// .exists({
|
.exists({
|
||||||
// checkFalsy: true,
|
checkFalsy: true,
|
||||||
// checkNull: true
|
checkNull: true
|
||||||
// })
|
})
|
||||||
// .isLength({ min: 36, max: 36 })
|
.isLength({ min: 36, max: 36 })
|
||||||
// ];
|
];
|
||||||
|
|
||||||
const signup = [
|
const signup = [
|
||||||
body("password", "Password is not valid.")
|
body("password", "Password is not valid.")
|
||||||
@ -336,18 +336,14 @@ const changeEmail = [
|
|||||||
.withMessage("Email length must be max 255.")
|
.withMessage("Email length must be max 255.")
|
||||||
];
|
];
|
||||||
|
|
||||||
// export const resetPasswordRequest = [
|
const resetPassword = [
|
||||||
// body("email", "Email is not valid.")
|
body("email", "Email is not valid.")
|
||||||
// .exists({ checkFalsy: true, checkNull: true })
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
// .trim()
|
.trim()
|
||||||
// .isEmail()
|
.isEmail()
|
||||||
// .isLength({ min: 0, max: 255 })
|
.isLength({ min: 0, max: 255 })
|
||||||
// .withMessage("Email length must be max 255."),
|
.withMessage("Email length must be max 255.")
|
||||||
// body("password", "Password is not valid.")
|
];
|
||||||
// .exists({ checkFalsy: true, checkNull: true })
|
|
||||||
// .isLength({ min: 8, max: 64 })
|
|
||||||
// .withMessage("Password length must be between 8 and 64.")
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// export const resetEmailRequest = [
|
// export const resetEmailRequest = [
|
||||||
// body("email", "Email is not valid.")
|
// body("email", "Email is not valid.")
|
||||||
@ -496,9 +492,13 @@ module.exports = {
|
|||||||
deleteLink,
|
deleteLink,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
editLink,
|
editLink,
|
||||||
|
getStats,
|
||||||
linksCount,
|
linksCount,
|
||||||
login,
|
login,
|
||||||
malware,
|
malware,
|
||||||
|
redirectProtected,
|
||||||
removeDomain,
|
removeDomain,
|
||||||
|
reportLink,
|
||||||
|
resetPassword,
|
||||||
signup,
|
signup,
|
||||||
}
|
}
|
@ -100,8 +100,23 @@ async function resetPasswordToken(user) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendReportEmail(link) {
|
||||||
|
const mail = await transporter.sendMail({
|
||||||
|
from: env.MAIL_FROM || env.MAIL_USER,
|
||||||
|
to: env.REPORT_EMAIL,
|
||||||
|
subject: "[REPORT]",
|
||||||
|
text: link,
|
||||||
|
html: link
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mail.accepted.length) {
|
||||||
|
throw new CustomError("Couldn't submit the report. Try again later.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
changeEmail,
|
changeEmail,
|
||||||
verification,
|
verification,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
|
sendReportEmail,
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// const visit = require("./visit.queries");
|
|
||||||
const domain = require("./domain.queries");
|
const domain = require("./domain.queries");
|
||||||
|
const visit = require("./visit.queries");
|
||||||
const link = require("./link.queries");
|
const link = require("./link.queries");
|
||||||
const user = require("./user.queries");
|
const user = require("./user.queries");
|
||||||
const host = require("./host.queries");
|
const host = require("./host.queries");
|
||||||
@ -11,5 +11,5 @@ module.exports = {
|
|||||||
ip,
|
ip,
|
||||||
link,
|
link,
|
||||||
user,
|
user,
|
||||||
// visit
|
visit
|
||||||
};
|
};
|
||||||
|
180
server/queries/visit.queries.js
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
const { isAfter, subDays, subHours, set } = require("date-fns");
|
||||||
|
|
||||||
|
const utils = require("../utils");
|
||||||
|
const redis = require("../redis");
|
||||||
|
const knex = require("../knex");
|
||||||
|
|
||||||
|
async function add(params) {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function find(match, total) {
|
||||||
|
// if (match.link_id) {
|
||||||
|
// const key = redis.key.stats(match.link_id);
|
||||||
|
// const cached = await redis.client.get(key);
|
||||||
|
// if (cached) return JSON.parse(cached);
|
||||||
|
// }
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
lastDay: {
|
||||||
|
stats: utils.getInitStats(),
|
||||||
|
views: new Array(24).fill(0),
|
||||||
|
total: 0
|
||||||
|
},
|
||||||
|
lastWeek: {
|
||||||
|
stats: utils.getInitStats(),
|
||||||
|
views: new Array(7).fill(0),
|
||||||
|
total: 0
|
||||||
|
},
|
||||||
|
lastMonth: {
|
||||||
|
stats: utils.getInitStats(),
|
||||||
|
views: new Array(30).fill(0),
|
||||||
|
total: 0
|
||||||
|
},
|
||||||
|
lastYear: {
|
||||||
|
stats: utils.getInitStats(),
|
||||||
|
views: new Array(12).fill(0),
|
||||||
|
total: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visitsStream = knex("visits").where(match).stream();
|
||||||
|
const nowUTC = utils.getUTCDate();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const periods = utils.getStatsPeriods(now);
|
||||||
|
|
||||||
|
for await (const visit of visitsStream) {
|
||||||
|
periods.forEach(([type, fromDate]) => {
|
||||||
|
const isIncluded = isAfter(new Date(visit.created_at), fromDate);
|
||||||
|
if (!isIncluded) return;
|
||||||
|
const diffFunction = utils.getDifferenceFunction(type);
|
||||||
|
const diff = diffFunction(now, new Date(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] += visit.total;
|
||||||
|
stats[type].total += visit.total;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
lastYear: {
|
||||||
|
stats: utils.statsObjectToArray(stats.lastYear.stats),
|
||||||
|
views: stats.lastYear.views,
|
||||||
|
total: stats.lastYear.total
|
||||||
|
},
|
||||||
|
lastDay: {
|
||||||
|
stats: utils.statsObjectToArray(stats.lastDay.stats),
|
||||||
|
views: stats.lastDay.views,
|
||||||
|
total: stats.lastDay.total
|
||||||
|
},
|
||||||
|
lastMonth: {
|
||||||
|
stats: utils.statsObjectToArray(stats.lastMonth.stats),
|
||||||
|
views: stats.lastMonth.views,
|
||||||
|
total: stats.lastMonth.total
|
||||||
|
},
|
||||||
|
lastWeek: {
|
||||||
|
stats: utils.statsObjectToArray(stats.lastWeek.stats),
|
||||||
|
views: stats.lastWeek.views,
|
||||||
|
total: stats.lastWeek.total
|
||||||
|
},
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (match.link_id) {
|
||||||
|
const cacheTime = utils.getStatsCacheTime(total);
|
||||||
|
const key = redis.key.stats(match.link_id);
|
||||||
|
redis.client.set(key, JSON.stringify(response), "EX", cacheTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
add,
|
||||||
|
find,
|
||||||
|
}
|
@ -1,243 +0,0 @@
|
|||||||
import { isAfter, subDays, set } from "date-fns";
|
|
||||||
|
|
||||||
import * as utils from "../utils/utils";
|
|
||||||
import redisClient, * as redis from "../redis";
|
|
||||||
import knex from "../knex";
|
|
||||||
|
|
||||||
interface Add {
|
|
||||||
browser: string;
|
|
||||||
country: string;
|
|
||||||
domain?: string;
|
|
||||||
id: number;
|
|
||||||
os: string;
|
|
||||||
referrer: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const add = async (params: Add) => {
|
|
||||||
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 StatsResult {
|
|
||||||
stats: {
|
|
||||||
browser: { name: string; value: number }[];
|
|
||||||
os: { name: string; value: number }[];
|
|
||||||
country: { name: string; value: number }[];
|
|
||||||
referrer: { name: string; value: number }[];
|
|
||||||
};
|
|
||||||
views: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IGetStatsResponse {
|
|
||||||
allTime: StatsResult;
|
|
||||||
lastDay: StatsResult;
|
|
||||||
lastMonth: StatsResult;
|
|
||||||
lastWeek: StatsResult;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const find = async (match: Partial<Visit>, total: number) => {
|
|
||||||
if (match.link_id) {
|
|
||||||
const key = redis.key.stats(match.link_id);
|
|
||||||
const cached = await redisClient.get(key);
|
|
||||||
if (cached) return JSON.parse(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
lastDay: {
|
|
||||||
stats: utils.getInitStats(),
|
|
||||||
views: new Array(24).fill(0)
|
|
||||||
},
|
|
||||||
lastWeek: {
|
|
||||||
stats: utils.getInitStats(),
|
|
||||||
views: new Array(7).fill(0)
|
|
||||||
},
|
|
||||||
lastMonth: {
|
|
||||||
stats: utils.getInitStats(),
|
|
||||||
views: new Array(30).fill(0)
|
|
||||||
},
|
|
||||||
allTime: {
|
|
||||||
stats: utils.getInitStats(),
|
|
||||||
views: new Array(18).fill(0)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const visitsStream: any = knex<Visit>("visits").where(match).stream();
|
|
||||||
const nowUTC = utils.getUTCDate();
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
for await (const visit of visitsStream as Visit[]) {
|
|
||||||
utils.STATS_PERIODS.forEach(([days, type]) => {
|
|
||||||
const isIncluded = isAfter(
|
|
||||||
new Date(visit.created_at),
|
|
||||||
subDays(nowUTC, days)
|
|
||||||
);
|
|
||||||
if (isIncluded) {
|
|
||||||
const diffFunction = utils.getDifferenceFunction(type);
|
|
||||||
const diff = diffFunction(now, new Date(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 = utils.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: utils.statsObjectToArray(stats.allTime.stats),
|
|
||||||
views: stats.allTime.views
|
|
||||||
},
|
|
||||||
lastDay: {
|
|
||||||
stats: utils.statsObjectToArray(stats.lastDay.stats),
|
|
||||||
views: stats.lastDay.views
|
|
||||||
},
|
|
||||||
lastMonth: {
|
|
||||||
stats: utils.statsObjectToArray(stats.lastMonth.stats),
|
|
||||||
views: stats.lastMonth.views
|
|
||||||
},
|
|
||||||
lastWeek: {
|
|
||||||
stats: utils.statsObjectToArray(stats.lastWeek.stats),
|
|
||||||
views: stats.lastWeek.views
|
|
||||||
},
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (match.link_id) {
|
|
||||||
const cacheTime = utils.getStatsCacheTime(total);
|
|
||||||
const key = redis.key.stats(match.link_id);
|
|
||||||
redisClient.set(key, JSON.stringify(response), "EX", cacheTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
|
5
server/queues/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const { visit } = require("./queues");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
visit,
|
||||||
|
};
|
@ -1,7 +0,0 @@
|
|||||||
import { visit } from "./queues";
|
|
||||||
|
|
||||||
const queues = {
|
|
||||||
visit
|
|
||||||
};
|
|
||||||
|
|
||||||
export default queues;
|
|
75
server/queues/queues.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
const Queue = require("bull");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const env = require("../env");
|
||||||
|
|
||||||
|
const redis = {
|
||||||
|
port: env.REDIS_PORT,
|
||||||
|
host: env.REDIS_HOST,
|
||||||
|
...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
|
||||||
|
};
|
||||||
|
|
||||||
|
function onComplete(job) {
|
||||||
|
console.log('complete');
|
||||||
|
return job.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const visit = new Queue("visit", { redis });
|
||||||
|
|
||||||
|
const a = require(__dirname + "/visit.js");
|
||||||
|
// visit.clean(5000, "completed");
|
||||||
|
visit.process(__dirname + "/visit.js");
|
||||||
|
visit.on("completed", onComplete);
|
||||||
|
|
||||||
|
visit.on('error', function (error) {
|
||||||
|
console.log('error');
|
||||||
|
})
|
||||||
|
|
||||||
|
visit.on('waiting', function (jobId) {
|
||||||
|
console.log('waiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
visit.on('active', function (job, jobPromise) {
|
||||||
|
console.log('active');
|
||||||
|
})
|
||||||
|
|
||||||
|
visit.on('stalled', function (job) {
|
||||||
|
console.log('stalled');
|
||||||
|
})
|
||||||
|
|
||||||
|
visit.on('lock-extension-failed', function (job, err) {
|
||||||
|
console.log('lock-extension-failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
visit.on('progress', function (job, progress) {
|
||||||
|
console.log('progress');
|
||||||
|
})
|
||||||
|
|
||||||
|
visit.on('failed', function (job, err) {
|
||||||
|
console.log(err);
|
||||||
|
console.log('failed');
|
||||||
|
})
|
||||||
|
|
||||||
|
visit.on('paused', function () {
|
||||||
|
console.log('paused');
|
||||||
|
})
|
||||||
|
|
||||||
|
visit.on('resumed', function (job) {
|
||||||
|
console.log('resumed');
|
||||||
|
})
|
||||||
|
|
||||||
|
visit.on('cleaned', function (jobs, type) {
|
||||||
|
console.log('cleaned');
|
||||||
|
});
|
||||||
|
|
||||||
|
visit.on('drained', function () {
|
||||||
|
console.log('drained');
|
||||||
|
});
|
||||||
|
|
||||||
|
visit.on('removed', function (job) {
|
||||||
|
console.log('removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
visit,
|
||||||
|
}
|
@ -1,20 +0,0 @@
|
|||||||
import Queue from "bull";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import env from "../env";
|
|
||||||
|
|
||||||
const redis = {
|
|
||||||
port: env.REDIS_PORT,
|
|
||||||
host: env.REDIS_HOST,
|
|
||||||
...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeJob = job => job.remove();
|
|
||||||
|
|
||||||
export const visit = new Queue("visit", { redis });
|
|
||||||
|
|
||||||
visit.clean(5000, "completed");
|
|
||||||
|
|
||||||
visit.process(8, path.resolve(__dirname, "visit.js"));
|
|
||||||
|
|
||||||
visit.on("completed", removeJob);
|
|
@ -1,30 +1,40 @@
|
|||||||
import useragent from "useragent";
|
const useragent = require("useragent");
|
||||||
import geoip from "geoip-lite";
|
const geoip = require("geoip-lite");
|
||||||
import URL from "url";
|
const URL = require("url");
|
||||||
|
|
||||||
import query from "../queries";
|
const { getStatsLimit, removeWww } = require("../utils");
|
||||||
import { getStatsLimit, removeWww } from "../utils/utils";
|
const query = require("../queries");
|
||||||
|
|
||||||
const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
|
const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
|
||||||
const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];
|
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 default function visit({ data }) {
|
function filterInBrowser(agent) {
|
||||||
|
return function(item) {
|
||||||
|
return agent.family.toLowerCase().includes(item.toLocaleLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterInOs(agent) {
|
||||||
|
return function(item) {
|
||||||
|
return agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function({ data }) {
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
|
|
||||||
tasks.push(query.link.incrementVisit({ id: data.link.id }));
|
tasks.push(query.link.incrementVisit({ id: data.link.id }));
|
||||||
|
|
||||||
if (data.link.visit_count < getStatsLimit()) {
|
if (data.link.visit_count < getStatsLimit()) {
|
||||||
const agent = useragent.parse(data.headers["user-agent"]);
|
const agent = useragent.parse(data.headers["user-agent"]);
|
||||||
const [browser = "Other"] = browsersList.filter(filterInBrowser(agent));
|
const [browser = "Other"] = browsersList.filter(filterInBrowser(agent));
|
||||||
const [os = "Other"] = osList.filter(filterInOs(agent));
|
const [os = "Other"] = osList.filter(filterInOs(agent));
|
||||||
const referrer =
|
const referrer =
|
||||||
data.referrer && removeWww(URL.parse(data.referrer).hostname);
|
data.referrer && removeWww(URL.parse(data.referrer).hostname);
|
||||||
const location = geoip.lookup(data.realIP);
|
const location = geoip.lookup(data.realIP);
|
||||||
const country = location && location.country;
|
const country = location && location.country;
|
||||||
|
|
||||||
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
query.visit.add({
|
query.visit.add({
|
||||||
browser: browser.toLowerCase(),
|
browser: browser.toLowerCase(),
|
||||||
@ -37,4 +47,4 @@ export default function visit({ data }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all(tasks);
|
return Promise.all(tasks);
|
||||||
}
|
}
|
@ -1 +0,0 @@
|
|||||||
module.exports = require("./renders");
|
|
@ -1,27 +0,0 @@
|
|||||||
function renderError(res, template, errors) {
|
|
||||||
const error = errors[0].msg;
|
|
||||||
|
|
||||||
const params = {};
|
|
||||||
|
|
||||||
errors.forEach(e => {
|
|
||||||
if (params[e.param]) return;
|
|
||||||
params[e.param + "_error"] = e.msg;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.render(template, {
|
|
||||||
layout: null,
|
|
||||||
error,
|
|
||||||
...params
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {import("express").Handler}
|
|
||||||
*/
|
|
||||||
function addErrorRenderer(req, res, next) {
|
|
||||||
res.render.error = (template, errors) => renderError(res, template, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
addErrorRenderer,
|
|
||||||
}
|
|
@ -4,6 +4,7 @@ const { Router } = require("express");
|
|||||||
const validators = require("../handlers/validators.handler");
|
const validators = require("../handlers/validators.handler");
|
||||||
const helpers = require("../handlers/helpers.handler");
|
const helpers = require("../handlers/helpers.handler");
|
||||||
const auth = require("../handlers/auth.handler");
|
const auth = require("../handlers/auth.handler");
|
||||||
|
const utils = require("../utils");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -52,6 +53,12 @@ router.post(
|
|||||||
asyncHandler(auth.generateApiKey)
|
asyncHandler(auth.generateApiKey)
|
||||||
);
|
);
|
||||||
|
|
||||||
// router.post("/reset-password", asyncHandler(auth.resetPasswordRequest));
|
router.post(
|
||||||
|
"/reset-password",
|
||||||
|
helpers.viewTemplate("partials/reset_password/form"),
|
||||||
|
validators.resetPassword,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(auth.resetPasswordRequest)
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Router } from "express";
|
const { Router } = require("express");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", (_, res) => res.send("OK"));
|
router.get("/", (_, res) => res.send("OK"));
|
||||||
|
|
||||||
export default router;
|
module.exports = router;
|
@ -66,27 +66,32 @@ router.post(
|
|||||||
asyncHandler(link.ban)
|
asyncHandler(link.ban)
|
||||||
);
|
);
|
||||||
|
|
||||||
// router.get(
|
router.get(
|
||||||
// "/:id/stats",
|
"/:id/stats",
|
||||||
// asyncHandler(auth.apikey),
|
helpers.viewTemplate("partials/stats"),
|
||||||
// asyncHandler(auth.jwt),
|
asyncHandler(auth.apikey),
|
||||||
// validators.getStats,
|
asyncHandler(auth.jwt),
|
||||||
// asyncHandler(link.stats)
|
validators.getStats,
|
||||||
// );
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(link.stats)
|
||||||
|
);
|
||||||
|
|
||||||
// router.post(
|
router.post(
|
||||||
// "/:id/protected",
|
"/:id/protected",
|
||||||
// validators.redirectProtected,
|
helpers.viewTemplate("partials/protected/form"),
|
||||||
// asyncHandler(helpers.verify),
|
locals.protected,
|
||||||
// asyncHandler(link.redirectProtected)
|
validators.redirectProtected,
|
||||||
// );
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(link.redirectProtected)
|
||||||
|
);
|
||||||
|
|
||||||
// router.post(
|
router.post(
|
||||||
// "/report",
|
"/report",
|
||||||
// validators.reportLink,
|
helpers.viewTemplate("partials/report/form"),
|
||||||
// asyncHandler(helpers.verify),
|
validators.reportLink,
|
||||||
// asyncHandler(link.report)
|
asyncHandler(helpers.verify),
|
||||||
// );
|
asyncHandler(link.report)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
@ -2,8 +2,8 @@ const asyncHandler = require("express-async-handler");
|
|||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
|
|
||||||
const helpers = require("../handlers/helpers.handler");
|
const helpers = require("../handlers/helpers.handler");
|
||||||
|
const renders = require("../handlers/renders.handler");
|
||||||
const auth = require("../handlers/auth.handler");
|
const auth = require("../handlers/auth.handler");
|
||||||
const renders = require("./renders.handler");
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -27,6 +27,12 @@ router.get(
|
|||||||
asyncHandler(renders.logout)
|
asyncHandler(renders.logout)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/404",
|
||||||
|
asyncHandler(auth.jwtLoose),
|
||||||
|
asyncHandler(renders.notFound)
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/settings",
|
"/settings",
|
||||||
asyncHandler(auth.jwtLoose),
|
asyncHandler(auth.jwtLoose),
|
||||||
@ -34,6 +40,57 @@ router.get(
|
|||||||
asyncHandler(renders.settings)
|
asyncHandler(renders.settings)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/stats",
|
||||||
|
asyncHandler(auth.jwtLoose),
|
||||||
|
asyncHandler(helpers.addUserLocals),
|
||||||
|
asyncHandler(renders.stats)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/banned",
|
||||||
|
asyncHandler(renders.banned)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/report",
|
||||||
|
asyncHandler(renders.report)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/reset-password",
|
||||||
|
asyncHandler(renders.resetPassword)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/reset-password/:resetPasswordToken",
|
||||||
|
asyncHandler(auth.resetPassword),
|
||||||
|
asyncHandler(auth.jwtLoose),
|
||||||
|
asyncHandler(helpers.addUserLocals),
|
||||||
|
asyncHandler(renders.resetPasswordResult)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/verify-email/:changeEmailToken",
|
||||||
|
asyncHandler(auth.changeEmail),
|
||||||
|
asyncHandler(auth.jwtLoose),
|
||||||
|
asyncHandler(helpers.addUserLocals),
|
||||||
|
asyncHandler(renders.verifyChangeEmail)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/verify/:verificationToken",
|
||||||
|
asyncHandler(auth.verify),
|
||||||
|
asyncHandler(auth.jwtLoose),
|
||||||
|
asyncHandler(helpers.addUserLocals),
|
||||||
|
asyncHandler(renders.verify)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/terms",
|
||||||
|
asyncHandler(renders.terms)
|
||||||
|
);
|
||||||
|
|
||||||
// partial renders
|
// partial renders
|
||||||
router.get(
|
router.get(
|
||||||
"/confirm-link-delete",
|
"/confirm-link-delete",
|
||||||
@ -73,4 +130,11 @@ router.get(
|
|||||||
asyncHandler(renders.confirmDomainDelete)
|
asyncHandler(renders.confirmDomainDelete)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/get-report-email",
|
||||||
|
helpers.addNoLayoutLocals,
|
||||||
|
helpers.viewTemplate("partials/report/email"),
|
||||||
|
asyncHandler(renders.getReportEmail)
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
@ -1,19 +1,25 @@
|
|||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
|
|
||||||
const helpers = require("./../handlers/helpers.handler");
|
const helpers = require("./../handlers/helpers.handler");
|
||||||
|
const renders = require("./renders.routes");
|
||||||
const domains = require("./domain.routes");
|
const domains = require("./domain.routes");
|
||||||
// import health from "./health.routes";
|
const health = require("./health.routes");
|
||||||
const link = require("./link.routes");
|
const link = require("./link.routes");
|
||||||
const user = require("./user.routes");
|
const user = require("./user.routes");
|
||||||
const auth = require("./auth.routes");
|
const auth = require("./auth.routes");
|
||||||
|
|
||||||
const router = Router();
|
const apiRouter = Router();
|
||||||
|
const renderRouter = Router();
|
||||||
|
|
||||||
router.use(helpers.addNoLayoutLocals);
|
renderRouter.use(renders);
|
||||||
router.use("/domains", domains);
|
apiRouter.use(helpers.addNoLayoutLocals);
|
||||||
// router.use("/health", health);
|
apiRouter.use("/domains", domains);
|
||||||
router.use("/links", link);
|
apiRouter.use("/health", health);
|
||||||
router.use("/users", user);
|
apiRouter.use("/links", link);
|
||||||
router.use("/auth", auth);
|
apiRouter.use("/users", user);
|
||||||
|
apiRouter.use("/auth", auth);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = {
|
||||||
|
api: apiRouter,
|
||||||
|
render: renderRouter,
|
||||||
|
};
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
const env = require("./env");
|
const env = require("./env");
|
||||||
|
|
||||||
// import asyncHandler from "express-async-handler";
|
const asyncHandler = require("express-async-handler");
|
||||||
// import passport from "passport";
|
|
||||||
const cookieParser = require("cookie-parser");
|
const cookieParser = require("cookie-parser");
|
||||||
|
const compression = require("compression");
|
||||||
|
const passport = require("passport");
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const helmet = require("helmet");
|
const helmet = require("helmet");
|
||||||
const morgan = require("morgan");
|
const morgan = require("morgan");
|
||||||
@ -10,18 +11,21 @@ const path = require("path");
|
|||||||
const hbs = require("hbs");
|
const hbs = require("hbs");
|
||||||
|
|
||||||
const helpers = require("./handlers/helpers.handler");
|
const helpers = require("./handlers/helpers.handler");
|
||||||
// import * as links from "./handlers/links";
|
const links = require("./handlers/links.handler");
|
||||||
// import * as auth from "./handlers/auth";
|
const { stream } = require("./config/winston");
|
||||||
const routes = require("./routes");
|
const routes = require("./routes");
|
||||||
const renders = require("./renders");
|
|
||||||
const utils = require("./utils");
|
const utils = require("./utils");
|
||||||
const { stream } = require("./config/winston")
|
|
||||||
|
|
||||||
// import "./cron";
|
// import "./cron";
|
||||||
require("./passport");
|
require("./passport");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// enable gzip on dev
|
||||||
|
if (env.isDev) {
|
||||||
|
app.use(compression());
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: comments
|
// TODO: comments
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
@ -35,7 +39,7 @@ app.use(express.json());
|
|||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(express.static("static"));
|
app.use(express.static("static"));
|
||||||
|
|
||||||
// app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
// app.use(helpers.ip);
|
// app.use(helpers.ip);
|
||||||
app.use(helpers.isHTML);
|
app.use(helpers.isHTML);
|
||||||
app.use(helpers.addConfigLocals);
|
app.use(helpers.addConfigLocals);
|
||||||
@ -45,38 +49,19 @@ app.set("view engine", "hbs");
|
|||||||
app.set("views", path.join(__dirname, "views"));
|
app.set("views", path.join(__dirname, "views"));
|
||||||
utils.registerHandlebarsHelpers();
|
utils.registerHandlebarsHelpers();
|
||||||
|
|
||||||
app.use("/", renders);
|
app.use("/", routes.render);
|
||||||
|
|
||||||
// app.use(asyncHandler(links.redirectCustomDomain));
|
// if is custom domain, redirect to the set homepage
|
||||||
|
app.use(asyncHandler(links.redirectCustomDomainHomepage));
|
||||||
|
|
||||||
app.use("/api/v2", routes);
|
app.use("/api/v2", routes.api);
|
||||||
app.use("/api", routes);
|
app.use("/api", routes.api);
|
||||||
|
|
||||||
// server.get(
|
// finally, redirect the short link to the target
|
||||||
// "/reset-password/:resetPasswordToken?",
|
app.get("/:id", asyncHandler(links.redirect));
|
||||||
// asyncHandler(auth.resetPassword),
|
|
||||||
// (req, res) => app.render(req, res, "/reset-password", { token: req.token })
|
|
||||||
// );
|
|
||||||
|
|
||||||
// server.get(
|
|
||||||
// "/verify-email/:changeEmailToken",
|
|
||||||
// asyncHandler(auth.changeEmail),
|
|
||||||
// (req, res) => app.render(req, res, "/verify-email", { token: req.token })
|
|
||||||
// );
|
|
||||||
|
|
||||||
// server.get(
|
|
||||||
// "/verify/:verificationToken?",
|
|
||||||
// asyncHandler(auth.verify),
|
|
||||||
// (req, res) => app.render(req, res, "/verify", { token: req.token })
|
|
||||||
// );
|
|
||||||
|
|
||||||
// server.get("/:id", asyncHandler(links.redirect(app)));
|
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
app.use(helpers.error);
|
app.use(helpers.error);
|
||||||
|
|
||||||
// Handler everything else by Next.js
|
|
||||||
// server.get("*", (req, res) => handle(req, res));
|
|
||||||
|
|
||||||
app.listen(env.PORT, () => {
|
app.listen(env.PORT, () => {
|
||||||
console.log(`> Ready on http://localhost:${env.PORT}`);
|
console.log(`> Ready on http://localhost:${env.PORT}`);
|
||||||
|
892
server/utils/map.json
Normal file
@ -2,7 +2,7 @@ const ms = require("ms");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const nanoid = require("nanoid/generate");
|
const nanoid = require("nanoid/generate");
|
||||||
const JWT = require("jsonwebtoken");
|
const JWT = require("jsonwebtoken");
|
||||||
const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays } = require("date-fns");
|
const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears } = require("date-fns");
|
||||||
const hbs = require("hbs");
|
const hbs = require("hbs");
|
||||||
|
|
||||||
const env = require("../env");
|
const env = require("../env");
|
||||||
@ -16,8 +16,6 @@ class CustomError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = require("../queries");
|
|
||||||
|
|
||||||
function isAdmin(email) {
|
function isAdmin(email) {
|
||||||
return env.ADMIN_EMAILS.split(",")
|
return env.ADMIN_EMAILS.split(",")
|
||||||
.map((e) => e.trim())
|
.map((e) => e.trim())
|
||||||
@ -37,7 +35,7 @@ function signToken(user) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateId(domain_id) {
|
async function generateId(query, domain_id) {
|
||||||
const address = nanoid(
|
const address = nanoid(
|
||||||
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
|
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
|
||||||
env.LINK_LENGTH
|
env.LINK_LENGTH
|
||||||
@ -53,7 +51,7 @@ function addProtocol(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getShortURL(address, domain) {
|
function getShortURL(address, domain) {
|
||||||
const protocol = env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
|
const protocol = (env.CUSTOM_DOMAIN_USE_HTTPS || !domain) && !env.isDev ? "https://" : "http://";
|
||||||
const link = `${domain || env.DEFAULT_DOMAIN}/${address}`;
|
const link = `${domain || env.DEFAULT_DOMAIN}/${address}`;
|
||||||
const url = `${protocol}${link}`;
|
const url = `${protocol}${link}`;
|
||||||
return { link, url };
|
return { link, url };
|
||||||
@ -96,7 +94,7 @@ function getDifferenceFunction(type) {
|
|||||||
if (type === "lastDay") return differenceInHours;
|
if (type === "lastDay") return differenceInHours;
|
||||||
if (type === "lastWeek") return differenceInDays;
|
if (type === "lastWeek") return differenceInDays;
|
||||||
if (type === "lastMonth") return differenceInDays;
|
if (type === "lastMonth") return differenceInDays;
|
||||||
if (type === "allTime") return differenceInMonths;
|
if (type === "lastYear") return differenceInMonths;
|
||||||
throw new Error("Unknown type.");
|
throw new Error("Unknown type.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,11 +108,14 @@ function getUTCDate(dateString) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATS_PERIODS = [
|
function getStatsPeriods(now) {
|
||||||
[1, "lastDay"],
|
return [
|
||||||
[7, "lastWeek"],
|
["lastDay", subHours(now, 24)],
|
||||||
[30, "lastMonth"]
|
["lastWeek", subDays(now, 7)],
|
||||||
];
|
["lastMonth", subDays(now, 30)],
|
||||||
|
["lastYear", subMonths(now, 12)],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const preservedURLs = [
|
const preservedURLs = [
|
||||||
"login",
|
"login",
|
||||||
@ -233,6 +234,10 @@ function registerHandlebarsHelpers() {
|
|||||||
hbs.registerHelper("ifEquals", function(arg1, arg2, options) {
|
hbs.registerHelper("ifEquals", function(arg1, arg2, options) {
|
||||||
return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
|
return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
hbs.registerHelper("json", function(context) {
|
||||||
|
return JSON.stringify(context);
|
||||||
|
});
|
||||||
|
|
||||||
const blocks = {};
|
const blocks = {};
|
||||||
|
|
||||||
@ -270,6 +275,6 @@ module.exports = {
|
|||||||
sanitize,
|
sanitize,
|
||||||
signToken,
|
signToken,
|
||||||
sleep,
|
sleep,
|
||||||
STATS_PERIODS,
|
getStatsPeriods,
|
||||||
statsObjectToArray,
|
statsObjectToArray,
|
||||||
}
|
}
|
10
server/views/404.hbs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{{> header}}
|
||||||
|
<div id="notfound" class="section-container">
|
||||||
|
<h2>
|
||||||
|
404 | Link could not be found.
|
||||||
|
</h2>
|
||||||
|
<a class="back-to-home" href="/">
|
||||||
|
← Back to homepage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{> footer}}
|
14
server/views/banned.hbs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="banned" class="section-container">
|
||||||
|
<h2>
|
||||||
|
Link has been banned and removed because of
|
||||||
|
<span class="bold underline">malware or scam</span>.
|
||||||
|
</h2>
|
||||||
|
<h4>
|
||||||
|
If you noticed a malware/scam link shortened by {{default_domain}},
|
||||||
|
<a href="/report" title="Send report">
|
||||||
|
send us a report
|
||||||
|
</a>.
|
||||||
|
</h4>
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
@ -5,7 +5,6 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
{{#unless user}}
|
{{#unless user}}
|
||||||
{{> introduction}}
|
{{> introduction}}
|
||||||
{{> features}}
|
|
||||||
{{> browser_extensions}}
|
{{> browser_extensions}}
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
{{> footer}}
|
{{> footer}}
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
|
<link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
|
||||||
<link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
|
<link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
|
||||||
<link rel="mask-icon" href="/images/icon.svg" color="blue" />
|
<link rel="mask-icon" href="/images/icon.svg" color="blue" />
|
||||||
<link rel="manifest" href="manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="theme-color" content="#f3f3f3" />
|
<meta name="theme-color" content="#f3f3f3" />
|
||||||
<meta property="fb:app_id" content="123456789" />
|
<meta property="fb:app_id" content="123456789" />
|
||||||
<meta name="htmx-config" content='{"withCredentials":true}'>
|
<meta name="htmx-config" content='{"withCredentials":true}'>
|
||||||
|
@ -25,11 +25,12 @@
|
|||||||
{{!-- TODO: Agree with terms --}}
|
{{!-- TODO: Agree with terms --}}
|
||||||
<div class="buttons-wrapper">
|
<div class="buttons-wrapper">
|
||||||
<button type="submit" class="primary login">
|
<button type="submit" class="primary login">
|
||||||
<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4m-5-4 5-5-5-5m3.8 5H3"/></svg>
|
<span>{{> icons/login}}</span>
|
||||||
<svg class="with-text spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
<span>{{> icons/spinner}}</span>
|
||||||
Log in
|
Log in
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="secondary signup"
|
class="secondary signup"
|
||||||
hx-post="/api/auth/signup"
|
hx-post="/api/auth/signup"
|
||||||
hx-target="#login-signup"
|
hx-target="#login-signup"
|
||||||
@ -40,12 +41,12 @@
|
|||||||
hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')"
|
hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')"
|
||||||
hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')"
|
hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')"
|
||||||
>
|
>
|
||||||
<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>
|
<span>{{> icons/new_user}}</span>
|
||||||
<svg class="with-text spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
<span>{{> icons/spinner}}</span>
|
||||||
Sign up
|
Sign up
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<a class="forgot-password" href="/forgot-password" title="Reset password">Forgot your password?</a>
|
<a class="forgot-password" href="/reset-password" title="Reset password">Forgot your password?</a>
|
||||||
{{#unless errors}}
|
{{#unless errors}}
|
||||||
{{#if error}}
|
{{#if error}}
|
||||||
<p class="error">{{error}}</p>
|
<p class="error">{{error}}</p>
|
||||||
|
@ -3,28 +3,28 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 14.7V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.3"/><path d="m18 2 4 4-10 10H8v-4z"/></svg>
|
{{> icons/write}}
|
||||||
</div>
|
</div>
|
||||||
<h4>Managing links</h4>
|
<h4>Managing links</h4>
|
||||||
<p>Create, protect and delete your links and monitor them with detailed statistics.</p>
|
<p>Create, protect and delete your links and monitor them with detailed statistics.</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 3h5v5M4 20 20.2 3.8M21 16v5h-5m-1-6 5.1 5.1M4 4l5 5"/></svg>
|
{{> icons/shuffle}}
|
||||||
</div>
|
</div>
|
||||||
<h4>Custom domain</h4>
|
<h4>Custom domain</h4>
|
||||||
<p>Use custom domains for your links. Add or remove them for free.</p>
|
<p>Use custom domains for your links. Add or remove them for free.</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9z"/></svg>
|
{{> icons/zap}}
|
||||||
</div>
|
</div>
|
||||||
<h4>API</h4>
|
<h4>API</h4>
|
||||||
<p>Use the provided API to create, delete, and get URLs from anywhere.</p>
|
<p>Use the provided API to create, delete, and get URLs from anywhere.</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.7 0l-1.1 1-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1 7.8 7.8 7.8-7.7 1-1.1a5.5 5.5 0 0 0 0-7.8z"/></svg>
|
{{> icons/heart}}
|
||||||
</div>
|
</div>
|
||||||
<h4>Free & open source</h4>
|
<h4>Free & open source</h4>
|
||||||
<p>Completely open source and free. You can host it on your own server.</p>
|
<p>Completely open source and free. You can host it on your own server.</p>
|
||||||
|
1
server/views/partials/icons/arrow_left.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="-5 -5 24 24"><path d="m3.41 7.66 3.95 3.95a1 1 0 0 1-1.41 1.41L.29 7.36a1 1 0 0 1 0-1.41L5.95.29a1 1 0 1 1 1.41 1.42L3.41 5.66H13a1 1 0 0 1 0 2z"/></svg>
|
After Width: | Height: | Size: 222 B |
1
server/views/partials/icons/chevron_left.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>
|
After Width: | Height: | Size: 126 B |
1
server/views/partials/icons/chevron_right.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
|
After Width: | Height: | Size: 125 B |
1
server/views/partials/icons/eye.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="-2 -6 24 24"><path d="M18 6c0-1.8-3.8-4-8-4S2 4.2 2 6s3.8 4 8 4 8-2.2 8-4m-8 6C5 12 0 9.3 0 6s5-6 10-6 10 2.7 10 6-5 6-10 6m0-2a4 4 0 1 1 0-8 4 4 0 0 1 0 8m0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4"/></svg>
|
After Width: | Height: | Size: 265 B |
1
server/views/partials/icons/heart.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.7 0l-1.1 1-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1 7.8 7.8 7.8-7.7 1-1.1a5.5 5.5 0 0 0 0-7.8z"/></svg>
|
After Width: | Height: | Size: 213 B |
1
server/views/partials/icons/login.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4m-5-4 5-5-5-5m3.8 5H3"/></svg>
|
After Width: | Height: | Size: 189 B |
1
server/views/partials/icons/new_user.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>
|
After Width: | Height: | Size: 225 B |
1
server/views/partials/icons/send.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg class="send" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="m2 21 21-9L2 3v7l15 2-15 2z"/></svg>
|
After Width: | Height: | Size: 138 B |
1
server/views/partials/icons/shuffle.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 3h5v5M4 20 20.2 3.8M21 16v5h-5m-1-6 5.1 5.1M4 4l5 5"/></svg>
|
After Width: | Height: | Size: 160 B |
1
server/views/partials/icons/write.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 14.7V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.3"/><path d="m18 2 4 4-10 10H8v-4z"/></svg>
|
After Width: | Height: | Size: 198 B |
1
server/views/partials/icons/x.hbs
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
After Width: | Height: | Size: 124 B |
@ -9,9 +9,14 @@
|
|||||||
{{> icons/stop}}
|
{{> icons/stop}}
|
||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<button class="action stats">
|
<a
|
||||||
|
class="button action stats"
|
||||||
|
href="/stats?id={{id}}"
|
||||||
|
title="Stats"
|
||||||
|
class="action stats"
|
||||||
|
>
|
||||||
{{> icons/chart}}
|
{{> icons/chart}}
|
||||||
</button>
|
</a>
|
||||||
<button
|
<button
|
||||||
class="action qrcode"
|
class="action qrcode"
|
||||||
hx-on:click="handleQRCode(this)"
|
hx-on:click="handleQRCode(this)"
|
||||||
|
@ -22,8 +22,9 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button hx-on:click="closeDialog()">Cancel</button>
|
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="danger confirm"
|
class="danger confirm"
|
||||||
hx-post="/api/links/admin/ban/{id}"
|
hx-post="/api/links/admin/ban/{id}"
|
||||||
hx-ext="path-params"
|
hx-ext="path-params"
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="icon success">
|
<div class="icon success">
|
||||||
<svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
|
{{> icons/check}}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
The link <b>"{{link}}"</b> is banned.
|
The link <b>"{{link}}"</b> is banned.
|
||||||
</p>
|
</p>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button hx-on:click="closeDialog()">Close</button>
|
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -4,8 +4,9 @@
|
|||||||
Are you sure do you want to delete the link "<b>{{link}}</b>"?
|
Are you sure do you want to delete the link "<b>{{link}}</b>"?
|
||||||
</p>
|
</p>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button hx-on:click="closeDialog()">Cancel</button>
|
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="danger confirm"
|
class="danger confirm"
|
||||||
hx-delete="/api/links/{id}"
|
hx-delete="/api/links/{id}"
|
||||||
hx-ext="path-params"
|
hx-ext="path-params"
|
||||||
@ -15,10 +16,10 @@
|
|||||||
hx-indicator="closest .content"
|
hx-indicator="closest .content"
|
||||||
hx-select-oob="#dialog-error"
|
hx-select-oob="#dialog-error"
|
||||||
>
|
>
|
||||||
<svg class="with-text action" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M3 6h18m-2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m-6 5v6m4-6v6"/></svg>
|
<span>{{> icons/trash}}</span>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
{{> icons/spinner}}
|
||||||
</div>
|
</div>
|
||||||
<div id="dialog-error">
|
<div id="dialog-error">
|
||||||
{{#if error}}
|
{{#if error}}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="icon success">
|
<div class="icon success">
|
||||||
<svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
|
{{> icons/check}}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Your link <b>"{{link}}"</b> has been deleted.
|
Your link <b>"{{link}}"</b> has been deleted.
|
||||||
</p>
|
</p>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button hx-on:click="closeDialog()">Close</button>
|
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="content-wrapper"></div>
|
<div class="content-wrapper"></div>
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
{{> icons/spinner}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@ -5,7 +5,7 @@
|
|||||||
<p>{{message}}</p>
|
<p>{{message}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button hx-on:click="closeDialog()">Close</button>
|
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -77,6 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onclick="
|
onclick="
|
||||||
const tr = closest('tr');
|
const tr = closest('tr');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
@ -86,7 +87,7 @@
|
|||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button class="primary">
|
<button type="submit" class="primary">
|
||||||
<span class="reload">
|
<span class="reload">
|
||||||
{{> icons/reload}}
|
{{> icons/reload}}
|
||||||
</span>
|
</span>
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<tr class="loading-placeholder">
|
<tr class="loading-placeholder">
|
||||||
<td>
|
<td>
|
||||||
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
{{> icons/spinner}}
|
||||||
Loading links...
|
Loading links...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
<th class="nav" >
|
<th class="nav" >
|
||||||
<div class="limit">
|
<div class="limit">
|
||||||
<button class="table-nav" onclick="setLinksLimit(event)" disabled="true">10</button>
|
<button type="button" class="nav" onclick="setLinksLimit(event)" disabled="true">10</button>
|
||||||
<button class="table-nav" onclick="setLinksLimit(event)">20</button>
|
<button type="button" class="nav" onclick="setLinksLimit(event)">20</button>
|
||||||
<button class="table-nav" onclick="setLinksLimit(event)">50</button>
|
<button type="button" class="nav" onclick="setLinksLimit(event)">50</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-nav-divider"></div>
|
<div class="nav-divider"></div>
|
||||||
<div id="pagination" class="pagination">
|
<div id="pagination" class="pagination">
|
||||||
<button class="table-nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true">
|
<button type="button" class="nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>
|
{{> icons/chevron_left}}
|
||||||
</button>
|
</button>
|
||||||
<button class="table-nav next" onclick="setLinksSkip(event, 'next')">
|
<button type="button" class="nav next" onclick="setLinksSkip(event, 'next')">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
|
{{> icons/chevron_right}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
@ -14,7 +14,7 @@
|
|||||||
load once,
|
load once,
|
||||||
reloadLinks from:body,
|
reloadLinks from:body,
|
||||||
change from:[name='all'],
|
change from:[name='all'],
|
||||||
click delay:100ms from:button.table-nav,
|
click delay:100ms from:button.nav,
|
||||||
input changed delay:500ms from:[name='search'],
|
input changed delay:500ms from:[name='search'],
|
||||||
"
|
"
|
||||||
hx-on:htmx:after-on-load="updateLinksNav()"
|
hx-on:htmx:after-on-load="updateLinksNav()"
|
||||||
|
@ -5,10 +5,12 @@
|
|||||||
<input id="total" name="total" type="hidden" value="{{total}}" />
|
<input id="total" name="total" type="hidden" value="{{total}}" />
|
||||||
<input id="limit" name="limit" type="hidden" value="10" />
|
<input id="limit" name="limit" type="hidden" value="10" />
|
||||||
<input id="skip" name="skip" type="hidden" value="0" />
|
<input id="skip" name="skip" type="hidden" value="0" />
|
||||||
<label id="all" class="checkbox">
|
{{#if @root.isAdmin}}
|
||||||
<input name="all" type="checkbox" />
|
<label id="all" class="checkbox">
|
||||||
All links
|
<input name="all" type="checkbox" />
|
||||||
</label>
|
All links
|
||||||
|
</label>
|
||||||
|
{{/if}}
|
||||||
</th>
|
</th>
|
||||||
{{> links/nav}}
|
{{> links/nav}}
|
||||||
</tr>
|
</tr>
|
||||||
|
33
server/views/partials/protected/form.hbs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<form
|
||||||
|
id="report-form"
|
||||||
|
hx-post="/api/links/{id}/protected"
|
||||||
|
hx-sync="this:abort"
|
||||||
|
hx-ext="path-params"
|
||||||
|
hx-vals='{"id":"{{id}}"}'
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{{#if message}}
|
||||||
|
<p class="success">{{message}}</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="inputs-wrapper">
|
||||||
|
<label>
|
||||||
|
Password:
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="protected-link-password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Password..."
|
||||||
|
hx-preserve="true"
|
||||||
|
class="{{#if errors.link}}error{{/if}}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="primary">
|
||||||
|
<span>{{> icons/spinner}}</span>
|
||||||
|
<span>{{> icons/key}}</span>
|
||||||
|
Unlock & Go
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{#if error}}<p class="error">{{error}}</p>{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</form>
|
17
server/views/partials/report/email.hbs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<div id="report-email">
|
||||||
|
{{#unless report_email}}
|
||||||
|
<button
|
||||||
|
class="link"
|
||||||
|
hx-get="/get-report-email"
|
||||||
|
hx-sync="this:abort"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-target="#report-email"
|
||||||
|
>
|
||||||
|
<span class="eye-icon">{{> icons/eye}}</span>
|
||||||
|
<span>{{> icons/spinner}}</span>
|
||||||
|
show email address
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
{{report_email}}
|
||||||
|
{{/unless}}
|
||||||
|
</div>
|
30
server/views/partials/report/form.hbs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<form
|
||||||
|
id="report-form"
|
||||||
|
hx-post="/api/links/report"
|
||||||
|
hx-sync="this:abort"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{{#if message}}
|
||||||
|
<p class="success">{{message}}</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="inputs-wrapper">
|
||||||
|
<label>
|
||||||
|
URL containing malware/scam:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="link"
|
||||||
|
name="link"
|
||||||
|
placeholder="{{default_domain}}/example"
|
||||||
|
hx-preserve="true"
|
||||||
|
class="{{#if errors.link}}error{{/if}}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="primary">
|
||||||
|
<span>{{> icons/spinner}}</span>
|
||||||
|
Send report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{#if error}}<p class="error">{{error}}</p>{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</form>
|
30
server/views/partials/reset_password/form.hbs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<form
|
||||||
|
id="reset-password-form"
|
||||||
|
hx-post="/api/auth/reset-password"
|
||||||
|
hx-sync="this:abort"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{{#if message}}
|
||||||
|
<p class="success">{{message}}</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="inputs-wrapper">
|
||||||
|
<label>
|
||||||
|
Email address:
|
||||||
|
<input
|
||||||
|
id="reset-password-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email address..."
|
||||||
|
hx-preserve="true"
|
||||||
|
class="{{#if errors.email}}error{{/if}}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="primary">
|
||||||
|
<span>{{> icons/spinner}}</span>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{#if error}}<p class="error">{{error}}</p>{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</form>
|
@ -12,6 +12,7 @@
|
|||||||
{{#if user.apikey}}
|
{{#if user.apikey}}
|
||||||
<div class="clipboard small">
|
<div class="clipboard small">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
aria-label="Copy"
|
aria-label="Copy"
|
||||||
hx-on:click="handleShortURLCopyLink(this);"
|
hx-on:click="handleShortURLCopyLink(this);"
|
||||||
data-url="{{user.apikey}}"
|
data-url="{{user.apikey}}"
|
||||||
@ -37,7 +38,7 @@
|
|||||||
hx-target="#apikey-wrapper"
|
hx-target="#apikey-wrapper"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<button class="secondary">
|
<button type="button" class="secondary">
|
||||||
<span>{{> icons/zap}}</span>
|
<span>{{> icons/zap}}</span>
|
||||||
<span>{{> icons/spinner}}</span>
|
<span>{{> icons/spinner}}</span>
|
||||||
{{#if user.apikey}}Reg{{else}}G{{/if}}enerate key
|
{{#if user.apikey}}Reg{{else}}G{{/if}}enerate key
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
{{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
|
{{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary" type="submit">
|
<button type="submit" class="primary">
|
||||||
<span>{{> icons/reload}}</span>
|
<span>{{> icons/reload}}</span>
|
||||||
<span>{{> icons/spinner}}</span>
|
<span>{{> icons/spinner}}</span>
|
||||||
Update
|
Update
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
{{#if errors.newpassword}}<p class="error">{{errors.newpassword}}</p>{{/if}}
|
{{#if errors.newpassword}}<p class="error">{{errors.newpassword}}</p>{{/if}}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary" type="submit">
|
<button type="submit" class="primary">
|
||||||
<span>{{> icons/reload}}</span>
|
<span>{{> icons/reload}}</span>
|
||||||
<span>{{> icons/spinner}}</span>
|
<span>{{> icons/spinner}}</span>
|
||||||
Update
|
Update
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
|
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="danger" type="submit">
|
<button type="submit" class="danger">
|
||||||
<span>{{> icons/trash}}</span>
|
<span>{{> icons/trash}}</span>
|
||||||
<span>{{> icons/spinner}}</span>
|
<span>{{> icons/spinner}}</span>
|
||||||
Delete
|
Delete
|
||||||
|
@ -4,8 +4,9 @@
|
|||||||
Are you sure do you want to delete the domain "<b>{{address}}</b>"?
|
Are you sure do you want to delete the domain "<b>{{address}}</b>"?
|
||||||
</p>
|
</p>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button hx-on:click="closeDialog()">Cancel</button>
|
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="danger confirm"
|
class="danger confirm"
|
||||||
hx-delete="/api/domains/{id}"
|
hx-delete="/api/domains/{id}"
|
||||||
hx-ext="path-params"
|
hx-ext="path-params"
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="icon success">
|
<div class="icon success">
|
||||||
<svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
|
{{> icons/check}}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Your domain <b>"{{address}}"</b> has been deleted.
|
Your domain <b>"{{address}}"</b> has been deleted.
|
||||||
</p>
|
</p>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button hx-on:click="closeDialog()">Close</button>
|
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{> settings/domain/table}}
|
{{> settings/domain/table}}
|
@ -2,7 +2,7 @@
|
|||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="content-wrapper"></div>
|
<div class="content-wrapper"></div>
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
{{> icons/spinner}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@ -13,6 +13,7 @@
|
|||||||
{{> settings/domain/table}}
|
{{> settings/domain/table}}
|
||||||
<div class="add-domain-wrapper">
|
<div class="add-domain-wrapper">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="secondary show-domain-form"
|
class="secondary show-domain-form"
|
||||||
hx-indicator=".add-domain-wrapper"
|
hx-indicator=".add-domain-wrapper"
|
||||||
hx-get="/add-domain-form"
|
hx-get="/add-domain-form"
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="action delete"
|
class="action delete"
|
||||||
hx-on:click='openDialog("domain-dialog")'
|
hx-on:click='openDialog("domain-dialog")'
|
||||||
hx-get="/confirm-domain-delete"
|
hx-get="/confirm-domain-delete"
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
{{#if link}}
|
{{#if link}}
|
||||||
<div class="clipboard">
|
<div class="clipboard">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
aria-label="Copy"
|
aria-label="Copy"
|
||||||
hx-on:click="handleShortURLCopyLink(this);"
|
hx-on:click="handleShortURLCopyLink(this);"
|
||||||
data-url="{{url}}"
|
data-url="{{url}}"
|
||||||
@ -43,8 +44,8 @@
|
|||||||
hx-preserve="true"
|
hx-preserve="true"
|
||||||
/>
|
/>
|
||||||
<button class="submit">
|
<button class="submit">
|
||||||
<svg class="send" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="m2 21 21-9L2 3v7l15 2-15 2z"/></svg>
|
{{> icons/send}}
|
||||||
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
{{> icons/spinner}}
|
||||||
</button>
|
</button>
|
||||||
{{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
|
{{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
|
||||||
{{#unless errors}}
|
{{#unless errors}}
|
||||||
|
100
server/views/partials/stats.hbs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
{{#if error}}
|
||||||
|
<div class="stats-error">
|
||||||
|
<p>{{> icons/x}} {{error}}</p>
|
||||||
|
<div class="stats-back-to-home">
|
||||||
|
<a class="back-to-home" href="/">
|
||||||
|
← Back to homepage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="stats-info">
|
||||||
|
<h2>
|
||||||
|
Stats for:
|
||||||
|
<a href="{{link.link.url}}" title="Short link">
|
||||||
|
{{link.link.link}}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<p>{{link.target}}</p>
|
||||||
|
</div>
|
||||||
|
<main id="stats">
|
||||||
|
<div class="stats-head">
|
||||||
|
<p>
|
||||||
|
Total views: <span class="total-number">{{link.visit_count}}</span>
|
||||||
|
</p>
|
||||||
|
<nav class="stats-nav">
|
||||||
|
<button type="button" class="nav" data-period="year" onclick="changeStatsPeriod(event)">Year</button>
|
||||||
|
<button type="button" class="nav" data-period="month" onclick="changeStatsPeriod(event)">Month</button>
|
||||||
|
<button type="button" class="nav" data-period="week" onclick="changeStatsPeriod(event)">Week</button>
|
||||||
|
<button type="button" class="nav" data-period="day" onclick="changeStatsPeriod(event)" disabled="true">Day</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-period">
|
||||||
|
<h2 data-period="day"><span class="total-in-period">{{stats.lastDay.total}}</span> tracked visits in the last day.</h2>
|
||||||
|
<h2 class="hidden" data-period="week"><span class="total-in-period">{{stats.lastWeek.total}}</span> tracked visits in the last week.</h2>
|
||||||
|
<h2 class="hidden" data-period="month"><span class="total-in-period">{{stats.lastMonth.total}}</span> tracked visits in the last month.</h2>
|
||||||
|
<h2 class="hidden" data-period="year"><span class="total-in-period">{{stats.lastYear.total}}</span> tracked visits in the last year.</h2>
|
||||||
|
<p class="last-update">Last update at <span class="last-update-value" data-date="{{stats.updatedAt}}"></span>.</p>
|
||||||
|
<canvas class="visits" height="350" data-period="day" data-data="{{json stats.lastDay.views}}"></canvas>
|
||||||
|
<canvas class="visits hidden" height="350" data-period="week" data-data="{{json stats.lastWeek.views}}"></canvas>
|
||||||
|
<canvas class="visits hidden" height="350" data-period="month" data-data="{{json stats.lastMonth.views}}"></canvas>
|
||||||
|
<canvas class="visits hidden" height="350" data-period="year" data-data="{{json stats.lastYear.views}}"></canvas>
|
||||||
|
<hr />
|
||||||
|
<div class="stats-columns-wrapper">
|
||||||
|
<div>
|
||||||
|
<h2>Referrers.</h2>
|
||||||
|
<canvas class="referrers" height="325" data-period="day" data-data="{{json stats.lastDay.stats.referrer}}"></canvas>
|
||||||
|
<canvas class="referrers hidden" height="325" data-period="week" data-data="{{json stats.lastWeek.stats.referrer}}"></canvas>
|
||||||
|
<canvas class="referrers hidden" height="325" data-period="month" data-data="{{json stats.lastMonth.stats.referrer}}"></canvas>
|
||||||
|
<canvas class="referrers hidden" height="325" data-period="year" data-data="{{json stats.lastYear.stats.referrer}}"></canvas>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Browsers.</h2>
|
||||||
|
<canvas class="browsers" height="350" data-period="day" data-data="{{json stats.lastDay.stats.browser}}"></canvas>
|
||||||
|
<canvas class="browsers hidden" height="350" data-period="week" data-data="{{json stats.lastWeek.stats.browser}}"></canvas>
|
||||||
|
<canvas class="browsers hidden" height="350" data-period="month" data-data="{{json stats.lastMonth.stats.browser}}"></canvas>
|
||||||
|
<canvas class="browsers hidden" height="350" data-period="year" data-data="{{json stats.lastYear.stats.browser}}"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="stats-columns-wrapper">
|
||||||
|
<div>
|
||||||
|
<h2>Countries.</h2>
|
||||||
|
<div id="map-tooltip"></div>
|
||||||
|
<svg
|
||||||
|
class="map"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="world map"
|
||||||
|
viewBox="{{map.viewBox}}"
|
||||||
|
data-day="{{json stats.lastDay.stats.country}}"
|
||||||
|
data-week="{{json stats.lastWeek.stats.country}}"
|
||||||
|
data-month="{{json stats.lastMonth.stats.country}}"
|
||||||
|
data-year="{{json stats.lastYear.stats.country}}"
|
||||||
|
onmouseout="mapTooltipHoverOut()"
|
||||||
|
onmousemove="mapTooltipHoverOver(event)"
|
||||||
|
onpointerdown="mapTooltipHoverOver(event)"
|
||||||
|
onpointerup="mapTooltipHoverOut()"
|
||||||
|
>
|
||||||
|
{{#each map.layers}}
|
||||||
|
<path data-id="{{id}}" aria-label="{{name}}" d="{{d}}"></path>
|
||||||
|
{{/each}}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Operation systems.</h2>
|
||||||
|
<canvas class="os" height="350" data-period="day" data-data="{{json stats.lastDay.stats.os}}"></canvas>
|
||||||
|
<canvas class="os hidden" height="350" data-period="week" data-data="{{json stats.lastWeek.stats.os}}"></canvas>
|
||||||
|
<canvas class="os hidden" height="350" data-period="month" data-data="{{json stats.lastMonth.stats.os}}"></canvas>
|
||||||
|
<canvas class="os hidden" height="350" data-period="year" data-data="{{json stats.lastYear.stats.os}}"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="stats-back-to-home">
|
||||||
|
<a class="back-to-home" href="/">
|
||||||
|
← Back to homepage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
11
server/views/protected.hbs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="protected" class="section-container">
|
||||||
|
<h2>
|
||||||
|
Protected link.
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Enter the password to be redirected to the link.
|
||||||
|
</p>
|
||||||
|
{{> protected/form}}
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
13
server/views/report.hbs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="report" class="section-container">
|
||||||
|
<h2>
|
||||||
|
Report abuse.
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Report abuses, malware and phishing links to the email address below
|
||||||
|
or use the form. We will review as soon as we can.
|
||||||
|
</p>
|
||||||
|
{{> report/email}}
|
||||||
|
{{> report/form}}
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
12
server/views/reset_password.hbs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="reset-password" class="section-container">
|
||||||
|
<h2>
|
||||||
|
Reset password.
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
If you forgot you password you can use the form below to get a reset
|
||||||
|
password link.
|
||||||
|
</p>
|
||||||
|
{{> reset_password/form}}
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
15
server/views/reset_password_result.hbs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="reset-password-token" class="section-container verify-page">
|
||||||
|
{{#if token_verified}}
|
||||||
|
<h2 hx-get="/settings" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/settings">
|
||||||
|
Welcome back. Change your password from the settings page. Redirecting...
|
||||||
|
</h2>
|
||||||
|
{{else}}
|
||||||
|
<h2>
|
||||||
|
{{> icons/x}}
|
||||||
|
Password token is invalid. Please try again.
|
||||||
|
</h2>
|
||||||
|
<a href="/reset-password" title="Reset password">Reset password →</a>
|
||||||
|
{{/if}}
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
@ -1,5 +1,5 @@
|
|||||||
{{> header}}
|
{{> header}}
|
||||||
<section id="settings">
|
<section id="settings" class="section-container">
|
||||||
<h1 class="settings-welcome">
|
<h1 class="settings-welcome">
|
||||||
Welcome, <span>{{user.email}}</span>.
|
Welcome, <span>{{user.email}}</span>.
|
||||||
</h1>
|
</h1>
|
||||||
|
24
server/views/stats.hbs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section
|
||||||
|
id="stats-section"
|
||||||
|
class="section-container"
|
||||||
|
hx-get="/api/links/{id}/stats"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="load once"
|
||||||
|
hx-vals='js:{ id: getQueryParams().id || "" }'
|
||||||
|
hx-ext="path-params"
|
||||||
|
hx-on::after-swap="
|
||||||
|
trimText('.stats-info p', 80);
|
||||||
|
formatDateHour('#stats .last-update-value');
|
||||||
|
createCharts();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="loading-stats">
|
||||||
|
{{> icons/spinner}}
|
||||||
|
Loading stats...
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
||||||
|
{{#extend "scripts"}}
|
||||||
|
<script src="/libs/chart.min.js"></script>
|
||||||
|
{{/extend}}
|
50
server/views/terms.hbs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="terms" class="section-container">
|
||||||
|
<h3>{{default_domain}} Terms of Service</h3>
|
||||||
|
<p>
|
||||||
|
By accessing the website at
|
||||||
|
<a href="https://{{default_domain}}">https://{{default_domain}}</a>, you are agreeing to be bound by these terms of service, all applicable
|
||||||
|
laws and regulations, and agree that you are responsible for compliance
|
||||||
|
with any applicable local laws. If you do not agree with any of these
|
||||||
|
terms, you are prohibited from using or accessing this site. The
|
||||||
|
materials contained in this website are protected by applicable
|
||||||
|
copyright and trademark law.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In no event shall {{site_name}} or its suppliers be
|
||||||
|
liable for any damages (including, without limitation, damages for loss
|
||||||
|
of data or profit, or due to business interruption) arising out of the
|
||||||
|
use or inability to use the materials on
|
||||||
|
{{default_domain}} website, even if
|
||||||
|
{{site_name}} or a {{site_name}}
|
||||||
|
authorized representative has been notified orally or in writing of the
|
||||||
|
possibility of such damage. Because some jurisdictions do not allow
|
||||||
|
limitations on implied warranties, or limitations of liability for
|
||||||
|
consequential or incidental damages, these limitations may not apply to
|
||||||
|
you.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The materials appearing on {{site_name}} website could
|
||||||
|
include technical, typographical, or photographic errors.
|
||||||
|
{{site_name}} does not warrant that any of the
|
||||||
|
materials on its website are accurate, complete or current.
|
||||||
|
{{site_name}} may make changes to the materials
|
||||||
|
contained on its website at any time without notice. However
|
||||||
|
{{site_name}} does not make any commitment to update
|
||||||
|
the materials.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{site_name}} has not reviewed all of the sites linked
|
||||||
|
to its website and is not responsible for the contents of any such
|
||||||
|
linked site. The inclusion of any link does not imply endorsement by
|
||||||
|
{{site_name}} of the site. Use of any such linked
|
||||||
|
website is at the "user's" own risk.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{site_name}} may revise these terms of service for
|
||||||
|
its website at any time without notice. By using this website you are
|
||||||
|
agreeing to be bound by the then current version of these terms of
|
||||||
|
service.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
6
server/views/url_info.hbs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="url-info" class="section-container">
|
||||||
|
<h3>Target for <b>{{link}}</b>:</h3>
|
||||||
|
<p>{{target}}</p>
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
15
server/views/verify.hbs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="verify" class="section-container verify-page">
|
||||||
|
{{#if token_verified}}
|
||||||
|
<h2 hx-get="/" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/">
|
||||||
|
Your account has been verified. Redirecting to homepage...
|
||||||
|
</h2>
|
||||||
|
{{else}}
|
||||||
|
<h2>
|
||||||
|
{{> icons/x}}
|
||||||
|
Invalid verification. Please try again.
|
||||||
|
</h2>
|
||||||
|
<a href="/login" title="Log in or sign up">Log in / sign up →</a>
|
||||||
|
{{/if}}
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
19
server/views/verify_change_email.hbs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{{> header}}
|
||||||
|
<section id="verify-change-email" class="section-container verify-page">
|
||||||
|
{{#if token_verified}}
|
||||||
|
<h2 hx-get="/" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/">
|
||||||
|
Email address is verified. Redirecting to homepage...
|
||||||
|
</h2>
|
||||||
|
{{else}}
|
||||||
|
<h2>
|
||||||
|
{{> icons/x}}
|
||||||
|
Couldn't verify the email address. Please try again.
|
||||||
|
</h2>
|
||||||
|
{{#if user}}
|
||||||
|
<a href="/settings" title="Settings">Settings →</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="/login" title="Log in or sign up">Log in / sign up →</a>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</section>
|
||||||
|
{{> footer}}
|
@ -83,14 +83,19 @@ hr {
|
|||||||
background-color: hsl(200, 20%, 92%);
|
background-color: hsl(200, 20%, 92%);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
span.bold { font-weight: bold; }
|
||||||
|
span.underline { border-bottom: 2px dotted #999; }
|
||||||
|
|
||||||
|
a,
|
||||||
|
button.link {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
border-bottom: 1px dotted transparent;
|
border-bottom: 1px dotted transparent;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.2s ease-out;
|
transition: all 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover,
|
||||||
|
button.link:hover {
|
||||||
border-bottom-color: var(--color-primary);
|
border-bottom-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,9 +196,13 @@ button.success:hover {
|
|||||||
box-shadow: 0 6px 15px var(--button-bg-success-box-shadow-color);
|
box-shadow: 0 6px 15px var(--button-bg-success-box-shadow-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button:disabled,
|
||||||
button:disabled { cursor: default; }
|
button:disabled { cursor: default; }
|
||||||
|
a.button:disabled:hover,
|
||||||
button:disabled:hover { transform: none; }
|
button:disabled:hover { transform: none; }
|
||||||
|
|
||||||
|
a.button svg.with-text,
|
||||||
|
a.button span svg,
|
||||||
button svg.with-text,
|
button svg.with-text,
|
||||||
button span svg {
|
button span svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@ -203,6 +212,7 @@ button span svg {
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action,
|
||||||
button.action {
|
button.action {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@ -210,68 +220,82 @@ button.action {
|
|||||||
box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
|
box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action:disabled,
|
||||||
button.action:disabled {
|
button.action:disabled {
|
||||||
background: none;
|
background: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action svg,
|
||||||
button.action svg {
|
button.action svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.delete,
|
||||||
button.action.delete {
|
button.action.delete {
|
||||||
background: hsl(0, 100%, 96%);
|
background: hsl(0, 100%, 96%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.delete svg,
|
||||||
button.action.delete svg {
|
button.action.delete svg {
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
stroke: hsl(0, 100%, 69%);
|
stroke: hsl(0, 100%, 69%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.edit,
|
||||||
button.action.edit {
|
button.action.edit {
|
||||||
background: hsl(46, 100%, 94%);
|
background: hsl(46, 100%, 94%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.edit svg,
|
||||||
button.action.edit svg {
|
button.action.edit svg {
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
stroke: hsl(46, 90%, 50%);
|
stroke: hsl(46, 90%, 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.qrcode,
|
||||||
button.action.qrcode {
|
button.action.qrcode {
|
||||||
background: hsl(0, 0%, 94%);
|
background: hsl(0, 0%, 94%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.qrcode svg,
|
||||||
button.action.qrcode svg {
|
button.action.qrcode svg {
|
||||||
fill: hsl(0, 0%, 35%);
|
fill: hsl(0, 0%, 35%);
|
||||||
stroke: none;
|
stroke: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.stats,
|
||||||
button.action.stats {
|
button.action.stats {
|
||||||
background: hsl(260, 100%, 96%);
|
background: hsl(260, 100%, 96%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.stats svg,
|
||||||
button.action.stats svg {
|
button.action.stats svg {
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
stroke: hsl(260, 100%, 69%);
|
stroke: hsl(260, 100%, 69%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.ban,
|
||||||
button.action.ban {
|
button.action.ban {
|
||||||
background: hsl(10, 100%, 96%);
|
background: hsl(10, 100%, 96%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.ban svg,
|
||||||
button.action.ban svg {
|
button.action.ban svg {
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
stroke: hsl(10, 100%, 40%);
|
stroke: hsl(10, 100%, 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.button.action.password sv,
|
||||||
button.action.password svg,
|
button.action.password svg,
|
||||||
|
a.button.action.banned svg,
|
||||||
button.action.banned svg {
|
button.action.banned svg {
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
stroke: #bbb;
|
stroke: #bbb;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.table-nav {
|
button.nav {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@ -290,7 +314,7 @@ button.table-nav {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.table-nav:disabled {
|
button.nav:disabled {
|
||||||
background-color: #f6f6f6;
|
background-color: #f6f6f6;
|
||||||
box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
|
box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
@ -298,15 +322,47 @@ button.table-nav:disabled {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.table-nav svg {
|
button.nav svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.table-nav svg { stroke-width: 2.5; }
|
button.nav svg { stroke-width: 2.5; }
|
||||||
|
|
||||||
button.table-nav:hover { transform: translateY(-2px); }
|
button.nav:hover { transform: translateY(-2px); }
|
||||||
button.table-nav:disabled:hover { transform: none; }
|
button.nav:disabled:hover { transform: none; }
|
||||||
|
|
||||||
|
button.link {
|
||||||
|
position: relative;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0 0 2px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: normal;
|
||||||
|
border-radius: 0;
|
||||||
|
text-align: left;
|
||||||
|
line-height: normal;
|
||||||
|
word-break: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.link:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.link span {
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.link svg {
|
||||||
|
stroke: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
svg.spinner {
|
svg.spinner {
|
||||||
animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
|
animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
|
||||||
@ -459,6 +515,8 @@ label.checkbox input[type="checkbox"] {
|
|||||||
|
|
||||||
p.error,
|
p.error,
|
||||||
p.success {
|
p.success {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
animation: fadein 0.3s ease-in-out;
|
animation: fadein 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
@ -755,6 +813,15 @@ table tr.loading-placeholder td {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-container {
|
||||||
|
max-width: 90%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* LOGIN & SIGNUP */
|
/* LOGIN & SIGNUP */
|
||||||
|
|
||||||
form#login-signup {
|
form#login-signup {
|
||||||
@ -797,8 +864,8 @@ form#login-signup a.forgot-password {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form#login-signup svg.spinner { display: none; }
|
form#login-signup svg.spinner { display: none; }
|
||||||
|
form#login-signup.htmx-request:not(.signup) .login svg { display: none; }
|
||||||
form#login-signup.htmx-request:not(.signup) .login svg.spinner { display: block; }
|
form#login-signup.htmx-request:not(.signup) .login svg.spinner { display: block; }
|
||||||
form#login-signup.htmx-request:not(.signup) .login svg.icon { display: none; }
|
|
||||||
form#login-signup.htmx-request.signup .signup svg.spinner { display: block; }
|
form#login-signup.htmx-request.signup .signup svg.spinner { display: block; }
|
||||||
form#login-signup.htmx-request.signup .signup svg.icon { display: none; }
|
form#login-signup.htmx-request.signup .signup svg.icon { display: none; }
|
||||||
form#login-signup.htmx-request .error { opacity: 0; }
|
form#login-signup.htmx-request .error { opacity: 0; }
|
||||||
@ -1158,7 +1225,9 @@ main form label#advanced input {
|
|||||||
#links-table-wrapper table .short-link { flex: 3 3 0; }
|
#links-table-wrapper table .short-link { flex: 3 3 0; }
|
||||||
#links-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; }
|
#links-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; }
|
||||||
#links-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
|
#links-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
|
||||||
|
#links-table-wrapper table .actions a.button,
|
||||||
#links-table-wrapper table .actions button { margin-right: 0.5rem; }
|
#links-table-wrapper table .actions button { margin-right: 0.5rem; }
|
||||||
|
#links-table-wrapper table .actions a.button:last-child,
|
||||||
#links-table-wrapper table .actions button:last-child { margin-right: 0; }
|
#links-table-wrapper table .actions button:last-child { margin-right: 0; }
|
||||||
|
|
||||||
#links-table-wrapper table td.original-url,
|
#links-table-wrapper table td.original-url,
|
||||||
@ -1231,10 +1300,10 @@ main form label#advanced input {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#links-table-wrapper table button.table-nav { margin-right: 0.75rem; }
|
#links-table-wrapper table button.nav { margin-right: 0.75rem; }
|
||||||
#links-table-wrapper table button.table-nav:last-child { margin-right: 0; }
|
#links-table-wrapper table button.nav:last-child { margin-right: 0; }
|
||||||
|
|
||||||
#links-table-wrapper table .table-nav-divider {
|
#links-table-wrapper table .nav-divider {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
@ -1346,116 +1415,6 @@ main form label#advanced input {
|
|||||||
.dialog .ban-checklist label { margin: 1rem 1.5rem 1rem 0; }
|
.dialog .ban-checklist label { margin: 1rem 1.5rem 1rem 0; }
|
||||||
.dialog .ban-checklist label:last-child { margin-right: 0; }
|
.dialog .ban-checklist label:last-child { margin-right: 0; }
|
||||||
|
|
||||||
/* SETTINGS */
|
|
||||||
|
|
||||||
#settings {
|
|
||||||
width: 600px;
|
|
||||||
max-width: 90%;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1.settings-welcome {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1.settings-welcome span {
|
|
||||||
border-bottom: 2px dotted #999;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SETTINGS - DOMAIN */
|
|
||||||
|
|
||||||
#domains-table { margin-top: 1rem; }
|
|
||||||
#domains-table .domain { flex: 2 2 0; }
|
|
||||||
#domains-table .homepage { flex: 2 2 0; }
|
|
||||||
#domains-table .actions { flex: 1 1 0; justify-content: flex-end; padding-right: 1rem; }
|
|
||||||
#domains-table .no-entry { flex: 1 1 0; opacity: 0.8; }
|
|
||||||
|
|
||||||
.add-domain-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin: 1.5rem 0 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-domain-wrapper > .spinner {
|
|
||||||
width: 20px;
|
|
||||||
display: none;
|
|
||||||
margin: 1rem 0 0 1rem;
|
|
||||||
}
|
|
||||||
.add-domain-wrapper.htmx-request > button { display: none; }
|
|
||||||
.add-domain-wrapper.htmx-request > .spinner { display: block; }
|
|
||||||
|
|
||||||
form#add-domain { margin-top: 1rem; }
|
|
||||||
form#add-domain .buttons-wrapper { display: flex; }
|
|
||||||
form#add-domain button { margin-right: 1rem }
|
|
||||||
form#add-domain .spinner { width: 20px; display: none; }
|
|
||||||
form#add-domain.htmx-request .buttons-wrapper { display: none; }
|
|
||||||
form#add-domain.htmx-request .spinner { display: block; }
|
|
||||||
form#add-domain .error { font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* SETTINGS - API */
|
|
||||||
|
|
||||||
#apikey-wrapper { margin-bottom: 1.5rem; }
|
|
||||||
|
|
||||||
#apikey {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#apikey p {
|
|
||||||
font-weight: bold;
|
|
||||||
border-bottom: 1px dotted #999;
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#apikey p:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
form#generate-apikey .spinner { display: none; }
|
|
||||||
form#generate-apikey.htmx-request svg { display: none; }
|
|
||||||
form#generate-apikey.htmx-request .spinner { display: block; }
|
|
||||||
|
|
||||||
/* SETTINGS - CHANGE PASSWORD */
|
|
||||||
|
|
||||||
#change-password-wrapper { margin-bottom: 1.5rem; }
|
|
||||||
|
|
||||||
form#change-password { margin-top: 1.5rem; }
|
|
||||||
form#change-password button { margin-top: 1rem; }
|
|
||||||
form#change-password .spinner { display: none; }
|
|
||||||
form#change-password.htmx-request svg { display: none; }
|
|
||||||
form#change-password.htmx-request .spinner { display: block; }
|
|
||||||
|
|
||||||
/* SETTINGS - CHANGE EMAIL */
|
|
||||||
|
|
||||||
#change-email-wrapper { margin-bottom: 1.5rem; }
|
|
||||||
|
|
||||||
form#change-email { margin-top: 1.5rem; }
|
|
||||||
form#change-email button { margin-top: 1rem; }
|
|
||||||
form#change-email .spinner { display: none; }
|
|
||||||
form#change-email.htmx-request svg { display: none; }
|
|
||||||
form#change-email.htmx-request .spinner { display: block; }
|
|
||||||
|
|
||||||
|
|
||||||
/* SETTINGS - DELETE ACCOUNT */
|
|
||||||
|
|
||||||
#delete-account-wrapper { margin-bottom: 1.5rem; }
|
|
||||||
|
|
||||||
form#delete-account { margin-top: 1.5rem; }
|
|
||||||
form#delete-account button { margin-top: 1rem; }
|
|
||||||
form#delete-account .spinner { display: none; }
|
|
||||||
form#delete-account.htmx-request svg { display: none; }
|
|
||||||
form#delete-account.htmx-request .spinner { display: block; }
|
|
||||||
|
|
||||||
/* INTRO */
|
/* INTRO */
|
||||||
|
|
||||||
@ -1632,4 +1591,379 @@ footer {
|
|||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SETTINGS */
|
||||||
|
|
||||||
|
#settings {
|
||||||
|
width: 600px;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1.settings-welcome {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1.settings-welcome span {
|
||||||
|
border-bottom: 2px dotted #999;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SETTINGS - DOMAIN */
|
||||||
|
|
||||||
|
#domains-table { margin-top: 1rem; }
|
||||||
|
#domains-table .domain { flex: 2 2 0; }
|
||||||
|
#domains-table .homepage { flex: 2 2 0; }
|
||||||
|
#domains-table .actions { flex: 1 1 0; justify-content: flex-end; padding-right: 1rem; }
|
||||||
|
#domains-table .no-entry { flex: 1 1 0; opacity: 0.8; }
|
||||||
|
|
||||||
|
.add-domain-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 1.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-domain-wrapper > .spinner {
|
||||||
|
width: 20px;
|
||||||
|
display: none;
|
||||||
|
margin: 1rem 0 0 1rem;
|
||||||
|
}
|
||||||
|
.add-domain-wrapper.htmx-request > button { display: none; }
|
||||||
|
.add-domain-wrapper.htmx-request > .spinner { display: block; }
|
||||||
|
|
||||||
|
form#add-domain { margin-top: 1rem; }
|
||||||
|
form#add-domain .buttons-wrapper { display: flex; }
|
||||||
|
form#add-domain button { margin-right: 1rem }
|
||||||
|
form#add-domain .spinner { width: 20px; display: none; }
|
||||||
|
form#add-domain.htmx-request .buttons-wrapper { display: none; }
|
||||||
|
form#add-domain.htmx-request .spinner { display: block; }
|
||||||
|
form#add-domain .error { font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* SETTINGS - API */
|
||||||
|
|
||||||
|
#apikey-wrapper { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
#apikey {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#apikey p {
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px dotted #999;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#apikey p:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
form#generate-apikey .spinner { display: none; }
|
||||||
|
form#generate-apikey.htmx-request svg { display: none; }
|
||||||
|
form#generate-apikey.htmx-request .spinner { display: block; }
|
||||||
|
|
||||||
|
/* SETTINGS - CHANGE PASSWORD */
|
||||||
|
|
||||||
|
#change-password-wrapper { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
form#change-password { margin-top: 1.5rem; }
|
||||||
|
form#change-password button { margin-top: 1rem; }
|
||||||
|
form#change-password .spinner { display: none; }
|
||||||
|
form#change-password.htmx-request svg { display: none; }
|
||||||
|
form#change-password.htmx-request .spinner { display: block; }
|
||||||
|
|
||||||
|
/* SETTINGS - CHANGE EMAIL */
|
||||||
|
|
||||||
|
#change-email-wrapper { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
form#change-email { margin-top: 1.5rem; }
|
||||||
|
form#change-email button { margin-top: 1rem; }
|
||||||
|
form#change-email .spinner { display: none; }
|
||||||
|
form#change-email.htmx-request svg { display: none; }
|
||||||
|
form#change-email.htmx-request .spinner { display: block; }
|
||||||
|
|
||||||
|
|
||||||
|
/* SETTINGS - DELETE ACCOUNT */
|
||||||
|
|
||||||
|
#delete-account-wrapper { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
form#delete-account { margin-top: 1.5rem; }
|
||||||
|
form#delete-account button { margin-top: 1rem; }
|
||||||
|
form#delete-account .spinner { display: none; }
|
||||||
|
form#delete-account.htmx-request svg { display: none; }
|
||||||
|
form#delete-account.htmx-request .spinner { display: block; }
|
||||||
|
|
||||||
|
/* STATS */
|
||||||
|
|
||||||
|
#stats-section {
|
||||||
|
width: 1200px;
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-stats {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 0;
|
||||||
|
margin-top: -5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.loading-stats .spinner {
|
||||||
|
width: 1.25rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-info {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-info h2 { font-weight: 300; font-size: 24px; }
|
||||||
|
.stats-info p { font-size: 14px; }
|
||||||
|
.stats-info h2,
|
||||||
|
.stats-info p { margin: 0 }
|
||||||
|
|
||||||
|
#stats {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 6px 15px hsla(200, 20%, 70%, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-head {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: hsl(200, 12%, 95%);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-number { font-weight: bold; }
|
||||||
|
|
||||||
|
.stats-nav { display: flex; align-items: center; }
|
||||||
|
|
||||||
|
.stats-nav button { margin-right: 0.75rem; }
|
||||||
|
.stats-nav button:last-child { margin-right: 0; }
|
||||||
|
|
||||||
|
.stats-period {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-period h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 300;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-period span.total-in-period {
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px dotted hsl(200, 35%, 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
p.last-update {
|
||||||
|
font-size: 14px;
|
||||||
|
color: hsl(200, 14%, 60%);
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stats canvas {
|
||||||
|
width: 100%;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-columns-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-columns-wrapper > div {
|
||||||
|
flex: 1 1 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.map path {
|
||||||
|
fill: hsl(200, 15%, 92%);
|
||||||
|
stroke: #fff;
|
||||||
|
transition: all 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.map path.color-1 { fill: hsl(261, 46%, 90%); }
|
||||||
|
svg.map path.color-2 { fill: hsl(261, 46%, 86%); }
|
||||||
|
svg.map path.color-3 { fill: hsl(261, 46%, 82%); }
|
||||||
|
svg.map path.color-4 { fill: hsl(261, 46%, 76%); }
|
||||||
|
svg.map path.color-5 { fill: hsl(261, 46%, 72%); }
|
||||||
|
svg.map path.color-6 { fill: hsl(261, 46%, 68%); }
|
||||||
|
svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; }
|
||||||
|
|
||||||
|
#map-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map-tooltip.visible::before,
|
||||||
|
#map-tooltip.visible::after {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map-tooltip:before {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
#map-tooltip:after {
|
||||||
|
box-shadow: 0 1em 2em -0.5em rgba(0, 0, 0, 0.15);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-back-to-home {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-error {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-error p { margin-top: -3rem; display: flex; align-items: center; }
|
||||||
|
.stats-error p svg { width: 1.2rem; margin: 0 0.5rem 0.1rem 0; }
|
||||||
|
.stats-error .stats-back-to-home { margin-top: 0 }
|
||||||
|
|
||||||
|
/* 404 - NOT FOUND */
|
||||||
|
|
||||||
|
#notfound {
|
||||||
|
width: 800px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notfound h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BANNED */
|
||||||
|
|
||||||
|
#banned { width: 1200px; align-items: center; }
|
||||||
|
#banned h2 { font-weight: normal; }
|
||||||
|
#banned h4 { font-weight: normal; margin: 0; }
|
||||||
|
|
||||||
|
/* REPORT */
|
||||||
|
|
||||||
|
#report { width: 600px; }
|
||||||
|
|
||||||
|
#report form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#report form .inputs-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#report form button { margin: 0 0 0.2rem 1rem; }
|
||||||
|
#report form .spinner { display: none; }
|
||||||
|
#report form.htmx-request svg { display: none; }
|
||||||
|
#report form.htmx-request .spinner { display: block; }
|
||||||
|
|
||||||
|
#report-email .spinner { display: none; }
|
||||||
|
#report-email .htmx-request svg { display: none; }
|
||||||
|
#report-email .htmx-request .spinner { display: block; }
|
||||||
|
|
||||||
|
.eye-icon svg { stroke-width: 0.5; }
|
||||||
|
|
||||||
|
/* RESET PASSWORD */
|
||||||
|
|
||||||
|
#reset-password form {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reset-password form .inputs-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: 2rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#reset-password form label { flex: 0 0 280px; }
|
||||||
|
#reset-password form label input { width: 100%; }
|
||||||
|
#reset-password form button { margin: 0 0 0.2rem 1rem; }
|
||||||
|
#reset-password .spinner { display: none; }
|
||||||
|
#reset-password .htmx-request svg { display: none; }
|
||||||
|
#reset-password .htmx-request .spinner { display: block; }
|
||||||
|
|
||||||
|
/* VERIFY USER */
|
||||||
|
/* VERIFY CHANGE EMAIL */
|
||||||
|
/* RESET PASSWORD TOKEN */
|
||||||
|
|
||||||
|
.verify-page {
|
||||||
|
width: 600px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-page h2,
|
||||||
|
.verify-page h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-page h2 svg,
|
||||||
|
.verify-page h3 svg {
|
||||||
|
width: 1.15em;
|
||||||
|
height: auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL INFO */
|
||||||
|
|
||||||
|
#url-info {
|
||||||
|
width: 1200px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#url-info h3 { font-weight: normal; margin: 0; }
|
||||||
|
|
||||||
|
/* PROTECTED */
|
||||||
|
|
||||||
|
#protected { width: 600px; }
|
||||||
|
|
||||||
|
#protected form { width: 100%; margin-top: 1rem; }
|
||||||
|
#protected form .inputs-wrapper { width: 100%; display: flex; align-items: flex-end; }
|
||||||
|
#protected form label { flex: 0 0 280px; }
|
||||||
|
#protected form label input { width: 100%; }
|
||||||
|
#protected form button { margin: 0 0 0.2rem 1rem; }
|
||||||
|
#protected form .spinner { display: none; }
|
||||||
|
#protected form.htmx-request svg { display: none; }
|
||||||
|
#protected form.htmx-request .spinner { display: block; }
|
||||||
|
|
||||||
|
/* TERMS */
|
||||||
|
|
||||||
|
#terms { width: 600px; }
|
20
static/libs/chart.min.js
vendored
Normal file
@ -52,6 +52,38 @@ function closest(selector, elm) {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// get url query param
|
||||||
|
function getQueryParams() {
|
||||||
|
const search = window.location.search.replace("?", "");
|
||||||
|
const query = {};
|
||||||
|
search.split("&").map(q => {
|
||||||
|
const keyvalue = q.split("=");
|
||||||
|
query[keyvalue[0]] = keyvalue[1];
|
||||||
|
});
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim text
|
||||||
|
function trimText(selector, length) {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!element) return;
|
||||||
|
let text = element.textContent;
|
||||||
|
if (typeof text !== "string") return;
|
||||||
|
text = text.trim();
|
||||||
|
if (text.length > length) {
|
||||||
|
element.textContent = text.split("").slice(0, length).join("") + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateHour(selector) {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!element) return;
|
||||||
|
const dateString = element.dataset.date;
|
||||||
|
if (!dateString) return;
|
||||||
|
const date = new Date(dateString);
|
||||||
|
element.textContent = date.getHours() + ":" + date.getMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
// show QR code
|
// show QR code
|
||||||
function handleQRCode(element) {
|
function handleQRCode(element) {
|
||||||
const dialog = document.querySelector("#link-dialog");
|
const dialog = document.querySelector("#link-dialog");
|
||||||
@ -175,4 +207,450 @@ function resetLinkNav() {
|
|||||||
document.querySelectorAll('table .nav .limit button').forEach(b => {
|
document.querySelectorAll('table .nav .limit button').forEach(b => {
|
||||||
b.disabled = b.textContent === limit.toString();
|
b.disabled = b.textContent === limit.toString();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// create views chart label
|
||||||
|
function createViewsChartLabel(ctx) {
|
||||||
|
const period = ctx.dataset.period;
|
||||||
|
let labels = [];
|
||||||
|
|
||||||
|
if (period === "day") {
|
||||||
|
const nowHour = new Date().getHours();
|
||||||
|
for (let i = 23; i >= 0; --i) {
|
||||||
|
let h = nowHour - i;
|
||||||
|
if (h < 0) h = 24 + h;
|
||||||
|
labels.push(`${Math.floor(h)}:00`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === "week") {
|
||||||
|
const nowDay = new Date().getDate();
|
||||||
|
for (let i = 6; i >= 0; --i) {
|
||||||
|
const date = new Date(new Date().setDate(nowDay - i));
|
||||||
|
labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === "month") {
|
||||||
|
const nowDay = new Date().getDate();
|
||||||
|
for (let i = 29; i >= 0; --i) {
|
||||||
|
const date = new Date(new Date().setDate(nowDay - i));
|
||||||
|
labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === "year") {
|
||||||
|
const nowMonth = new Date().getMonth();
|
||||||
|
for (let i = 11; i >= 0; --i) {
|
||||||
|
const date = new Date(new Date().setMonth(nowMonth - i));
|
||||||
|
labels.push(`${date.toLocaleString("default",{month:"short"})} ${date.toLocaleString("default",{year:"numeric"})}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create views chart
|
||||||
|
function createViewsChart() {
|
||||||
|
const canvases = document.querySelectorAll("canvas.visits");
|
||||||
|
if (!canvases || !canvases.length) return;
|
||||||
|
|
||||||
|
canvases.forEach(ctx => {
|
||||||
|
const data = JSON.parse(ctx.dataset.data);
|
||||||
|
const period = ctx.dataset.period;
|
||||||
|
|
||||||
|
const labels = createViewsChartLabel(ctx);
|
||||||
|
const maxTicksLimitX = period === "year" ? 6 : period === "month" ? 15 : 12;
|
||||||
|
|
||||||
|
const gradient = ctx.getContext("2d").createLinearGradient(0, 0, 0, 300);
|
||||||
|
gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
|
||||||
|
gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: "Views",
|
||||||
|
data,
|
||||||
|
tension: 0.3,
|
||||||
|
|
||||||
|
elements: {
|
||||||
|
point: {
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
target: "start",
|
||||||
|
},
|
||||||
|
backgroundColor: gradient,
|
||||||
|
borderColor: "rgb(179, 157, 219)",
|
||||||
|
borderWidth: 1,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||||
|
titleColor: "#333",
|
||||||
|
titleFont: { weight: "normal", size: 15 },
|
||||||
|
bodyFont: { weight: "normal", size: 16 },
|
||||||
|
bodyColor: "rgb(179, 157, 219)",
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 2,
|
||||||
|
borderColor: "rgba(0, 0, 0, 0.1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
displayColors: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
usePointStyle: true,
|
||||||
|
mode: "index",
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
grace: "10%",
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
maxTicksLimit: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
maxTicksLimit: maxTicksLimitX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset the display: block style that chart.js applies automatically
|
||||||
|
ctx.style.display = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// beautify browser lables
|
||||||
|
function beautifyBrowserName(name) {
|
||||||
|
if (name === "firefox") return "Firefox";
|
||||||
|
if (name === "chrome") return "Chrome";
|
||||||
|
if (name === "edge") return "Edge";
|
||||||
|
if (name === "opera") return "Opera";
|
||||||
|
if (name === "safari") return "Safari";
|
||||||
|
if (name === "other") return "Other";
|
||||||
|
if (name === "ie") return "IE";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create browsers chart
|
||||||
|
function createBrowsersChart() {
|
||||||
|
const canvases = document.querySelectorAll("canvas.browsers");
|
||||||
|
if (!canvases || !canvases.length) return;
|
||||||
|
|
||||||
|
canvases.forEach(ctx => {
|
||||||
|
const data = JSON.parse(ctx.dataset.data);
|
||||||
|
const period = ctx.dataset.period;
|
||||||
|
|
||||||
|
const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
|
||||||
|
const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
|
||||||
|
gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
|
||||||
|
gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
|
||||||
|
gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");
|
||||||
|
gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => beautifyBrowserName(d.name)),
|
||||||
|
datasets: [{
|
||||||
|
label: "Views",
|
||||||
|
data: data.map(d => d.value),
|
||||||
|
backgroundColor: gradient,
|
||||||
|
borderColor: "rgba(179, 157, 219, 1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
hoverBackgroundColor: gradientHover,
|
||||||
|
hoverBorderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: "y",
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||||
|
titleColor: "#333",
|
||||||
|
titleFont: { weight: "normal", size: 15 },
|
||||||
|
bodyFont: { weight: "normal", size: 16 },
|
||||||
|
bodyColor: "rgb(179, 157, 219)",
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 2,
|
||||||
|
borderColor: "rgba(0, 0, 0, 0.1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
displayColors: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: "index",
|
||||||
|
axis: "y"
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grace: "5%",
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
maxTicksLimit: 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset the display: block style that chart.js applies automatically
|
||||||
|
ctx.style.display = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// create referrers chart
|
||||||
|
function createReferrersChart() {
|
||||||
|
const canvases = document.querySelectorAll("canvas.referrers");
|
||||||
|
if (!canvases || !canvases.length) return;
|
||||||
|
|
||||||
|
canvases.forEach(ctx => {
|
||||||
|
const data = JSON.parse(ctx.dataset.data);
|
||||||
|
const period = ctx.dataset.period;
|
||||||
|
let max = Array.from(data).sort((a, b) => a.value > b.value ? -1 : 1)[0];
|
||||||
|
|
||||||
|
let tooltipEnabled = true;
|
||||||
|
let hoverBackgroundColor = "rgba(179, 157, 219, 1)";
|
||||||
|
let hoverBorderWidth = 2;
|
||||||
|
let borderColor = "rgba(179, 157, 219, 1)";
|
||||||
|
if (data.length === 0) {
|
||||||
|
data.push({ name: "No views.", value: 1 });
|
||||||
|
max = { value: 1000 };
|
||||||
|
tooltipEnabled = false;
|
||||||
|
hoverBackgroundColor = "rgba(179, 157, 219, 0.1)";
|
||||||
|
hoverBorderWidth = 1;
|
||||||
|
borderColor = "rgba(179, 157, 219, 0.2)";
|
||||||
|
}
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: "doughnut",
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => d.name.replace(/\[dot\]/g, ".")),
|
||||||
|
datasets: [{
|
||||||
|
label: "Views",
|
||||||
|
data: data.map(d => d.value),
|
||||||
|
backgroundColor: data.map(d => `rgba(179, 157, 219, ${Math.max((d.value / max.value) - 0.2, 0.1).toFixed(2)})`),
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor,
|
||||||
|
hoverBackgroundColor,
|
||||||
|
hoverBorderWidth,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "left",
|
||||||
|
labels: {
|
||||||
|
boxWidth: 25,
|
||||||
|
font: { size: 11 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: tooltipEnabled,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||||
|
titleColor: "#333",
|
||||||
|
titleFont: { weight: "normal", size: 15 },
|
||||||
|
bodyFont: { weight: "normal", size: 16 },
|
||||||
|
bodyColor: "rgb(179, 157, 219)",
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 2,
|
||||||
|
borderColor: "rgba(0, 0, 0, 0.1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
displayColors: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responsive: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset the display: block style that chart.js applies automatically
|
||||||
|
ctx.style.display = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// beautify browser lables
|
||||||
|
function beautifyOsName(name) {
|
||||||
|
if (name === "android") return "Android";
|
||||||
|
if (name === "ios") return "iOS";
|
||||||
|
if (name === "linux") return "Linux";
|
||||||
|
if (name === "macos") return "macOS";
|
||||||
|
if (name === "windows") return "Windows";
|
||||||
|
if (name === "other") return "Other";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// create operation systems chart
|
||||||
|
function createOsChart() {
|
||||||
|
const canvases = document.querySelectorAll("canvas.os");
|
||||||
|
if (!canvases || !canvases.length) return;
|
||||||
|
|
||||||
|
canvases.forEach(ctx => {
|
||||||
|
const data = JSON.parse(ctx.dataset.data);
|
||||||
|
const period = ctx.dataset.period;
|
||||||
|
|
||||||
|
const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
|
||||||
|
const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
|
||||||
|
gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
|
||||||
|
gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
|
||||||
|
gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");
|
||||||
|
gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => beautifyOsName(d.name)),
|
||||||
|
datasets: [{
|
||||||
|
label: "Views",
|
||||||
|
data: data.map(d => d.value),
|
||||||
|
backgroundColor: gradient,
|
||||||
|
borderColor: "rgba(179, 157, 219, 1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
hoverBackgroundColor: gradientHover,
|
||||||
|
hoverBorderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: "y",
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||||
|
titleColor: "#333",
|
||||||
|
titleFont: { weight: "normal", size: 15 },
|
||||||
|
bodyFont: { weight: "normal", size: 16 },
|
||||||
|
bodyColor: "rgb(179, 157, 219)",
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 2,
|
||||||
|
borderColor: "rgba(0, 0, 0, 0.1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
displayColors: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: "index",
|
||||||
|
axis: "y"
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grace:"5%",
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
maxTicksLimit: 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset the display: block style that chart.js applies automatically
|
||||||
|
ctx.style.display = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// add data to the map
|
||||||
|
function feedMapData(period) {
|
||||||
|
const map = document.querySelector("svg.map");
|
||||||
|
const paths = map.querySelectorAll("path");
|
||||||
|
if (!map || !paths || !paths.length) return;
|
||||||
|
|
||||||
|
let data = JSON.parse(map.dataset[period || "day"]);
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
let max = data.sort((a, b) => a.value > b.value ? -1 : 1)[0];
|
||||||
|
|
||||||
|
if (!max) max = { value: 1 }
|
||||||
|
|
||||||
|
data = data.reduce((a, c) => ({ ...a, [c.name]: c.value }), {});
|
||||||
|
|
||||||
|
for (let i = 0; i < paths.length; ++i) {
|
||||||
|
const id = paths[i].dataset.id;
|
||||||
|
const views = data[id] || 0;
|
||||||
|
paths[i].dataset.views = views;
|
||||||
|
const colorLevel = Math.ceil((views / max.value) * 6);
|
||||||
|
const classList = paths[i].classList;
|
||||||
|
for (let j = 1; j < 7; j++) {
|
||||||
|
paths[i].classList.remove(`color-${j}`);
|
||||||
|
}
|
||||||
|
paths[i].classList.add(`color-${colorLevel}`)
|
||||||
|
paths[i].dataset.views = views;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle map tooltip hover
|
||||||
|
function mapTooltipHoverOver() {
|
||||||
|
const tooltip = document.querySelector("#map-tooltip");
|
||||||
|
if (!tooltip) return;
|
||||||
|
if (!event.target.dataset.id) return mapTooltipHoverOut();
|
||||||
|
if (!tooltip.classList.contains("active")) {
|
||||||
|
tooltip.classList.add("visible");
|
||||||
|
}
|
||||||
|
tooltip.dataset.tooltip = `${event.target.ariaLabel}: ${event.target.dataset.views || 0}`;
|
||||||
|
const rect = event.target.getBoundingClientRect();
|
||||||
|
tooltip.style.top = rect.top + (rect.height / 2) + "px";
|
||||||
|
tooltip.style.left = rect.left + (rect.width / 2) + "px";
|
||||||
|
event.target.classList.add("active");
|
||||||
|
}
|
||||||
|
function mapTooltipHoverOut() {
|
||||||
|
const tooltip = document.querySelector("#map-tooltip");
|
||||||
|
const map = document.querySelector("svg.map");
|
||||||
|
const paths = map.querySelectorAll("path");
|
||||||
|
if (!tooltip || !map) return;
|
||||||
|
tooltip.classList.remove("visible");
|
||||||
|
for (let i = 0; i < paths.length; ++i) {
|
||||||
|
paths[i].classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create stats charts
|
||||||
|
function createCharts() {
|
||||||
|
createViewsChart();
|
||||||
|
createBrowsersChart();
|
||||||
|
createReferrersChart();
|
||||||
|
createOsChart();
|
||||||
|
feedMapData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// change stats period for showing charts and data
|
||||||
|
function changeStatsPeriod(event) {
|
||||||
|
const period = event.target.dataset.period;
|
||||||
|
if (!period) return;
|
||||||
|
const canvases = document.querySelector("#stats").querySelectorAll("[data-period]");
|
||||||
|
const buttons = document.querySelector("#stats").querySelectorAll(".nav");
|
||||||
|
if (!buttons || !canvases) return;
|
||||||
|
buttons.forEach(b => b.disabled = false);
|
||||||
|
event.target.disabled = true;
|
||||||
|
canvases.forEach(canvas => {
|
||||||
|
if (canvas.dataset.period === period) {
|
||||||
|
canvas.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
canvas.classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
feedMapData(period);
|
||||||
}
|
}
|