htmx almost done

This commit is contained in:
Pouria Ezzati 2024-09-08 14:10:02 +03:30
parent 5b647f05be
commit dbc14c8fb6
No known key found for this signature in database
87 changed files with 3181 additions and 879 deletions

View File

@ -444,9 +444,6 @@ export default {
Stats: { Stats: {
type: "object", type: "object",
properties: { properties: {
allTime: {
$ref: "#/components/schemas/StatsItem"
},
lastDay: { lastDay: {
$ref: "#/components/schemas/StatsItem" $ref: "#/components/schemas/StatsItem"
}, },
@ -456,6 +453,9 @@ export default {
lastWeek: { lastWeek: {
$ref: "#/components/schemas/StatsItem" $ref: "#/components/schemas/StatsItem"
}, },
lastYear: {
$ref: "#/components/schemas/StatsItem"
},
updatedAt: { updatedAt: {
type: "string" type: "string"
}, },

69
package-lock.json generated
View File

@ -12,7 +12,8 @@
"app-root-path": "^3.1.0", "app-root-path": "^3.1.0",
"axios": "^1.1.3", "axios": "^1.1.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bull": "^4.10.1", "bull": "^4.16.2",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@ -3027,9 +3028,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/bull": { "node_modules/bull": {
"version": "4.16.0", "version": "4.16.2",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.0.tgz", "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.2.tgz",
"integrity": "sha512-dgHRLULPexLkpm9wP/7F7Vlf2fdvmffdwhv3Bqu5lFhO+XDDJ4yGqlTPE61Jj1zM8CgchLmJEgIfe7y69jtuOg==", "integrity": "sha512-VCy33UdPGiIoZHDTrslGXKXWxcIUHNH5Z82pihr8HicbIfAH4SHug1HxlwKEbibVv85hq8rJ9tKAW/cuxv2T0A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cron-parser": "^4.2.1", "cron-parser": "^4.2.1",
@ -3410,6 +3411,66 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
"integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.5",
"bytes": "3.0.0",
"compressible": "~2.0.16",
"debug": "2.6.9",
"on-headers": "~1.0.2",
"safe-buffer": "5.1.2",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",

View File

@ -32,7 +32,8 @@
"app-root-path": "^3.1.0", "app-root-path": "^3.1.0",
"axios": "^1.1.3", "axios": "^1.1.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bull": "^4.10.1", "bull": "^4.16.2",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",

View File

@ -54,9 +54,6 @@ const jwt = authenticate("jwt", "Unauthorized.", true);
const jwtLoose = authenticate("jwt", "Unauthorized.", false); const jwtLoose = authenticate("jwt", "Unauthorized.", false);
const apikey = authenticate("localapikey", "API key is not correct.", false); const apikey = authenticate("localapikey", "API key is not correct.", false);
/**
* @type {import("express").Handler}
*/
async function cooldown(req, res, next) { async function cooldown(req, res, next) {
if (env.DISALLOW_ANONYMOUS_LINKS) return next(); if (env.DISALLOW_ANONYMOUS_LINKS) return next();
const cooldownConfig = env.NON_USER_COOLDOWN; const cooldownConfig = env.NON_USER_COOLDOWN;
@ -78,18 +75,12 @@ async function cooldown(req, res, next) {
next(); next();
} }
/**
* @type {import("express").Handler}
*/
function admin(req, res, next) { function admin(req, res, next) {
// FIXME: attaching to req is risky, find another way // FIXME: attaching to req is risky, find another way
if (req.user.admin) return next(); if (req.user.admin) return next();
throw new CustomError("Unauthorized", 401); throw new CustomError("Unauthorized", 401);
} }
/**
* @type {import("express").Handler}
*/
async function signup(req, res) { async function signup(req, res) {
const salt = await bcrypt.genSalt(12); const salt = await bcrypt.genSalt(12);
const password = await bcrypt.hash(req.body.password, salt); const password = await bcrypt.hash(req.body.password, salt);
@ -109,9 +100,6 @@ async function signup(req, res) {
return res.status(201).send({ message: "A verification email has been sent." }); return res.status(201).send({ message: "A verification email has been sent." });
} }
/**
* @type {import("express").Handler}
*/
function login(req, res) { function login(req, res) {
const token = utils.signToken(req.user); const token = utils.signToken(req.user);
@ -128,9 +116,6 @@ function login(req, res) {
return res.status(200).send({ token }); return res.status(200).send({ token });
} }
/**
* @type {import("express").Handler}
*/
async function verify(req, res, next) { async function verify(req, res, next) {
if (!req.params.verificationToken) return next(); if (!req.params.verificationToken) return next();
@ -148,15 +133,19 @@ async function verify(req, res, next) {
if (user) { if (user) {
const token = utils.signToken(user); const token = utils.signToken(user);
req.token = token; res.clearCookie("token", { httpOnly: true, secure: env.isProd });
res.cookie("token", token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
httpOnly: true,
secure: env.isProd
});
res.locals.token_verified = true;
req.cookies.token = token;
} }
return next(); return next();
} }
/**
* @type {import("express").Handler}
*/
async function changePassword(req, res) { async function changePassword(req, res) {
const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password); const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password);
if (!isMatch) { if (!isMatch) {
@ -174,8 +163,6 @@ async function changePassword(req, res) {
throw new CustomError("Couldn't change the password. Try again later."); throw new CustomError("Couldn't change the password. Try again later.");
} }
await utils.sleep(1000);
if (req.isHTML) { if (req.isHTML) {
res.setHeader("HX-Trigger-After-Swap", "resetChangePasswordForm"); res.setHeader("HX-Trigger-After-Swap", "resetChangePasswordForm");
res.render("partials/settings/change_password", { res.render("partials/settings/change_password", {
@ -189,9 +176,6 @@ async function changePassword(req, res) {
.send({ message: "Your password has been changed successfully." }); .send({ message: "Your password has been changed successfully." });
} }
/**
* @type {import("express").Handler}
*/
async function generateApiKey(req, res) { async function generateApiKey(req, res) {
const apikey = nanoid(40); const apikey = nanoid(40);
@ -203,8 +187,6 @@ async function generateApiKey(req, res) {
throw new CustomError("Couldn't generate API key. Please try again later."); throw new CustomError("Couldn't generate API key. Please try again later.");
} }
await utils.sleep(1000);
if (req.isHTML) { if (req.isHTML) {
res.render("partials/settings/apikey", { res.render("partials/settings/apikey", {
user: { apikey }, user: { apikey },
@ -215,9 +197,6 @@ async function generateApiKey(req, res) {
return res.status(201).send({ apikey }); return res.status(201).send({ apikey });
} }
/**
* @type {import("express").Handler}
*/
async function resetPasswordRequest(req, res) { async function resetPasswordRequest(req, res) {
const [user] = await query.user.update( const [user] = await query.user.update(
{ email: req.body.email }, { email: req.body.email },
@ -228,7 +207,15 @@ async function resetPasswordRequest(req, res) {
); );
if (user) { if (user) {
await mail.resetPasswordToken(user); // TODO: handle error
await mail.resetPasswordToken(user).catch(() => null);
}
if (req.isHTML) {
res.render("partials/reset_password/form", {
message: "If the email address exists, a reset password email will be sent to it."
});
return;
} }
return res.status(200).send({ return res.status(200).send({
@ -236,12 +223,9 @@ async function resetPasswordRequest(req, res) {
}); });
} }
/**
* @type {import("express").Handler}
*/
async function resetPassword(req, res, next) { async function resetPassword(req, res, next) {
const { resetPasswordToken } = req.params; const resetPasswordToken = req.params.resetPasswordToken;
if (resetPasswordToken) { if (resetPasswordToken) {
const [user] = await query.user.update( const [user] = await query.user.update(
{ {
@ -253,23 +237,25 @@ async function resetPassword(req, res, next) {
if (user) { if (user) {
const token = utils.signToken(user); const token = utils.signToken(user);
req.token = token; res.clearCookie("token", { httpOnly: true, secure: env.isProd });
res.cookie("token", token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
httpOnly: true,
secure: env.isProd
});
res.locals.token_verified = true;
req.cookies.token = token;
} }
} }
return next();
next();
} }
/**
* @type {import("express").Handler}
*/
function signupAccess(req, res, next) { function signupAccess(req, res, next) {
if (!env.DISALLOW_REGISTRATION) return next(); if (!env.DISALLOW_REGISTRATION) return next();
return res.status(403).send({ message: "Registration is not allowed." }); return res.status(403).send({ message: "Registration is not allowed." });
} }
/**
* @type {import("express").Handler}
*/
async function changeEmailRequest(req, res) { async function changeEmailRequest(req, res) {
const { email, password } = req.body; const { email, password } = req.body;
@ -317,11 +303,10 @@ async function changeEmailRequest(req, res) {
return res.status(200).send({ message }); return res.status(200).send({ message });
} }
/**
* @type {import("express").Handler}
*/
async function changeEmail(req, res, next) { async function changeEmail(req, res, next) {
const { changeEmailToken } = req.params; const changeEmailToken = req.params.changeEmailToken;
console.log("-", changeEmailToken, "-");
if (changeEmailToken) { if (changeEmailToken) {
const foundUser = await query.user.find({ const foundUser = await query.user.find({
@ -347,7 +332,14 @@ async function changeEmail(req, res, next) {
if (user) { if (user) {
const token = utils.signToken(user); const token = utils.signToken(user);
req.token = token; res.clearCookie("token", { httpOnly: true, secure: env.isProd });
res.cookie("token", token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
httpOnly: true,
secure: env.isProd
});
res.locals.token_verified = true;
req.cookies.token = token;
} }
} }
return next(); return next();

View File

@ -13,8 +13,6 @@ async function add(req, res) {
user_id: req.user.id user_id: req.user.id
}); });
await sleep(1000);
if (req.isHTML) { if (req.isHTML) {
const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain); const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain);
res.setHeader("HX-Reswap", "none"); res.setHeader("HX-Reswap", "none");
@ -38,8 +36,6 @@ async function remove(req, res) {
redis.remove.domain(domain); redis.remove.domain(domain);
await sleep(1000);
if (!domain) { if (!domain) {
throw new CustomError("Could not delete the domain.", 500); throw new CustomError("Could not delete the domain.", 500);
} }

View File

@ -12,9 +12,6 @@ const env = require("../env");
// return next(); // return next();
// }; // };
/**
* @type {import("express").Handler}
*/
function isHTML(req, res, next) { function isHTML(req, res, next) {
const accepts = req.accepts(["json", "html"]); const accepts = req.accepts(["json", "html"]);
req.isHTML = accepts === "html"; req.isHTML = accepts === "html";
@ -22,9 +19,6 @@ function isHTML(req, res, next) {
} }
function addNoLayoutLocals(req, res, next) { function addNoLayoutLocals(req, res, next) {
/**
* @type {import("express").Handler}
*/
res.locals.layout = null; res.locals.layout = null;
next(); next();
} }
@ -36,17 +30,12 @@ function viewTemplate(template) {
} }
} }
/**
* @type {import("express").Handler}
*/
function addConfigLocals(req, res, next) { function addConfigLocals(req, res, next) {
res.locals.default_domain = env.DEFAULT_DOMAIN; res.locals.default_domain = env.DEFAULT_DOMAIN;
res.locals.site_name = env.SITE_NAME;
next(); next();
} }
/**
* @type {import("express").Handler}
*/
async function addUserLocals(req, res, next) { async function addUserLocals(req, res, next) {
const user = req.user; const user = req.user;
res.locals.user = user; res.locals.user = user;
@ -54,9 +43,6 @@ async function addUserLocals(req, res, next) {
next(); next();
} }
/**
* @type {import("express").ErrorRequestHandler}
*/
function error(error, req, res, _next) { function error(error, req, res, _next) {
if (env.isDev) { if (env.isDev) {
signale.fatal(error); signale.fatal(error);
@ -74,9 +60,6 @@ function error(error, req, res, _next) {
}; };
/**
* @type {import("express").Handler}
*/
function verify(req, res, next) { function verify(req, res, next) {
const result = validationResult(req); const result = validationResult(req);
if (result.isEmpty()) return next(); if (result.isEmpty()) return next();
@ -124,7 +107,7 @@ function parseQuery(req, res, next) {
req.context = { req.context = {
limit: limit > 50 ? 50 : limit, limit: limit > 50 ? 50 : limit,
skip, skip,
all: admin ? req.query.all === "true" : false all: admin ? req.query.all === "true" || req.query.all === "on" : false
}; };
next(); next();

View File

@ -5,9 +5,10 @@ const URL = require("url");
const dns = require("dns"); const dns = require("dns");
const validators = require("./validators.handler"); const validators = require("./validators.handler");
// const transporter = require("../mail"); const map = require("../utils/map.json");
const transporter = require("../mail");
const query = require("../queries"); const query = require("../queries");
// const queue = require("../queues"); const queue = require("../queues");
const utils = require("../utils"); const utils = require("../utils");
const env = require("../env"); const env = require("../env");
const { differenceInSeconds } = require("date-fns"); const { differenceInSeconds } = require("date-fns");
@ -15,9 +16,6 @@ const { differenceInSeconds } = require("date-fns");
const CustomError = utils.CustomError; const CustomError = utils.CustomError;
const dnsLookup = promisify(dns.lookup); const dnsLookup = promisify(dns.lookup);
/**
* @type {import("express").Handler}
*/
async function get(req, res) { async function get(req, res) {
const { limit, skip, all } = req.context; const { limit, skip, all } = req.context;
const search = req.query.search; const search = req.query.search;
@ -34,8 +32,6 @@ async function get(req, res) {
const links = data.map(utils.sanitize.link); const links = data.map(utils.sanitize.link);
await utils.sleep(1000);
if (req.isHTML) { if (req.isHTML) {
res.render("partials/links/table", { res.render("partials/links/table", {
total, total,
@ -54,9 +50,6 @@ async function get(req, res) {
}); });
}; };
/**
* @type {import("express").Handler}
*/
async function create(req, res) { async function create(req, res) {
const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body; const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
const domain_id = fetched_domain ? fetched_domain.id : null; const domain_id = fetched_domain ? fetched_domain.id : null;
@ -78,7 +71,7 @@ async function create(req, res) {
address: customurl, address: customurl,
domain_id domain_id
}), }),
!customurl && utils.generateId(domain_id), !customurl && utils.generateId(query, domain_id),
validators.bannedDomain(targetDomain), validators.bannedDomain(targetDomain),
validators.bannedHost(targetDomain) validators.bannedHost(targetDomain)
]); ]);
@ -161,8 +154,6 @@ async function edit(req, res) {
isChanged = true; isChanged = true;
}); });
await utils.sleep(1000);
if (!isChanged) { if (!isChanged) {
throw new CustomError("Should at least update one field."); throw new CustomError("Should at least update one field.");
} }
@ -215,9 +206,6 @@ async function edit(req, res) {
return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink })); return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
}; };
/**
* @type {import("express").Handler}
*/
async function remove(req, res) { async function remove(req, res) {
const { error, isRemoved, link } = await query.link.remove({ const { error, isRemoved, link } = await query.link.remove({
uuid: req.params.id, uuid: req.params.id,
@ -229,8 +217,6 @@ async function remove(req, res) {
throw new CustomError(messsage); throw new CustomError(messsage);
} }
await utils.sleep(1000);
if (req.isHTML) { if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML"); res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadLinks"); res.setHeader("HX-Trigger", "reloadLinks");
@ -245,24 +231,22 @@ async function remove(req, res) {
.send({ message: "Link has been deleted successfully." }); .send({ message: "Link has been deleted successfully." });
}; };
// export const report: Handler = async (req, res) => { async function report(req, res) {
// const { link } = req.body; const { link } = req.body;
// const mail = await transporter.sendMail({ await transporter.sendReportEmail(link);
// from: env.MAIL_FROM || env.MAIL_USER,
// to: env.REPORT_EMAIL,
// subject: "[REPORT]",
// text: link,
// html: link
// });
// if (!mail.accepted.length) { if (req.isHTML) {
// throw new CustomError("Couldn't submit the report. Try again later."); res.render("partials/report/form", {
// } message: "Report was received. We'll take actions shortly."
// return res });
// .status(200) return;
// .send({ message: "Thanks for the report, we'll take actions shortly." }); }
// };
return res
.status(200)
.send({ message: "Thanks for the report, we'll take actions shortly." });
};
async function ban(req, res) { async function ban(req, res) {
const { id } = req.params; const { id } = req.params;
@ -320,8 +304,6 @@ async function ban(req, res) {
}); });
// 8. Send response // 8. Send response
await utils.sleep(1000);
if (req.isHTML) { if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML"); res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadLinks"); res.setHeader("HX-Trigger", "reloadLinks");
@ -334,148 +316,178 @@ async function ban(req, res) {
return res.status(200).send({ message: "Banned link successfully." }); return res.status(200).send({ message: "Banned link successfully." });
}; };
// export const redirect = (app) => async ( async function redirect(req, res, next) {
// req, const isPreservedUrl = utils.preservedURLs.some(
// res, item => item === req.path.replace("/", "")
// next );
// ) => {
// const isBot = isbot(req.headers["user-agent"]);
// const isPreservedUrl = validators.preservedUrls.some(
// item => item === req.path.replace("/", "")
// );
// if (isPreservedUrl) return next(); if (isPreservedUrl) return next();
// // 1. If custom domain, get domain info // 1. If custom domain, get domain info
// const host = utils.removeWww(req.headers.host); const host = utils.removeWww(req.headers.host);
// const domain = const domain =
// host !== env.DEFAULT_DOMAIN host !== env.DEFAULT_DOMAIN
// ? await query.domain.find({ address: host }) ? await query.domain.find({ address: host })
// : null; : null;
// // 2. Get link // 2. Get link
// const address = req.params.id.replace("+", ""); const address = req.params.id.replace("+", "");
// const link = await query.link.find({ const link = await query.link.find({
// address, address,
// domain_id: domain ? domain.id : null domain_id: domain ? domain.id : null
// }); });
// // 3. When no link, if has domain redirect to domain's homepage // 3. When no link, if has domain redirect to domain's homepage
// // otherwise redirect to 404 // otherwise redirect to 404
// if (!link) { if (!link) {
// return res.redirect(302, domain ? domain.homepage : "/404"); return res.redirect(domain.homepage || "/404");
// } }
// // 4. If link is banned, redirect to banned page. // 4. If link is banned, redirect to banned page.
// if (link.banned) { if (link.banned) {
// return res.redirect("/banned"); return res.redirect("/banned");
// } }
// // 5. If wants to see link info, then redirect // 5. If wants to see link info, then redirect
// const doesRequestInfo = /.*\+$/gi.test(req.params.id); const isRequestingInfo = /.*\+$/gi.test(req.params.id);
// if (doesRequestInfo && !link.password) { if (isRequestingInfo && !link.password) {
// return app.render(req, res, "/url-info", { target: link.target }); if (req.isHTML) {
// } res.render("url_info", {
title: "Short link information",
target: link.target,
link: utils.getShortURL(link.address, link.domain).link
});
return;
}
return res.send({ target: link.target });
}
// // 6. If link is protected, redirect to password page // 6. If link is protected, redirect to password page
// if (link.password) { if (link.password) {
// return res.redirect(`/protected/${link.uuid}`); res.render("protected", {
// } title: "Protected short link",
id: link.uuid
});
return;
}
// // 7. Create link visit // 7. Create link visit
// if (link.user_id && !isBot) { const isBot = isbot(req.headers["user-agent"]);
// queue.visit.add({ if (link.user_id && !isBot) {
// headers: req.headers, queue.visit.add({
// realIP: req.realIP, headers: req.headers,
// referrer: req.get("Referrer"), realIP: req.realIP,
// link referrer: req.get("Referrer"),
// }); link
// } });
}
// // 8. Redirect to target // 8. Redirect to target
// return res.redirect(link.target); return res.redirect(link.target);
// }; };
// export const redirectProtected: Handler = async (req, res) => { async function redirectProtected(req, res) {
// // 1. Get link // 1. Get link
// const uuid = req.params.id; const uuid = req.params.id;
// const link = await query.link.find({ uuid }); const link = await query.link.find({ uuid });
// // 2. Throw error if no link // 2. Throw error if no link
// if (!link || !link.password) { if (!link || !link.password) {
// throw new CustomError("Couldn't find the link.", 400); throw new CustomError("Couldn't find the link.", 400);
// } }
// // 3. Check if password matches // 3. Check if password matches
// const matches = await bcrypt.compare(req.body.password, link.password); const matches = await bcrypt.compare(req.body.password, link.password);
// if (!matches) { if (!matches) {
// throw new CustomError("Password is not correct.", 401); throw new CustomError("Password is not correct.", 401);
// } }
// // 4. Create visit // 4. Create visit
// if (link.user_id) { if (link.user_id) {
// queue.visit.add({ queue.visit.add({
// headers: req.headers, headers: req.headers,
// realIP: req.realIP, realIP: req.realIP,
// referrer: req.get("Referrer"), referrer: req.get("Referrer"),
// link link
// }); });
// } }
// // 5. Send target // 5. Send target
// return res.status(200).send({ target: link.target }); if (req.isHTML) {
// }; res.setHeader("HX-Redirect", link.target);
res.render("partials/protected/form", {
id: link.uuid,
message: "Redirecting...",
});
return;
}
return res.status(200).send({ target: link.target });
};
// export const redirectCustomDomain: Handler = async (req, res, next) => { async function redirectCustomDomainHomepage(req, res, next) {
// const { path } = req; const path = req.path;
// const host = utils.removeWww(req.headers.host); const host = utils.removeWww(req.headers.host);
// if (host === env.DEFAULT_DOMAIN) { if (host === env.DEFAULT_DOMAIN) {
// return next(); return next();
// } }
// if ( if (
// path === "/" || path === "/" ||
// validators.preservedUrls utils.preservedURLs
// .filter(l => l !== "url-password") .filter(l => l !== "url-password")
// .some(item => item === path.replace("/", "")) .some(item => item === path.replace("/", ""))
// ) { ) {
// const domain = await query.domain.find({ address: host }); const domain = await query.domain.find({ address: host });
// const redirectURL = domain const redirectURL = domain
// ? domain.homepage ? domain.homepage
// : `https://${env.DEFAULT_DOMAIN + path}`; : `https://${env.DEFAULT_DOMAIN + path}`;
// return res.redirect(302, redirectURL); return res.redirect(302, redirectURL);
// } }
// return next(); return next();
// }; };
// export const stats: Handler = async (req, res) => { async function stats(req, res) {
// const { user } = req; const { user } = req;
// const uuid = req.params.id; const uuid = req.params.id;
// const link = await query.link.find({ const link = await query.link.find({
// ...(!user.admin && { user_id: user.id }), ...(!user.admin && { user_id: user.id }),
// uuid uuid
// }); });
// if (!link) { if (!link) {
// throw new CustomError("Link could not be found."); if (req.isHTML) {
// } res.setHeader("HX-Redirect", "/404");
res.status(200).send("");
return;
}
throw new CustomError("Link could not be found.");
}
// const stats = await query.visit.find({ link_id: link.id }, link.visit_count); const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
// if (!stats) { if (!stats) {
// throw new CustomError("Could not get the short link stats."); throw new CustomError("Could not get the short link stats. Try again later.");
// } }
// return res.status(200).send({ if (req.isHTML) {
// ...stats, res.render("partials/stats", {
// ...utils.sanitize.link(link) link: utils.sanitize.link(link),
// }); stats,
// }; map,
});
return;
}
return res.status(200).send({
...stats,
...utils.sanitize.link(link)
});
};
module.exports = { module.exports = {
ban, ban,
@ -483,4 +495,9 @@ module.exports = {
edit, edit,
get, get,
remove, remove,
report,
stats,
redirect,
redirectProtected,
redirectCustomDomainHomepage,
} }

View File

@ -1,21 +1,21 @@
/**
* @type {import("express").Handler}
*/
function createLink(req, res, next) { function createLink(req, res, next) {
res.locals.show_advanced = !!req.body.show_advanced; res.locals.show_advanced = !!req.body.show_advanced;
next(); next();
} }
/**
* @type {import("express").Handler}
*/
function editLink(req, res, next) { function editLink(req, res, next) {
res.locals.id = req.params.id; res.locals.id = req.params.id;
res.locals.class = "no-animation"; res.locals.class = "no-animation";
next(); next();
} }
function protected(req, res, next) {
res.locals.id = req.params.id;
next();
}
module.exports = { module.exports = {
createLink, createLink,
editLink, editLink,
protected,
} }

View File

@ -2,18 +2,12 @@ const utils = require("../utils");
const query = require("../queries") const query = require("../queries")
const env = require("../env"); const env = require("../env");
/**
* @type {import("express").Handler}
*/
async function homepage(req, res) { async function homepage(req, res) {
res.render("homepage", { res.render("homepage", {
title: "Modern open source URL shortener", title: "Modern open source URL shortener",
}); });
} }
/**
* @type {import("express").Handler}
*/
function login(req, res) { function login(req, res) {
if (req.user) { if (req.user) {
return res.redirect("/"); return res.redirect("/");
@ -23,9 +17,6 @@ function login(req, res) {
}); });
} }
/**
* @type {import("express").Handler}
*/
function logout(req, res) { function logout(req, res) {
res.clearCookie("token", { httpOnly: true, secure: env.isProd }); res.clearCookie("token", { httpOnly: true, secure: env.isProd });
res.render("logout", { res.render("logout", {
@ -33,9 +24,12 @@ function logout(req, res) {
}); });
} }
/** function notFound(req, res) {
* @type {import("express").Handler} res.render("404", {
*/ title: "404 - Not found"
});
}
function settings(req, res) { function settings(req, res) {
// TODO: make this a middelware function, apply it to where it's necessary // TODO: make this a middelware function, apply it to where it's necessary
if (!req.user) { if (!req.user) {
@ -46,16 +40,64 @@ function settings(req, res) {
}); });
} }
function stats(req, res) {
// TODO: make this a middelware function, apply it to where it's necessary
if (!req.user) {
return res.redirect("/");
}
const id = req.query.id;
res.render("stats", {
title: "Stats"
});
}
async function banned(req, res) {
res.render("banned", {
title: "Banned link",
});
}
async function report(req, res) {
res.render("report", {
title: "Report abuse",
});
}
async function resetPassword(req, res) {
res.render("reset_password", {
title: "Reset password",
});
}
async function resetPasswordResult(req, res) {
res.render("reset_password_result", {
title: "Reset password",
});
}
async function verifyChangeEmail(req, res) {
res.render("verify_change_email", {
title: "Verifying email",
});
}
async function verify(req, res) {
res.render("verify", {
title: "Verify",
});
}
async function terms(req, res) {
res.render("terms", {
title: "Terms of Service",
});
}
/**
* @type {import("express").Handler}
*/
async function confirmLinkDelete(req, res) { async function confirmLinkDelete(req, res) {
const link = await query.link.find({ const link = await query.link.find({
uuid: req.query.id, uuid: req.query.id,
...(!req.user.admin && { user_id: req.user.id }) ...(!req.user.admin && { user_id: req.user.id })
}); });
await utils.sleep(500);
if (!link) { if (!link) {
return res.render("partials/links/dialog/message", { return res.render("partials/links/dialog/message", {
layout: false, layout: false,
@ -69,15 +111,11 @@ async function confirmLinkDelete(req, res) {
}); });
} }
/**
* @type {import("express").Handler}
*/
async function confirmLinkBan(req, res) { async function confirmLinkBan(req, res) {
const link = await query.link.find({ const link = await query.link.find({
uuid: req.query.id, uuid: req.query.id,
...(!req.user.admin && { user_id: req.user.id }) ...(!req.user.admin && { user_id: req.user.id })
}); });
await utils.sleep(500);
if (!link) { if (!link) {
return res.render("partials/links/dialog/message", { return res.render("partials/links/dialog/message", {
message: "Could not find the link." message: "Could not find the link."
@ -89,23 +127,15 @@ async function confirmLinkBan(req, res) {
}); });
} }
/**
* @type {import("express").Handler}
*/
async function addDomainForm(req, res) { async function addDomainForm(req, res) {
await utils.sleep(1000);
res.render("partials/settings/domain/add_form"); res.render("partials/settings/domain/add_form");
} }
/**
* @type {import("express").Handler}
*/
async function confirmDomainDelete(req, res) { async function confirmDomainDelete(req, res) {
const domain = await query.domain.find({ const domain = await query.domain.find({
uuid: req.query.id, uuid: req.query.id,
user_id: req.user.id user_id: req.user.id
}); });
await utils.sleep(500);
if (!domain) { if (!domain) {
throw new utils.CustomError("Could not find the link", 400); throw new utils.CustomError("Could not find the link", 400);
} }
@ -115,15 +145,21 @@ async function confirmDomainDelete(req, res) {
} }
/** async function getReportEmail(req, res) {
* @type {import("express").Handler} if (!env.REPORT_EMAIL) {
*/ throw new utils.CustomError("No report email is available.", 400);
}
res.render("partials/report/email", {
report_email: env.REPORT_EMAIL.replace("@", "[at]")
});
}
async function linkEdit(req, res) { async function linkEdit(req, res) {
const link = await query.link.find({ const link = await query.link.find({
uuid: req.params.id, uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id }) ...(!req.user.admin && { user_id: req.user.id })
}); });
await utils.sleep(500);
// TODO: handle when no link // TODO: handle when no link
// if (!link) { // if (!link) {
// return res.render("partials/links/dialog/message", { // return res.render("partials/links/dialog/message", {
@ -138,12 +174,22 @@ async function linkEdit(req, res) {
module.exports = { module.exports = {
addDomainForm, addDomainForm,
banned,
confirmDomainDelete,
confirmLinkBan,
confirmLinkDelete,
getReportEmail,
homepage, homepage,
linkEdit, linkEdit,
login, login,
logout, logout,
confirmDomainDelete, notFound,
confirmLinkBan, report,
confirmLinkDelete, resetPassword,
resetPasswordResult,
settings, settings,
stats,
terms,
verifyChangeEmail,
verify,
} }

View File

@ -17,8 +17,6 @@ async function get(req, res) {
async function remove(req, res) { async function remove(req, res) {
await query.user.remove(req.user); await query.user.remove(req.user);
await utils.sleep(1000);
if (req.isHTML) { if (req.isHTML) {
res.clearCookie("token", { httpOnly: true, secure: env.isProd }); res.clearCookie("token", { httpOnly: true, secure: env.isProd });
res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage"); res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");

View File

@ -167,16 +167,16 @@ const editLink = [
.isLength({ min: 36, max: 36 }) .isLength({ min: 36, max: 36 })
]; ];
// export const redirectProtected = [ const redirectProtected = [
// body("password", "Password is invalid.") body("password", "Password is invalid.")
// .exists({ checkFalsy: true, checkNull: true }) .exists({ checkFalsy: true, checkNull: true })
// .isString() .isString()
// .isLength({ min: 3, max: 64 }) .isLength({ min: 3, max: 64 })
// .withMessage("Password length must be between 3 and 64."), .withMessage("Password length must be between 3 and 64."),
// param("id", "ID is invalid.") param("id", "ID is invalid.")
// .exists({ checkFalsy: true, checkNull: true }) .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 36, max: 36 }) .isLength({ min: 36, max: 36 })
// ]; ];
const addDomain = [ const addDomain = [
body("address", "Domain is not valid.") body("address", "Domain is not valid.")
@ -221,18 +221,18 @@ const deleteLink = [
.isLength({ min: 36, max: 36 }) .isLength({ min: 36, max: 36 })
]; ];
// export const reportLink = [ const reportLink = [
// body("link", "No link has been provided.") body("link", "No link has been provided.")
// .exists({ .exists({
// checkFalsy: true, checkFalsy: true,
// checkNull: true checkNull: true
// }) })
// .customSanitizer(addProtocol) .customSanitizer(addProtocol)
// .custom( .custom(
// value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN value => removeWww(URL.parse(value).host) === env.DEFAULT_DOMAIN
// ) )
// .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`) .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
// ]; ];
const banLink = [ const banLink = [
param("id", "ID is invalid.") param("id", "ID is invalid.")
@ -267,14 +267,14 @@ const banLink = [
.isBoolean() .isBoolean()
]; ];
// export const getStats = [ const getStats = [
// param("id", "ID is invalid.") param("id", "ID is invalid.")
// .exists({ .exists({
// checkFalsy: true, checkFalsy: true,
// checkNull: true checkNull: true
// }) })
// .isLength({ min: 36, max: 36 }) .isLength({ min: 36, max: 36 })
// ]; ];
const signup = [ const signup = [
body("password", "Password is not valid.") body("password", "Password is not valid.")
@ -336,18 +336,14 @@ const changeEmail = [
.withMessage("Email length must be max 255.") .withMessage("Email length must be max 255.")
]; ];
// export const resetPasswordRequest = [ const resetPassword = [
// body("email", "Email is not valid.") body("email", "Email is not valid.")
// .exists({ checkFalsy: true, checkNull: true }) .exists({ checkFalsy: true, checkNull: true })
// .trim() .trim()
// .isEmail() .isEmail()
// .isLength({ min: 0, max: 255 }) .isLength({ min: 0, max: 255 })
// .withMessage("Email length must be max 255."), .withMessage("Email length must be max 255.")
// body("password", "Password is not valid.") ];
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 8, max: 64 })
// .withMessage("Password length must be between 8 and 64.")
// ];
// export const resetEmailRequest = [ // export const resetEmailRequest = [
// body("email", "Email is not valid.") // body("email", "Email is not valid.")
@ -496,9 +492,13 @@ module.exports = {
deleteLink, deleteLink,
deleteUser, deleteUser,
editLink, editLink,
getStats,
linksCount, linksCount,
login, login,
malware, malware,
redirectProtected,
removeDomain, removeDomain,
reportLink,
resetPassword,
signup, signup,
} }

View File

@ -100,8 +100,23 @@ async function resetPasswordToken(user) {
} }
} }
async function sendReportEmail(link) {
const mail = await transporter.sendMail({
from: env.MAIL_FROM || env.MAIL_USER,
to: env.REPORT_EMAIL,
subject: "[REPORT]",
text: link,
html: link
});
if (!mail.accepted.length) {
throw new CustomError("Couldn't submit the report. Try again later.");
}
}
module.exports = { module.exports = {
changeEmail, changeEmail,
verification, verification,
resetPasswordToken, resetPasswordToken,
sendReportEmail,
} }

View File

@ -1,5 +1,5 @@
// const visit = require("./visit.queries");
const domain = require("./domain.queries"); const domain = require("./domain.queries");
const visit = require("./visit.queries");
const link = require("./link.queries"); const link = require("./link.queries");
const user = require("./user.queries"); const user = require("./user.queries");
const host = require("./host.queries"); const host = require("./host.queries");
@ -11,5 +11,5 @@ module.exports = {
ip, ip,
link, link,
user, user,
// visit visit
}; };

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

View File

@ -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
View File

@ -0,0 +1,5 @@
const { visit } = require("./queues");
module.exports = {
visit,
};

View File

@ -1,7 +0,0 @@
import { visit } from "./queues";
const queues = {
visit
};
export default queues;

75
server/queues/queues.js Normal file
View 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,
}

View File

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

View File

@ -1,30 +1,40 @@
import useragent from "useragent"; const useragent = require("useragent");
import geoip from "geoip-lite"; const geoip = require("geoip-lite");
import URL from "url"; const URL = require("url");
import query from "../queries"; const { getStatsLimit, removeWww } = require("../utils");
import { getStatsLimit, removeWww } from "../utils/utils"; const query = require("../queries");
const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"]; const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"]; const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];
const filterInBrowser = (agent) => (item) =>
agent.family.toLowerCase().includes(item.toLocaleLowerCase());
const filterInOs = (agent) => (item) =>
agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
export default function visit({ data }) { function filterInBrowser(agent) {
return function(item) {
return agent.family.toLowerCase().includes(item.toLocaleLowerCase());
}
}
function filterInOs(agent) {
return function(item) {
return agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
}
}
module.exports = function({ data }) {
const tasks = []; const tasks = [];
tasks.push(query.link.incrementVisit({ id: data.link.id })); tasks.push(query.link.incrementVisit({ id: data.link.id }));
if (data.link.visit_count < getStatsLimit()) { if (data.link.visit_count < getStatsLimit()) {
const agent = useragent.parse(data.headers["user-agent"]); const agent = useragent.parse(data.headers["user-agent"]);
const [browser = "Other"] = browsersList.filter(filterInBrowser(agent)); const [browser = "Other"] = browsersList.filter(filterInBrowser(agent));
const [os = "Other"] = osList.filter(filterInOs(agent)); const [os = "Other"] = osList.filter(filterInOs(agent));
const referrer = const referrer =
data.referrer && removeWww(URL.parse(data.referrer).hostname); data.referrer && removeWww(URL.parse(data.referrer).hostname);
const location = geoip.lookup(data.realIP); const location = geoip.lookup(data.realIP);
const country = location && location.country; const country = location && location.country;
tasks.push( tasks.push(
query.visit.add({ query.visit.add({
browser: browser.toLowerCase(), browser: browser.toLowerCase(),
@ -37,4 +47,4 @@ export default function visit({ data }) {
} }
return Promise.all(tasks); return Promise.all(tasks);
} }

View File

@ -1 +0,0 @@
module.exports = require("./renders");

View File

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

View File

@ -4,6 +4,7 @@ const { Router } = require("express");
const validators = require("../handlers/validators.handler"); const validators = require("../handlers/validators.handler");
const helpers = require("../handlers/helpers.handler"); const helpers = require("../handlers/helpers.handler");
const auth = require("../handlers/auth.handler"); const auth = require("../handlers/auth.handler");
const utils = require("../utils");
const router = Router(); const router = Router();
@ -52,6 +53,12 @@ router.post(
asyncHandler(auth.generateApiKey) asyncHandler(auth.generateApiKey)
); );
// router.post("/reset-password", asyncHandler(auth.resetPasswordRequest)); router.post(
"/reset-password",
helpers.viewTemplate("partials/reset_password/form"),
validators.resetPassword,
asyncHandler(helpers.verify),
asyncHandler(auth.resetPasswordRequest)
);
module.exports = router; module.exports = router;

View File

@ -1,7 +1,7 @@
import { Router } from "express"; const { Router } = require("express");
const router = Router(); const router = Router();
router.get("/", (_, res) => res.send("OK")); router.get("/", (_, res) => res.send("OK"));
export default router; module.exports = router;

View File

@ -66,27 +66,32 @@ router.post(
asyncHandler(link.ban) asyncHandler(link.ban)
); );
// router.get( router.get(
// "/:id/stats", "/:id/stats",
// asyncHandler(auth.apikey), helpers.viewTemplate("partials/stats"),
// asyncHandler(auth.jwt), asyncHandler(auth.apikey),
// validators.getStats, asyncHandler(auth.jwt),
// asyncHandler(link.stats) validators.getStats,
// ); asyncHandler(helpers.verify),
asyncHandler(link.stats)
);
// router.post( router.post(
// "/:id/protected", "/:id/protected",
// validators.redirectProtected, helpers.viewTemplate("partials/protected/form"),
// asyncHandler(helpers.verify), locals.protected,
// asyncHandler(link.redirectProtected) validators.redirectProtected,
// ); asyncHandler(helpers.verify),
asyncHandler(link.redirectProtected)
);
// router.post( router.post(
// "/report", "/report",
// validators.reportLink, helpers.viewTemplate("partials/report/form"),
// asyncHandler(helpers.verify), validators.reportLink,
// asyncHandler(link.report) asyncHandler(helpers.verify),
// ); asyncHandler(link.report)
);
module.exports = router; module.exports = router;

View File

@ -2,8 +2,8 @@ const asyncHandler = require("express-async-handler");
const { Router } = require("express"); const { Router } = require("express");
const helpers = require("../handlers/helpers.handler"); const helpers = require("../handlers/helpers.handler");
const renders = require("../handlers/renders.handler");
const auth = require("../handlers/auth.handler"); const auth = require("../handlers/auth.handler");
const renders = require("./renders.handler");
const router = Router(); const router = Router();
@ -27,6 +27,12 @@ router.get(
asyncHandler(renders.logout) asyncHandler(renders.logout)
); );
router.get(
"/404",
asyncHandler(auth.jwtLoose),
asyncHandler(renders.notFound)
);
router.get( router.get(
"/settings", "/settings",
asyncHandler(auth.jwtLoose), asyncHandler(auth.jwtLoose),
@ -34,6 +40,57 @@ router.get(
asyncHandler(renders.settings) asyncHandler(renders.settings)
); );
router.get(
"/stats",
asyncHandler(auth.jwtLoose),
asyncHandler(helpers.addUserLocals),
asyncHandler(renders.stats)
);
router.get(
"/banned",
asyncHandler(renders.banned)
);
router.get(
"/report",
asyncHandler(renders.report)
);
router.get(
"/reset-password",
asyncHandler(renders.resetPassword)
);
router.get(
"/reset-password/:resetPasswordToken",
asyncHandler(auth.resetPassword),
asyncHandler(auth.jwtLoose),
asyncHandler(helpers.addUserLocals),
asyncHandler(renders.resetPasswordResult)
);
router.get(
"/verify-email/:changeEmailToken",
asyncHandler(auth.changeEmail),
asyncHandler(auth.jwtLoose),
asyncHandler(helpers.addUserLocals),
asyncHandler(renders.verifyChangeEmail)
);
router.get(
"/verify/:verificationToken",
asyncHandler(auth.verify),
asyncHandler(auth.jwtLoose),
asyncHandler(helpers.addUserLocals),
asyncHandler(renders.verify)
);
router.get(
"/terms",
asyncHandler(renders.terms)
);
// partial renders // partial renders
router.get( router.get(
"/confirm-link-delete", "/confirm-link-delete",
@ -73,4 +130,11 @@ router.get(
asyncHandler(renders.confirmDomainDelete) asyncHandler(renders.confirmDomainDelete)
); );
router.get(
"/get-report-email",
helpers.addNoLayoutLocals,
helpers.viewTemplate("partials/report/email"),
asyncHandler(renders.getReportEmail)
);
module.exports = router; module.exports = router;

View File

@ -1,19 +1,25 @@
const { Router } = require("express"); const { Router } = require("express");
const helpers = require("./../handlers/helpers.handler"); const helpers = require("./../handlers/helpers.handler");
const renders = require("./renders.routes");
const domains = require("./domain.routes"); const domains = require("./domain.routes");
// import health from "./health.routes"; const health = require("./health.routes");
const link = require("./link.routes"); const link = require("./link.routes");
const user = require("./user.routes"); const user = require("./user.routes");
const auth = require("./auth.routes"); const auth = require("./auth.routes");
const router = Router(); const apiRouter = Router();
const renderRouter = Router();
router.use(helpers.addNoLayoutLocals); renderRouter.use(renders);
router.use("/domains", domains); apiRouter.use(helpers.addNoLayoutLocals);
// router.use("/health", health); apiRouter.use("/domains", domains);
router.use("/links", link); apiRouter.use("/health", health);
router.use("/users", user); apiRouter.use("/links", link);
router.use("/auth", auth); apiRouter.use("/users", user);
apiRouter.use("/auth", auth);
module.exports = router; module.exports = {
api: apiRouter,
render: renderRouter,
};

View File

@ -1,8 +1,9 @@
const env = require("./env"); const env = require("./env");
// import asyncHandler from "express-async-handler"; const asyncHandler = require("express-async-handler");
// import passport from "passport";
const cookieParser = require("cookie-parser"); const cookieParser = require("cookie-parser");
const compression = require("compression");
const passport = require("passport");
const express = require("express"); const express = require("express");
const helmet = require("helmet"); const helmet = require("helmet");
const morgan = require("morgan"); const morgan = require("morgan");
@ -10,18 +11,21 @@ const path = require("path");
const hbs = require("hbs"); const hbs = require("hbs");
const helpers = require("./handlers/helpers.handler"); const helpers = require("./handlers/helpers.handler");
// import * as links from "./handlers/links"; const links = require("./handlers/links.handler");
// import * as auth from "./handlers/auth"; const { stream } = require("./config/winston");
const routes = require("./routes"); const routes = require("./routes");
const renders = require("./renders");
const utils = require("./utils"); const utils = require("./utils");
const { stream } = require("./config/winston")
// import "./cron"; // import "./cron";
require("./passport"); require("./passport");
const app = express(); const app = express();
// enable gzip on dev
if (env.isDev) {
app.use(compression());
}
// TODO: comments // TODO: comments
app.set("trust proxy", true); app.set("trust proxy", true);
@ -35,7 +39,7 @@ app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.static("static")); app.use(express.static("static"));
// app.use(passport.initialize()); app.use(passport.initialize());
// app.use(helpers.ip); // app.use(helpers.ip);
app.use(helpers.isHTML); app.use(helpers.isHTML);
app.use(helpers.addConfigLocals); app.use(helpers.addConfigLocals);
@ -45,38 +49,19 @@ app.set("view engine", "hbs");
app.set("views", path.join(__dirname, "views")); app.set("views", path.join(__dirname, "views"));
utils.registerHandlebarsHelpers(); utils.registerHandlebarsHelpers();
app.use("/", renders); app.use("/", routes.render);
// app.use(asyncHandler(links.redirectCustomDomain)); // if is custom domain, redirect to the set homepage
app.use(asyncHandler(links.redirectCustomDomainHomepage));
app.use("/api/v2", routes); app.use("/api/v2", routes.api);
app.use("/api", routes); app.use("/api", routes.api);
// server.get( // finally, redirect the short link to the target
// "/reset-password/:resetPasswordToken?", app.get("/:id", asyncHandler(links.redirect));
// asyncHandler(auth.resetPassword),
// (req, res) => app.render(req, res, "/reset-password", { token: req.token })
// );
// server.get(
// "/verify-email/:changeEmailToken",
// asyncHandler(auth.changeEmail),
// (req, res) => app.render(req, res, "/verify-email", { token: req.token })
// );
// server.get(
// "/verify/:verificationToken?",
// asyncHandler(auth.verify),
// (req, res) => app.render(req, res, "/verify", { token: req.token })
// );
// server.get("/:id", asyncHandler(links.redirect(app)));
// Error handler // Error handler
app.use(helpers.error); app.use(helpers.error);
// Handler everything else by Next.js
// server.get("*", (req, res) => handle(req, res));
app.listen(env.PORT, () => { app.listen(env.PORT, () => {
console.log(`> Ready on http://localhost:${env.PORT}`); console.log(`> Ready on http://localhost:${env.PORT}`);

892
server/utils/map.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@ const ms = require("ms");
const path = require("path"); const path = require("path");
const nanoid = require("nanoid/generate"); const nanoid = require("nanoid/generate");
const JWT = require("jsonwebtoken"); const JWT = require("jsonwebtoken");
const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays } = require("date-fns"); const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears } = require("date-fns");
const hbs = require("hbs"); const hbs = require("hbs");
const env = require("../env"); const env = require("../env");
@ -16,8 +16,6 @@ class CustomError extends Error {
} }
} }
const query = require("../queries");
function isAdmin(email) { function isAdmin(email) {
return env.ADMIN_EMAILS.split(",") return env.ADMIN_EMAILS.split(",")
.map((e) => e.trim()) .map((e) => e.trim())
@ -37,7 +35,7 @@ function signToken(user) {
) )
} }
async function generateId(domain_id) { async function generateId(query, domain_id) {
const address = nanoid( const address = nanoid(
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789", "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
env.LINK_LENGTH env.LINK_LENGTH
@ -53,7 +51,7 @@ function addProtocol(url) {
} }
function getShortURL(address, domain) { function getShortURL(address, domain) {
const protocol = env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://"; const protocol = (env.CUSTOM_DOMAIN_USE_HTTPS || !domain) && !env.isDev ? "https://" : "http://";
const link = `${domain || env.DEFAULT_DOMAIN}/${address}`; const link = `${domain || env.DEFAULT_DOMAIN}/${address}`;
const url = `${protocol}${link}`; const url = `${protocol}${link}`;
return { link, url }; return { link, url };
@ -96,7 +94,7 @@ function getDifferenceFunction(type) {
if (type === "lastDay") return differenceInHours; if (type === "lastDay") return differenceInHours;
if (type === "lastWeek") return differenceInDays; if (type === "lastWeek") return differenceInDays;
if (type === "lastMonth") return differenceInDays; if (type === "lastMonth") return differenceInDays;
if (type === "allTime") return differenceInMonths; if (type === "lastYear") return differenceInMonths;
throw new Error("Unknown type."); throw new Error("Unknown type.");
} }
@ -110,11 +108,14 @@ function getUTCDate(dateString) {
); );
} }
const STATS_PERIODS = [ function getStatsPeriods(now) {
[1, "lastDay"], return [
[7, "lastWeek"], ["lastDay", subHours(now, 24)],
[30, "lastMonth"] ["lastWeek", subDays(now, 7)],
]; ["lastMonth", subDays(now, 30)],
["lastYear", subMonths(now, 12)],
]
}
const preservedURLs = [ const preservedURLs = [
"login", "login",
@ -233,6 +234,10 @@ function registerHandlebarsHelpers() {
hbs.registerHelper("ifEquals", function(arg1, arg2, options) { hbs.registerHelper("ifEquals", function(arg1, arg2, options) {
return (arg1 === arg2) ? options.fn(this) : options.inverse(this); return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
}); });
hbs.registerHelper("json", function(context) {
return JSON.stringify(context);
});
const blocks = {}; const blocks = {};
@ -270,6 +275,6 @@ module.exports = {
sanitize, sanitize,
signToken, signToken,
sleep, sleep,
STATS_PERIODS, getStatsPeriods,
statsObjectToArray, statsObjectToArray,
} }

10
server/views/404.hbs Normal file
View 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
View 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}}

View File

@ -5,7 +5,6 @@
{{/if}} {{/if}}
{{#unless user}} {{#unless user}}
{{> introduction}} {{> introduction}}
{{> features}}
{{> browser_extensions}} {{> browser_extensions}}
{{/unless}} {{/unless}}
{{> footer}} {{> footer}}

View File

@ -8,7 +8,7 @@
<link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" /> <link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/images/favicon-196x196.png" /> <link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
<link rel="mask-icon" href="/images/icon.svg" color="blue" /> <link rel="mask-icon" href="/images/icon.svg" color="blue" />
<link rel="manifest" href="manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#f3f3f3" /> <meta name="theme-color" content="#f3f3f3" />
<meta property="fb:app_id" content="123456789" /> <meta property="fb:app_id" content="123456789" />
<meta name="htmx-config" content='{"withCredentials":true}'> <meta name="htmx-config" content='{"withCredentials":true}'>

View File

@ -25,11 +25,12 @@
{{!-- TODO: Agree with terms --}} {{!-- TODO: Agree with terms --}}
<div class="buttons-wrapper"> <div class="buttons-wrapper">
<button type="submit" class="primary login"> <button type="submit" class="primary login">
<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4m-5-4 5-5-5-5m3.8 5H3"/></svg> <span>{{> icons/login}}</span>
<svg class="with-text spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg> <span>{{> icons/spinner}}</span>
Log in Log in
</button> </button>
<button <button
type="button"
class="secondary signup" class="secondary signup"
hx-post="/api/auth/signup" hx-post="/api/auth/signup"
hx-target="#login-signup" hx-target="#login-signup"
@ -40,12 +41,12 @@
hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')" hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')"
hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')" hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')"
> >
<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg> <span>{{> icons/new_user}}</span>
<svg class="with-text spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg> <span>{{> icons/spinner}}</span>
Sign up Sign up
</button> </button>
</div> </div>
<a class="forgot-password" href="/forgot-password" title="Reset password">Forgot your password?</a> <a class="forgot-password" href="/reset-password" title="Reset password">Forgot your password?</a>
{{#unless errors}} {{#unless errors}}
{{#if error}} {{#if error}}
<p class="error">{{error}}</p> <p class="error">{{error}}</p>

View File

@ -3,28 +3,28 @@
<ul> <ul>
<li> <li>
<div class="icon"> <div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 14.7V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.3"/><path d="m18 2 4 4-10 10H8v-4z"/></svg> {{> icons/write}}
</div> </div>
<h4>Managing links</h4> <h4>Managing links</h4>
<p>Create, protect and delete your links and monitor them with detailed statistics.</p> <p>Create, protect and delete your links and monitor them with detailed statistics.</p>
</li> </li>
<li> <li>
<div class="icon"> <div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 3h5v5M4 20 20.2 3.8M21 16v5h-5m-1-6 5.1 5.1M4 4l5 5"/></svg> {{> icons/shuffle}}
</div> </div>
<h4>Custom domain</h4> <h4>Custom domain</h4>
<p>Use custom domains for your links. Add or remove them for free.</p> <p>Use custom domains for your links. Add or remove them for free.</p>
</li> </li>
<li> <li>
<div class="icon"> <div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9z"/></svg> {{> icons/zap}}
</div> </div>
<h4>API</h4> <h4>API</h4>
<p>Use the provided API to create, delete, and get URLs from anywhere.</p> <p>Use the provided API to create, delete, and get URLs from anywhere.</p>
</li> </li>
<li> <li>
<div class="icon"> <div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.7 0l-1.1 1-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1 7.8 7.8 7.8-7.7 1-1.1a5.5 5.5 0 0 0 0-7.8z"/></svg> {{> icons/heart}}
</div> </div>
<h4>Free & open source</h4> <h4>Free & open source</h4>
<p>Completely open source and free. You can host it on your own server.</p> <p>Completely open source and free. You can host it on your own server.</p>

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -9,9 +9,14 @@
{{> icons/stop}} {{> icons/stop}}
</button> </button>
{{/if}} {{/if}}
<button class="action stats"> <a
class="button action stats"
href="/stats?id={{id}}"
title="Stats"
class="action stats"
>
{{> icons/chart}} {{> icons/chart}}
</button> </a>
<button <button
class="action qrcode" class="action qrcode"
hx-on:click="handleQRCode(this)" hx-on:click="handleQRCode(this)"

View File

@ -22,8 +22,9 @@
</label> </label>
</div> </div>
<div class="buttons"> <div class="buttons">
<button hx-on:click="closeDialog()">Cancel</button> <button type="button" hx-on:click="closeDialog()">Cancel</button>
<button <button
type="button"
class="danger confirm" class="danger confirm"
hx-post="/api/links/admin/ban/{id}" hx-post="/api/links/admin/ban/{id}"
hx-ext="path-params" hx-ext="path-params"

View File

@ -1,12 +1,12 @@
<div class="content"> <div class="content">
<div class="icon success"> <div class="icon success">
<svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg> {{> icons/check}}
</div> </div>
<p> <p>
The link <b>"{{link}}"</b> is banned. The link <b>"{{link}}"</b> is banned.
</p> </p>
<div class="buttons"> <div class="buttons">
<button hx-on:click="closeDialog()">Close</button> <button type="button" hx-on:click="closeDialog()">Close</button>
</div> </div>
</div> </div>

View File

@ -4,8 +4,9 @@
Are you sure do you want to delete the link &quot;<b>{{link}}</b>&quot;? Are you sure do you want to delete the link &quot;<b>{{link}}</b>&quot;?
</p> </p>
<div class="buttons"> <div class="buttons">
<button hx-on:click="closeDialog()">Cancel</button> <button type="button" hx-on:click="closeDialog()">Cancel</button>
<button <button
type="button"
class="danger confirm" class="danger confirm"
hx-delete="/api/links/{id}" hx-delete="/api/links/{id}"
hx-ext="path-params" hx-ext="path-params"
@ -15,10 +16,10 @@
hx-indicator="closest .content" hx-indicator="closest .content"
hx-select-oob="#dialog-error" hx-select-oob="#dialog-error"
> >
<svg class="with-text action" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M3 6h18m-2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m-6 5v6m4-6v6"/></svg> <span>{{> icons/trash}}</span>
Delete Delete
</button> </button>
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg> {{> icons/spinner}}
</div> </div>
<div id="dialog-error"> <div id="dialog-error">
{{#if error}} {{#if error}}

View File

@ -1,12 +1,12 @@
<div class="content"> <div class="content">
<div class="icon success"> <div class="icon success">
<svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg> {{> icons/check}}
</div> </div>
<p> <p>
Your link <b>"{{link}}"</b> has been deleted. Your link <b>"{{link}}"</b> has been deleted.
</p> </p>
<div class="buttons"> <div class="buttons">
<button hx-on:click="closeDialog()">Close</button> <button type="button" hx-on:click="closeDialog()">Close</button>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
<div class="box"> <div class="box">
<div class="content-wrapper"></div> <div class="content-wrapper"></div>
<div class="loading"> <div class="loading">
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg> {{> icons/spinner}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@
<p>{{message}}</p> <p>{{message}}</p>
{{/if}} {{/if}}
<div class="buttons"> <div class="buttons">
<button hx-on:click="closeDialog()">Close</button> <button type="button" hx-on:click="closeDialog()">Close</button>
</div> </div>
</div> </div>

View File

@ -77,6 +77,7 @@
</div> </div>
<div> <div>
<button <button
type="button"
onclick=" onclick="
const tr = closest('tr'); const tr = closest('tr');
if (!tr) return; if (!tr) return;
@ -86,7 +87,7 @@
> >
Close Close
</button> </button>
<button class="primary"> <button type="submit" class="primary">
<span class="reload"> <span class="reload">
{{> icons/reload}} {{> icons/reload}}
</span> </span>

View File

@ -8,7 +8,7 @@
{{else}} {{else}}
<tr class="loading-placeholder"> <tr class="loading-placeholder">
<td> <td>
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg> {{> icons/spinner}}
Loading links... Loading links...
</td> </td>
</tr> </tr>

View File

@ -1,16 +1,16 @@
<th class="nav" > <th class="nav" >
<div class="limit"> <div class="limit">
<button class="table-nav" onclick="setLinksLimit(event)" disabled="true">10</button> <button type="button" class="nav" onclick="setLinksLimit(event)" disabled="true">10</button>
<button class="table-nav" onclick="setLinksLimit(event)">20</button> <button type="button" class="nav" onclick="setLinksLimit(event)">20</button>
<button class="table-nav" onclick="setLinksLimit(event)">50</button> <button type="button" class="nav" onclick="setLinksLimit(event)">50</button>
</div> </div>
<div class="table-nav-divider"></div> <div class="nav-divider"></div>
<div id="pagination" class="pagination"> <div id="pagination" class="pagination">
<button class="table-nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true"> <button type="button" class="nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg> {{> icons/chevron_left}}
</button> </button>
<button class="table-nav next" onclick="setLinksSkip(event, 'next')"> <button type="button" class="nav next" onclick="setLinksSkip(event, 'next')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg> {{> icons/chevron_right}}
</button> </button>
</div> </div>
</th> </th>

View File

@ -14,7 +14,7 @@
load once, load once,
reloadLinks from:body, reloadLinks from:body,
change from:[name='all'], change from:[name='all'],
click delay:100ms from:button.table-nav, click delay:100ms from:button.nav,
input changed delay:500ms from:[name='search'], input changed delay:500ms from:[name='search'],
" "
hx-on:htmx:after-on-load="updateLinksNav()" hx-on:htmx:after-on-load="updateLinksNav()"

View File

@ -5,10 +5,12 @@
<input id="total" name="total" type="hidden" value="{{total}}" /> <input id="total" name="total" type="hidden" value="{{total}}" />
<input id="limit" name="limit" type="hidden" value="10" /> <input id="limit" name="limit" type="hidden" value="10" />
<input id="skip" name="skip" type="hidden" value="0" /> <input id="skip" name="skip" type="hidden" value="0" />
<label id="all" class="checkbox"> {{#if @root.isAdmin}}
<input name="all" type="checkbox" /> <label id="all" class="checkbox">
All links <input name="all" type="checkbox" />
</label> All links
</label>
{{/if}}
</th> </th>
{{> links/nav}} {{> links/nav}}
</tr> </tr>

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

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

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

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

View File

@ -12,6 +12,7 @@
{{#if user.apikey}} {{#if user.apikey}}
<div class="clipboard small"> <div class="clipboard small">
<button <button
type="button"
aria-label="Copy" aria-label="Copy"
hx-on:click="handleShortURLCopyLink(this);" hx-on:click="handleShortURLCopyLink(this);"
data-url="{{user.apikey}}" data-url="{{user.apikey}}"
@ -37,7 +38,7 @@
hx-target="#apikey-wrapper" hx-target="#apikey-wrapper"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
<button class="secondary"> <button type="button" class="secondary">
<span>{{> icons/zap}}</span> <span>{{> icons/zap}}</span>
<span>{{> icons/spinner}}</span> <span>{{> icons/spinner}}</span>
{{#if user.apikey}}Reg{{else}}G{{/if}}enerate key {{#if user.apikey}}Reg{{else}}G{{/if}}enerate key

View File

@ -34,7 +34,7 @@
{{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}} {{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
</label> </label>
</div> </div>
<button class="primary" type="submit"> <button type="submit" class="primary">
<span>{{> icons/reload}}</span> <span>{{> icons/reload}}</span>
<span>{{> icons/spinner}}</span> <span>{{> icons/spinner}}</span>
Update Update

View File

@ -34,7 +34,7 @@
{{#if errors.newpassword}}<p class="error">{{errors.newpassword}}</p>{{/if}} {{#if errors.newpassword}}<p class="error">{{errors.newpassword}}</p>{{/if}}
</label> </label>
</div> </div>
<button class="primary" type="submit"> <button type="submit" class="primary">
<span>{{> icons/reload}}</span> <span>{{> icons/reload}}</span>
<span>{{> icons/spinner}}</span> <span>{{> icons/spinner}}</span>
Update Update

View File

@ -27,7 +27,7 @@
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}} {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
</label> </label>
</div> </div>
<button class="danger" type="submit"> <button type="submit" class="danger">
<span>{{> icons/trash}}</span> <span>{{> icons/trash}}</span>
<span>{{> icons/spinner}}</span> <span>{{> icons/spinner}}</span>
Delete Delete

View File

@ -4,8 +4,9 @@
Are you sure do you want to delete the domain &quot;<b>{{address}}</b>&quot;? Are you sure do you want to delete the domain &quot;<b>{{address}}</b>&quot;?
</p> </p>
<div class="buttons"> <div class="buttons">
<button hx-on:click="closeDialog()">Cancel</button> <button type="button" hx-on:click="closeDialog()">Cancel</button>
<button <button
type="button"
class="danger confirm" class="danger confirm"
hx-delete="/api/domains/{id}" hx-delete="/api/domains/{id}"
hx-ext="path-params" hx-ext="path-params"

View File

@ -1,12 +1,12 @@
<div class="content"> <div class="content">
<div class="icon success"> <div class="icon success">
<svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg> {{> icons/check}}
</div> </div>
<p> <p>
Your domain <b>"{{address}}"</b> has been deleted. Your domain <b>"{{address}}"</b> has been deleted.
</p> </p>
<div class="buttons"> <div class="buttons">
<button hx-on:click="closeDialog()">Close</button> <button type="button" hx-on:click="closeDialog()">Close</button>
</div> </div>
</div> </div>
{{> settings/domain/table}} {{> settings/domain/table}}

View File

@ -2,7 +2,7 @@
<div class="box"> <div class="box">
<div class="content-wrapper"></div> <div class="content-wrapper"></div>
<div class="loading"> <div class="loading">
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg> {{> icons/spinner}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -13,6 +13,7 @@
{{> settings/domain/table}} {{> settings/domain/table}}
<div class="add-domain-wrapper"> <div class="add-domain-wrapper">
<button <button
type="button"
class="secondary show-domain-form" class="secondary show-domain-form"
hx-indicator=".add-domain-wrapper" hx-indicator=".add-domain-wrapper"
hx-get="/add-domain-form" hx-get="/add-domain-form"

View File

@ -22,6 +22,7 @@
</td> </td>
<td class="actions"> <td class="actions">
<button <button
type="button"
class="action delete" class="action delete"
hx-on:click='openDialog("domain-dialog")' hx-on:click='openDialog("domain-dialog")'
hx-get="/confirm-domain-delete" hx-get="/confirm-domain-delete"

View File

@ -3,6 +3,7 @@
{{#if link}} {{#if link}}
<div class="clipboard"> <div class="clipboard">
<button <button
type="button"
aria-label="Copy" aria-label="Copy"
hx-on:click="handleShortURLCopyLink(this);" hx-on:click="handleShortURLCopyLink(this);"
data-url="{{url}}" data-url="{{url}}"
@ -43,8 +44,8 @@
hx-preserve="true" hx-preserve="true"
/> />
<button class="submit"> <button class="submit">
<svg class="send" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="m2 21 21-9L2 3v7l15 2-15 2z"/></svg> {{> icons/send}}
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg> {{> icons/spinner}}
</button> </button>
{{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}} {{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
{{#unless errors}} {{#unless errors}}

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

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

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

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

View File

@ -1,5 +1,5 @@
{{> header}} {{> header}}
<section id="settings"> <section id="settings" class="section-container">
<h1 class="settings-welcome"> <h1 class="settings-welcome">
Welcome, <span>{{user.email}}</span>. Welcome, <span>{{user.email}}</span>.
</h1> </h1>

24
server/views/stats.hbs Normal file
View 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
View 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}}

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

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

View File

@ -83,14 +83,19 @@ hr {
background-color: hsl(200, 20%, 92%); background-color: hsl(200, 20%, 92%);
} }
a { span.bold { font-weight: bold; }
span.underline { border-bottom: 2px dotted #999; }
a,
button.link {
color: var(--color-primary); color: var(--color-primary);
border-bottom: 1px dotted transparent; border-bottom: 1px dotted transparent;
text-decoration: none; text-decoration: none;
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
} }
a:hover { a:hover,
button.link:hover {
border-bottom-color: var(--color-primary); border-bottom-color: var(--color-primary);
} }
@ -191,9 +196,13 @@ button.success:hover {
box-shadow: 0 6px 15px var(--button-bg-success-box-shadow-color); box-shadow: 0 6px 15px var(--button-bg-success-box-shadow-color);
} }
a.button:disabled,
button:disabled { cursor: default; } button:disabled { cursor: default; }
a.button:disabled:hover,
button:disabled:hover { transform: none; } button:disabled:hover { transform: none; }
a.button svg.with-text,
a.button span svg,
button svg.with-text, button svg.with-text,
button span svg { button span svg {
width: 16px; width: 16px;
@ -203,6 +212,7 @@ button span svg {
stroke-width: 2; stroke-width: 2;
} }
a.button.action,
button.action { button.action {
padding: 5px; padding: 5px;
width: 24px; width: 24px;
@ -210,68 +220,82 @@ button.action {
box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12); box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
} }
a.button.action:disabled,
button.action:disabled { button.action:disabled {
background: none; background: none;
box-shadow: none; box-shadow: none;
} }
a.button.action svg,
button.action svg { button.action svg {
width: 100%; width: 100%;
margin-right: 0; margin-right: 0;
} }
a.button.action.delete,
button.action.delete { button.action.delete {
background: hsl(0, 100%, 96%); background: hsl(0, 100%, 96%);
} }
a.button.action.delete svg,
button.action.delete svg { button.action.delete svg {
stroke-width: 2; stroke-width: 2;
stroke: hsl(0, 100%, 69%); stroke: hsl(0, 100%, 69%);
} }
a.button.action.edit,
button.action.edit { button.action.edit {
background: hsl(46, 100%, 94%); background: hsl(46, 100%, 94%);
} }
a.button.action.edit svg,
button.action.edit svg { button.action.edit svg {
stroke-width: 2.5; stroke-width: 2.5;
stroke: hsl(46, 90%, 50%); stroke: hsl(46, 90%, 50%);
} }
a.button.action.qrcode,
button.action.qrcode { button.action.qrcode {
background: hsl(0, 0%, 94%); background: hsl(0, 0%, 94%);
} }
a.button.action.qrcode svg,
button.action.qrcode svg { button.action.qrcode svg {
fill: hsl(0, 0%, 35%); fill: hsl(0, 0%, 35%);
stroke: none; stroke: none;
} }
a.button.action.stats,
button.action.stats { button.action.stats {
background: hsl(260, 100%, 96%); background: hsl(260, 100%, 96%);
} }
a.button.action.stats svg,
button.action.stats svg { button.action.stats svg {
stroke-width: 2.5; stroke-width: 2.5;
stroke: hsl(260, 100%, 69%); stroke: hsl(260, 100%, 69%);
} }
a.button.action.ban,
button.action.ban { button.action.ban {
background: hsl(10, 100%, 96%); background: hsl(10, 100%, 96%);
} }
a.button.action.ban svg,
button.action.ban svg { button.action.ban svg {
stroke-width: 2; stroke-width: 2;
stroke: hsl(10, 100%, 40%); stroke: hsl(10, 100%, 40%);
} }
a.button.action.password sv,
button.action.password svg, button.action.password svg,
a.button.action.banned svg,
button.action.banned svg { button.action.banned svg {
stroke-width: 2.5; stroke-width: 2.5;
stroke: #bbb; stroke: #bbb;
} }
button.table-nav { button.nav {
box-sizing: border-box; box-sizing: border-box;
width: auto; width: auto;
height: 28px; height: 28px;
@ -290,7 +314,7 @@ button.table-nav {
cursor: pointer; cursor: pointer;
} }
button.table-nav:disabled { button.nav:disabled {
background-color: #f6f6f6; background-color: #f6f6f6;
box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1); box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
opacity: 0.9; opacity: 0.9;
@ -298,15 +322,47 @@ button.table-nav:disabled {
cursor: default; cursor: default;
} }
button.table-nav svg { button.nav svg {
width: 14px; width: 14px;
height: auto; height: auto;
} }
button.table-nav svg { stroke-width: 2.5; } button.nav svg { stroke-width: 2.5; }
button.table-nav:hover { transform: translateY(-2px); } button.nav:hover { transform: translateY(-2px); }
button.table-nav:disabled:hover { transform: none; } button.nav:disabled:hover { transform: none; }
button.link {
position: relative;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 0 2px 0;
font-size: 1rem;
font-weight: normal;
border-radius: 0;
text-align: left;
line-height: normal;
word-break: normal;
cursor: pointer;
background: none;
box-shadow: none;
}
button.link:hover {
box-shadow: none;
transform: none;
}
button.link span {
height: 1rem;
}
button.link svg {
stroke: var(--color-primary);
}
svg.spinner { svg.spinner {
animation: spin 1s linear infinite, fadein 0.3s ease-in-out; animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
@ -459,6 +515,8 @@ label.checkbox input[type="checkbox"] {
p.error, p.error,
p.success { p.success {
display: flex;
align-items: center;
font-weight: normal; font-weight: normal;
animation: fadein 0.3s ease-in-out; animation: fadein 0.3s ease-in-out;
} }
@ -755,6 +813,15 @@ table tr.loading-placeholder td {
flex-direction: column; flex-direction: column;
} }
.section-container {
max-width: 90%;
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 1rem;
}
/* LOGIN & SIGNUP */ /* LOGIN & SIGNUP */
form#login-signup { form#login-signup {
@ -797,8 +864,8 @@ form#login-signup a.forgot-password {
} }
form#login-signup svg.spinner { display: none; } form#login-signup svg.spinner { display: none; }
form#login-signup.htmx-request:not(.signup) .login svg { display: none; }
form#login-signup.htmx-request:not(.signup) .login svg.spinner { display: block; } form#login-signup.htmx-request:not(.signup) .login svg.spinner { display: block; }
form#login-signup.htmx-request:not(.signup) .login svg.icon { display: none; }
form#login-signup.htmx-request.signup .signup svg.spinner { display: block; } form#login-signup.htmx-request.signup .signup svg.spinner { display: block; }
form#login-signup.htmx-request.signup .signup svg.icon { display: none; } form#login-signup.htmx-request.signup .signup svg.icon { display: none; }
form#login-signup.htmx-request .error { opacity: 0; } form#login-signup.htmx-request .error { opacity: 0; }
@ -1158,7 +1225,9 @@ main form label#advanced input {
#links-table-wrapper table .short-link { flex: 3 3 0; } #links-table-wrapper table .short-link { flex: 3 3 0; }
#links-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; } #links-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; }
#links-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; } #links-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
#links-table-wrapper table .actions a.button,
#links-table-wrapper table .actions button { margin-right: 0.5rem; } #links-table-wrapper table .actions button { margin-right: 0.5rem; }
#links-table-wrapper table .actions a.button:last-child,
#links-table-wrapper table .actions button:last-child { margin-right: 0; } #links-table-wrapper table .actions button:last-child { margin-right: 0; }
#links-table-wrapper table td.original-url, #links-table-wrapper table td.original-url,
@ -1231,10 +1300,10 @@ main form label#advanced input {
align-items: center; align-items: center;
} }
#links-table-wrapper table button.table-nav { margin-right: 0.75rem; } #links-table-wrapper table button.nav { margin-right: 0.75rem; }
#links-table-wrapper table button.table-nav:last-child { margin-right: 0; } #links-table-wrapper table button.nav:last-child { margin-right: 0; }
#links-table-wrapper table .table-nav-divider { #links-table-wrapper table .nav-divider {
height: 20px; height: 20px;
width: 1px; width: 1px;
opacity: 0.4; opacity: 0.4;
@ -1346,116 +1415,6 @@ main form label#advanced input {
.dialog .ban-checklist label { margin: 1rem 1.5rem 1rem 0; } .dialog .ban-checklist label { margin: 1rem 1.5rem 1rem 0; }
.dialog .ban-checklist label:last-child { margin-right: 0; } .dialog .ban-checklist label:last-child { margin-right: 0; }
/* SETTINGS */
#settings {
width: 600px;
max-width: 90%;
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 2rem;
}
h1.settings-welcome {
font-size: 28px;
font-weight: 300;
}
h1.settings-welcome span {
border-bottom: 2px dotted #999;
padding-bottom: 2px;
font-weight: normal;
}
/* SETTINGS - DOMAIN */
#domains-table { margin-top: 1rem; }
#domains-table .domain { flex: 2 2 0; }
#domains-table .homepage { flex: 2 2 0; }
#domains-table .actions { flex: 1 1 0; justify-content: flex-end; padding-right: 1rem; }
#domains-table .no-entry { flex: 1 1 0; opacity: 0.8; }
.add-domain-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
margin: 1.5rem 0 2rem;
}
.add-domain-wrapper > .spinner {
width: 20px;
display: none;
margin: 1rem 0 0 1rem;
}
.add-domain-wrapper.htmx-request > button { display: none; }
.add-domain-wrapper.htmx-request > .spinner { display: block; }
form#add-domain { margin-top: 1rem; }
form#add-domain .buttons-wrapper { display: flex; }
form#add-domain button { margin-right: 1rem }
form#add-domain .spinner { width: 20px; display: none; }
form#add-domain.htmx-request .buttons-wrapper { display: none; }
form#add-domain.htmx-request .spinner { display: block; }
form#add-domain .error { font-size: 0.85rem; }
/* SETTINGS - API */
#apikey-wrapper { margin-bottom: 1.5rem; }
#apikey {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
#apikey p {
font-weight: bold;
border-bottom: 1px dotted #999;
transition: opacity 0.2s ease-in-out;
cursor: pointer;
}
#apikey p:hover {
opacity: 0.8;
}
form#generate-apikey .spinner { display: none; }
form#generate-apikey.htmx-request svg { display: none; }
form#generate-apikey.htmx-request .spinner { display: block; }
/* SETTINGS - CHANGE PASSWORD */
#change-password-wrapper { margin-bottom: 1.5rem; }
form#change-password { margin-top: 1.5rem; }
form#change-password button { margin-top: 1rem; }
form#change-password .spinner { display: none; }
form#change-password.htmx-request svg { display: none; }
form#change-password.htmx-request .spinner { display: block; }
/* SETTINGS - CHANGE EMAIL */
#change-email-wrapper { margin-bottom: 1.5rem; }
form#change-email { margin-top: 1.5rem; }
form#change-email button { margin-top: 1rem; }
form#change-email .spinner { display: none; }
form#change-email.htmx-request svg { display: none; }
form#change-email.htmx-request .spinner { display: block; }
/* SETTINGS - DELETE ACCOUNT */
#delete-account-wrapper { margin-bottom: 1.5rem; }
form#delete-account { margin-top: 1.5rem; }
form#delete-account button { margin-top: 1rem; }
form#delete-account .spinner { display: none; }
form#delete-account.htmx-request svg { display: none; }
form#delete-account.htmx-request .spinner { display: block; }
/* INTRO */ /* INTRO */
@ -1632,4 +1591,379 @@ footer {
padding: 1rem 0; padding: 1rem 0;
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
} }
/* SETTINGS */
#settings {
width: 600px;
max-width: 90%;
}
h1.settings-welcome {
font-size: 28px;
font-weight: 300;
}
h1.settings-welcome span {
border-bottom: 2px dotted #999;
padding-bottom: 2px;
font-weight: normal;
}
/* SETTINGS - DOMAIN */
#domains-table { margin-top: 1rem; }
#domains-table .domain { flex: 2 2 0; }
#domains-table .homepage { flex: 2 2 0; }
#domains-table .actions { flex: 1 1 0; justify-content: flex-end; padding-right: 1rem; }
#domains-table .no-entry { flex: 1 1 0; opacity: 0.8; }
.add-domain-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
margin: 1.5rem 0 2rem;
}
.add-domain-wrapper > .spinner {
width: 20px;
display: none;
margin: 1rem 0 0 1rem;
}
.add-domain-wrapper.htmx-request > button { display: none; }
.add-domain-wrapper.htmx-request > .spinner { display: block; }
form#add-domain { margin-top: 1rem; }
form#add-domain .buttons-wrapper { display: flex; }
form#add-domain button { margin-right: 1rem }
form#add-domain .spinner { width: 20px; display: none; }
form#add-domain.htmx-request .buttons-wrapper { display: none; }
form#add-domain.htmx-request .spinner { display: block; }
form#add-domain .error { font-size: 0.85rem; }
/* SETTINGS - API */
#apikey-wrapper { margin-bottom: 1.5rem; }
#apikey {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
#apikey p {
font-weight: bold;
border-bottom: 1px dotted #999;
transition: opacity 0.2s ease-in-out;
cursor: pointer;
}
#apikey p:hover {
opacity: 0.8;
}
form#generate-apikey .spinner { display: none; }
form#generate-apikey.htmx-request svg { display: none; }
form#generate-apikey.htmx-request .spinner { display: block; }
/* SETTINGS - CHANGE PASSWORD */
#change-password-wrapper { margin-bottom: 1.5rem; }
form#change-password { margin-top: 1.5rem; }
form#change-password button { margin-top: 1rem; }
form#change-password .spinner { display: none; }
form#change-password.htmx-request svg { display: none; }
form#change-password.htmx-request .spinner { display: block; }
/* SETTINGS - CHANGE EMAIL */
#change-email-wrapper { margin-bottom: 1.5rem; }
form#change-email { margin-top: 1.5rem; }
form#change-email button { margin-top: 1rem; }
form#change-email .spinner { display: none; }
form#change-email.htmx-request svg { display: none; }
form#change-email.htmx-request .spinner { display: block; }
/* SETTINGS - DELETE ACCOUNT */
#delete-account-wrapper { margin-bottom: 1.5rem; }
form#delete-account { margin-top: 1.5rem; }
form#delete-account button { margin-top: 1rem; }
form#delete-account .spinner { display: none; }
form#delete-account.htmx-request svg { display: none; }
form#delete-account.htmx-request .spinner { display: block; }
/* STATS */
#stats-section {
width: 1200px;
max-width: 95%;
}
.loading-stats {
width: 100%;
flex: 1 1 0;
margin-top: -5rem;
display: flex;
align-items: center;
justify-content: center;
}
.loading-stats .spinner {
width: 1.25rem;
margin-right: 0.5rem;
}
.stats-info {
width: 100%;
display: flex;
align-items: flex-end;
justify-content: space-between;
}
.stats-info h2 { font-weight: 300; font-size: 24px; }
.stats-info p { font-size: 14px; }
.stats-info h2,
.stats-info p { margin: 0 }
#stats {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
background-color: white;
border-radius: 12px;
box-shadow: 0 6px 15px hsla(200, 20%, 70%, 0.3);
overflow: hidden;
padding: 0;
}
.stats-head {
width: 100%;
display: flex;
align-items: center;
background-color: hsl(200, 12%, 95%);
justify-content: space-between;
padding: 0.75rem 1.5rem;
}
.total-number { font-weight: bold; }
.stats-nav { display: flex; align-items: center; }
.stats-nav button { margin-right: 0.75rem; }
.stats-nav button:last-child { margin-right: 0; }
.stats-period {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0.75rem 1.5rem;
}
.stats-period h2 {
font-size: 24px;
font-weight: 300;
margin: 1rem 0 0;
}
.stats-period span.total-in-period {
font-weight: bold;
border-bottom: 1px dotted hsl(200, 35%, 65%);
}
p.last-update {
font-size: 14px;
color: hsl(200, 14%, 60%);
margin: 0.75rem 0 0;
}
#stats canvas {
width: 100%;
margin: 2rem 0;
}
.stats-columns-wrapper {
display: flex;
align-items: flex-start;
}
.stats-columns-wrapper > div {
flex: 1 1 50%;
}
svg.map path {
fill: hsl(200, 15%, 92%);
stroke: #fff;
transition: all 0.1s ease-in-out;
}
svg.map path.color-1 { fill: hsl(261, 46%, 90%); }
svg.map path.color-2 { fill: hsl(261, 46%, 86%); }
svg.map path.color-3 { fill: hsl(261, 46%, 82%); }
svg.map path.color-4 { fill: hsl(261, 46%, 76%); }
svg.map path.color-5 { fill: hsl(261, 46%, 72%); }
svg.map path.color-6 { fill: hsl(261, 46%, 68%); }
svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; }
#map-tooltip {
position: fixed;
}
#map-tooltip.visible::before,
#map-tooltip.visible::after {
display: block !important;
}
#map-tooltip:before {
border-top-color: rgba(255, 255, 255, 0.95);
}
#map-tooltip:after {
box-shadow: 0 1em 2em -0.5em rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.95);
color: #333;
}
.stats-back-to-home {
width: 100%;
display: flex;
justify-content: center;
margin: 2rem 0;
}
.stats-error {
width: 100%;
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.stats-error p { margin-top: -3rem; display: flex; align-items: center; }
.stats-error p svg { width: 1.2rem; margin: 0 0.5rem 0.1rem 0; }
.stats-error .stats-back-to-home { margin-top: 0 }
/* 404 - NOT FOUND */
#notfound {
width: 800px;
align-items: center;
}
#notfound h2 {
font-size: 28px;
font-weight: 300;
}
/* BANNED */
#banned { width: 1200px; align-items: center; }
#banned h2 { font-weight: normal; }
#banned h4 { font-weight: normal; margin: 0; }
/* REPORT */
#report { width: 600px; }
#report form {
display: flex;
flex-direction: column;
margin-top: 2rem;
}
#report form .inputs-wrapper {
display: flex;
align-items: flex-end;
}
#report form button { margin: 0 0 0.2rem 1rem; }
#report form .spinner { display: none; }
#report form.htmx-request svg { display: none; }
#report form.htmx-request .spinner { display: block; }
#report-email .spinner { display: none; }
#report-email .htmx-request svg { display: none; }
#report-email .htmx-request .spinner { display: block; }
.eye-icon svg { stroke-width: 0.5; }
/* RESET PASSWORD */
#reset-password form {
width: 100%;
display: flex;
flex-direction: column;
}
#reset-password form .inputs-wrapper {
display: flex;
align-items: flex-end;
margin-top: 2rem;
}
#reset-password form label { flex: 0 0 280px; }
#reset-password form label input { width: 100%; }
#reset-password form button { margin: 0 0 0.2rem 1rem; }
#reset-password .spinner { display: none; }
#reset-password .htmx-request svg { display: none; }
#reset-password .htmx-request .spinner { display: block; }
/* VERIFY USER */
/* VERIFY CHANGE EMAIL */
/* RESET PASSWORD TOKEN */
.verify-page {
width: 600px;
align-items: center;
}
.verify-page h2,
.verify-page h3 {
display: flex;
align-items: center;
text-align: center;
font-weight: normal;
}
.verify-page h2 svg,
.verify-page h3 svg {
width: 1.15em;
height: auto;
margin-right: 0.5rem;
}
/* URL INFO */
#url-info {
width: 1200px;
align-items: center;
text-align: center;
}
#url-info h3 { font-weight: normal; margin: 0; }
/* PROTECTED */
#protected { width: 600px; }
#protected form { width: 100%; margin-top: 1rem; }
#protected form .inputs-wrapper { width: 100%; display: flex; align-items: flex-end; }
#protected form label { flex: 0 0 280px; }
#protected form label input { width: 100%; }
#protected form button { margin: 0 0 0.2rem 1rem; }
#protected form .spinner { display: none; }
#protected form.htmx-request svg { display: none; }
#protected form.htmx-request .spinner { display: block; }
/* TERMS */
#terms { width: 600px; }

20
static/libs/chart.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -52,6 +52,38 @@ function closest(selector, elm) {
return null; return null;
}; };
// get url query param
function getQueryParams() {
const search = window.location.search.replace("?", "");
const query = {};
search.split("&").map(q => {
const keyvalue = q.split("=");
query[keyvalue[0]] = keyvalue[1];
});
return query;
}
// trim text
function trimText(selector, length) {
const element = document.querySelector(selector);
if (!element) return;
let text = element.textContent;
if (typeof text !== "string") return;
text = text.trim();
if (text.length > length) {
element.textContent = text.split("").slice(0, length).join("") + "...";
}
}
function formatDateHour(selector) {
const element = document.querySelector(selector);
if (!element) return;
const dateString = element.dataset.date;
if (!dateString) return;
const date = new Date(dateString);
element.textContent = date.getHours() + ":" + date.getMinutes();
}
// show QR code // show QR code
function handleQRCode(element) { function handleQRCode(element) {
const dialog = document.querySelector("#link-dialog"); const dialog = document.querySelector("#link-dialog");
@ -175,4 +207,450 @@ function resetLinkNav() {
document.querySelectorAll('table .nav .limit button').forEach(b => { document.querySelectorAll('table .nav .limit button').forEach(b => {
b.disabled = b.textContent === limit.toString(); b.disabled = b.textContent === limit.toString();
}); });
}
// create views chart label
function createViewsChartLabel(ctx) {
const period = ctx.dataset.period;
let labels = [];
if (period === "day") {
const nowHour = new Date().getHours();
for (let i = 23; i >= 0; --i) {
let h = nowHour - i;
if (h < 0) h = 24 + h;
labels.push(`${Math.floor(h)}:00`);
}
}
if (period === "week") {
const nowDay = new Date().getDate();
for (let i = 6; i >= 0; --i) {
const date = new Date(new Date().setDate(nowDay - i));
labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
}
}
if (period === "month") {
const nowDay = new Date().getDate();
for (let i = 29; i >= 0; --i) {
const date = new Date(new Date().setDate(nowDay - i));
labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
}
}
if (period === "year") {
const nowMonth = new Date().getMonth();
for (let i = 11; i >= 0; --i) {
const date = new Date(new Date().setMonth(nowMonth - i));
labels.push(`${date.toLocaleString("default",{month:"short"})} ${date.toLocaleString("default",{year:"numeric"})}`);
}
}
return labels;
}
// create views chart
function createViewsChart() {
const canvases = document.querySelectorAll("canvas.visits");
if (!canvases || !canvases.length) return;
canvases.forEach(ctx => {
const data = JSON.parse(ctx.dataset.data);
const period = ctx.dataset.period;
const labels = createViewsChartLabel(ctx);
const maxTicksLimitX = period === "year" ? 6 : period === "month" ? 15 : 12;
const gradient = ctx.getContext("2d").createLinearGradient(0, 0, 0, 300);
gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [{
label: "Views",
data,
tension: 0.3,
elements: {
point: {
pointRadius: 0,
pointHoverRadius: 4
}
},
fill: {
target: "start",
},
backgroundColor: gradient,
borderColor: "rgb(179, 157, 219)",
borderWidth: 1,
}]
},
options: {
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(255, 255, 255, 0.95)",
titleColor: "#333",
titleFont: { weight: "normal", size: 15 },
bodyFont: { weight: "normal", size: 16 },
bodyColor: "rgb(179, 157, 219)",
padding: 12,
cornerRadius: 2,
borderColor: "rgba(0, 0, 0, 0.1)",
borderWidth: 1,
displayColors: false,
}
},
responsive: true,
interaction: {
intersect: false,
usePointStyle: true,
mode: "index",
},
scales: {
y: {
grace: "10%",
beginAtZero: true,
ticks: {
maxTicksLimit: 5
}
},
x: {
ticks: {
maxTicksLimit: maxTicksLimitX,
}
}
}
}
});
// reset the display: block style that chart.js applies automatically
ctx.style.display = "";
});
}
// beautify browser lables
function beautifyBrowserName(name) {
if (name === "firefox") return "Firefox";
if (name === "chrome") return "Chrome";
if (name === "edge") return "Edge";
if (name === "opera") return "Opera";
if (name === "safari") return "Safari";
if (name === "other") return "Other";
if (name === "ie") return "IE";
return name;
}
// create browsers chart
function createBrowsersChart() {
const canvases = document.querySelectorAll("canvas.browsers");
if (!canvases || !canvases.length) return;
canvases.forEach(ctx => {
const data = JSON.parse(ctx.dataset.data);
const period = ctx.dataset.period;
const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");
gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
new Chart(ctx, {
type: "bar",
data: {
labels: data.map(d => beautifyBrowserName(d.name)),
datasets: [{
label: "Views",
data: data.map(d => d.value),
backgroundColor: gradient,
borderColor: "rgba(179, 157, 219, 1)",
borderWidth: 1,
hoverBackgroundColor: gradientHover,
hoverBorderWidth: 2
}]
},
options: {
indexAxis: "y",
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(255, 255, 255, 0.95)",
titleColor: "#333",
titleFont: { weight: "normal", size: 15 },
bodyFont: { weight: "normal", size: 16 },
bodyColor: "rgb(179, 157, 219)",
padding: 12,
cornerRadius: 2,
borderColor: "rgba(0, 0, 0, 0.1)",
borderWidth: 1,
displayColors: false,
}
},
responsive: true,
interaction: {
intersect: false,
mode: "index",
axis: "y"
},
scales: {
x: {
grace: "5%",
beginAtZero: true,
ticks: {
maxTicksLimit: 6,
}
}
}
}
});
// reset the display: block style that chart.js applies automatically
ctx.style.display = "";
});
}
// create referrers chart
function createReferrersChart() {
const canvases = document.querySelectorAll("canvas.referrers");
if (!canvases || !canvases.length) return;
canvases.forEach(ctx => {
const data = JSON.parse(ctx.dataset.data);
const period = ctx.dataset.period;
let max = Array.from(data).sort((a, b) => a.value > b.value ? -1 : 1)[0];
let tooltipEnabled = true;
let hoverBackgroundColor = "rgba(179, 157, 219, 1)";
let hoverBorderWidth = 2;
let borderColor = "rgba(179, 157, 219, 1)";
if (data.length === 0) {
data.push({ name: "No views.", value: 1 });
max = { value: 1000 };
tooltipEnabled = false;
hoverBackgroundColor = "rgba(179, 157, 219, 0.1)";
hoverBorderWidth = 1;
borderColor = "rgba(179, 157, 219, 0.2)";
}
new Chart(ctx, {
type: "doughnut",
data: {
labels: data.map(d => d.name.replace(/\[dot\]/g, ".")),
datasets: [{
label: "Views",
data: data.map(d => d.value),
backgroundColor: data.map(d => `rgba(179, 157, 219, ${Math.max((d.value / max.value) - 0.2, 0.1).toFixed(2)})`),
borderWidth: 1,
borderColor,
hoverBackgroundColor,
hoverBorderWidth,
}]
},
options: {
plugins: {
legend: {
position: "left",
labels: {
boxWidth: 25,
font: { size: 11 }
}
},
tooltip: {
enabled: tooltipEnabled,
backgroundColor: "rgba(255, 255, 255, 0.95)",
titleColor: "#333",
titleFont: { weight: "normal", size: 15 },
bodyFont: { weight: "normal", size: 16 },
bodyColor: "rgb(179, 157, 219)",
padding: 12,
cornerRadius: 2,
borderColor: "rgba(0, 0, 0, 0.1)",
borderWidth: 1,
displayColors: false,
}
},
responsive: false,
}
});
// reset the display: block style that chart.js applies automatically
ctx.style.display = "";
});
}
// beautify browser lables
function beautifyOsName(name) {
if (name === "android") return "Android";
if (name === "ios") return "iOS";
if (name === "linux") return "Linux";
if (name === "macos") return "macOS";
if (name === "windows") return "Windows";
if (name === "other") return "Other";
return name;
}
// create operation systems chart
function createOsChart() {
const canvases = document.querySelectorAll("canvas.os");
if (!canvases || !canvases.length) return;
canvases.forEach(ctx => {
const data = JSON.parse(ctx.dataset.data);
const period = ctx.dataset.period;
const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");
gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
new Chart(ctx, {
type: "bar",
data: {
labels: data.map(d => beautifyOsName(d.name)),
datasets: [{
label: "Views",
data: data.map(d => d.value),
backgroundColor: gradient,
borderColor: "rgba(179, 157, 219, 1)",
borderWidth: 1,
hoverBackgroundColor: gradientHover,
hoverBorderWidth: 2
}]
},
options: {
indexAxis: "y",
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(255, 255, 255, 0.95)",
titleColor: "#333",
titleFont: { weight: "normal", size: 15 },
bodyFont: { weight: "normal", size: 16 },
bodyColor: "rgb(179, 157, 219)",
padding: 12,
cornerRadius: 2,
borderColor: "rgba(0, 0, 0, 0.1)",
borderWidth: 1,
displayColors: false,
}
},
responsive: true,
interaction: {
intersect: false,
mode: "index",
axis: "y"
},
scales: {
x: {
grace:"5%",
beginAtZero: true,
ticks: {
maxTicksLimit: 6,
}
}
}
}
});
// reset the display: block style that chart.js applies automatically
ctx.style.display = "";
});
}
// add data to the map
function feedMapData(period) {
const map = document.querySelector("svg.map");
const paths = map.querySelectorAll("path");
if (!map || !paths || !paths.length) return;
let data = JSON.parse(map.dataset[period || "day"]);
if (!data) return;
let max = data.sort((a, b) => a.value > b.value ? -1 : 1)[0];
if (!max) max = { value: 1 }
data = data.reduce((a, c) => ({ ...a, [c.name]: c.value }), {});
for (let i = 0; i < paths.length; ++i) {
const id = paths[i].dataset.id;
const views = data[id] || 0;
paths[i].dataset.views = views;
const colorLevel = Math.ceil((views / max.value) * 6);
const classList = paths[i].classList;
for (let j = 1; j < 7; j++) {
paths[i].classList.remove(`color-${j}`);
}
paths[i].classList.add(`color-${colorLevel}`)
paths[i].dataset.views = views;
}
}
// handle map tooltip hover
function mapTooltipHoverOver() {
const tooltip = document.querySelector("#map-tooltip");
if (!tooltip) return;
if (!event.target.dataset.id) return mapTooltipHoverOut();
if (!tooltip.classList.contains("active")) {
tooltip.classList.add("visible");
}
tooltip.dataset.tooltip = `${event.target.ariaLabel}: ${event.target.dataset.views || 0}`;
const rect = event.target.getBoundingClientRect();
tooltip.style.top = rect.top + (rect.height / 2) + "px";
tooltip.style.left = rect.left + (rect.width / 2) + "px";
event.target.classList.add("active");
}
function mapTooltipHoverOut() {
const tooltip = document.querySelector("#map-tooltip");
const map = document.querySelector("svg.map");
const paths = map.querySelectorAll("path");
if (!tooltip || !map) return;
tooltip.classList.remove("visible");
for (let i = 0; i < paths.length; ++i) {
paths[i].classList.remove("active");
}
}
// create stats charts
function createCharts() {
createViewsChart();
createBrowsersChart();
createReferrersChart();
createOsChart();
feedMapData();
}
// change stats period for showing charts and data
function changeStatsPeriod(event) {
const period = event.target.dataset.period;
if (!period) return;
const canvases = document.querySelector("#stats").querySelectorAll("[data-period]");
const buttons = document.querySelector("#stats").querySelectorAll(".nav");
if (!buttons || !canvases) return;
buttons.forEach(b => b.disabled = false);
event.target.disabled = true;
canvases.forEach(canvas => {
if (canvas.dataset.period === period) {
canvas.classList.remove("hidden");
} else {
canvas.classList.add("hidden");
}
});
feedMapData(period);
} }