htmx almost done
@ -444,9 +444,6 @@ export default {
|
||||
Stats: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allTime: {
|
||||
$ref: "#/components/schemas/StatsItem"
|
||||
},
|
||||
lastDay: {
|
||||
$ref: "#/components/schemas/StatsItem"
|
||||
},
|
||||
@ -456,6 +453,9 @@ export default {
|
||||
lastWeek: {
|
||||
$ref: "#/components/schemas/StatsItem"
|
||||
},
|
||||
lastYear: {
|
||||
$ref: "#/components/schemas/StatsItem"
|
||||
},
|
||||
updatedAt: {
|
||||
type: "string"
|
||||
},
|
||||
|
69
package-lock.json
generated
@ -12,7 +12,8 @@
|
||||
"app-root-path": "^3.1.0",
|
||||
"axios": "^1.1.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bull": "^4.10.1",
|
||||
"bull": "^4.16.2",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^7.0.3",
|
||||
@ -3027,9 +3028,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bull": {
|
||||
"version": "4.16.0",
|
||||
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.0.tgz",
|
||||
"integrity": "sha512-dgHRLULPexLkpm9wP/7F7Vlf2fdvmffdwhv3Bqu5lFhO+XDDJ4yGqlTPE61Jj1zM8CgchLmJEgIfe7y69jtuOg==",
|
||||
"version": "4.16.2",
|
||||
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.2.tgz",
|
||||
"integrity": "sha512-VCy33UdPGiIoZHDTrslGXKXWxcIUHNH5Z82pihr8HicbIfAH4SHug1HxlwKEbibVv85hq8rJ9tKAW/cuxv2T0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "^4.2.1",
|
||||
@ -3410,6 +3411,66 @@
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
@ -32,7 +32,8 @@
|
||||
"app-root-path": "^3.1.0",
|
||||
"axios": "^1.1.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bull": "^4.10.1",
|
||||
"bull": "^4.16.2",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^7.0.3",
|
||||
|
@ -54,9 +54,6 @@ const jwt = authenticate("jwt", "Unauthorized.", true);
|
||||
const jwtLoose = authenticate("jwt", "Unauthorized.", false);
|
||||
const apikey = authenticate("localapikey", "API key is not correct.", false);
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function cooldown(req, res, next) {
|
||||
if (env.DISALLOW_ANONYMOUS_LINKS) return next();
|
||||
const cooldownConfig = env.NON_USER_COOLDOWN;
|
||||
@ -78,18 +75,12 @@ async function cooldown(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function admin(req, res, next) {
|
||||
// FIXME: attaching to req is risky, find another way
|
||||
if (req.user.admin) return next();
|
||||
throw new CustomError("Unauthorized", 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function signup(req, res) {
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
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." });
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function login(req, res) {
|
||||
const token = utils.signToken(req.user);
|
||||
|
||||
@ -128,9 +116,6 @@ function login(req, res) {
|
||||
return res.status(200).send({ token });
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function verify(req, res, next) {
|
||||
if (!req.params.verificationToken) return next();
|
||||
|
||||
@ -148,15 +133,19 @@ async function verify(req, res, next) {
|
||||
|
||||
if (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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function changePassword(req, res) {
|
||||
const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password);
|
||||
if (!isMatch) {
|
||||
@ -174,8 +163,6 @@ async function changePassword(req, res) {
|
||||
throw new CustomError("Couldn't change the password. Try again later.");
|
||||
}
|
||||
|
||||
await utils.sleep(1000);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Trigger-After-Swap", "resetChangePasswordForm");
|
||||
res.render("partials/settings/change_password", {
|
||||
@ -189,9 +176,6 @@ async function changePassword(req, res) {
|
||||
.send({ message: "Your password has been changed successfully." });
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function generateApiKey(req, res) {
|
||||
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.");
|
||||
}
|
||||
|
||||
await utils.sleep(1000);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.render("partials/settings/apikey", {
|
||||
user: { apikey },
|
||||
@ -215,9 +197,6 @@ async function generateApiKey(req, res) {
|
||||
return res.status(201).send({ apikey });
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function resetPasswordRequest(req, res) {
|
||||
const [user] = await query.user.update(
|
||||
{ email: req.body.email },
|
||||
@ -228,7 +207,15 @@ async function resetPasswordRequest(req, res) {
|
||||
);
|
||||
|
||||
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({
|
||||
@ -236,11 +223,8 @@ async function resetPasswordRequest(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function resetPassword(req, res, next) {
|
||||
const { resetPasswordToken } = req.params;
|
||||
const resetPasswordToken = req.params.resetPasswordToken;
|
||||
|
||||
if (resetPasswordToken) {
|
||||
const [user] = await query.user.update(
|
||||
@ -253,23 +237,25 @@ async function resetPassword(req, res, next) {
|
||||
|
||||
if (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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
next();
|
||||
}
|
||||
|
||||
function signupAccess(req, res, next) {
|
||||
if (!env.DISALLOW_REGISTRATION) return next();
|
||||
return res.status(403).send({ message: "Registration is not allowed." });
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function changeEmailRequest(req, res) {
|
||||
const { email, password } = req.body;
|
||||
|
||||
@ -317,11 +303,10 @@ async function changeEmailRequest(req, res) {
|
||||
return res.status(200).send({ message });
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function changeEmail(req, res, next) {
|
||||
const { changeEmailToken } = req.params;
|
||||
const changeEmailToken = req.params.changeEmailToken;
|
||||
|
||||
console.log("-", changeEmailToken, "-");
|
||||
|
||||
if (changeEmailToken) {
|
||||
const foundUser = await query.user.find({
|
||||
@ -347,7 +332,14 @@ async function changeEmail(req, res, next) {
|
||||
|
||||
if (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();
|
||||
|
@ -13,8 +13,6 @@ async function add(req, res) {
|
||||
user_id: req.user.id
|
||||
});
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
if (req.isHTML) {
|
||||
const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain);
|
||||
res.setHeader("HX-Reswap", "none");
|
||||
@ -38,8 +36,6 @@ async function remove(req, res) {
|
||||
|
||||
redis.remove.domain(domain);
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
if (!domain) {
|
||||
throw new CustomError("Could not delete the domain.", 500);
|
||||
}
|
||||
|
@ -12,9 +12,6 @@ const env = require("../env");
|
||||
// return next();
|
||||
// };
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function isHTML(req, res, next) {
|
||||
const accepts = req.accepts(["json", "html"]);
|
||||
req.isHTML = accepts === "html";
|
||||
@ -22,9 +19,6 @@ function isHTML(req, res, next) {
|
||||
}
|
||||
|
||||
function addNoLayoutLocals(req, res, next) {
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
res.locals.layout = null;
|
||||
next();
|
||||
}
|
||||
@ -36,17 +30,12 @@ function viewTemplate(template) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function addConfigLocals(req, res, next) {
|
||||
res.locals.default_domain = env.DEFAULT_DOMAIN;
|
||||
res.locals.site_name = env.SITE_NAME;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function addUserLocals(req, res, next) {
|
||||
const user = req.user;
|
||||
res.locals.user = user;
|
||||
@ -54,9 +43,6 @@ async function addUserLocals(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").ErrorRequestHandler}
|
||||
*/
|
||||
function error(error, req, res, _next) {
|
||||
if (env.isDev) {
|
||||
signale.fatal(error);
|
||||
@ -74,9 +60,6 @@ function error(error, req, res, _next) {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function verify(req, res, next) {
|
||||
const result = validationResult(req);
|
||||
if (result.isEmpty()) return next();
|
||||
@ -124,7 +107,7 @@ function parseQuery(req, res, next) {
|
||||
req.context = {
|
||||
limit: limit > 50 ? 50 : limit,
|
||||
skip,
|
||||
all: admin ? req.query.all === "true" : false
|
||||
all: admin ? req.query.all === "true" || req.query.all === "on" : false
|
||||
};
|
||||
|
||||
next();
|
||||
|
@ -5,9 +5,10 @@ const URL = require("url");
|
||||
const dns = require("dns");
|
||||
|
||||
const validators = require("./validators.handler");
|
||||
// const transporter = require("../mail");
|
||||
const map = require("../utils/map.json");
|
||||
const transporter = require("../mail");
|
||||
const query = require("../queries");
|
||||
// const queue = require("../queues");
|
||||
const queue = require("../queues");
|
||||
const utils = require("../utils");
|
||||
const env = require("../env");
|
||||
const { differenceInSeconds } = require("date-fns");
|
||||
@ -15,9 +16,6 @@ const { differenceInSeconds } = require("date-fns");
|
||||
const CustomError = utils.CustomError;
|
||||
const dnsLookup = promisify(dns.lookup);
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function get(req, res) {
|
||||
const { limit, skip, all } = req.context;
|
||||
const search = req.query.search;
|
||||
@ -34,8 +32,6 @@ async function get(req, res) {
|
||||
|
||||
const links = data.map(utils.sanitize.link);
|
||||
|
||||
await utils.sleep(1000);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.render("partials/links/table", {
|
||||
total,
|
||||
@ -54,9 +50,6 @@ async function get(req, res) {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function create(req, res) {
|
||||
const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
|
||||
const domain_id = fetched_domain ? fetched_domain.id : null;
|
||||
@ -78,7 +71,7 @@ async function create(req, res) {
|
||||
address: customurl,
|
||||
domain_id
|
||||
}),
|
||||
!customurl && utils.generateId(domain_id),
|
||||
!customurl && utils.generateId(query, domain_id),
|
||||
validators.bannedDomain(targetDomain),
|
||||
validators.bannedHost(targetDomain)
|
||||
]);
|
||||
@ -161,8 +154,6 @@ async function edit(req, res) {
|
||||
isChanged = true;
|
||||
});
|
||||
|
||||
await utils.sleep(1000);
|
||||
|
||||
if (!isChanged) {
|
||||
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 }));
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function remove(req, res) {
|
||||
const { error, isRemoved, link } = await query.link.remove({
|
||||
uuid: req.params.id,
|
||||
@ -229,8 +217,6 @@ async function remove(req, res) {
|
||||
throw new CustomError(messsage);
|
||||
}
|
||||
|
||||
await utils.sleep(1000);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Reswap", "outerHTML");
|
||||
res.setHeader("HX-Trigger", "reloadLinks");
|
||||
@ -245,24 +231,22 @@ async function remove(req, res) {
|
||||
.send({ message: "Link has been deleted successfully." });
|
||||
};
|
||||
|
||||
// export const report: Handler = async (req, res) => {
|
||||
// const { link } = req.body;
|
||||
async function report(req, res) {
|
||||
const { link } = req.body;
|
||||
|
||||
// const mail = await transporter.sendMail({
|
||||
// from: env.MAIL_FROM || env.MAIL_USER,
|
||||
// to: env.REPORT_EMAIL,
|
||||
// subject: "[REPORT]",
|
||||
// text: link,
|
||||
// html: link
|
||||
// });
|
||||
await transporter.sendReportEmail(link);
|
||||
|
||||
// if (!mail.accepted.length) {
|
||||
// throw new CustomError("Couldn't submit the report. Try again later.");
|
||||
// }
|
||||
// return res
|
||||
// .status(200)
|
||||
// .send({ message: "Thanks for the report, we'll take actions shortly." });
|
||||
// };
|
||||
if (req.isHTML) {
|
||||
res.render("partials/report/form", {
|
||||
message: "Report was received. We'll take actions shortly."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.send({ message: "Thanks for the report, we'll take actions shortly." });
|
||||
};
|
||||
|
||||
async function ban(req, res) {
|
||||
const { id } = req.params;
|
||||
@ -320,8 +304,6 @@ async function ban(req, res) {
|
||||
});
|
||||
|
||||
// 8. Send response
|
||||
await utils.sleep(1000);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Reswap", "outerHTML");
|
||||
res.setHeader("HX-Trigger", "reloadLinks");
|
||||
@ -334,148 +316,178 @@ async function ban(req, res) {
|
||||
return res.status(200).send({ message: "Banned link successfully." });
|
||||
};
|
||||
|
||||
// export const redirect = (app) => async (
|
||||
// req,
|
||||
// res,
|
||||
// next
|
||||
// ) => {
|
||||
// const isBot = isbot(req.headers["user-agent"]);
|
||||
// const isPreservedUrl = validators.preservedUrls.some(
|
||||
// item => item === req.path.replace("/", "")
|
||||
// );
|
||||
async function redirect(req, res, next) {
|
||||
const isPreservedUrl = utils.preservedURLs.some(
|
||||
item => item === req.path.replace("/", "")
|
||||
);
|
||||
|
||||
// if (isPreservedUrl) return next();
|
||||
if (isPreservedUrl) return next();
|
||||
|
||||
// // 1. If custom domain, get domain info
|
||||
// const host = utils.removeWww(req.headers.host);
|
||||
// const domain =
|
||||
// host !== env.DEFAULT_DOMAIN
|
||||
// ? await query.domain.find({ address: host })
|
||||
// : null;
|
||||
// 1. If custom domain, get domain info
|
||||
const host = utils.removeWww(req.headers.host);
|
||||
const domain =
|
||||
host !== env.DEFAULT_DOMAIN
|
||||
? await query.domain.find({ address: host })
|
||||
: null;
|
||||
|
||||
// // 2. Get link
|
||||
// const address = req.params.id.replace("+", "");
|
||||
// const link = await query.link.find({
|
||||
// address,
|
||||
// domain_id: domain ? domain.id : null
|
||||
// });
|
||||
// 2. Get link
|
||||
const address = req.params.id.replace("+", "");
|
||||
const link = await query.link.find({
|
||||
address,
|
||||
domain_id: domain ? domain.id : null
|
||||
});
|
||||
|
||||
// // 3. When no link, if has domain redirect to domain's homepage
|
||||
// // otherwise redirect to 404
|
||||
// if (!link) {
|
||||
// return res.redirect(302, domain ? domain.homepage : "/404");
|
||||
// }
|
||||
// 3. When no link, if has domain redirect to domain's homepage
|
||||
// otherwise redirect to 404
|
||||
if (!link) {
|
||||
return res.redirect(domain.homepage || "/404");
|
||||
}
|
||||
|
||||
// // 4. If link is banned, redirect to banned page.
|
||||
// if (link.banned) {
|
||||
// return res.redirect("/banned");
|
||||
// }
|
||||
// 4. If link is banned, redirect to banned page.
|
||||
if (link.banned) {
|
||||
return res.redirect("/banned");
|
||||
}
|
||||
|
||||
// // 5. If wants to see link info, then redirect
|
||||
// const doesRequestInfo = /.*\+$/gi.test(req.params.id);
|
||||
// if (doesRequestInfo && !link.password) {
|
||||
// return app.render(req, res, "/url-info", { target: link.target });
|
||||
// }
|
||||
// 5. If wants to see link info, then redirect
|
||||
const isRequestingInfo = /.*\+$/gi.test(req.params.id);
|
||||
if (isRequestingInfo && !link.password) {
|
||||
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
|
||||
// if (link.password) {
|
||||
// return res.redirect(`/protected/${link.uuid}`);
|
||||
// }
|
||||
// 6. If link is protected, redirect to password page
|
||||
if (link.password) {
|
||||
res.render("protected", {
|
||||
title: "Protected short link",
|
||||
id: link.uuid
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// // 7. Create link visit
|
||||
// if (link.user_id && !isBot) {
|
||||
// queue.visit.add({
|
||||
// headers: req.headers,
|
||||
// realIP: req.realIP,
|
||||
// referrer: req.get("Referrer"),
|
||||
// link
|
||||
// });
|
||||
// }
|
||||
// 7. Create link visit
|
||||
const isBot = isbot(req.headers["user-agent"]);
|
||||
if (link.user_id && !isBot) {
|
||||
queue.visit.add({
|
||||
headers: req.headers,
|
||||
realIP: req.realIP,
|
||||
referrer: req.get("Referrer"),
|
||||
link
|
||||
});
|
||||
}
|
||||
|
||||
// // 8. Redirect to target
|
||||
// return res.redirect(link.target);
|
||||
// };
|
||||
// 8. Redirect to target
|
||||
return res.redirect(link.target);
|
||||
};
|
||||
|
||||
// export const redirectProtected: Handler = async (req, res) => {
|
||||
// // 1. Get link
|
||||
// const uuid = req.params.id;
|
||||
// const link = await query.link.find({ uuid });
|
||||
async function redirectProtected(req, res) {
|
||||
// 1. Get link
|
||||
const uuid = req.params.id;
|
||||
const link = await query.link.find({ uuid });
|
||||
|
||||
// // 2. Throw error if no link
|
||||
// if (!link || !link.password) {
|
||||
// throw new CustomError("Couldn't find the link.", 400);
|
||||
// }
|
||||
// 2. Throw error if no link
|
||||
if (!link || !link.password) {
|
||||
throw new CustomError("Couldn't find the link.", 400);
|
||||
}
|
||||
|
||||
// // 3. Check if password matches
|
||||
// const matches = await bcrypt.compare(req.body.password, link.password);
|
||||
// 3. Check if password matches
|
||||
const matches = await bcrypt.compare(req.body.password, link.password);
|
||||
|
||||
// if (!matches) {
|
||||
// throw new CustomError("Password is not correct.", 401);
|
||||
// }
|
||||
if (!matches) {
|
||||
throw new CustomError("Password is not correct.", 401);
|
||||
}
|
||||
|
||||
// // 4. Create visit
|
||||
// if (link.user_id) {
|
||||
// queue.visit.add({
|
||||
// headers: req.headers,
|
||||
// realIP: req.realIP,
|
||||
// referrer: req.get("Referrer"),
|
||||
// link
|
||||
// });
|
||||
// }
|
||||
// 4. Create visit
|
||||
if (link.user_id) {
|
||||
queue.visit.add({
|
||||
headers: req.headers,
|
||||
realIP: req.realIP,
|
||||
referrer: req.get("Referrer"),
|
||||
link
|
||||
});
|
||||
}
|
||||
|
||||
// // 5. Send target
|
||||
// return res.status(200).send({ target: link.target });
|
||||
// };
|
||||
// 5. Send 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) => {
|
||||
// const { path } = req;
|
||||
// const host = utils.removeWww(req.headers.host);
|
||||
async function redirectCustomDomainHomepage(req, res, next) {
|
||||
const path = req.path;
|
||||
const host = utils.removeWww(req.headers.host);
|
||||
|
||||
// if (host === env.DEFAULT_DOMAIN) {
|
||||
// return next();
|
||||
// }
|
||||
if (host === env.DEFAULT_DOMAIN) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// if (
|
||||
// path === "/" ||
|
||||
// validators.preservedUrls
|
||||
// .filter(l => l !== "url-password")
|
||||
// .some(item => item === path.replace("/", ""))
|
||||
// ) {
|
||||
// const domain = await query.domain.find({ address: host });
|
||||
// const redirectURL = domain
|
||||
// ? domain.homepage
|
||||
// : `https://${env.DEFAULT_DOMAIN + path}`;
|
||||
if (
|
||||
path === "/" ||
|
||||
utils.preservedURLs
|
||||
.filter(l => l !== "url-password")
|
||||
.some(item => item === path.replace("/", ""))
|
||||
) {
|
||||
const domain = await query.domain.find({ address: host });
|
||||
const redirectURL = domain
|
||||
? domain.homepage
|
||||
: `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) => {
|
||||
// const { user } = req;
|
||||
// const uuid = req.params.id;
|
||||
async function stats(req, res) {
|
||||
const { user } = req;
|
||||
const uuid = req.params.id;
|
||||
|
||||
// const link = await query.link.find({
|
||||
// ...(!user.admin && { user_id: user.id }),
|
||||
// uuid
|
||||
// });
|
||||
const link = await query.link.find({
|
||||
...(!user.admin && { user_id: user.id }),
|
||||
uuid
|
||||
});
|
||||
|
||||
// if (!link) {
|
||||
// throw new CustomError("Link could not be found.");
|
||||
// }
|
||||
if (!link) {
|
||||
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) {
|
||||
// throw new CustomError("Could not get the short link stats.");
|
||||
// }
|
||||
if (!stats) {
|
||||
throw new CustomError("Could not get the short link stats. Try again later.");
|
||||
}
|
||||
|
||||
// return res.status(200).send({
|
||||
// ...stats,
|
||||
// ...utils.sanitize.link(link)
|
||||
// });
|
||||
// };
|
||||
if (req.isHTML) {
|
||||
res.render("partials/stats", {
|
||||
link: utils.sanitize.link(link),
|
||||
stats,
|
||||
map,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
...stats,
|
||||
...utils.sanitize.link(link)
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ban,
|
||||
@ -483,4 +495,9 @@ module.exports = {
|
||||
edit,
|
||||
get,
|
||||
remove,
|
||||
report,
|
||||
stats,
|
||||
redirect,
|
||||
redirectProtected,
|
||||
redirectCustomDomainHomepage,
|
||||
}
|
@ -1,21 +1,21 @@
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function createLink(req, res, next) {
|
||||
res.locals.show_advanced = !!req.body.show_advanced;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function editLink(req, res, next) {
|
||||
res.locals.id = req.params.id;
|
||||
res.locals.class = "no-animation";
|
||||
next();
|
||||
}
|
||||
|
||||
function protected(req, res, next) {
|
||||
res.locals.id = req.params.id;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLink,
|
||||
editLink,
|
||||
protected,
|
||||
}
|
@ -2,18 +2,12 @@ const utils = require("../utils");
|
||||
const query = require("../queries")
|
||||
const env = require("../env");
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function homepage(req, res) {
|
||||
res.render("homepage", {
|
||||
title: "Modern open source URL shortener",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function login(req, res) {
|
||||
if (req.user) {
|
||||
return res.redirect("/");
|
||||
@ -23,9 +17,6 @@ function login(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function logout(req, res) {
|
||||
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
||||
res.render("logout", {
|
||||
@ -33,9 +24,12 @@ function logout(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function notFound(req, res) {
|
||||
res.render("404", {
|
||||
title: "404 - Not found"
|
||||
});
|
||||
}
|
||||
|
||||
function settings(req, res) {
|
||||
// TODO: make this a middelware function, apply it to where it's necessary
|
||||
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) {
|
||||
const link = await query.link.find({
|
||||
uuid: req.query.id,
|
||||
...(!req.user.admin && { user_id: req.user.id })
|
||||
});
|
||||
await utils.sleep(500);
|
||||
if (!link) {
|
||||
return res.render("partials/links/dialog/message", {
|
||||
layout: false,
|
||||
@ -69,15 +111,11 @@ async function confirmLinkDelete(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function confirmLinkBan(req, res) {
|
||||
const link = await query.link.find({
|
||||
uuid: req.query.id,
|
||||
...(!req.user.admin && { user_id: req.user.id })
|
||||
});
|
||||
await utils.sleep(500);
|
||||
if (!link) {
|
||||
return res.render("partials/links/dialog/message", {
|
||||
message: "Could not find the link."
|
||||
@ -89,23 +127,15 @@ async function confirmLinkBan(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function addDomainForm(req, res) {
|
||||
await utils.sleep(1000);
|
||||
res.render("partials/settings/domain/add_form");
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function confirmDomainDelete(req, res) {
|
||||
const domain = await query.domain.find({
|
||||
uuid: req.query.id,
|
||||
user_id: req.user.id
|
||||
});
|
||||
await utils.sleep(500);
|
||||
if (!domain) {
|
||||
throw new utils.CustomError("Could not find the link", 400);
|
||||
}
|
||||
@ -115,15 +145,21 @@ async function confirmDomainDelete(req, res) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function getReportEmail(req, res) {
|
||||
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) {
|
||||
const link = await query.link.find({
|
||||
uuid: req.params.id,
|
||||
...(!req.user.admin && { user_id: req.user.id })
|
||||
});
|
||||
await utils.sleep(500);
|
||||
// TODO: handle when no link
|
||||
// if (!link) {
|
||||
// return res.render("partials/links/dialog/message", {
|
||||
@ -138,12 +174,22 @@ async function linkEdit(req, res) {
|
||||
|
||||
module.exports = {
|
||||
addDomainForm,
|
||||
banned,
|
||||
confirmDomainDelete,
|
||||
confirmLinkBan,
|
||||
confirmLinkDelete,
|
||||
getReportEmail,
|
||||
homepage,
|
||||
linkEdit,
|
||||
login,
|
||||
logout,
|
||||
confirmDomainDelete,
|
||||
confirmLinkBan,
|
||||
confirmLinkDelete,
|
||||
notFound,
|
||||
report,
|
||||
resetPassword,
|
||||
resetPasswordResult,
|
||||
settings,
|
||||
stats,
|
||||
terms,
|
||||
verifyChangeEmail,
|
||||
verify,
|
||||
}
|
@ -17,8 +17,6 @@ async function get(req, res) {
|
||||
async function remove(req, res) {
|
||||
await query.user.remove(req.user);
|
||||
|
||||
await utils.sleep(1000);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.clearCookie("token", { httpOnly: true, secure: env.isProd });
|
||||
res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");
|
||||
|
@ -167,16 +167,16 @@ const editLink = [
|
||||
.isLength({ min: 36, max: 36 })
|
||||
];
|
||||
|
||||
// export const redirectProtected = [
|
||||
// body("password", "Password is invalid.")
|
||||
// .exists({ checkFalsy: true, checkNull: true })
|
||||
// .isString()
|
||||
// .isLength({ min: 3, max: 64 })
|
||||
// .withMessage("Password length must be between 3 and 64."),
|
||||
// param("id", "ID is invalid.")
|
||||
// .exists({ checkFalsy: true, checkNull: true })
|
||||
// .isLength({ min: 36, max: 36 })
|
||||
// ];
|
||||
const redirectProtected = [
|
||||
body("password", "Password is invalid.")
|
||||
.exists({ checkFalsy: true, checkNull: true })
|
||||
.isString()
|
||||
.isLength({ min: 3, max: 64 })
|
||||
.withMessage("Password length must be between 3 and 64."),
|
||||
param("id", "ID is invalid.")
|
||||
.exists({ checkFalsy: true, checkNull: true })
|
||||
.isLength({ min: 36, max: 36 })
|
||||
];
|
||||
|
||||
const addDomain = [
|
||||
body("address", "Domain is not valid.")
|
||||
@ -221,18 +221,18 @@ const deleteLink = [
|
||||
.isLength({ min: 36, max: 36 })
|
||||
];
|
||||
|
||||
// export const reportLink = [
|
||||
// body("link", "No link has been provided.")
|
||||
// .exists({
|
||||
// checkFalsy: true,
|
||||
// checkNull: true
|
||||
// })
|
||||
// .customSanitizer(addProtocol)
|
||||
// .custom(
|
||||
// value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN
|
||||
// )
|
||||
// .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
|
||||
// ];
|
||||
const reportLink = [
|
||||
body("link", "No link has been provided.")
|
||||
.exists({
|
||||
checkFalsy: true,
|
||||
checkNull: true
|
||||
})
|
||||
.customSanitizer(addProtocol)
|
||||
.custom(
|
||||
value => removeWww(URL.parse(value).host) === env.DEFAULT_DOMAIN
|
||||
)
|
||||
.withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
|
||||
];
|
||||
|
||||
const banLink = [
|
||||
param("id", "ID is invalid.")
|
||||
@ -267,14 +267,14 @@ const banLink = [
|
||||
.isBoolean()
|
||||
];
|
||||
|
||||
// export const getStats = [
|
||||
// param("id", "ID is invalid.")
|
||||
// .exists({
|
||||
// checkFalsy: true,
|
||||
// checkNull: true
|
||||
// })
|
||||
// .isLength({ min: 36, max: 36 })
|
||||
// ];
|
||||
const getStats = [
|
||||
param("id", "ID is invalid.")
|
||||
.exists({
|
||||
checkFalsy: true,
|
||||
checkNull: true
|
||||
})
|
||||
.isLength({ min: 36, max: 36 })
|
||||
];
|
||||
|
||||
const signup = [
|
||||
body("password", "Password is not valid.")
|
||||
@ -336,18 +336,14 @@ const changeEmail = [
|
||||
.withMessage("Email length must be max 255.")
|
||||
];
|
||||
|
||||
// export const resetPasswordRequest = [
|
||||
// body("email", "Email is not valid.")
|
||||
// .exists({ checkFalsy: true, checkNull: true })
|
||||
// .trim()
|
||||
// .isEmail()
|
||||
// .isLength({ min: 0, 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.")
|
||||
// ];
|
||||
const resetPassword = [
|
||||
body("email", "Email is not valid.")
|
||||
.exists({ checkFalsy: true, checkNull: true })
|
||||
.trim()
|
||||
.isEmail()
|
||||
.isLength({ min: 0, max: 255 })
|
||||
.withMessage("Email length must be max 255.")
|
||||
];
|
||||
|
||||
// export const resetEmailRequest = [
|
||||
// body("email", "Email is not valid.")
|
||||
@ -496,9 +492,13 @@ module.exports = {
|
||||
deleteLink,
|
||||
deleteUser,
|
||||
editLink,
|
||||
getStats,
|
||||
linksCount,
|
||||
login,
|
||||
malware,
|
||||
redirectProtected,
|
||||
removeDomain,
|
||||
reportLink,
|
||||
resetPassword,
|
||||
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 = {
|
||||
changeEmail,
|
||||
verification,
|
||||
resetPasswordToken,
|
||||
sendReportEmail,
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
// const visit = require("./visit.queries");
|
||||
const domain = require("./domain.queries");
|
||||
const visit = require("./visit.queries");
|
||||
const link = require("./link.queries");
|
||||
const user = require("./user.queries");
|
||||
const host = require("./host.queries");
|
||||
@ -11,5 +11,5 @@ module.exports = {
|
||||
ip,
|
||||
link,
|
||||
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,18 +1,26 @@
|
||||
import useragent from "useragent";
|
||||
import geoip from "geoip-lite";
|
||||
import URL from "url";
|
||||
const useragent = require("useragent");
|
||||
const geoip = require("geoip-lite");
|
||||
const URL = require("url");
|
||||
|
||||
import query from "../queries";
|
||||
import { getStatsLimit, removeWww } from "../utils/utils";
|
||||
const { getStatsLimit, removeWww } = require("../utils");
|
||||
const query = require("../queries");
|
||||
|
||||
const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
|
||||
const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];
|
||||
const filterInBrowser = (agent) => (item) =>
|
||||
agent.family.toLowerCase().includes(item.toLocaleLowerCase());
|
||||
const filterInOs = (agent) => (item) =>
|
||||
agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
|
||||
|
||||
export 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 = [];
|
||||
|
||||
tasks.push(query.link.incrementVisit({ id: data.link.id }));
|
||||
@ -25,6 +33,8 @@ export default function visit({ data }) {
|
||||
data.referrer && removeWww(URL.parse(data.referrer).hostname);
|
||||
const location = geoip.lookup(data.realIP);
|
||||
const country = location && location.country;
|
||||
|
||||
|
||||
tasks.push(
|
||||
query.visit.add({
|
||||
browser: browser.toLowerCase(),
|
@ -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 helpers = require("../handlers/helpers.handler");
|
||||
const auth = require("../handlers/auth.handler");
|
||||
const utils = require("../utils");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -52,6 +53,12 @@ router.post(
|
||||
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;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Router } from "express";
|
||||
const { Router } = require("express");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_, res) => res.send("OK"));
|
||||
|
||||
export default router;
|
||||
module.exports = router;
|
@ -66,27 +66,32 @@ router.post(
|
||||
asyncHandler(link.ban)
|
||||
);
|
||||
|
||||
// router.get(
|
||||
// "/:id/stats",
|
||||
// asyncHandler(auth.apikey),
|
||||
// asyncHandler(auth.jwt),
|
||||
// validators.getStats,
|
||||
// asyncHandler(link.stats)
|
||||
// );
|
||||
router.get(
|
||||
"/:id/stats",
|
||||
helpers.viewTemplate("partials/stats"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
validators.getStats,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(link.stats)
|
||||
);
|
||||
|
||||
// router.post(
|
||||
// "/:id/protected",
|
||||
// validators.redirectProtected,
|
||||
// asyncHandler(helpers.verify),
|
||||
// asyncHandler(link.redirectProtected)
|
||||
// );
|
||||
router.post(
|
||||
"/:id/protected",
|
||||
helpers.viewTemplate("partials/protected/form"),
|
||||
locals.protected,
|
||||
validators.redirectProtected,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(link.redirectProtected)
|
||||
);
|
||||
|
||||
// router.post(
|
||||
// "/report",
|
||||
// validators.reportLink,
|
||||
// asyncHandler(helpers.verify),
|
||||
// asyncHandler(link.report)
|
||||
// );
|
||||
router.post(
|
||||
"/report",
|
||||
helpers.viewTemplate("partials/report/form"),
|
||||
validators.reportLink,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(link.report)
|
||||
);
|
||||
|
||||
|
||||
module.exports = router;
|
||||
|
@ -2,8 +2,8 @@ const asyncHandler = require("express-async-handler");
|
||||
const { Router } = require("express");
|
||||
|
||||
const helpers = require("../handlers/helpers.handler");
|
||||
const renders = require("../handlers/renders.handler");
|
||||
const auth = require("../handlers/auth.handler");
|
||||
const renders = require("./renders.handler");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -27,6 +27,12 @@ router.get(
|
||||
asyncHandler(renders.logout)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/404",
|
||||
asyncHandler(auth.jwtLoose),
|
||||
asyncHandler(renders.notFound)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings",
|
||||
asyncHandler(auth.jwtLoose),
|
||||
@ -34,6 +40,57 @@ router.get(
|
||||
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
|
||||
router.get(
|
||||
"/confirm-link-delete",
|
||||
@ -73,4 +130,11 @@ router.get(
|
||||
asyncHandler(renders.confirmDomainDelete)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/get-report-email",
|
||||
helpers.addNoLayoutLocals,
|
||||
helpers.viewTemplate("partials/report/email"),
|
||||
asyncHandler(renders.getReportEmail)
|
||||
);
|
||||
|
||||
module.exports = router;
|
@ -1,19 +1,25 @@
|
||||
const { Router } = require("express");
|
||||
|
||||
const helpers = require("./../handlers/helpers.handler");
|
||||
const renders = require("./renders.routes");
|
||||
const domains = require("./domain.routes");
|
||||
// import health from "./health.routes";
|
||||
const health = require("./health.routes");
|
||||
const link = require("./link.routes");
|
||||
const user = require("./user.routes");
|
||||
const auth = require("./auth.routes");
|
||||
|
||||
const router = Router();
|
||||
const apiRouter = Router();
|
||||
const renderRouter = Router();
|
||||
|
||||
router.use(helpers.addNoLayoutLocals);
|
||||
router.use("/domains", domains);
|
||||
// router.use("/health", health);
|
||||
router.use("/links", link);
|
||||
router.use("/users", user);
|
||||
router.use("/auth", auth);
|
||||
renderRouter.use(renders);
|
||||
apiRouter.use(helpers.addNoLayoutLocals);
|
||||
apiRouter.use("/domains", domains);
|
||||
apiRouter.use("/health", health);
|
||||
apiRouter.use("/links", link);
|
||||
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");
|
||||
|
||||
// import asyncHandler from "express-async-handler";
|
||||
// import passport from "passport";
|
||||
const asyncHandler = require("express-async-handler");
|
||||
const cookieParser = require("cookie-parser");
|
||||
const compression = require("compression");
|
||||
const passport = require("passport");
|
||||
const express = require("express");
|
||||
const helmet = require("helmet");
|
||||
const morgan = require("morgan");
|
||||
@ -10,18 +11,21 @@ const path = require("path");
|
||||
const hbs = require("hbs");
|
||||
|
||||
const helpers = require("./handlers/helpers.handler");
|
||||
// import * as links from "./handlers/links";
|
||||
// import * as auth from "./handlers/auth";
|
||||
const links = require("./handlers/links.handler");
|
||||
const { stream } = require("./config/winston");
|
||||
const routes = require("./routes");
|
||||
const renders = require("./renders");
|
||||
const utils = require("./utils");
|
||||
const { stream } = require("./config/winston")
|
||||
|
||||
// import "./cron";
|
||||
require("./passport");
|
||||
|
||||
const app = express();
|
||||
|
||||
// enable gzip on dev
|
||||
if (env.isDev) {
|
||||
app.use(compression());
|
||||
}
|
||||
|
||||
// TODO: comments
|
||||
app.set("trust proxy", true);
|
||||
|
||||
@ -35,7 +39,7 @@ app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.static("static"));
|
||||
|
||||
// app.use(passport.initialize());
|
||||
app.use(passport.initialize());
|
||||
// app.use(helpers.ip);
|
||||
app.use(helpers.isHTML);
|
||||
app.use(helpers.addConfigLocals);
|
||||
@ -45,39 +49,20 @@ app.set("view engine", "hbs");
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
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", routes);
|
||||
app.use("/api/v2", routes.api);
|
||||
app.use("/api", routes.api);
|
||||
|
||||
// server.get(
|
||||
// "/reset-password/:resetPasswordToken?",
|
||||
// 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)));
|
||||
// finally, redirect the short link to the target
|
||||
app.get("/:id", asyncHandler(links.redirect));
|
||||
|
||||
// Error handler
|
||||
app.use(helpers.error);
|
||||
|
||||
// Handler everything else by Next.js
|
||||
// server.get("*", (req, res) => handle(req, res));
|
||||
|
||||
app.listen(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 nanoid = require("nanoid/generate");
|
||||
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 env = require("../env");
|
||||
@ -16,8 +16,6 @@ class CustomError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const query = require("../queries");
|
||||
|
||||
function isAdmin(email) {
|
||||
return env.ADMIN_EMAILS.split(",")
|
||||
.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(
|
||||
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
|
||||
env.LINK_LENGTH
|
||||
@ -53,7 +51,7 @@ function addProtocol(url) {
|
||||
}
|
||||
|
||||
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 url = `${protocol}${link}`;
|
||||
return { link, url };
|
||||
@ -96,7 +94,7 @@ function getDifferenceFunction(type) {
|
||||
if (type === "lastDay") return differenceInHours;
|
||||
if (type === "lastWeek") return differenceInDays;
|
||||
if (type === "lastMonth") return differenceInDays;
|
||||
if (type === "allTime") return differenceInMonths;
|
||||
if (type === "lastYear") return differenceInMonths;
|
||||
throw new Error("Unknown type.");
|
||||
}
|
||||
|
||||
@ -110,11 +108,14 @@ function getUTCDate(dateString) {
|
||||
);
|
||||
}
|
||||
|
||||
const STATS_PERIODS = [
|
||||
[1, "lastDay"],
|
||||
[7, "lastWeek"],
|
||||
[30, "lastMonth"]
|
||||
];
|
||||
function getStatsPeriods(now) {
|
||||
return [
|
||||
["lastDay", subHours(now, 24)],
|
||||
["lastWeek", subDays(now, 7)],
|
||||
["lastMonth", subDays(now, 30)],
|
||||
["lastYear", subMonths(now, 12)],
|
||||
]
|
||||
}
|
||||
|
||||
const preservedURLs = [
|
||||
"login",
|
||||
@ -234,6 +235,10 @@ function registerHandlebarsHelpers() {
|
||||
return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
|
||||
});
|
||||
|
||||
hbs.registerHelper("json", function(context) {
|
||||
return JSON.stringify(context);
|
||||
});
|
||||
|
||||
const blocks = {};
|
||||
|
||||
hbs.registerHelper("extend", function(name, context) {
|
||||
@ -270,6 +275,6 @@ module.exports = {
|
||||
sanitize,
|
||||
signToken,
|
||||
sleep,
|
||||
STATS_PERIODS,
|
||||
getStatsPeriods,
|
||||
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}}
|
||||
{{#unless user}}
|
||||
{{> introduction}}
|
||||
{{> features}}
|
||||
{{> browser_extensions}}
|
||||
{{/unless}}
|
||||
{{> footer}}
|
||||
|
@ -8,7 +8,7 @@
|
||||
<link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
|
||||
<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 property="fb:app_id" content="123456789" />
|
||||
<meta name="htmx-config" content='{"withCredentials":true}'>
|
||||
|
@ -25,11 +25,12 @@
|
||||
{{!-- TODO: Agree with terms --}}
|
||||
<div class="buttons-wrapper">
|
||||
<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>
|
||||
<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/login}}</span>
|
||||
<span>{{> icons/spinner}}</span>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary signup"
|
||||
hx-post="/api/auth/signup"
|
||||
hx-target="#login-signup"
|
||||
@ -40,12 +41,12 @@
|
||||
hx-on:htmx:before-request="htmx.addClass('#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>
|
||||
<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/new_user}}</span>
|
||||
<span>{{> icons/spinner}}</span>
|
||||
Sign up
|
||||
</button>
|
||||
</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}}
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
|
@ -3,28 +3,28 @@
|
||||
<ul>
|
||||
<li>
|
||||
<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>
|
||||
<h4>Managing links</h4>
|
||||
<p>Create, protect and delete your links and monitor them with detailed statistics.</p>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<h4>Custom domain</h4>
|
||||
<p>Use custom domains for your links. Add or remove them for free.</p>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<h4>API</h4>
|
||||
<p>Use the provided API to create, delete, and get URLs from anywhere.</p>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<h4>Free & open source</h4>
|
||||
<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}}
|
||||
</button>
|
||||
{{/if}}
|
||||
<button class="action stats">
|
||||
<a
|
||||
class="button action stats"
|
||||
href="/stats?id={{id}}"
|
||||
title="Stats"
|
||||
class="action stats"
|
||||
>
|
||||
{{> icons/chart}}
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
class="action qrcode"
|
||||
hx-on:click="handleQRCode(this)"
|
||||
|
@ -22,8 +22,9 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Cancel</button>
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger confirm"
|
||||
hx-post="/api/links/admin/ban/{id}"
|
||||
hx-ext="path-params"
|
||||
|
@ -1,12 +1,12 @@
|
||||
<div class="content">
|
||||
<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>
|
||||
<p>
|
||||
The link <b>"{{link}}"</b> is banned.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Close</button>
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -4,8 +4,9 @@
|
||||
Are you sure do you want to delete the link "<b>{{link}}</b>"?
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Cancel</button>
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger confirm"
|
||||
hx-delete="/api/links/{id}"
|
||||
hx-ext="path-params"
|
||||
@ -15,10 +16,10 @@
|
||||
hx-indicator="closest .content"
|
||||
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
|
||||
</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 id="dialog-error">
|
||||
{{#if error}}
|
||||
|
@ -1,12 +1,12 @@
|
||||
<div class="content">
|
||||
<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>
|
||||
<p>
|
||||
Your link <b>"{{link}}"</b> has been deleted.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Close</button>
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="box">
|
||||
<div class="content-wrapper"></div>
|
||||
<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>
|
@ -5,7 +5,7 @@
|
||||
<p>{{message}}</p>
|
||||
{{/if}}
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Close</button>
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -77,6 +77,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick="
|
||||
const tr = closest('tr');
|
||||
if (!tr) return;
|
||||
@ -86,7 +87,7 @@
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button class="primary">
|
||||
<button type="submit" class="primary">
|
||||
<span class="reload">
|
||||
{{> icons/reload}}
|
||||
</span>
|
||||
|
@ -8,7 +8,7 @@
|
||||
{{else}}
|
||||
<tr class="loading-placeholder">
|
||||
<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...
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -1,16 +1,16 @@
|
||||
<th class="nav" >
|
||||
<div class="limit">
|
||||
<button class="table-nav" onclick="setLinksLimit(event)" disabled="true">10</button>
|
||||
<button class="table-nav" onclick="setLinksLimit(event)">20</button>
|
||||
<button class="table-nav" onclick="setLinksLimit(event)">50</button>
|
||||
<button type="button" class="nav" onclick="setLinksLimit(event)" disabled="true">10</button>
|
||||
<button type="button" class="nav" onclick="setLinksLimit(event)">20</button>
|
||||
<button type="button" class="nav" onclick="setLinksLimit(event)">50</button>
|
||||
</div>
|
||||
<div class="table-nav-divider"></div>
|
||||
<div class="nav-divider"></div>
|
||||
<div id="pagination" class="pagination">
|
||||
<button class="table-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>
|
||||
<button type="button" class="nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true">
|
||||
{{> icons/chevron_left}}
|
||||
</button>
|
||||
<button class="table-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>
|
||||
<button type="button" class="nav next" onclick="setLinksSkip(event, 'next')">
|
||||
{{> icons/chevron_right}}
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
@ -14,7 +14,7 @@
|
||||
load once,
|
||||
reloadLinks from:body,
|
||||
change from:[name='all'],
|
||||
click delay:100ms from:button.table-nav,
|
||||
click delay:100ms from:button.nav,
|
||||
input changed delay:500ms from:[name='search'],
|
||||
"
|
||||
hx-on:htmx:after-on-load="updateLinksNav()"
|
||||
|
@ -5,10 +5,12 @@
|
||||
<input id="total" name="total" type="hidden" value="{{total}}" />
|
||||
<input id="limit" name="limit" type="hidden" value="10" />
|
||||
<input id="skip" name="skip" type="hidden" value="0" />
|
||||
{{#if @root.isAdmin}}
|
||||
<label id="all" class="checkbox">
|
||||
<input name="all" type="checkbox" />
|
||||
All links
|
||||
</label>
|
||||
{{/if}}
|
||||
</th>
|
||||
{{> links/nav}}
|
||||
</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}}
|
||||
<div class="clipboard small">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy"
|
||||
hx-on:click="handleShortURLCopyLink(this);"
|
||||
data-url="{{user.apikey}}"
|
||||
@ -37,7 +38,7 @@
|
||||
hx-target="#apikey-wrapper"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<button class="secondary">
|
||||
<button type="button" class="secondary">
|
||||
<span>{{> icons/zap}}</span>
|
||||
<span>{{> icons/spinner}}</span>
|
||||
{{#if user.apikey}}Reg{{else}}G{{/if}}enerate key
|
||||
|
@ -34,7 +34,7 @@
|
||||
{{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
|
||||
</label>
|
||||
</div>
|
||||
<button class="primary" type="submit">
|
||||
<button type="submit" class="primary">
|
||||
<span>{{> icons/reload}}</span>
|
||||
<span>{{> icons/spinner}}</span>
|
||||
Update
|
||||
|
@ -34,7 +34,7 @@
|
||||
{{#if errors.newpassword}}<p class="error">{{errors.newpassword}}</p>{{/if}}
|
||||
</label>
|
||||
</div>
|
||||
<button class="primary" type="submit">
|
||||
<button type="submit" class="primary">
|
||||
<span>{{> icons/reload}}</span>
|
||||
<span>{{> icons/spinner}}</span>
|
||||
Update
|
||||
|
@ -27,7 +27,7 @@
|
||||
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
|
||||
</label>
|
||||
</div>
|
||||
<button class="danger" type="submit">
|
||||
<button type="submit" class="danger">
|
||||
<span>{{> icons/trash}}</span>
|
||||
<span>{{> icons/spinner}}</span>
|
||||
Delete
|
||||
|
@ -4,8 +4,9 @@
|
||||
Are you sure do you want to delete the domain "<b>{{address}}</b>"?
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Cancel</button>
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger confirm"
|
||||
hx-delete="/api/domains/{id}"
|
||||
hx-ext="path-params"
|
||||
|
@ -1,12 +1,12 @@
|
||||
<div class="content">
|
||||
<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>
|
||||
<p>
|
||||
Your domain <b>"{{address}}"</b> has been deleted.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Close</button>
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
{{> settings/domain/table}}
|
@ -2,7 +2,7 @@
|
||||
<div class="box">
|
||||
<div class="content-wrapper"></div>
|
||||
<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>
|
@ -13,6 +13,7 @@
|
||||
{{> settings/domain/table}}
|
||||
<div class="add-domain-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary show-domain-form"
|
||||
hx-indicator=".add-domain-wrapper"
|
||||
hx-get="/add-domain-form"
|
||||
|
@ -22,6 +22,7 @@
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action delete"
|
||||
hx-on:click='openDialog("domain-dialog")'
|
||||
hx-get="/confirm-domain-delete"
|
||||
|
@ -3,6 +3,7 @@
|
||||
{{#if link}}
|
||||
<div class="clipboard">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy"
|
||||
hx-on:click="handleShortURLCopyLink(this);"
|
||||
data-url="{{url}}"
|
||||
@ -43,8 +44,8 @@
|
||||
hx-preserve="true"
|
||||
/>
|
||||
<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>
|
||||
<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/send}}
|
||||
{{> icons/spinner}}
|
||||
</button>
|
||||
{{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
|
||||
{{#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}}
|
||||
<section id="settings">
|
||||
<section id="settings" class="section-container">
|
||||
<h1 class="settings-welcome">
|
||||
Welcome, <span>{{user.email}}</span>.
|
||||
</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%);
|
||||
}
|
||||
|
||||
a {
|
||||
span.bold { font-weight: bold; }
|
||||
span.underline { border-bottom: 2px dotted #999; }
|
||||
|
||||
a,
|
||||
button.link {
|
||||
color: var(--color-primary);
|
||||
border-bottom: 1px dotted transparent;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
a:hover,
|
||||
button.link:hover {
|
||||
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);
|
||||
}
|
||||
|
||||
a.button:disabled,
|
||||
button:disabled { cursor: default; }
|
||||
a.button:disabled:hover,
|
||||
button:disabled:hover { transform: none; }
|
||||
|
||||
a.button svg.with-text,
|
||||
a.button span svg,
|
||||
button svg.with-text,
|
||||
button span svg {
|
||||
width: 16px;
|
||||
@ -203,6 +212,7 @@ button span svg {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
a.button.action,
|
||||
button.action {
|
||||
padding: 5px;
|
||||
width: 24px;
|
||||
@ -210,68 +220,82 @@ button.action {
|
||||
box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
|
||||
}
|
||||
|
||||
a.button.action:disabled,
|
||||
button.action:disabled {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
a.button.action svg,
|
||||
button.action svg {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
a.button.action.delete,
|
||||
button.action.delete {
|
||||
background: hsl(0, 100%, 96%);
|
||||
}
|
||||
|
||||
a.button.action.delete svg,
|
||||
button.action.delete svg {
|
||||
stroke-width: 2;
|
||||
stroke: hsl(0, 100%, 69%);
|
||||
}
|
||||
|
||||
a.button.action.edit,
|
||||
button.action.edit {
|
||||
background: hsl(46, 100%, 94%);
|
||||
}
|
||||
|
||||
a.button.action.edit svg,
|
||||
button.action.edit svg {
|
||||
stroke-width: 2.5;
|
||||
stroke: hsl(46, 90%, 50%);
|
||||
}
|
||||
|
||||
a.button.action.qrcode,
|
||||
button.action.qrcode {
|
||||
background: hsl(0, 0%, 94%);
|
||||
}
|
||||
|
||||
a.button.action.qrcode svg,
|
||||
button.action.qrcode svg {
|
||||
fill: hsl(0, 0%, 35%);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
a.button.action.stats,
|
||||
button.action.stats {
|
||||
background: hsl(260, 100%, 96%);
|
||||
}
|
||||
|
||||
a.button.action.stats svg,
|
||||
button.action.stats svg {
|
||||
stroke-width: 2.5;
|
||||
stroke: hsl(260, 100%, 69%);
|
||||
}
|
||||
|
||||
a.button.action.ban,
|
||||
button.action.ban {
|
||||
background: hsl(10, 100%, 96%);
|
||||
}
|
||||
|
||||
a.button.action.ban svg,
|
||||
button.action.ban svg {
|
||||
stroke-width: 2;
|
||||
stroke: hsl(10, 100%, 40%);
|
||||
}
|
||||
|
||||
a.button.action.password sv,
|
||||
button.action.password svg,
|
||||
a.button.action.banned svg,
|
||||
button.action.banned svg {
|
||||
stroke-width: 2.5;
|
||||
stroke: #bbb;
|
||||
}
|
||||
|
||||
button.table-nav {
|
||||
button.nav {
|
||||
box-sizing: border-box;
|
||||
width: auto;
|
||||
height: 28px;
|
||||
@ -290,7 +314,7 @@ button.table-nav {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.table-nav:disabled {
|
||||
button.nav:disabled {
|
||||
background-color: #f6f6f6;
|
||||
box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
|
||||
opacity: 0.9;
|
||||
@ -298,15 +322,47 @@ button.table-nav:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.table-nav svg {
|
||||
button.nav svg {
|
||||
width: 14px;
|
||||
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.table-nav:disabled:hover { transform: none; }
|
||||
button.nav:hover { transform: translateY(-2px); }
|
||||
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 {
|
||||
animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
|
||||
@ -459,6 +515,8 @@ label.checkbox input[type="checkbox"] {
|
||||
|
||||
p.error,
|
||||
p.success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
animation: fadein 0.3s ease-in-out;
|
||||
}
|
||||
@ -755,6 +813,15 @@ table tr.loading-placeholder td {
|
||||
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 */
|
||||
|
||||
form#login-signup {
|
||||
@ -797,8 +864,8 @@ form#login-signup a.forgot-password {
|
||||
}
|
||||
|
||||
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.icon { display: none; }
|
||||
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 .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 .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 a.button,
|
||||
#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 td.original-url,
|
||||
@ -1231,10 +1300,10 @@ main form label#advanced input {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#links-table-wrapper table button.table-nav { margin-right: 0.75rem; }
|
||||
#links-table-wrapper table button.table-nav:last-child { margin-right: 0; }
|
||||
#links-table-wrapper table button.nav { margin-right: 0.75rem; }
|
||||
#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;
|
||||
width: 1px;
|
||||
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: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 */
|
||||
|
||||
@ -1633,3 +1592,378 @@ footer {
|
||||
font-size: 13px;
|
||||
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;
|
||||
};
|
||||
|
||||
// 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
|
||||
function handleQRCode(element) {
|
||||
const dialog = document.querySelector("#link-dialog");
|
||||
@ -176,3 +208,449 @@ function resetLinkNav() {
|
||||
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);
|
||||
}
|