(wip): nextjs to htmx

This commit is contained in:
Pouria Ezzati 2024-08-11 18:41:03 +03:30
parent 041aed5ad6
commit 8fe106c2d6
No known key found for this signature in database
81 changed files with 8100 additions and 22329 deletions

View File

@ -1,5 +0,0 @@
.next/
flow-typed/
node_modules/
client/**/__test__/
production-server

View File

@ -1,26 +0,0 @@
{
"extends": [
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": ["./tsconfig.json", "./client/tsconfig.json"]
},
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
"@typescript-eslint/no-explicit-any": ["off"]
},
"env": {
"es6": true,
"browser": true,
"node": true
},
"settings": {
"react": {
"version": "detect"
}
}
}

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run lint:nofix

View File

@ -1,8 +0,0 @@
{
"useTabs": false,
"tabWidth": 2,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 80,
"endOfLine": "lf"
}

View File

@ -1,4 +1,4 @@
import env from "./server/env";
const env = require("./server/env");
module.exports = {
production: {

View File

@ -1,6 +0,0 @@
{
"watch": ["server/**/*.ts"],
"execMap": {
"ts": "rimraf production-server && tsc --project tsconfig.json && copyfiles -f \"server/mail/*.html\" production-server/mail && node production-server/server.js"
}
}

25424
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,15 +7,13 @@
"test": "jest --passWithNoTests",
"docker:build": "docker build -t kutt .",
"docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
"dev": "npm run migrate && cross-env NODE_ENV=development nodemon server/server.ts",
"dev": "node --watch server/server.js",
"dev:backup": "npm run migrate && cross-env NODE_ENV=development nodemon server/server.ts",
"build": "rimraf production-server && tsc --project tsconfig.json && copyfiles -f \"server/mail/*.html\" production-server/mail && next build client/ ",
"start": "npm run migrate && cross-env NODE_ENV=production node production-server/server.js",
"migrate": "knex migrate:latest --env production",
"migrate:make": "knex migrate:make --env production",
"lint": "eslint server/ --ext .js,.ts --fix",
"lint:nofix": "eslint server/ --ext .js,.ts",
"docs:build": "cd docs/api && tsc generate.ts --resolveJsonModule && node generate && cd ../..",
"prepare": "husky install"
"docs:build": "cd docs/api && tsc generate.ts --resolveJsonModule && node generate && cd ../.."
},
"repository": {
"type": "git",
@ -48,6 +46,7 @@
"express-async-handler": "1.1.4",
"express-validator": "^6.14.2",
"geoip-lite": "^1.4.6",
"hbs": "^4.2.0",
"helmet": "^6.0.0",
"ioredis": "^5.2.4",
"isbot": "^3.6.3",
@ -58,7 +57,6 @@
"morgan": "^1.10.0",
"ms": "^2.1.3",
"nanoid": "^2.1.11",
"next": "^12.3.3",
"node-cron": "^3.0.2",
"nodemailer": "^6.8.0",
"p-queue": "^7.3.0",
@ -70,13 +68,6 @@
"pg-query-stream": "^4.2.4",
"qrcode.react": "^3.1.0",
"query-string": "^7.1.1",
"re2": "^1.17.8",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^17.0.2",
"react-inlinesvg": "^3.0.1",
"react-tooltip": "^4.5.0",
"react-use-form-state": "^0.13.2",
"rebass": "^4.0.7",
"recharts": "^2.1.16",
"redis": "^4.5.0",
@ -95,6 +86,7 @@
"@types/cookie-parser": "^1.4.3",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.14",
"@types/hbs": "^4.0.4",
"@types/jest": "^26.0.20",
"@types/jsonwebtoken": "^7.2.8",
"@types/morgan": "^1.7.37",
@ -104,19 +96,10 @@
"@types/node-cron": "^2.0.2",
"@types/nodemailer": "^6.4.6",
"@types/pg": "^8.6.5",
"@types/qrcode.react": "^1.0.2",
"@types/react": "^17.0.52",
"@types/react-dom": "^17.0.18",
"@types/rebass": "^4.0.10",
"@types/signale": "^1.4.4",
"@types/styled-components": "^5.1.7",
"@typescript-eslint/eslint-plugin": "^5.42.1",
"@typescript-eslint/parser": "^5.42.1",
"copyfiles": "^2.4.1",
"eslint": "^8.27.0",
"eslint-config-next": "^13.0.3",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"husky": "^8.0.2",
"jest": "^29.3.1",
"nodemon": "^2.0.20",

View File

@ -1,6 +1,6 @@
import appRoot from "app-root-path";
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
const appRoot = require("app-root-path");
const winston = require("winston");
const DailyRotateFile = require("winston-daily-rotate-file");
const { combine, colorize, printf, timestamp } = winston.format;
@ -44,7 +44,7 @@ const options = {
};
// instantiate a new Winston Logger with the settings defined above
export const logger = winston.createLogger({
const logger = winston.createLogger({
format: combine(timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), logFormat),
transports: [
new DailyRotateFile(options.file),
@ -55,7 +55,7 @@ export const logger = winston.createLogger({
});
// create a stream object with a 'write' function that will be used by `morgan`
export const stream = {
const stream = {
write: message => {
logger.info(message);
}
@ -67,3 +67,8 @@ winston.addColors({
info: "green",
warn: "yellow"
});
module.exports = {
logger,
stream
}

View File

@ -1,7 +1,5 @@
import * as dotenv from "dotenv";
import { cleanEnv, num, str, bool } from "envalid";
dotenv.config();
require("dotenv").config();
const { cleanEnv, num, str, bool } = require("envalid");
const env = cleanEnv(process.env, {
PORT: num({ default: 3000 }),
@ -30,7 +28,7 @@ const env = cleanEnv(process.env, {
ADMIN_EMAILS: str({ default: "" }),
RECAPTCHA_SITE_KEY: str({ default: "" }),
RECAPTCHA_SECRET_KEY: str({ default: "" }),
GOOGLE_SAFE_BROWSING_KEY: str({ default: "" }),
GOOGLE_SAFE_BROWSING_KEY: str({ default: "" }), // TODO: something about it
MAIL_HOST: str(),
MAIL_PORT: num(),
MAIL_SECURE: bool({ default: false }),
@ -41,4 +39,4 @@ const env = cleanEnv(process.env, {
CONTACT_EMAIL: str({ default: "" })
});
export default env;
module.exports = env;

View File

@ -1,43 +1,61 @@
import { differenceInMinutes, addMinutes, subMinutes } from "date-fns";
import { Handler } from "express";
import passport from "passport";
import bcrypt from "bcryptjs";
import nanoid from "nanoid";
import { v4 as uuid } from "uuid";
import axios from "axios";
const { differenceInMinutes, addMinutes, subMinutes } = require("date-fns");
const passport = require("passport");
const { v4: uuid } = require("uuid");
const bcrypt = require("bcryptjs");
const nanoid = require("nanoid");
const axios = require("axios");
import { CustomError } from "../utils";
import * as utils from "../utils";
import * as redis from "../redis";
import * as mail from "../mail";
import query from "../queries";
import env from "../env";
const { CustomError } = require("../utils");
const query = require("../queries");
const utils = require("../utils");
const redis = require("../redis");
const mail = require("../mail");
const env = require("../env");
const authenticate = (
type: "jwt" | "local" | "localapikey",
error: string,
isStrict = true
) =>
async function auth(req, res, next) {
function authenticate(type, error, isStrict) {
return function auth(req, res, next) {
if (req.user) return next();
passport.authenticate(type, (err, user) => {
if (err) return next(err);
const accepts = req.accepts(["json", "html"]);
if (!user && isStrict) {
throw new CustomError(error, 401);
if (accepts === "html") {
return utils.sleep(2000).then(() => {
return res.render("partials/login_signup", {
layout: null,
error
});
});
} else {
throw new CustomError(error, 401);
}
}
if (user && isStrict && !user.verified) {
throw new CustomError(
"Your email address is not verified. " +
"Click on signup to get the verification link again.",
400
);
const errorMessage = "Your email address is not verified. " +
"Sign up to get the verification link again."
if (accepts === "html") {
return res.render("partials/login_signup", {
layout: null,
error: errorMessage
});
} else {
throw new CustomError(errorMessage, 400);
}
}
if (user && user.banned) {
throw new CustomError("You're banned from using this website.", 403);
const errorMessage = "You're banned from using this website.";
if (accepts === "html") {
return res.render("partials/login_signup", {
layout: null,
error: errorMessage
});
} else {
throw new CustomError(errorMessage, 403);
}
}
if (user) {
@ -49,27 +67,27 @@ const authenticate = (
}
return next();
})(req, res, next);
};
}
}
export const local = authenticate("local", "Login credentials are wrong.");
export const jwt = authenticate("jwt", "Unauthorized.");
export const jwtLoose = authenticate("jwt", "Unauthorized.", false);
export const apikey = authenticate(
"localapikey",
"API key is not correct.",
false
);
const local = authenticate("local", "Login credentials are wrong.", true);
const jwt = authenticate("jwt", "Unauthorized.", true);
const jwtLoose = authenticate("jwt", "Unauthorized.", false);
const apikey = authenticate("localapikey", "API key is not correct.", false);
export const cooldown: Handler = async (req, res, next) => {
/**
* @type {import("express").Handler}
*/
async function cooldown(req, res, next) {
if (env.DISALLOW_ANONYMOUS_LINKS) return next();
const cooldownConfig = env.NON_USER_COOLDOWN;
if (req.user || !cooldownConfig) return next();
const ip = await query.ip.find({
ip: req.realIP.toLowerCase(),
created_at: [">", subMinutes(new Date(), cooldownConfig).toISOString()]
});
if (ip) {
const timeToWait =
cooldownConfig - differenceInMinutes(new Date(), new Date(ip.created_at));
@ -79,60 +97,66 @@ export const cooldown: Handler = async (req, res, next) => {
);
}
next();
};
}
export const recaptcha: Handler = async (req, res, next) => {
if (env.isDev || req.user) return next();
if (env.DISALLOW_ANONYMOUS_LINKS) return next();
if (!env.RECAPTCHA_SECRET_KEY) return next();
const isReCaptchaValid = await axios({
method: "post",
url: "https://www.google.com/recaptcha/api/siteverify",
headers: {
"Content-type": "application/x-www-form-urlencoded"
},
params: {
secret: env.RECAPTCHA_SECRET_KEY,
response: req.body.reCaptchaToken,
remoteip: req.realIP
}
});
if (!isReCaptchaValid.data.success) {
throw new CustomError("reCAPTCHA is not valid. Try again.", 401);
}
return next();
};
export const admin: Handler = async (req, res, next) => {
/**
* @type {import("express").Handler}
*/
function admin(req, res, next) {
// FIXME: attaching to req is risky, find another way
if (req.user.admin) return next();
throw new CustomError("Unauthorized", 401);
};
}
export const signup: Handler = async (req, res) => {
/**
* @type {import("express").Handler}
*/
async function signup(req, res) {
const salt = await bcrypt.genSalt(12);
const password = await bcrypt.hash(req.body.password, salt);
const accepts = req.accepts(["json", "html"]);
const user = await query.user.add(
{ email: req.body.email, password },
req.user
);
await mail.verification(user);
return res.status(201).send({ message: "Verification email has been sent." });
};
if (accepts === "html") {
return res.render("partials/signup_verify_email", { layout: null });
}
return res.status(201).send({ message: "A verification email has been sent." });
}
export const token: Handler = async (req, res) => {
/**
* @type {import("express").Handler}
*/
function login(req, res) {
const token = utils.signToken(req.user);
const accepts = req.accepts(["json", "html"]);
if (accepts === "html") {
res.cookie("token", token, {
maxAge: 1000 * 60 * 15, // expire after seven days
httpOnly: true,
secure: env.isProd
});
return res.render("partials/login_welcome", { layout: false });
}
return res.status(200).send({ token });
};
}
export const verify: Handler = async (req, res, next) => {
/**
* @type {import("express").Handler}
*/
async function verify(req, res, next) {
if (!req.params.verificationToken) return next();
const [user] = await query.user.update(
{
verification_token: req.params.verificationToken,
@ -144,45 +168,54 @@ export const verify: Handler = async (req, res, next) => {
verification_expires: null
}
);
if (user) {
const token = utils.signToken(user);
req.token = token;
}
return next();
};
}
export const changePassword: Handler = async (req, res) => {
/**
* @type {import("express").Handler}
*/
async function changePassword(req, res) {
const salt = await bcrypt.genSalt(12);
const password = await bcrypt.hash(req.body.password, salt);
const [user] = await query.user.update({ id: req.user.id }, { password });
if (!user) {
throw new CustomError("Couldn't change the password. Try again later.");
}
return res
.status(200)
.send({ message: "Your password has been changed successfully." });
};
}
export const generateApiKey: Handler = async (req, res) => {
/**
* @type {import("express").Handler}
*/
async function generateApiKey(req, res) {
const apikey = nanoid(40);
redis.remove.user(req.user);
const [user] = await query.user.update({ id: req.user.id }, { apikey });
if (!user) {
throw new CustomError("Couldn't generate API key. Please try again later.");
}
return res.status(201).send({ apikey });
};
}
export const resetPasswordRequest: Handler = async (req, res) => {
/**
* @type {import("express").Handler}
*/
async function resetPasswordRequest(req, res) {
const [user] = await query.user.update(
{ email: req.body.email },
{
@ -190,19 +223,22 @@ export const resetPasswordRequest: Handler = async (req, res) => {
reset_password_expires: addMinutes(new Date(), 30).toISOString()
}
);
if (user) {
await mail.resetPasswordToken(user);
}
return res.status(200).send({
message: "If email address exists, a reset password email has been sent."
});
};
}
export const resetPassword: Handler = async (req, res, next) => {
/**
* @type {import("express").Handler}
*/
async function resetPassword(req, res, next) {
const { resetPasswordToken } = req.params;
if (resetPasswordToken) {
const [user] = await query.user.update(
{
@ -211,35 +247,41 @@ export const resetPassword: Handler = async (req, res, next) => {
},
{ reset_password_expires: null, reset_password_token: null }
);
if (user) {
const token = utils.signToken(user as UserJoined);
const token = utils.signToken(user);
req.token = token;
}
}
return next();
};
}
export const signupAccess: Handler = (req, res, next) => {
/**
* @type {import("express").Handler}
*/
function signupAccess(req, res, next) {
if (!env.DISALLOW_REGISTRATION) return next();
return res.status(403).send({ message: "Registration is not allowed." });
};
}
export const changeEmailRequest: Handler = async (req, res) => {
/**
* @type {import("express").Handler}
*/
async function changeEmailRequest(req, res) {
const { email, password } = req.body;
const isMatch = await bcrypt.compare(password, req.user.password);
if (!isMatch) {
throw new CustomError("Password is wrong.", 400);
}
const currentUser = await query.user.find({ email });
if (currentUser) {
throw new CustomError("Can't use this email address.", 400);
}
const [updatedUser] = await query.user.update(
{ id: req.user.id },
{
@ -248,30 +290,33 @@ export const changeEmailRequest: Handler = async (req, res) => {
change_email_expires: addMinutes(new Date(), 30).toISOString()
}
);
redis.remove.user(updatedUser);
if (updatedUser) {
await mail.changeEmail({ ...updatedUser, email });
}
return res.status(200).send({
message:
"If email address exists, an email " +
"with a verification link has been sent."
});
};
}
export const changeEmail: Handler = async (req, res, next) => {
/**
* @type {import("express").Handler}
*/
async function changeEmail(req, res, next) {
const { changeEmailToken } = req.params;
if (changeEmailToken) {
const foundUser = await query.user.find({
change_email_token: changeEmailToken
});
if (!foundUser) return next();
const [user] = await query.user.update(
{
change_email_token: changeEmailToken,
@ -284,13 +329,32 @@ export const changeEmail: Handler = async (req, res, next) => {
email: foundUser.change_email_address
}
);
redis.remove.user(foundUser);
if (user) {
const token = utils.signToken(user as UserJoined);
const token = utils.signToken(user);
req.token = token;
}
}
return next();
};
}
module.exports = {
admin,
apikey,
changeEmail,
changeEmailRequest,
changePassword,
cooldown,
generateApiKey,
jwt,
jwtLoose,
local,
login,
resetPassword,
resetPasswordRequest,
signup,
signupAccess,
verify,
}

View File

@ -1,7 +1,7 @@
import { Handler } from "express";
import query from "../queries";
import * as redis from "../redis";
import { CustomError, sanitize } from "../utils";
import { CustomError, sanitize } from "../utils/utils";
export const add: Handler = async (req, res) => {
const { address, homepage } = req.body;

View File

@ -0,0 +1,87 @@
const { validationResult } = require("express-validator");
const signale = require("signale");
const { logger } = require("../config/winston");
const { CustomError } = require("../utils");
const env = require("../env");
// export const ip: Handler = (req, res, next) => {
// req.realIP =
// (req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
// return next();
// };
/**
* @type {import("express").ErrorRequestHandler}
*/
function error(error, _req, res, _next) {
if (env.isDev) {
signale.fatal(error);
}
if (error instanceof CustomError) {
return res.status(error.statusCode || 500).json({ error: error.message });
}
return res.status(500).json({ error: "An error occurred." });
};
function verify(template) {
return function (req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const accepts = req.accepts(["json", "html"]);
const message = errors.array()[0].msg;
if (template && accepts === "html") {
return res.render(template, {
layout: null,
error: message
});
}
throw new CustomError(message, 400);
}
return next();
}
}
// export const query: Handler = (req, res, next) => {
// const { admin } = req.user || {};
// if (
// typeof req.query.limit !== "undefined" &&
// typeof req.query.limit !== "string"
// ) {
// return res.status(400).json({ error: "limit query is not valid." });
// }
// if (
// typeof req.query.skip !== "undefined" &&
// typeof req.query.skip !== "string"
// ) {
// return res.status(400).json({ error: "skip query is not valid." });
// }
// if (
// typeof req.query.search !== "undefined" &&
// typeof req.query.search !== "string"
// ) {
// return res.status(400).json({ error: "search query is not valid." });
// }
// const limit = parseInt(req.query.limit) || 10;
// const skip = parseInt(req.query.skip) || 0;
// req.context = {
// limit: limit > 50 ? 50 : limit,
// skip,
// all: admin ? req.query.all === "true" : false
// };
// next();
// };
module.exports = {
error,
verify,
}

View File

@ -1,73 +0,0 @@
import { Handler, ErrorRequestHandler } from "express";
import { validationResult } from "express-validator";
import signale from "signale";
import { CustomError } from "../utils";
import env from "../env";
import { logger } from "../config/winston";
export const ip: Handler = (req, res, next) => {
req.realIP =
(req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
return next();
};
// eslint-disable-next-line
export const error: ErrorRequestHandler = (error, _req, res, _next) => {
logger.error(error);
if (env.isDev) {
signale.fatal(error);
}
if (error instanceof CustomError) {
return res.status(error.statusCode || 500).json({ error: error.message });
}
return res.status(500).json({ error: "An error occurred." });
};
export const verify = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const message = errors.array()[0].msg;
throw new CustomError(message, 400);
}
return next();
};
export const query: Handler = (req, res, next) => {
const { admin } = req.user || {};
if (
typeof req.query.limit !== "undefined" &&
typeof req.query.limit !== "string"
) {
return res.status(400).json({ error: "limit query is not valid." });
}
if (
typeof req.query.skip !== "undefined" &&
typeof req.query.skip !== "string"
) {
return res.status(400).json({ error: "skip query is not valid." });
}
if (
typeof req.query.search !== "undefined" &&
typeof req.query.search !== "string"
) {
return res.status(400).json({ error: "search query is not valid." });
}
const limit = parseInt(req.query.limit) || 10;
const skip = parseInt(req.query.skip) || 0;
req.context = {
limit: limit > 50 ? 50 : limit,
skip,
all: admin ? req.query.all === "true" : false
};
next();
};

403
server/handlers/links.js Normal file
View File

@ -0,0 +1,403 @@
const promisify = require("util").promisify;
const bcrypt = require("bcryptjs");
const isbot = require("isbot");
const URL = require("url");
const dns = require("dns");
const validators = require("./validators");
// const transporter = require("../mail");
const query = require("../queries");
// const queue = require("../queues");
const utils = require("../utils");
const env = require("../env");
const CustomError = utils.CustomError;
const dnsLookup = promisify(dns.lookup);
// export const get: Handler = async (req, res) => {
// const { limit, skip, all } = req.context;
// const search = req.query.search as string;
// const userId = req.user.id;
// const match = {
// ...(!all && { user_id: userId })
// };
// const [links, total] = await Promise.all([
// query.link.get(match, { limit, search, skip }),
// query.link.total(match, { search })
// ]);
// const data = links.map(utils.sanitize.link);
// return res.send({
// total,
// limit,
// skip,
// data
// });
// };
/**
* @type {import("express").Handler}
*/
async function create(req, res) {
const { reuse, password, customurl, description, target, domain, expire_in } = req.body;
const domain_id = domain ? domain.id : null;
const targetDomain = utils.removeWww(URL.parse(target).hostname);
const queries = await Promise.all([
validators.cooldown(req.user),
validators.malware(req.user, target),
validators.linksCount(req.user),
reuse &&
query.link.find({
target,
user_id: req.user.id,
domain_id
}),
customurl &&
query.link.find({
address: customurl,
domain_id
}),
!customurl && utils.generateId(domain_id),
validators.bannedDomain(targetDomain),
validators.bannedHost(targetDomain)
]);
// if "reuse" is true, try to return
// the existent URL without creating one
if (queries[3]) {
return res.json(utils.sanitize.link(queries[3]));
}
// Check if custom link already exists
if (queries[4]) {
throw new CustomError("Custom URL is already in use.");
}
const accepts = req.accepts(["json", "html"]);
// Create new link
const address = customurl || queries[5];
const link = await query.link.create({
password,
address,
domain_id,
description,
target,
expire_in,
user_id: req.user && req.user.id
});
if (!req.user && env.NON_USER_COOLDOWN) {
query.ip.add(req.realIP);
}
if (accepts === "html") {
const shortURL = utils.getShortURL(link.address, link.domain);
return res.render("partials/shorturl", {
layout: null,
link: shortURL.link,
url: shortURL.url,
});
}
return res
.status(201)
.send(utils.sanitize.link({ ...link, domain: domain?.address }));
}
// export const edit: Handler = async (req, res) => {
// const { address, target, description, expire_in, password } = req.body;
// if (!address && !target) {
// throw new CustomError("Should at least update one field.");
// }
// const link = await query.link.find({
// uuid: req.params.id,
// ...(!req.user.admin && { user_id: req.user.id })
// });
// if (!link) {
// throw new CustomError("Link was not found.");
// }
// const targetDomain = utils.removeWww(URL.parse(target).hostname);
// const domain_id = link.domain_id || null;
// const queries = await Promise.all([
// validators.cooldown(req.user),
// validators.malware(req.user, target),
// address !== link.address &&
// query.link.find({
// address,
// domain_id
// }),
// validators.bannedDomain(targetDomain),
// validators.bannedHost(targetDomain)
// ]);
// // Check if custom link already exists
// if (queries[2]) {
// throw new CustomError("Custom URL is already in use.");
// }
// // Update link
// const [updatedLink] = await query.link.update(
// {
// id: link.id
// },
// {
// ...(address && { address }),
// ...(description && { description }),
// ...(target && { target }),
// ...(expire_in && { expire_in }),
// ...(password && { password })
// }
// );
// return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
// };
// export const remove: Handler = async (req, res) => {
// const link = await query.link.remove({
// uuid: req.params.id,
// ...(!req.user.admin && { user_id: req.user.id })
// });
// if (!link) {
// throw new CustomError("Could not delete the link");
// }
// return res
// .status(200)
// .send({ message: "Link has been deleted successfully." });
// };
// export const report: Handler = async (req, res) => {
// const { link } = req.body;
// const mail = await transporter.sendMail({
// from: env.MAIL_FROM || env.MAIL_USER,
// to: env.REPORT_EMAIL,
// subject: "[REPORT]",
// text: link,
// html: link
// });
// if (!mail.accepted.length) {
// throw new CustomError("Couldn't submit the report. Try again later.");
// }
// return res
// .status(200)
// .send({ message: "Thanks for the report, we'll take actions shortly." });
// };
// export const ban: Handler = async (req, res) => {
// const { id } = req.params;
// const update = {
// banned_by_id: req.user.id,
// banned: true
// };
// // 1. Check if link exists
// const link = await query.link.find({ uuid: id });
// if (!link) {
// throw new CustomError("No link has been found.", 400);
// }
// if (link.banned) {
// return res.status(200).send({ message: "Link has been banned already." });
// }
// const tasks = [];
// // 2. Ban link
// tasks.push(query.link.update({ uuid: id }, update));
// const domain = utils.removeWww(URL.parse(link.target).hostname);
// // 3. Ban target's domain
// if (req.body.domain) {
// tasks.push(query.domain.add({ ...update, address: domain }));
// }
// // 4. Ban target's host
// if (req.body.host) {
// const dnsRes = await dnsLookup(domain).catch(() => {
// throw new CustomError("Couldn't fetch DNS info.");
// });
// const host = dnsRes?.address;
// tasks.push(query.host.add({ ...update, address: host }));
// }
// // 5. Ban link owner
// if (req.body.user && link.user_id) {
// tasks.push(query.user.update({ id: link.user_id }, update));
// }
// // 6. Ban all of owner's links
// if (req.body.userLinks && link.user_id) {
// tasks.push(query.link.update({ user_id: link.user_id }, update));
// }
// // 7. Wait for all tasks to finish
// await Promise.all(tasks).catch(() => {
// throw new CustomError("Couldn't ban entries.");
// });
// // 8. Send response
// return res.status(200).send({ message: "Banned link successfully." });
// };
// export const redirect = (app) => async (
// req,
// res,
// next
// ) => {
// const isBot = isbot(req.headers["user-agent"]);
// const isPreservedUrl = validators.preservedUrls.some(
// item => item === req.path.replace("/", "")
// );
// if (isPreservedUrl) return next();
// // 1. If custom domain, get domain info
// const host = utils.removeWww(req.headers.host);
// const domain =
// host !== env.DEFAULT_DOMAIN
// ? await query.domain.find({ address: host })
// : null;
// // 2. Get link
// const address = req.params.id.replace("+", "");
// const link = await query.link.find({
// address,
// domain_id: domain ? domain.id : null
// });
// // 3. When no link, if has domain redirect to domain's homepage
// // otherwise redirect to 404
// if (!link) {
// return res.redirect(302, domain ? domain.homepage : "/404");
// }
// // 4. If link is banned, redirect to banned page.
// if (link.banned) {
// return res.redirect("/banned");
// }
// // 5. If wants to see link info, then redirect
// const doesRequestInfo = /.*\+$/gi.test(req.params.id);
// if (doesRequestInfo && !link.password) {
// return app.render(req, res, "/url-info", { target: link.target });
// }
// // 6. If link is protected, redirect to password page
// if (link.password) {
// return res.redirect(`/protected/${link.uuid}`);
// }
// // 7. Create link visit
// if (link.user_id && !isBot) {
// queue.visit.add({
// headers: req.headers,
// realIP: req.realIP,
// referrer: req.get("Referrer"),
// link
// });
// }
// // 8. Redirect to target
// return res.redirect(link.target);
// };
// export const redirectProtected: Handler = async (req, res) => {
// // 1. Get link
// const uuid = req.params.id;
// const link = await query.link.find({ uuid });
// // 2. Throw error if no link
// if (!link || !link.password) {
// throw new CustomError("Couldn't find the link.", 400);
// }
// // 3. Check if password matches
// const matches = await bcrypt.compare(req.body.password, link.password);
// if (!matches) {
// throw new CustomError("Password is not correct.", 401);
// }
// // 4. Create visit
// if (link.user_id) {
// queue.visit.add({
// headers: req.headers,
// realIP: req.realIP,
// referrer: req.get("Referrer"),
// link
// });
// }
// // 5. Send target
// return res.status(200).send({ target: link.target });
// };
// export const redirectCustomDomain: Handler = async (req, res, next) => {
// const { path } = req;
// const host = utils.removeWww(req.headers.host);
// if (host === env.DEFAULT_DOMAIN) {
// return next();
// }
// if (
// path === "/" ||
// validators.preservedUrls
// .filter(l => l !== "url-password")
// .some(item => item === path.replace("/", ""))
// ) {
// const domain = await query.domain.find({ address: host });
// const redirectURL = domain
// ? domain.homepage
// : `https://${env.DEFAULT_DOMAIN + path}`;
// return res.redirect(302, redirectURL);
// }
// return next();
// };
// export const stats: Handler = async (req, res) => {
// const { user } = req;
// const uuid = req.params.id;
// const link = await query.link.find({
// ...(!user.admin && { user_id: user.id }),
// uuid
// });
// if (!link) {
// throw new CustomError("Link could not be found.");
// }
// const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
// if (!stats) {
// throw new CustomError("Could not get the short link stats.");
// }
// return res.status(200).send({
// ...stats,
// ...utils.sanitize.link(link)
// });
// };
module.exports = {
create,
}

View File

@ -1,396 +0,0 @@
import { Handler } from "express";
import { promisify } from "util";
import bcrypt from "bcryptjs";
import isbot from "isbot";
import next from "next";
import URL from "url";
import dns from "dns";
import * as validators from "./validators";
import { CreateLinkReq } from "./types";
import { CustomError } from "../utils";
import transporter from "../mail/mail";
import * as utils from "../utils";
import query from "../queries";
import queue from "../queues";
import env from "../env";
const dnsLookup = promisify(dns.lookup);
export const get: Handler = async (req, res) => {
const { limit, skip, all } = req.context;
const search = req.query.search as string;
const userId = req.user.id;
const match = {
...(!all && { user_id: userId })
};
const [links, total] = await Promise.all([
query.link.get(match, { limit, search, skip }),
query.link.total(match, { search })
]);
const data = links.map(utils.sanitize.link);
return res.send({
total,
limit,
skip,
data
});
};
export const create: Handler = async (req: CreateLinkReq, res) => {
const {
reuse,
password,
customurl,
description,
target,
domain,
expire_in
} = req.body;
const domain_id = domain ? domain.id : null;
const targetDomain = utils.removeWww(URL.parse(target).hostname);
const queries = await Promise.all([
validators.cooldown(req.user),
validators.malware(req.user, target),
validators.linksCount(req.user),
reuse &&
query.link.find({
target,
user_id: req.user.id,
domain_id
}),
customurl &&
query.link.find({
address: customurl,
domain_id
}),
!customurl && utils.generateId(domain_id),
validators.bannedDomain(targetDomain),
validators.bannedHost(targetDomain)
]);
// if "reuse" is true, try to return
// the existent URL without creating one
if (queries[3]) {
return res.json(utils.sanitize.link(queries[3]));
}
// Check if custom link already exists
if (queries[4]) {
throw new CustomError("Custom URL is already in use.");
}
// Create new link
const address = customurl || queries[5];
const link = await query.link.create({
password,
address,
domain_id,
description,
target,
expire_in,
user_id: req.user && req.user.id
});
if (!req.user && env.NON_USER_COOLDOWN) {
query.ip.add(req.realIP);
}
return res
.status(201)
.send(utils.sanitize.link({ ...link, domain: domain?.address }));
};
export const edit: Handler = async (req, res) => {
const { address, target, description, expire_in, password } = req.body;
if (!address && !target) {
throw new CustomError("Should at least update one field.");
}
const link = await query.link.find({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
});
if (!link) {
throw new CustomError("Link was not found.");
}
const targetDomain = utils.removeWww(URL.parse(target).hostname);
const domain_id = link.domain_id || null;
const queries = await Promise.all([
validators.cooldown(req.user),
validators.malware(req.user, target),
address !== link.address &&
query.link.find({
address,
domain_id
}),
validators.bannedDomain(targetDomain),
validators.bannedHost(targetDomain)
]);
// Check if custom link already exists
if (queries[2]) {
throw new CustomError("Custom URL is already in use.");
}
// Update link
const [updatedLink] = await query.link.update(
{
id: link.id
},
{
...(address && { address }),
...(description && { description }),
...(target && { target }),
...(expire_in && { expire_in }),
...(password && { password })
}
);
return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
};
export const remove: Handler = async (req, res) => {
const link = await query.link.remove({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
});
if (!link) {
throw new CustomError("Could not delete the link");
}
return res
.status(200)
.send({ message: "Link has been deleted successfully." });
};
export const report: Handler = async (req, res) => {
const { link } = req.body;
const mail = await transporter.sendMail({
from: env.MAIL_FROM || env.MAIL_USER,
to: env.REPORT_EMAIL,
subject: "[REPORT]",
text: link,
html: link
});
if (!mail.accepted.length) {
throw new CustomError("Couldn't submit the report. Try again later.");
}
return res
.status(200)
.send({ message: "Thanks for the report, we'll take actions shortly." });
};
export const ban: Handler = async (req, res) => {
const { id } = req.params;
const update = {
banned_by_id: req.user.id,
banned: true
};
// 1. Check if link exists
const link = await query.link.find({ uuid: id });
if (!link) {
throw new CustomError("No link has been found.", 400);
}
if (link.banned) {
return res.status(200).send({ message: "Link has been banned already." });
}
const tasks = [];
// 2. Ban link
tasks.push(query.link.update({ uuid: id }, update));
const domain = utils.removeWww(URL.parse(link.target).hostname);
// 3. Ban target's domain
if (req.body.domain) {
tasks.push(query.domain.add({ ...update, address: domain }));
}
// 4. Ban target's host
if (req.body.host) {
const dnsRes = await dnsLookup(domain).catch(() => {
throw new CustomError("Couldn't fetch DNS info.");
});
const host = dnsRes?.address;
tasks.push(query.host.add({ ...update, address: host }));
}
// 5. Ban link owner
if (req.body.user && link.user_id) {
tasks.push(query.user.update({ id: link.user_id }, update));
}
// 6. Ban all of owner's links
if (req.body.userLinks && link.user_id) {
tasks.push(query.link.update({ user_id: link.user_id }, update));
}
// 7. Wait for all tasks to finish
await Promise.all(tasks).catch(() => {
throw new CustomError("Couldn't ban entries.");
});
// 8. Send response
return res.status(200).send({ message: "Banned link successfully." });
};
export const redirect = (app: ReturnType<typeof next>): Handler => async (
req,
res,
next
) => {
const isBot = isbot(req.headers["user-agent"]);
const isPreservedUrl = validators.preservedUrls.some(
item => item === req.path.replace("/", "")
);
if (isPreservedUrl) return next();
// 1. If custom domain, get domain info
const host = utils.removeWww(req.headers.host);
const domain =
host !== env.DEFAULT_DOMAIN
? await query.domain.find({ address: host })
: null;
// 2. Get link
const address = req.params.id.replace("+", "");
const link = await query.link.find({
address,
domain_id: domain ? domain.id : null
});
// 3. When no link, if has domain redirect to domain's homepage
// otherwise redirect to 404
if (!link) {
return res.redirect(302, domain ? domain.homepage : "/404");
}
// 4. If link is banned, redirect to banned page.
if (link.banned) {
return res.redirect("/banned");
}
// 5. If wants to see link info, then redirect
const doesRequestInfo = /.*\+$/gi.test(req.params.id);
if (doesRequestInfo && !link.password) {
return app.render(req, res, "/url-info", { target: link.target });
}
// 6. If link is protected, redirect to password page
if (link.password) {
return res.redirect(`/protected/${link.uuid}`);
}
// 7. Create link visit
if (link.user_id && !isBot) {
queue.visit.add({
headers: req.headers,
realIP: req.realIP,
referrer: req.get("Referrer"),
link
});
}
// 8. Redirect to target
return res.redirect(link.target);
};
export const redirectProtected: Handler = async (req, res) => {
// 1. Get link
const uuid = req.params.id;
const link = await query.link.find({ uuid });
// 2. Throw error if no link
if (!link || !link.password) {
throw new CustomError("Couldn't find the link.", 400);
}
// 3. Check if password matches
const matches = await bcrypt.compare(req.body.password, link.password);
if (!matches) {
throw new CustomError("Password is not correct.", 401);
}
// 4. Create visit
if (link.user_id) {
queue.visit.add({
headers: req.headers,
realIP: req.realIP,
referrer: req.get("Referrer"),
link
});
}
// 5. Send target
return res.status(200).send({ target: link.target });
};
export const redirectCustomDomain: Handler = async (req, res, next) => {
const { path } = req;
const host = utils.removeWww(req.headers.host);
if (host === env.DEFAULT_DOMAIN) {
return next();
}
if (
path === "/" ||
validators.preservedUrls
.filter(l => l !== "url-password")
.some(item => item === path.replace("/", ""))
) {
const domain = await query.domain.find({ address: host });
const redirectURL = domain
? domain.homepage
: `https://${env.DEFAULT_DOMAIN + path}`;
return res.redirect(302, redirectURL);
}
return next();
};
export const stats: Handler = async (req, res) => {
const { user } = req;
const uuid = req.params.id;
const link = await query.link.find({
...(!user.admin && { user_id: user.id }),
uuid
});
if (!link) {
throw new CustomError("Link could not be found.");
}
const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
if (!stats) {
throw new CustomError("Could not get the short link stats.");
}
return res.status(200).send({
...stats,
...utils.sanitize.link(link)
});
};

View File

@ -1,5 +1,5 @@
import query from "../queries";
import * as utils from "../utils";
import * as utils from "../utils/utils";
export const get = async (req, res) => {
const domains = await query.domain.get({ user_id: req.user.id });

View File

@ -0,0 +1,471 @@
const { body, param } = require("express-validator");
const { isAfter, subDays, subHours, addMilliseconds } = require("date-fns");
const urlRegex = require("url-regex-safe");
const { promisify } = require("util");
const bcrypt = require("bcryptjs");
const axios = require("axios");
const dns = require("dns");
const URL = require("url");
const ms = require("ms");
const { CustomError, addProtocol, preservedURLs, removeWww } = require("../utils");
const query = require("../queries");
const knex = require("../knex");
const env = require("../env");
const dnsLookup = promisify(dns.lookup);
const checkUser = (value, { req }) => !!req.user;
const createLink = [
body("target")
.exists({ checkNull: true, checkFalsy: true })
.withMessage("Target is missing.")
.isString()
.trim()
.isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.")
.customSanitizer(addProtocol)
.custom(
value =>
urlRegex({ exact: true, strict: false }).test(value) ||
/^(?!https?)(\w+):\/\//.test(value)
)
.withMessage("URL is not valid.")
.custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
.withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
body("password")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
body("customurl")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.trim()
.isLength({ min: 1, max: 64 })
.withMessage("Custom URL length must be between 1 and 64.")
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
.withMessage("Custom URL is not valid")
.custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
.withMessage("You can't use this custom URL."),
body("reuse")
.optional({ nullable: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isBoolean()
.withMessage("Reuse must be boolean."),
body("description")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
.withMessage("Minimum expire time should be '1 minute'.")
.customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
body("domain")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.withMessage("Domain should be string.")
.customSanitizer(value => value.toLowerCase())
.customSanitizer(value => removeWww(URL.parse(value).hostname || value))
.custom(async (address, { req }) => {
if (address === env.DEFAULT_DOMAIN) {
req.body.domain = null;
return;
}
const domain = await query.domain.find({
address,
user_id: req.user.id
});
req.body.domain = domain || null;
if (!domain) return Promise.reject();
})
.withMessage("You can't use this domain.")
];
// export const editLink = [
// body("target")
// .optional({ checkFalsy: true, nullable: true })
// .isString()
// .trim()
// .isLength({ min: 1, max: 2040 })
// .withMessage("Maximum URL length is 2040.")
// .customSanitizer(addProtocol)
// .custom(
// value =>
// urlRegex({ exact: true, strict: false }).test(value) ||
// /^(?!https?)(\w+):\/\//.test(value)
// )
// .withMessage("URL is not valid.")
// .custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
// .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
// body("password")
// .optional({ nullable: true, checkFalsy: true })
// .isString()
// .isLength({ min: 3, max: 64 })
// .withMessage("Password length must be between 3 and 64."),
// body("address")
// .optional({ checkFalsy: true, nullable: true })
// .isString()
// .trim()
// .isLength({ min: 1, max: 64 })
// .withMessage("Custom URL length must be between 1 and 64.")
// .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
// .withMessage("Custom URL is not valid")
// .custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
// .withMessage("You can't use this custom URL."),
// body("expire_in")
// .optional({ nullable: true, checkFalsy: true })
// .isString()
// .trim()
// .custom(value => {
// try {
// return !!ms(value);
// } catch {
// return false;
// }
// })
// .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
// .customSanitizer(ms)
// .custom(value => value >= ms("1m"))
// .withMessage("Minimum expire time should be '1 minute'.")
// .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
// body("description")
// .optional({ nullable: true, checkFalsy: true })
// .isString()
// .trim()
// .isLength({ min: 0, max: 2040 })
// .withMessage("Description length must be between 0 and 2040."),
// param("id", "ID is invalid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 36, max: 36 })
// ];
// export const redirectProtected = [
// body("password", "Password is invalid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isString()
// .isLength({ min: 3, max: 64 })
// .withMessage("Password length must be between 3 and 64."),
// param("id", "ID is invalid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 36, max: 36 })
// ];
// export const addDomain = [
// body("address", "Domain is not valid")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 3, max: 64 })
// .withMessage("Domain length must be between 3 and 64.")
// .trim()
// .customSanitizer(value => {
// const parsed = URL.parse(value);
// return removeWww(parsed.hostname || parsed.href);
// })
// .custom(value => urlRegex({ exact: true, strict: false }).test(value))
// .custom(value => value !== env.DEFAULT_DOMAIN)
// .withMessage("You can't use the default domain.")
// .custom(async value => {
// const domain = await query.domain.find({ address: value });
// if (domain?.user_id || domain?.banned) return Promise.reject();
// })
// .withMessage("You can't add this domain."),
// body("homepage")
// .optional({ checkFalsy: true, nullable: true })
// .customSanitizer(addProtocol)
// .custom(value => urlRegex({ exact: true, strict: false }).test(value))
// .withMessage("Homepage is not valid.")
// ];
// export const removeDomain = [
// param("id", "ID is invalid.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .isLength({ min: 36, max: 36 })
// ];
// export const deleteLink = [
// param("id", "ID is invalid.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .isLength({ min: 36, max: 36 })
// ];
// export const reportLink = [
// body("link", "No link has been provided.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .customSanitizer(addProtocol)
// .custom(
// value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN
// )
// .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
// ];
// export const banLink = [
// param("id", "ID is invalid.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .isLength({ min: 36, max: 36 }),
// body("host", '"host" should be a boolean.')
// .optional({
// nullable: true
// })
// .isBoolean(),
// body("user", '"user" should be a boolean.')
// .optional({
// nullable: true
// })
// .isBoolean(),
// body("userlinks", '"userlinks" should be a boolean.')
// .optional({
// nullable: true
// })
// .isBoolean(),
// body("domain", '"domain" should be a boolean.')
// .optional({
// nullable: true
// })
// .isBoolean()
// ];
// export const getStats = [
// param("id", "ID is invalid.")
// .exists({
// checkFalsy: true,
// checkNull: true
// })
// .isLength({ min: 36, max: 36 })
// ];
const signup = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
.custom(async (value, { req }) => {
const user = await query.user.find({ email: value });
if (user) {
req.user = user;
}
if (user?.verified) return Promise.reject();
})
.withMessage("You can't use this email address.")
];
const login = [
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."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
];
// export const changePassword = [
// 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 resetPasswordRequest = [
// body("email", "Email is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .trim()
// .isEmail()
// .isLength({ min: 0, max: 255 })
// .withMessage("Email length must be max 255."),
// body("password", "Password is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 8, max: 64 })
// .withMessage("Password length must be between 8 and 64.")
// ];
// export const resetEmailRequest = [
// body("email", "Email is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .trim()
// .isEmail()
// .isLength({ min: 0, max: 255 })
// .withMessage("Email length must be max 255.")
// ];
// export const deleteUser = [
// body("password", "Password is not valid.")
// .exists({ checkFalsy: true, checkNull: true })
// .isLength({ min: 8, max: 64 })
// .custom(async (password, { req }) => {
// const isMatch = await bcrypt.compare(password, req.user.password);
// if (!isMatch) return Promise.reject();
// })
// ];
// TODO: if user has posted malware should do something better
function cooldown(user) {
if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldowns) return;
// If has active cooldown then throw error
const hasCooldownNow = user.cooldowns.some(cooldown =>
isAfter(subHours(new Date(), 12), new Date(cooldown))
);
if (hasCooldownNow) {
throw new CustomError("Cooldown because of a malware URL. Wait 12h");
}
}
// TODO: if user or non-user has posted malware should do something better
async function malware(user, target) {
if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
const isMalware = await axios.post(
`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
{
client: {
clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
clientVersion: "1.0.0"
},
threatInfo: {
threatTypes: [
"THREAT_TYPE_UNSPECIFIED",
"MALWARE",
"SOCIAL_ENGINEERING",
"UNWANTED_SOFTWARE",
"POTENTIALLY_HARMFUL_APPLICATION"
],
platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
threatEntryTypes: [
"EXECUTABLE",
"URL",
"THREAT_ENTRY_TYPE_UNSPECIFIED"
],
threatEntries: [{ url: target }]
}
}
);
if (!isMalware.data || !isMalware.data.matches) return;
if (user) {
const [updatedUser] = await query.user.update(
{ id: user.id },
{
cooldowns: knex.raw("array_append(cooldowns, ?)", [
new Date().toISOString()
])
}
);
// Ban if too many cooldowns
if (updatedUser.cooldowns.length > 2) {
await query.user.update({ id: user.id }, { banned: true });
throw new CustomError("Too much malware requests. You are now banned.");
}
}
throw new CustomError(
user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
);
};
async function linksCount(user) {
if (!user) return;
const count = await query.link.total({
user_id: user.id,
created_at: [">", subDays(new Date(), 1).toISOString()]
});
if (count > env.USER_LIMIT_PER_DAY) {
throw new CustomError(
`You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
);
}
};
async function bannedDomain(domain) {
const isBanned = await query.domain.find({
address: domain,
banned: true
});
if (isBanned) {
throw new CustomError("URL is containing malware/scam.", 400);
}
};
async function bannedHost(domain) {
let isBanned;
try {
const dnsRes = await dnsLookup(domain);
if (!dnsRes || !dnsRes.address) return;
isBanned = await query.host.find({
address: dnsRes.address,
banned: true
});
} catch (error) {
isBanned = null;
}
if (isBanned) {
throw new CustomError("URL is containing malware/scam.", 400);
}
};
module.exports = {
bannedDomain,
bannedHost,
checkUser,
cooldown,
createLink,
linksCount,
login,
malware,
signup,
}

View File

@ -1,480 +0,0 @@
import { body, param } from "express-validator";
import { isAfter, subDays, subHours, addMilliseconds } from "date-fns";
import urlRegex from "url-regex-safe";
import { promisify } from "util";
import bcrypt from "bcryptjs";
import axios from "axios";
import dns from "dns";
import URL from "url";
import ms from "ms";
import { CustomError, addProtocol, removeWww } from "../utils";
import query from "../queries";
import knex from "../knex";
import env from "../env";
const dnsLookup = promisify(dns.lookup);
export const preservedUrls = [
"login",
"logout",
"signup",
"reset-password",
"resetpassword",
"url-password",
"url-info",
"settings",
"stats",
"verify",
"api",
"404",
"static",
"images",
"banned",
"terms",
"privacy",
"protected",
"report",
"pricing"
];
export const checkUser = (value, { req }) => !!req.user;
export const createLink = [
body("target")
.exists({ checkNull: true, checkFalsy: true })
.withMessage("Target is missing.")
.isString()
.trim()
.isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.")
.customSanitizer(addProtocol)
.custom(
value =>
urlRegex({ exact: true, strict: false }).test(value) ||
/^(?!https?)(\w+):\/\//.test(value)
)
.withMessage("URL is not valid.")
.custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
.withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
body("password")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
body("customurl")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.trim()
.isLength({ min: 1, max: 64 })
.withMessage("Custom URL length must be between 1 and 64.")
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
.withMessage("Custom URL is not valid")
.custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
.withMessage("You can't use this custom URL."),
body("reuse")
.optional({ nullable: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isBoolean()
.withMessage("Reuse must be boolean."),
body("description")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
.withMessage("Minimum expire time should be '1 minute'.")
.customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
body("domain")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
.withMessage("Only users can use this field.")
.isString()
.withMessage("Domain should be string.")
.customSanitizer(value => value.toLowerCase())
.customSanitizer(value => removeWww(URL.parse(value).hostname || value))
.custom(async (address, { req }) => {
if (address === env.DEFAULT_DOMAIN) {
req.body.domain = null;
return;
}
const domain = await query.domain.find({
address,
user_id: req.user.id
});
req.body.domain = domain || null;
if (!domain) return Promise.reject();
})
.withMessage("You can't use this domain.")
];
export const editLink = [
body("target")
.optional({ checkFalsy: true, nullable: true })
.isString()
.trim()
.isLength({ min: 1, max: 2040 })
.withMessage("Maximum URL length is 2040.")
.customSanitizer(addProtocol)
.custom(
value =>
urlRegex({ exact: true, strict: false }).test(value) ||
/^(?!https?)(\w+):\/\//.test(value)
)
.withMessage("URL is not valid.")
.custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
.withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
body("password")
.optional({ nullable: true, checkFalsy: true })
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
body("address")
.optional({ checkFalsy: true, nullable: true })
.isString()
.trim()
.isLength({ min: 1, max: 64 })
.withMessage("Custom URL length must be between 1 and 64.")
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
.withMessage("Custom URL is not valid")
.custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
.withMessage("You can't use this custom URL."),
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
.withMessage("Minimum expire time should be '1 minute'.")
.customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
body("description")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 36, max: 36 })
];
export const redirectProtected = [
body("password", "Password is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isString()
.isLength({ min: 3, max: 64 })
.withMessage("Password length must be between 3 and 64."),
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 36, max: 36 })
];
export const addDomain = [
body("address", "Domain is not valid")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 3, max: 64 })
.withMessage("Domain length must be between 3 and 64.")
.trim()
.customSanitizer(value => {
const parsed = URL.parse(value);
return removeWww(parsed.hostname || parsed.href);
})
.custom(value => urlRegex({ exact: true, strict: false }).test(value))
.custom(value => value !== env.DEFAULT_DOMAIN)
.withMessage("You can't use the default domain.")
.custom(async value => {
const domain = await query.domain.find({ address: value });
if (domain?.user_id || domain?.banned) return Promise.reject();
})
.withMessage("You can't add this domain."),
body("homepage")
.optional({ checkFalsy: true, nullable: true })
.customSanitizer(addProtocol)
.custom(value => urlRegex({ exact: true, strict: false }).test(value))
.withMessage("Homepage is not valid.")
];
export const removeDomain = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 })
];
export const deleteLink = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 })
];
export const reportLink = [
body("link", "No link has been provided.")
.exists({
checkFalsy: true,
checkNull: true
})
.customSanitizer(addProtocol)
.custom(
value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN
)
.withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
];
export const banLink = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 }),
body("host", '"host" should be a boolean.')
.optional({
nullable: true
})
.isBoolean(),
body("user", '"user" should be a boolean.')
.optional({
nullable: true
})
.isBoolean(),
body("userlinks", '"userlinks" should be a boolean.')
.optional({
nullable: true
})
.isBoolean(),
body("domain", '"domain" should be a boolean.')
.optional({
nullable: true
})
.isBoolean()
];
export const getStats = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isLength({ min: 36, max: 36 })
];
export const signup = [
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."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
.custom(async (value, { req }) => {
const user = await query.user.find({ email: value });
if (user) {
req.user = user;
}
if (user?.verified) return Promise.reject();
})
.withMessage("You can't use this email address.")
];
export const login = [
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."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
];
export const changePassword = [
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 resetPasswordRequest = [
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255."),
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64.")
];
export const resetEmailRequest = [
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
];
export const deleteUser = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.custom(async (password, { req }) => {
const isMatch = await bcrypt.compare(password, req.user.password);
if (!isMatch) return Promise.reject();
})
];
export const cooldown = (user: User) => {
if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldowns) return;
// If has active cooldown then throw error
const hasCooldownNow = user.cooldowns.some(cooldown =>
isAfter(subHours(new Date(), 12), new Date(cooldown))
);
if (hasCooldownNow) {
throw new CustomError("Cooldown because of a malware URL. Wait 12h");
}
};
export const malware = async (user: User, target: string) => {
if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
const isMalware = await axios.post(
`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
{
client: {
clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
clientVersion: "1.0.0"
},
threatInfo: {
threatTypes: [
"THREAT_TYPE_UNSPECIFIED",
"MALWARE",
"SOCIAL_ENGINEERING",
"UNWANTED_SOFTWARE",
"POTENTIALLY_HARMFUL_APPLICATION"
],
platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
threatEntryTypes: [
"EXECUTABLE",
"URL",
"THREAT_ENTRY_TYPE_UNSPECIFIED"
],
threatEntries: [{ url: target }]
}
}
);
if (!isMalware.data || !isMalware.data.matches) return;
if (user) {
const [updatedUser] = await query.user.update(
{ id: user.id },
{
cooldowns: knex.raw("array_append(cooldowns, ?)", [
new Date().toISOString()
]) as any
}
);
// Ban if too many cooldowns
if (updatedUser.cooldowns.length > 2) {
await query.user.update({ id: user.id }, { banned: true });
throw new CustomError("Too much malware requests. You are now banned.");
}
}
throw new CustomError(
user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
);
};
export const linksCount = async (user?: User) => {
if (!user) return;
const count = await query.link.total({
user_id: user.id,
created_at: [">", subDays(new Date(), 1).toISOString()]
});
if (count > env.USER_LIMIT_PER_DAY) {
throw new CustomError(
`You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
);
}
};
export const bannedDomain = async (domain: string) => {
const isBanned = await query.domain.find({
address: domain,
banned: true
});
if (isBanned) {
throw new CustomError("URL is containing malware/scam.", 400);
}
};
export const bannedHost = async (domain: string) => {
let isBanned;
try {
const dnsRes = await dnsLookup(domain);
if (!dnsRes || !dnsRes.address) return;
isBanned = await query.host.find({
address: dnsRes.address,
banned: true
});
} catch (error) {
isBanned = null;
}
if (isBanned) {
throw new CustomError("URL is containing malware/scam.", 400);
}
};

View File

@ -1,6 +1,6 @@
import knex from "knex";
const knex = require("knex");
import env from "./env";
const env = require("./env");
const db = knex({
client: "postgres",
@ -18,4 +18,4 @@ const db = knex({
}
});
export default db;
module.exports = db;

1
server/mail/index.js Normal file
View File

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

View File

@ -1 +0,0 @@
export * from "./mail";

View File

@ -1,10 +1,10 @@
import nodemailer from "nodemailer";
import path from "path";
import fs from "fs";
const nodemailer = require("nodemailer");
const path = require("path");
const fs = require("fs");
import { resetMailText, verifyMailText, changeEmailText } from "./text";
import { CustomError } from "../utils";
import env from "../env";
const { resetMailText, verifyMailText, changeEmailText } = require("./text");
const { CustomError } = require("../utils");
const env = require("../env");
const mailConfig = {
host: env.MAIL_HOST,
@ -20,8 +20,6 @@ const mailConfig = {
const transporter = nodemailer.createTransport(mailConfig);
export default transporter;
// Read email templates
const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
@ -42,7 +40,7 @@ const changeEmailTemplate = fs
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
.replace(/{{site_name}}/gm, env.SITE_NAME);
export const verification = async (user: User) => {
async function verification(user) {
const mail = await transporter.sendMail({
from: env.MAIL_FROM || env.MAIL_USER,
to: user.email,
@ -60,9 +58,9 @@ export const verification = async (user: User) => {
if (!mail.accepted.length) {
throw new CustomError("Couldn't send verification email. Try again later.");
}
};
}
export const changeEmail = async (user: User) => {
async function changeEmail(user) {
const mail = await transporter.sendMail({
from: env.MAIL_FROM || env.MAIL_USER,
to: user.change_email_address,
@ -76,13 +74,13 @@ export const changeEmail = async (user: User) => {
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
.replace(/{{site_name}}/gm, env.SITE_NAME)
});
if (!mail.accepted.length) {
throw new CustomError("Couldn't send verification email. Try again later.");
}
};
}
export const resetPasswordToken = async (user: User) => {
async function resetPasswordToken(user) {
const mail = await transporter.sendMail({
from: env.MAIL_FROM || env.MAIL_USER,
to: user.email,
@ -94,10 +92,16 @@ export const resetPasswordToken = async (user: User) => {
.replace(/{{resetpassword}}/gm, user.reset_password_token)
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
});
if (!mail.accepted.length) {
throw new CustomError(
"Couldn't send reset password email. Try again later."
);
}
};
}
module.exports = {
changeEmail,
verification,
resetPasswordToken,
}

View File

@ -1,17 +1,16 @@
/* eslint-disable max-len */
export const verifyMailText = `You're attempting to change your email address on {{site_name}}.
module.exports.verifyMailText = `You're attempting to change your email address on {{site_name}}.
Please verify your email address using the link below.
https://{{domain}}/verify/{{verification}}`;
export const changeEmailText = `Thanks for creating an account on {{site_name}}.
module.exports.changeEmailText = `Thanks for creating an account on {{site_name}}.
Please verify your email address using the link below.
https://{{domain}}/verify-email/{{verification}}`;
export const resetMailText = `A password reset has been requested for your account.
module.exports.resetMailText = `A password reset has been requested for your account.
Please click on the button below to reset your password. There's no need to take any action if you didn't request this.

View File

@ -1,7 +1,6 @@
import { Knex } from "knex";
import * as models from "../models";
const models = require("../models");
export async function up(knex: Knex): Promise<any> {
async function up(knex) {
await models.createUserTable(knex);
await models.createIPTable(knex);
await models.createDomainTable(knex);
@ -37,6 +36,11 @@ export async function up(knex: Knex): Promise<any> {
]);
}
export async function down(): Promise<any> {
async function down() {
// do nothing
}
module.exports = {
up,
down
}

View File

@ -1,7 +1,6 @@
import { Knex } from "knex";
import * as models from "../models";
const models = require("../models");
export async function up(knex: Knex): Promise<any> {
async function up(knex) {
await models.createUserTable(knex);
await models.createIPTable(knex);
await models.createDomainTable(knex);
@ -21,6 +20,11 @@ export async function up(knex: Knex): Promise<any> {
]);
}
export async function down(): Promise<any> {
async function down() {
// do nothing
}
module.exports = {
up,
down
}

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<any> {
async function up(knex) {
const hasDescription = await knex.schema.hasColumn("links", "description");
if (!hasDescription) {
await knex.schema.alterTable("links", table => {
@ -9,6 +7,11 @@ export async function up(knex: Knex): Promise<any> {
}
}
export async function down(): Promise<any> {
async function down() {
return null;
}
module.exports = {
up,
down
}

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<any> {
async function up(knex) {
const hasExpireIn = await knex.schema.hasColumn("links", "expire_in");
if (!hasExpireIn) {
await knex.schema.alterTable("links", table => {
@ -9,6 +7,11 @@ export async function up(knex: Knex): Promise<any> {
}
}
export async function down(): Promise<any> {
async function down() {
return null;
}
module.exports = {
up,
down
}

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<any> {
async function up(knex) {
const hasChangeEmail = await knex.schema.hasColumn(
"users",
"change_email_token"
@ -14,6 +12,12 @@ export async function up(knex: Knex): Promise<any> {
}
}
export async function down(): Promise<any> {
async function down() {
return null;
}
module.exports = {
up,
down
}

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function createDomainTable(knex: Knex) {
async function createDomainTable(knex) {
const hasTable = await knex.schema.hasTable("domains");
if (!hasTable) {
await knex.schema.raw('create extension if not exists "uuid-ossp"');
@ -32,3 +30,7 @@ export async function createDomainTable(knex: Knex) {
});
}
}
module.exports = {
createDomainTable
}

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function createHostTable(knex: Knex) {
async function createHostTable(knex) {
const hasTable = await knex.schema.hasTable("hosts");
if (!hasTable) {
await knex.schema.createTable("hosts", table => {
@ -21,3 +19,7 @@ export async function createHostTable(knex: Knex) {
});
}
}
module.exports = {
createHostTable
}

8
server/models/index.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
...require("./domain"),
...require("./host"),
...require("./ip"),
...require("./link"),
...require("./user"),
...require("./visit"),
}

View File

@ -1,6 +0,0 @@
export * from "./domain";
export * from "./host";
export * from "./ip";
export * from "./link";
export * from "./user";
export * from "./visit";

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function createIPTable(knex: Knex) {
async function createIPTable(knex) {
const hasTable = await knex.schema.hasTable("ips");
if (!hasTable) {
await knex.schema.createTable("ips", table => {
@ -13,3 +11,7 @@ export async function createIPTable(knex: Knex) {
});
}
}
module.exports = {
createIPTable
}

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function createLinkTable(knex: Knex) {
async function createLinkTable(knex) {
const hasTable = await knex.schema.hasTable("links");
if (!hasTable) {
@ -49,3 +47,7 @@ export async function createLinkTable(knex: Knex) {
});
}
}
module.exports = {
createLinkTable
}

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function createUserTable(knex: Knex) {
async function createUserTable(knex) {
const hasTable = await knex.schema.hasTable("users");
if (!hasTable) {
await knex.schema.createTable("users", table => {
@ -35,3 +33,7 @@ export async function createUserTable(knex: Knex) {
});
}
}
module.exports = {
createUserTable
};

View File

@ -1,6 +1,4 @@
import { Knex } from "knex";
export async function createVisitTable(knex: Knex) {
async function createVisitTable(knex) {
const hasTable = await knex.schema.hasTable("visits");
if (!hasTable) {
await knex.schema.createTable("visits", table => {
@ -84,3 +82,7 @@ export async function createVisitTable(knex: Knex) {
});
}
}
module.exports = {
createVisitTable
}

View File

@ -1,11 +1,11 @@
import { Strategy as LocalAPIKeyStrategy } from "passport-localapikey-update";
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
import { Strategy as LocalStrategy } from "passport-local";
import passport from "passport";
import bcrypt from "bcryptjs";
const { Strategy: LocalAPIKeyStrategy } = require("passport-localapikey-update");
const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");
const { Strategy: LocalStrategy } = require("passport-local");
const passport = require("passport");
const bcrypt = require("bcryptjs");
import query from "./queries";
import env from "./env";
const query = require("./queries");
const env = require("./env");
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromHeader("authorization"),

76
server/queries/domain.js Normal file
View File

@ -0,0 +1,76 @@
const redis = require("../redis");
const knex = require("../knex");
async function find(match) {
if (match.address) {
const cachedDomain = await redis.client.get(redis.key.domain(match.address));
if (cachedDomain) return JSON.parse(cachedDomain);
}
const domain = await knex("domains").where(match).first();
if (domain) {
redis.client.set(
redis.key.domain(domain.address),
JSON.stringify(domain),
"EX",
60 * 60 * 6
);
}
return domain;
}
function get(match) {
return knex("domains").where(match);
}
async function add(params) {
params.address = params.address.toLowerCase();
const exists = await knex("domains").where("address", params.address).first();
const newDomain = {
address: params.address,
homepage: params.homepage || null,
user_id: params.user_id || null,
banned: !!params.banned
};
let domain;
if (exists) {
const [response] = await knex("domains")
.where("id", exists.id)
.update(
{
...newDomain,
updated_at: params.updated_at || new Date().toISOString()
},
"*"
);
domain = response;
} else {
const [response] = await knex("domains").insert(newDomain, "*");
domain = response;
}
redis.remove.domain(domain);
return domain;
}
async function update(match, update) {
const domains = await knex("domains")
.where(match)
.update({ ...update, updated_at: new Date().toISOString() }, "*");
domains.forEach(redis.remove.domain);
return domains;
}
module.exports = {
add,
find,
get,
update,
}

View File

@ -1,84 +0,0 @@
import redisClient, * as redis from "../redis";
import knex from "../knex";
export const find = async (match: Partial<Domain>): Promise<Domain> => {
if (match.address) {
const cachedDomain = await redisClient.get(redis.key.domain(match.address));
if (cachedDomain) return JSON.parse(cachedDomain);
}
const domain = await knex<Domain>("domains")
.where(match)
.first();
if (domain) {
redisClient.set(
redis.key.domain(domain.address),
JSON.stringify(domain),
"EX",
60 * 60 * 6
);
}
return domain;
};
export const get = async (match: Partial<Domain>): Promise<Domain[]> => {
return knex<Domain>("domains").where(match);
};
interface Add extends Partial<Domain> {
address: string;
}
export const add = async (params: Add) => {
params.address = params.address.toLowerCase();
const exists = await knex<Domain>("domains")
.where("address", params.address)
.first();
const newDomain = {
address: params.address,
homepage: params.homepage || null,
user_id: params.user_id || null,
banned: !!params.banned
};
let domain: Domain;
if (exists) {
const [response]: Domain[] = await knex<Domain>("domains")
.where("id", exists.id)
.update(
{
...newDomain,
updated_at: params.updated_at || new Date().toISOString()
},
"*"
);
domain = response;
} else {
const [response]: Domain[] = await knex<Domain>("domains").insert(
newDomain,
"*"
);
domain = response;
}
redis.remove.domain(domain);
return domain;
};
export const update = async (
match: Partial<Domain>,
update: Partial<Domain>
) => {
const domains = await knex<Domain>("domains")
.where(match)
.update({ ...update, updated_at: new Date().toISOString() }, "*");
domains.forEach(redis.remove.domain);
return domains;
};

15
server/queries/index.js Normal file
View File

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

View File

@ -1,17 +0,0 @@
import * as domain from "./domain";
import * as visit from "./visit";
import * as link from "./link";
import * as user from "./user";
import * as host from "./host";
import * as ip from "./ip";
const queries = {
domain,
host,
ip,
link,
user,
visit
};
export default queries;

53
server/queries/ip.js Normal file
View File

@ -0,0 +1,53 @@
const { subMinutes } = require("date-fns");
const knex = require("../knex");
const env = require("../env");
async function add(ipToAdd) {
const ip = ipToAdd.toLowerCase();
const currentIP = await knex("ips").where("ip", ip).first();
if (currentIP) {
const currentDate = new Date().toISOString();
await knex("ips")
.where({ ip })
.update({
created_at: currentDate,
updated_at: currentDate
});
} else {
await knex("ips").insert({ ip });
}
return ip;
}
async function find(match) {
const query = knex("ips");
Object.entries(match).forEach(([key, value]) => {
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
const ip = await query.first();
return ip;
}
function clear() {
return knex<IP>("ips")
.where(
"created_at",
"<",
subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
)
.delete();
}
module.exports = {
add,
clear,
find,
}

View File

@ -1,47 +0,0 @@
import { subMinutes } from "date-fns";
import knex from "../knex";
import env from "../env";
export const add = async (ipToAdd: string) => {
const ip = ipToAdd.toLowerCase();
const currentIP = await knex<IP>("ips")
.where("ip", ip)
.first();
if (currentIP) {
const currentDate = new Date().toISOString();
await knex<IP>("ips")
.where({ ip })
.update({
created_at: currentDate,
updated_at: currentDate
});
} else {
await knex<IP>("ips").insert({ ip });
}
return ip;
};
export const find = async (match: Match<IP>) => {
const query = knex<IP>("ips");
Object.entries(match).forEach(([key, value]) => {
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
const ip = await query.first();
return ip;
};
export const clear = async () =>
knex<IP>("ips")
.where(
"created_at",
"<",
subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
)
.delete();

View File

@ -1,8 +1,9 @@
import bcrypt from "bcryptjs";
const bcrypt = require("bcryptjs");
import { CustomError } from "../utils";
import redisClient, * as redis from "../redis";
import knex from "../knex";
// FIXME: circular dependency
const CustomError = require("../utils").CustomError;
const redis = require("../redis");
const knex = require("../knex");
const selectable = [
"links.id",
@ -21,7 +22,7 @@ const selectable = [
"domains.address as domain"
];
const normalizeMatch = (match: Partial<Link>): Partial<Link> => {
function normalizeMatch(match) {
const newMatch = { ...match };
if (newMatch.address) {
@ -42,92 +43,76 @@ const normalizeMatch = (match: Partial<Link>): Partial<Link> => {
return newMatch;
};
interface TotalParams {
search?: string;
}
export const total = async (match: Match<Link>, params: TotalParams = {}) => {
const query = knex<Link>("links");
async function total(match, params) {
const query = knex("links");
Object.entries(match).forEach(([key, value]) => {
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
if (params.search) {
if (params?.search) {
query.andWhereRaw(
"links.description || ' ' || links.address || ' ' || target ILIKE '%' || ? || '%'",
[params.search]
);
}
const [{ count }] = await query.count("id");
return typeof count === "number" ? count : parseInt(count);
};
interface GetParams {
limit: number;
search?: string;
skip: number;
}
export const get = async (match: Partial<Link>, params: GetParams) => {
const query = knex<LinkJoinedDomain>("links")
async function get(match, params) {
const query = knex("links")
.select(...selectable)
.where(normalizeMatch(match))
.offset(params.skip)
.limit(params.limit)
.orderBy("created_at", "desc");
if (params.search) {
if (params?.search) {
query.andWhereRaw(
"concat_ws(' ', description, links.address, target, domains.address) ILIKE '%' || ? || '%'",
[params.search]
);
}
query.leftJoin("domains", "links.domain_id", "domains.id");
const links: LinkJoinedDomain[] = await query;
const links = await query;
return links;
};
}
export const find = async (match: Partial<Link>): Promise<Link> => {
async function find(match) {
if (match.address && match.domain_id) {
const key = redis.key.link(match.address, match.domain_id);
const cachedLink = await redisClient.get(key);
const cachedLink = await redis.client.get(key);
if (cachedLink) return JSON.parse(cachedLink);
}
const link = await knex<Link>("links")
const link = await knex("links")
.select(...selectable)
.where(normalizeMatch(match))
.leftJoin("domains", "links.domain_id", "domains.id")
.first();
if (link) {
const key = redis.key.link(link.address, link.domain_id);
redisClient.set(key, JSON.stringify(link), "EX", 60 * 60 * 2);
redis.client.set(key, JSON.stringify(link), "EX", 60 * 60 * 2);
}
return link;
};
interface Create extends Partial<Link> {
address: string;
target: string;
}
export const create = async (params: Create) => {
let encryptedPassword: string = null;
async function create(params) {
let encryptedPassword = null;
if (params.password) {
const salt = await bcrypt.genSalt(12);
encryptedPassword = await bcrypt.hash(params.password, salt);
}
const [link]: LinkJoinedDomain[] = await knex<LinkJoinedDomain>(
const [link] = await knex(
"links"
).insert(
{
@ -141,61 +126,66 @@ export const create = async (params: Create) => {
},
"*"
);
return link;
};
export const remove = async (match: Partial<Link>) => {
const link = await knex<Link>("links")
.where(match)
.first();
}
async function remove(match) {
const link = await knex("links").where(match).first();
if (!link) {
throw new CustomError("Link was not found.");
}
const deletedLink = await knex<Link>("links")
.where("id", link.id)
.delete();
const deletedLink = await knex("links").where("id", link.id).delete();
redis.remove.link(link);
return !!deletedLink;
};
export const batchRemove = async (match: Match<Link>) => {
const deleteQuery = knex<Link>("links");
const findQuery = knex<Link>("links");
}
async function batchRemove(match) {
const deleteQuery = knex("links");
const findQuery = knex("links");
Object.entries(match).forEach(([key, value]) => {
findQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
deleteQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
const links = await findQuery;
links.forEach(redis.remove.link);
await deleteQuery.delete();
};
}
export const update = async (match: Partial<Link>, update: Partial<Link>) => {
async function update(match, update) {
if (update.password) {
const salt = await bcrypt.genSalt(12);
update.password = await bcrypt.hash(update.password, salt);
}
const links = await knex<Link>("links")
const links = await knex("links")
.where(match)
.update({ ...update, updated_at: new Date().toISOString() }, "*");
links.forEach(redis.remove.link);
return links;
};
}
export const incrementVisit = async (match: Partial<Link>) => {
return knex<Link>("links")
.where(match)
.increment("visit_count", 1);
};
function incrementVisit(match) {
return knex("links").where(match).increment("visit_count", 1);
}
module.exports = {
normalizeMatch,
batchRemove,
create,
find,
get,
incrementVisit,
remove,
total,
update,
}

View File

@ -1,81 +1,84 @@
import { v4 as uuid } from "uuid";
import { addMinutes } from "date-fns";
const { addMinutes } = require("date-fns");
const { v4: uuid } = require("uuid");
import redisCLient, * as redis from "../redis";
import knex from "../knex";
const redis = require("../redis");
const knex = require("../knex");
export const find = async (match: Partial<User>) => {
async function find(match) {
if (match.email || match.apikey) {
const key = redis.key.user(match.email || match.apikey);
const cachedUser = await redisCLient.get(key);
if (cachedUser) return JSON.parse(cachedUser) as User;
const cachedUser = await redis.client.get(key);
if (cachedUser) return JSON.parse(cachedUser);
}
const user = await knex<User>("users").where(match).first();
const user = await knex("users").where(match).first();
if (user) {
const emailKey = redis.key.user(user.email);
redisCLient.set(emailKey, JSON.stringify(user), "EX", 60 * 60 * 1);
redis.client.set(emailKey, JSON.stringify(user), "EX", 60 * 60 * 1);
if (user.apikey) {
const apikeyKey = redis.key.user(user.apikey);
redisCLient.set(apikeyKey, JSON.stringify(user), "EX", 60 * 60 * 1);
redis.client.set(apikeyKey, JSON.stringify(user), "EX", 60 * 60 * 1);
}
}
return user;
};
interface Add {
email: string;
password: string;
}
export const add = async (params: Add, user?: User) => {
async function add(params, user) {
const data = {
email: params.email,
password: params.password,
verification_token: uuid(),
verification_expires: addMinutes(new Date(), 60).toISOString()
};
if (user) {
await knex<User>("users")
await knex("users")
.where("id", user.id)
.update({ ...data, updated_at: new Date().toISOString() });
} else {
await knex<User>("users").insert(data);
await knex("users").insert(data);
}
redis.remove.user(user);
return {
...user,
...data
};
};
export const update = async (match: Match<User>, update: Partial<User>) => {
const query = knex<User>("users");
}
async function update(match, update) {
const query = knex("users");
Object.entries(match).forEach(([key, value]) => {
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
const users = await query.update(
{ ...update, updated_at: new Date().toISOString() },
"*"
);
users.forEach(redis.remove.user);
return users;
};
export const remove = async (user: User) => {
const deletedUser = await knex<User>("users").where("id", user.id).delete();
}
async function remove(user) {
const deletedUser = await knex("users").where("id", user.id).delete();
redis.remove.user(user);
return !!deletedUser;
};
}
module.exports = {
add,
find,
remove,
update,
}

View File

@ -1,6 +1,6 @@
import { isAfter, subDays, set } from "date-fns";
import * as utils from "../utils";
import * as utils from "../utils/utils";
import redisClient, * as redis from "../redis";
import knex from "../knex";

View File

@ -3,7 +3,7 @@ import geoip from "geoip-lite";
import URL from "url";
import query from "../queries";
import { getStatsLimit, removeWww } from "../utils";
import { getStatsLimit, removeWww } from "../utils/utils";
const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];

View File

@ -1,6 +1,6 @@
import Redis from "ioredis";
const Redis = require("ioredis");
import env from "./env";
const env = require("./env");
const client = new Redis({
host: env.REDIS_HOST,
@ -9,31 +9,28 @@ const client = new Redis({
...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
});
export default client;
export const key = {
link: (address: string, domain_id?: number, user_id?: number) =>
`${address}-${domain_id || ""}-${user_id || ""}`,
domain: (address: string) => `d-${address}`,
stats: (link_id: number) => `s-${link_id}`,
host: (address: string) => `h-${address}`,
user: (emailOrKey: string) => `u-${emailOrKey}`
const key = {
link: (address, domain_id, user_id) => `${address}-${domain_id || ""}-${user_id || ""}`,
domain: (address) => `d-${address}`,
stats: (link_id) => `s-${link_id}`,
host: (address) => `h-${address}`,
user: (emailOrKey) => `u-${emailOrKey}`
};
export const remove = {
domain: (domain?: Domain) => {
const remove = {
domain: (domain) => {
if (!domain) return;
return client.del(key.domain(domain.address));
},
host: (host?: Host) => {
host: (host) => {
if (!host) return;
return client.del(key.host(host.address));
},
link: (link?: Link) => {
link: (link) => {
if (!link) return;
return client.del(key.link(link.address, link.domain_id));
},
user: (user?: User) => {
user: (user) => {
if (!user) return;
return Promise.all([
client.del(key.user(user.email)),
@ -41,3 +38,10 @@ export const remove = {
]);
}
};
module.exports = {
client,
key,
remove,
}

1
server/renders/index.js Normal file
View File

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

18
server/renders/renders.js Normal file
View File

@ -0,0 +1,18 @@
const { Router } = require("express");
const router = Router();
router.get("/", function homepage(req, res) {
console.log(req.cookies);
res.render("homepage", {
title: "Modern open source URL shortener"
});
});
router.get("/login", function login(req, res) {
res.render("login", {
title: "Log in or sign up"
});
});
module.exports = router;

52
server/routes/auth.js Normal file
View File

@ -0,0 +1,52 @@
const asyncHandler = require("express-async-handler");
const { Router } = require("express");
const validators = require("../handlers/validators");
const helpers = require("../handlers/helpers");
const auth = require("../handlers/auth");
const router = Router();
router.post(
"/login",
validators.login,
asyncHandler(helpers.verify("partials/login_signup")),
asyncHandler(auth.local),
asyncHandler(auth.login)
);
router.post(
"/signup",
auth.signupAccess,
validators.signup,
asyncHandler(helpers.verify("partials/login_signup")),
asyncHandler(auth.signup)
);
// router.post("/renew", asyncHandler(auth.jwt), asyncHandler(auth.token));
// router.post(
// "/change-password",
// asyncHandler(auth.jwt),
// validators.changePassword,
// asyncHandler(helpers.verify),
// asyncHandler(auth.changePassword)
// );
// router.post(
// "/change-email",
// asyncHandler(auth.jwt),
// validators.changePassword,
// asyncHandler(helpers.verify),
// asyncHandler(auth.changeEmailRequest)
// );
// router.post(
// "/apikey",
// asyncHandler(auth.jwt),
// asyncHandler(auth.generateApiKey)
// );
// router.post("/reset-password", asyncHandler(auth.resetPasswordRequest));
module.exports = router;

View File

@ -1,52 +0,0 @@
import asyncHandler from "express-async-handler";
import { Router } from "express";
import * as validators from "../handlers/validators";
import * as helpers from "../handlers/helpers";
import * as auth from "../handlers/auth";
const router = Router();
router.post(
"/login",
validators.login,
asyncHandler(helpers.verify),
asyncHandler(auth.local),
asyncHandler(auth.token)
);
router.post(
"/signup",
auth.signupAccess,
validators.signup,
asyncHandler(helpers.verify),
asyncHandler(auth.signup)
);
router.post("/renew", asyncHandler(auth.jwt), asyncHandler(auth.token));
router.post(
"/change-password",
asyncHandler(auth.jwt),
validators.changePassword,
asyncHandler(helpers.verify),
asyncHandler(auth.changePassword)
);
router.post(
"/change-email",
asyncHandler(auth.jwt),
validators.changePassword,
asyncHandler(helpers.verify),
asyncHandler(auth.changeEmailRequest)
);
router.post(
"/apikey",
asyncHandler(auth.jwt),
asyncHandler(auth.generateApiKey)
);
router.post("/reset-password", asyncHandler(auth.resetPasswordRequest));
export default router;

View File

@ -13,7 +13,7 @@ router.post(
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
validators.addDomain,
asyncHandler(helpers.verify),
asyncHandler(helpers.verify()),
asyncHandler(domains.add)
);
@ -22,7 +22,7 @@ router.delete(
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
validators.removeDomain,
asyncHandler(helpers.verify),
asyncHandler(helpers.verify()),
asyncHandler(domains.remove)
);

1
server/routes/index.js Normal file
View File

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

View File

@ -1 +0,0 @@
export { default } from "./routes";

83
server/routes/links.js Normal file
View File

@ -0,0 +1,83 @@
const { Router } = require("express");
const asyncHandler = require("express-async-handler");
const cors = require("cors");
const validators = require("../handlers/validators");
const helpers = require("../handlers/helpers");
const link = require("../handlers/links");
const auth = require("../handlers/auth");
const env = require("../env");
const router = Router();
// router.get(
// "/",
// asyncHandler(auth.apikey),
// asyncHandler(auth.jwt),
// helpers.query,
// asyncHandler(link.get)
// );
router.post(
"/",
cors(),
asyncHandler(auth.apikey),
asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose),
asyncHandler(auth.cooldown),
validators.createLink,
asyncHandler(helpers.verify()),
asyncHandler(link.create)
);
// router.patch(
// "/:id",
// asyncHandler(auth.apikey),
// asyncHandler(auth.jwt),
// validators.editLink,
// asyncHandler(helpers.verify),
// asyncHandler(link.edit)
// );
// router.delete(
// "/:id",
// asyncHandler(auth.apikey),
// asyncHandler(auth.jwt),
// validators.deleteLink,
// asyncHandler(helpers.verify),
// asyncHandler(link.remove)
// );
// router.get(
// "/:id/stats",
// asyncHandler(auth.apikey),
// asyncHandler(auth.jwt),
// validators.getStats,
// asyncHandler(link.stats)
// );
// router.post(
// "/:id/protected",
// validators.redirectProtected,
// asyncHandler(helpers.verify),
// asyncHandler(link.redirectProtected)
// );
// router.post(
// "/report",
// validators.reportLink,
// asyncHandler(helpers.verify),
// asyncHandler(link.report)
// );
// router.post(
// "/admin/ban/:id",
// asyncHandler(auth.apikey),
// asyncHandler(auth.jwt),
// asyncHandler(auth.admin),
// validators.banLink,
// asyncHandler(helpers.verify),
// asyncHandler(link.ban)
// );
module.exports = router;

View File

@ -1,83 +0,0 @@
import { Router } from "express";
import asyncHandler from "express-async-handler";
import cors from "cors";
import * as validators from "../handlers/validators";
import * as helpers from "../handlers/helpers";
import * as link from "../handlers/links";
import * as auth from "../handlers/auth";
import env from "../env";
const router = Router();
router.get(
"/",
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
helpers.query,
asyncHandler(link.get)
);
router.post(
"/",
cors(),
asyncHandler(auth.apikey),
asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose),
asyncHandler(auth.recaptcha),
asyncHandler(auth.cooldown),
validators.createLink,
asyncHandler(helpers.verify),
asyncHandler(link.create)
);
router.patch(
"/:id",
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
validators.editLink,
asyncHandler(helpers.verify),
asyncHandler(link.edit)
);
router.delete(
"/:id",
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
validators.deleteLink,
asyncHandler(helpers.verify),
asyncHandler(link.remove)
);
router.get(
"/:id/stats",
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
validators.getStats,
asyncHandler(link.stats)
);
router.post(
"/:id/protected",
validators.redirectProtected,
asyncHandler(helpers.verify),
asyncHandler(link.redirectProtected)
);
router.post(
"/report",
validators.reportLink,
asyncHandler(helpers.verify),
asyncHandler(link.report)
);
router.post(
"/admin/ban/:id",
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
validators.banLink,
asyncHandler(helpers.verify),
asyncHandler(link.ban)
);
export default router;

17
server/routes/routes.js Normal file
View File

@ -0,0 +1,17 @@
const { Router } = require("express");
// import domains from "./domains";
// import health from "./health";
const links = require("./links");
// import user from "./users";
const auth = require("./auth");
const router = Router();
// router.use("/domains", domains);
// router.use("/health", health);
router.use("/links", links);
// router.use("/users", user);
router.use("/auth", auth);
module.exports = router;

View File

@ -1,17 +0,0 @@
import { Router } from "express";
import domains from "./domains";
import health from "./health";
import links from "./links";
import user from "./users";
import auth from "./auth";
const router = Router();
router.use("/domains", domains);
router.use("/health", health);
router.use("/links", links);
router.use("/users", user);
router.use("/auth", auth);
export default router;

View File

@ -20,7 +20,7 @@ router.post(
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
validators.deleteUser,
asyncHandler(helpers.verify),
asyncHandler(helpers.verif()),
asyncHandler(user.remove)
);

81
server/server.js Normal file
View File

@ -0,0 +1,81 @@
const env = require("./env");
// import asyncHandler from "express-async-handler";
// import passport from "passport";
const cookieParser = require("cookie-parser");
const express = require("express");
const helmet = require("helmet");
const morgan = require("morgan");
const path = require("path");
const hbs = require("hbs");
const helpers = require("./handlers/helpers");
// import * as links from "./handlers/links";
// import * as auth from "./handlers/auth";
const routes = require("./routes");
const renders = require("./renders");
const utils = require("./utils");
const { stream } = require("./config/winston")
// import "./cron";
require("./passport");
const app = express();
// TODO: comments
app.set("trust proxy", true);
if (env.isDev) {
app.use(morgan("combined", { stream }));
}
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static("static"));
// app.use(passport.initialize());
// app.use(helpers.ip);
// template engine / serve html
app.set("view engine", "hbs");
app.set("views", path.join(__dirname, "views"));
utils.extendHbs();
app.use("/", renders);
// app.use(asyncHandler(links.redirectCustomDomain));
app.use("/api/v2", routes);
app.use("/api", routes);
// server.get(
// "/reset-password/:resetPasswordToken?",
// asyncHandler(auth.resetPassword),
// (req, res) => app.render(req, res, "/reset-password", { token: req.token })
// );
// server.get(
// "/verify-email/:changeEmailToken",
// asyncHandler(auth.changeEmail),
// (req, res) => app.render(req, res, "/verify-email", { token: req.token })
// );
// server.get(
// "/verify/:verificationToken?",
// asyncHandler(auth.verify),
// (req, res) => app.render(req, res, "/verify", { token: req.token })
// );
// server.get("/:id", asyncHandler(links.redirect(app)));
// Error handler
app.use(helpers.error);
// Handler everything else by Next.js
// server.get("*", (req, res) => handle(req, res));
app.listen(env.PORT, () => {
console.log(`> Ready on http://localhost:${env.PORT}`);
});

View File

@ -1,74 +0,0 @@
import env from "./env";
import asyncHandler from "express-async-handler";
import cookieParser from "cookie-parser";
import passport from "passport";
import express from "express";
import helmet from "helmet";
import morgan from "morgan";
import nextApp from "next";
import * as helpers from "./handlers/helpers";
import * as links from "./handlers/links";
import * as auth from "./handlers/auth";
import routes from "./routes";
import { stream } from "./config/winston";
import "./cron";
import "./passport";
const port = env.PORT;
const app = nextApp({ dir: "./client", dev: env.isDev });
const handle = app.getRequestHandler();
app.prepare().then(async () => {
const server = express();
server.set("trust proxy", true);
if (env.isDev) {
server.use(morgan("combined", { stream }));
}
server.use(helmet({ contentSecurityPolicy: false }));
server.use(cookieParser());
server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use(passport.initialize());
server.use(express.static("static"));
server.use(helpers.ip);
server.use(asyncHandler(links.redirectCustomDomain));
server.use("/api/v2", routes);
server.get(
"/reset-password/:resetPasswordToken?",
asyncHandler(auth.resetPassword),
(req, res) => app.render(req, res, "/reset-password", { token: req.token })
);
server.get(
"/verify-email/:changeEmailToken",
asyncHandler(auth.changeEmail),
(req, res) => app.render(req, res, "/verify-email", { token: req.token })
);
server.get(
"/verify/:verificationToken?",
asyncHandler(auth.verify),
(req, res) => app.render(req, res, "/verify", { token: req.token })
);
server.get("/:id", asyncHandler(links.redirect(app)));
// Error handler
server.use(helpers.error);
// Handler everything else by Next.js
server.get("*", (req, res) => handle(req, res));
server.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`);
});
});

1
server/utils/index.js Normal file
View File

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

View File

@ -1,170 +0,0 @@
import ms from "ms";
import nanoid from "nanoid/generate";
import JWT from "jsonwebtoken";
import {
differenceInDays,
differenceInHours,
differenceInMonths,
addDays
} from "date-fns";
import query from "../queries";
import env from "../env";
export class CustomError extends Error {
public statusCode?: number;
public data?: any;
public constructor(message: string, statusCode = 500, data?: any) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.data = data;
}
}
export const isAdmin = (email: string): boolean =>
env.ADMIN_EMAILS.split(",")
.map((e) => e.trim())
.includes(email);
export const signToken = (user: UserJoined) =>
JWT.sign(
{
iss: "ApiAuth",
sub: user.email,
domain: user.domain || "",
admin: isAdmin(user.email),
iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
} as Record<string, any>,
env.JWT_SECRET
);
export const generateId = async (domain_id: number = null) => {
const address = nanoid(
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
env.LINK_LENGTH
);
const link = await query.link.find({ address, domain_id });
if (!link) return address;
return generateId(domain_id);
};
export const addProtocol = (url: string): string => {
const hasProtocol = /^\w+:\/\//.test(url);
return hasProtocol ? url : `http://${url}`;
};
export const generateShortLink = (id: string, domain?: string): string => {
const protocol =
env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
return `${protocol}${domain || env.DEFAULT_DOMAIN}/${id}`;
};
export const getRedisKey = {
// TODO: remove user id and make domain id required
link: (address: string, domain_id?: number, user_id?: number) =>
`${address}-${domain_id || ""}-${user_id || ""}`,
domain: (address: string) => `d-${address}`,
host: (address: string) => `h-${address}`,
user: (emailOrKey: string) => `u-${emailOrKey}`
};
// TODO: Add statsLimit
export const getStatsLimit = (): number =>
env.DEFAULT_MAX_STATS_PER_LINK || 100000000;
export const getStatsCacheTime = (total?: number): number => {
return (total > 50000 ? ms("5 minutes") : ms("1 minutes")) / 1000;
};
export const statsObjectToArray = (obj: Stats) => {
const objToArr = (key) =>
Array.from(Object.keys(obj[key]))
.map((name) => ({
name,
value: obj[key][name]
}))
.sort((a, b) => b.value - a.value);
return {
browser: objToArr("browser"),
os: objToArr("os"),
country: objToArr("country"),
referrer: objToArr("referrer")
};
};
export const getDifferenceFunction = (
type: "lastDay" | "lastWeek" | "lastMonth" | "allTime"
) => {
if (type === "lastDay") return differenceInHours;
if (type === "lastWeek") return differenceInDays;
if (type === "lastMonth") return differenceInDays;
if (type === "allTime") return differenceInMonths;
throw new Error("Unknown type.");
};
export const getUTCDate = (dateString?: Date) => {
const date = new Date(dateString || Date.now());
return new Date(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours()
);
};
export const STATS_PERIODS: [number, "lastDay" | "lastWeek" | "lastMonth"][] = [
[1, "lastDay"],
[7, "lastWeek"],
[30, "lastMonth"]
];
export const getInitStats = (): Stats => {
return Object.create({
browser: {
chrome: 0,
edge: 0,
firefox: 0,
ie: 0,
opera: 0,
other: 0,
safari: 0
},
os: {
android: 0,
ios: 0,
linux: 0,
macos: 0,
other: 0,
windows: 0
},
country: {},
referrer: {}
});
};
export const sanitize = {
domain: (domain: Domain): DomainSanitized => ({
...domain,
id: domain.uuid,
uuid: undefined,
user_id: undefined,
banned_by_id: undefined
}),
link: (link: LinkJoinedDomain): LinkSanitized => ({
...link,
banned_by_id: undefined,
domain_id: undefined,
user_id: undefined,
uuid: undefined,
id: link.uuid,
password: !!link.password,
link: generateShortLink(link.address, link.domain)
})
};
export const removeWww = (host = "") => {
return host.replace("www.", "");
};

233
server/utils/utils.js Normal file
View File

@ -0,0 +1,233 @@
const ms = require("ms");
const path = require("path");
const nanoid = require("nanoid/generate");
const JWT = require("jsonwebtoken");
const { differenceInDays, differenceInHours, differenceInMonths, addDays } = require("date-fns");
const hbs = require("hbs");
const env = require("../env");
class CustomError extends Error {
constructor(message, statusCode, data) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode ?? 500;
this.data = data;
}
}
const query = require("../queries");
function isAdmin(email) {
return env.ADMIN_EMAILS.split(",")
.map((e) => e.trim())
.includes(email)
}
function signToken(user) {
return JWT.sign(
{
iss: "ApiAuth",
sub: user.email,
domain: user.domain || "",
admin: isAdmin(user.email),
iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
},
env.JWT_SECRET
)
}
async function generateId(domain_id) {
const address = nanoid(
"abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
env.LINK_LENGTH
);
const link = await query.link.find({ address, domain_id });
if (!link) return address;
return generateId(domain_id);
}
function addProtocol(url) {
const hasProtocol = /^\w+:\/\//.test(url);
return hasProtocol ? url : `http://${url}`;
}
function getShortURL(id, domain) {
const protocol = env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
const link = `${domain || env.DEFAULT_DOMAIN}/${id}`;
const url = `${protocol}${link}`;
return { link, url };
}
const getRedisKey = {
// TODO: remove user id and make domain id required
link: (address, domain_id, user_id) => `${address}-${domain_id || ""}-${user_id || ""}`,
domain: (address) => `d-${address}`,
host: (address) => `h-${address}`,
user: (emailOrKey) => `u-${emailOrKey}`
};
function getStatsLimit() {
return env.DEFAULT_MAX_STATS_PER_LINK || 100000000;
};
function getStatsCacheTime(total) {
return (total > 50000 ? ms("5 minutes") : ms("1 minutes")) / 1000
};
function statsObjectToArray(obj) {
const objToArr = (key) =>
Array.from(Object.keys(obj[key]))
.map((name) => ({
name,
value: obj[key][name]
}))
.sort((a, b) => b.value - a.value);
return {
browser: objToArr("browser"),
os: objToArr("os"),
country: objToArr("country"),
referrer: objToArr("referrer")
};
}
function getDifferenceFunction(type) {
if (type === "lastDay") return differenceInHours;
if (type === "lastWeek") return differenceInDays;
if (type === "lastMonth") return differenceInDays;
if (type === "allTime") return differenceInMonths;
throw new Error("Unknown type.");
}
function getUTCDate(dateString) {
const date = new Date(dateString || Date.now());
return new Date(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours()
);
}
const STATS_PERIODS = [
[1, "lastDay"],
[7, "lastWeek"],
[30, "lastMonth"]
];
const preservedURLs = [
"login",
"logout",
"signup",
"reset-password",
"resetpassword",
"url-password",
"url-info",
"settings",
"stats",
"verify",
"api",
"404",
"static",
"images",
"banned",
"terms",
"privacy",
"protected",
"report",
"pricing"
];
function getInitStats() {
return Object.create({
browser: {
chrome: 0,
edge: 0,
firefox: 0,
ie: 0,
opera: 0,
other: 0,
safari: 0
},
os: {
android: 0,
ios: 0,
linux: 0,
macos: 0,
other: 0,
windows: 0
},
country: {},
referrer: {}
});
}
const sanitize = {
domain: domain => ({
...domain,
id: domain.uuid,
uuid: undefined,
user_id: undefined,
banned_by_id: undefined
}),
link: link => ({
...link,
banned_by_id: undefined,
domain_id: undefined,
user_id: undefined,
uuid: undefined,
id: link.uuid,
password: !!link.password,
link: getShortURL(link.address, link.domain)
})
};
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function removeWww(host) {
return host.replace("www.", "");
};
function extendHbs() {
const blocks = {};
hbs.registerHelper("extend", function(name, context) {
let block = blocks[name];
if (!block) {
block = blocks[name] = [];
}
block.push(context.fn(this));
});
hbs.registerHelper("block", function(name) {
const val = (blocks[name] || []).join('\n');
blocks[name] = [];
return val;
});
hbs.registerPartials(path.join(__dirname, "../views/partials"), function (err) {});
}
module.exports = {
addProtocol,
CustomError,
generateId,
getShortURL,
getDifferenceFunction,
getInitStats,
getRedisKey,
getStatsCacheTime,
getStatsLimit,
getUTCDate,
extendHbs,
isAdmin,
preservedURLs,
removeWww,
sanitize,
signToken,
sleep,
STATS_PERIODS,
statsObjectToArray,
}

81
server/views/homepage.hbs Normal file
View File

@ -0,0 +1,81 @@
{{> header}}
<main>
<div id="shorturl">
<h1>Kutt your links <span>shorter</span>.</h1>
</div>
<form hx-post="/api/links" hx-trigger="submit queue:none" hx-target="#shorturl">
<div class="target-wrapper">
<input
id="target"
name="target"
type="text"
placeholder="Paste your long URL"
aria-label="target"
autofocus="true"
data-lpignore="true"
/>
<button class="submit">
<svg class="send" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="m2 21 21-9L2 3v7l15 2-15 2z"/></svg>
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
</button>
</div>
<label id="advanced" class="checkbox">
<input type="checkbox" />
Show advanced options
</label>
</form>
</main>
<section class="introduction">
<div class="text-wrapper">
<h2>Manage links, set custom <b>domains</b> and view <b>stats</b>.</h2>
<a class="button primary">Log in / Sign up</a>
</div>
<img src="/images/callout.png" alt="callout image" />
</section>
<section class="features">
<h3>Kutting edge features.</h3>
<ul>
<li>
<div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 14.7V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.3"/><path d="m18 2 4 4-10 10H8v-4z"/></svg>
</div>
<h4>Managing links</h4>
<p>Create, protect and delete your links and monitor them with detailed statistics.</p>
</li>
<li>
<div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 3h5v5M4 20 20.2 3.8M21 16v5h-5m-1-6 5.1 5.1M4 4l5 5"/></svg>
</div>
<h4>Custom domain</h4>
<p>Use custom domains for your links. Add or remove them for free.</p>
</li>
<li>
<div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9z"/></svg>
</div>
<h4>API</h4>
<p>Use the provided API to create, delete, and get URLs from anywhere.</p>
</li>
<li>
<div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.7 0l-1.1 1-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1 7.8 7.8 7.8-7.7 1-1.1a5.5 5.5 0 0 0 0-7.8z"/></svg>
</div>
<h4>Free & open source</h4>
<p>Completely open source and free. You can host it on your own server.</p>
</li>
</ul>
</section>
<section class="extensions">
<h3>Browser extentions.</h3>
<div class="extenstions-wrapper">
<a class="extension-button chrome" href="https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd" target="_blank" rel="noopener noreferrer" title="Chrome extension">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16.2 8.7 23 7a12 12 0 0 1 1.1 5 12 12 0 0 1-13 12l5-8.4.8-1.3a6 6 0 0 0 0-4.7zM13 17.3l-2.1 6.6A12 12 0 0 1 2 5.3l5 8.4c.2.5 1 2.5 3 3.3q1.5.6 3 .3m-1-9.7c-2 0-3.9 1.6-4.3 3.5a5 5 0 0 0 1.2 4 5 5 0 0 0 4.8 1c1.4-.6 2.4-2 2.7-3.4.2-1.9-.8-3.9-2.5-4.7a4 4 0 0 0-2-.4M7 10 2.3 5A12 12 0 0 1 12 0a12 12 0 0 1 10.8 6.7H12.6Q9.8 6.6 8.3 8A5 5 0 0 0 7 10"/></svg>
Download for Chrome
</a>
<a class="extension-button firefox" href="https://addons.mozilla.org/en-US/firefox/addon/kutt/" target="_blank" rel="noopener noreferrer" title="Firefox extension">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.4 11v-.4l-.3.3-.3-1.5a10 10 0 0 0-1.3-2.9l-.2-.3-1.5-2q-.6-1-.8-2l-.3 1.3-1.4-1.2C15.8.9 16 0 16 0s-2.8 3.2-1.6 6.4q.6 1.6 2 2.8c1.3 1 2.5 1.7 3.2 3.7q-.9-1.6-2.4-2.5.5 1 .5 2.2a5.3 5.3 0 0 1-6.5 5.2l-1.3-.5q-1-.5-1.6-1.4h.1l.7.2q1.4.2 2.6-.3 1.3-.8 1.8-.7.7 0 .4-.7-.8-1-2-.8c-1 .1-1.7.7-2.8.1H9h.2l-.7-.5h.1l-.7-.7q-.3-.6 0-1.1 0-.3.4-.4h.2l.5.3.3.2v-.1q0-.3-.3-.4l.4.2v-1h.1V10l.2-.2 1-.6.9-.4q.4-.3.5-.8v-.2c0-.2-.3-.3-1.8-.5q-1-.1-1.1-1v.2-.2q.5-1.1 1.5-1.8h-.1l.3-.2-.6-.2-.5.2.2-.2-1.1.5v-.1q-.4.1-.7.5l-.4.3Q6.5 4.7 5.1 5l-.4-.5-.2-.3-.2-.3Q4 3.5 4 2.8q-.5.3-.6.7l-.1.2v-.2q0 .2-.2.3V4v-.1H3a7 7 0 0 0-.6 2.3v.4l-.6.8Q1 8.8.6 10.6l.7-1.2a11 11 0 0 0-.8 4l.3-1.2q0 2.6 1 4.8 1.4 3.3 4.4 5 1.2.9 2.6 1.3l.3.1q1.5.5 3.3.5c4 0 5.3-1.6 5.4-1.7l.5-.7h.2l.2-.1 1.7-1q1.1-1 1.5-2.4.3-.5 0-1l.2-.3q1.2-2 1.4-4.6z"/></svg>
Download for Firefox
</a>
</div>
</section>
{{> footer}}

62
server/views/layout.hbs Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="icon" sizes="196x196" href="/images/favicon-196x196.png" />
<link rel="icon" sizes="32x32" href="/images/favicon-32x32.png" />
<link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
<link rel="mask-icon" href="/images/icon.svg" color="blue" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#f3f3f3" />
<meta property="fb:app_id" content="123456789" />
<meta name="htmx-config" content='{"withCredentials":true}'>
{{!-- TODO: meta tags --}}
{{!-- <meta
property="og:url"
content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}`}
/>
<meta property="og:type" content="website" />
<meta property="og:title" content={publicRuntimeConfig.SITE_NAME} />
<meta
property="og:image"
content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}/images/card.png`}
/>
<meta
property="og:description"
content="Free & Open Source Modern URL Shortener"
/>
<meta
name="twitter:url"
content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}`}
/>
<meta name="twitter:title" content={publicRuntimeConfig.SITE_NAME} />
<meta
name="twitter:description"
content="Free & Open Source Modern URL Shortener"
/>
<meta
name="twitter:image"
content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}/images/card.png`}
/> --}}
{{!-- TODO: configurable title --}}
<meta name="description" content="Kutt is a free and open source URL shortener with custom domains and stats." />
{{!-- TODO: configurable title --}}
<title>Kutt | {{title}}</title>
<link rel="stylesheet" href="/css/styles.css">
{{{block "stylesheets"}}}
</head>
<body>
<div class="main-wrapper">
{{{body}}}
</div>
{{{block "scripts"}}}
<script src="/libs/htmx.min.js"></script>
<script src="/scripts/main.js"></script>
<script>
htmx.logAll();
</script>
</body>
</html>

3
server/views/login.hbs Normal file
View File

@ -0,0 +1,3 @@
{{> header}}
{{> login_signup}}
{{> footer}}

View File

@ -0,0 +1,9 @@
<footer>
<p>
Made with love by <a href="https://thedevs.network" title="The Devs" target="_blank" rel="noopener noreferrer">The Devs</a>. <span>|</span>
<a href="https://github.com/thedevs-network/kutt" title="GitHub" target="_blank" rel="noopener noreferrer">GitHub</a> <span>|</span>
<a href="/terms" title="Terms of Service">Terms of Service</a> <span>|</span>
<a href="/report" title="Report abuse">Report Abuse</a> <span>|</span>
<a href="mailto:support@kutt.it" title="Contact us">Contact us</a>
</p>
</footer>

View File

@ -0,0 +1,40 @@
<header hx-boost="true">
<div class="logo-wrapper">
<a class="logo nav" href="/" title="Kutt">
<img src="/images/logo.svg" alt="kutt" width="18" height="24" />
{{!-- TODO: configurable site name --}}
Kutt
</a>
<ul class="logo-links">
<li>
<a class="nav" href="https://github.com/thedevs-network/kutt" target="_blank" rel="noopener noreferrer" title="GitHub">
GitHub
</a>
</li>
<li>
<a class="nav" href="/report" title="Report abuse">
Report
</a>
</li>
</ul>
</div>
<nav>
<ul>
<li>
<a class="button primary" href="/login" title="Log in or sign up">
Log in / Sign up
</a>
</li>
{{!-- <li>
<a class="button primary" href="/settings" title="Settings">
Settings
</a>
</li>
<li>
<a class="nav" href="/logout" title="Log out">
Log out
</a>
</li> --}}
</ul>
</nav>
</header>

View File

@ -0,0 +1,50 @@
<form id="login-signup" hx-post="/api/auth/login" hx-swap="outerHTML">
<label>
Email address:
<input
name="email"
id="email"
type="email"
autofocus="true"
placeholder="Email address..."
hx-preserve="true"
/>
</label>
<label>
Password:
<input
name="password"
id="password"
type="password"
placeholder="Password..."
hx-preserve="true"
/>
</label>
{{!-- TODO: Agree with terms --}}
<div class="buttons-wrapper">
<button type="submit" class="primary login">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4m-5-4 5-5-5-5m3.8 5H3"/></svg>
<svg class="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>
Log in
</button>
<button
class="secondary signup"
hx-post="/api/auth/signup"
hx-target="#login-signup"
hx-trigger="click"
hx-indicator="#login-signup"
hx-swap="outerHTML"
hx-sync="closest form"
hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')"
hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')"
>
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>
<svg class="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>
Sign up
</button>
</div>
<a class="forgot-password" href="/forgot-password" title="Reset password">Forgot your password?</a>
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
</form>

View File

@ -0,0 +1,5 @@
<div class="login-signup-message" hx-get="/" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/">
<h1>
Welcome. Redirecting to homepage...
</h1>
</div>

View File

@ -0,0 +1,7 @@
<div class="clipboard">
<button aria-label="Copy" hx-on:click="handleShortURLCopyLink(this);" data-url="{{url}}">
<svg class="copy" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect width="13" height="13" x="9" y="9" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
<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>
</div>
<h1 class="link" hx-on:click="handleShortURLCopyLink(this);" data-url="{{url}}">{{link}}</h1>

View File

@ -0,0 +1,5 @@
<div class="login-signup-message">
<h1>
A verification email has been sent to you.
</h1>
</div>

768
static/css/styles.css Normal file
View File

@ -0,0 +1,768 @@
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 200 1000;
src: url(/fonts/nunito-variable.woff2) format('woff2');
}
:root {
--bg-color: hsl(206, 12%, 95%);
--text-color: hsl(200, 35%, 25%);
--color-primary: #2196f3;
--outline-color: #14e0ff;
--button-bg: linear-gradient(to right, #e0e0e0, #bdbdbd);
--button-bg-box-shadow-color: rgba(160, 160, 160, 0.5);
--button-bg-primary: linear-gradient(to right, #42a5f5, #2979ff);
--button-bg-primary-box-shadow-color: rgba(66, 165, 245, 0.5);
--button-bg-secondary: linear-gradient(to right, #7e57c2, #6200ea);
--button-bg-secondary-box-shadow-color: rgba(81, 45, 168, 0.5);
--button-bg-danger: linear-gradient(to right, #ee3b3b, #e11c1c);
--button-bg-danger-box-shadow-color: rgba(168, 45, 45, 0.5);
--features-bg: hsl(230, 15%, 92%);
--extensions-bg: hsl(230, 15%, 20%);
--send-icon-hover-color: #673ab7;
--send-spinner-icon-color: hsl(200, 15%, 70%);
--copy-icon-color: hsl(144, 40%, 57%);
--copy-icon-bg-color: hsl(144, 100%, 96%);
--keyframe-slidey-offset: 0;
}
/* ANIMATIONS */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes fadein {
from { opacity: 0 }
to { opacity: 1 }
}
@keyframes slidey {
from { transform: translateY(var(--keyframe-slidey-offset)) }
to { transform: translateY(0) }
}
/* GENERAL */
body {
margin: 0;
padding: 0;
background-color: var(--bg-color);
font: 16px/1.45 'Nunito', sans-serif;
overflow-x: hidden;
color: var(--text-color);
}
* {
box-sizing: border-box;
outline-color: var(--outline-color);
}
*::-moz-focus-inner {
border: none;
}
a {
color: var(--color-primary);
border-bottom: 1px dotted transparent;
text-decoration: none;
transition: all 0.2s ease-out;
}
a:hover {
border-bottom-color: var(--color-primary);
}
a.nav {
color: inherit;
padding-bottom: 2px;
}
a.nav:hover {
color: var(--color-primary);
}
a.button,
button {
position: relative;
width: auto;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 32px;
font-size: 13px;
font-weight: normal;
text-align: center;
line-height: 1;
word-break: keep-all;
color: #444;
border: none;
border-radius: 100px;
transition: all 0.4s ease-out;
cursor: pointer;
overflow: hidden;
background: var(--button-bg);
box-shadow: 0 5px 6px var(--button-bg-box-shadow-color);
}
a.button.primary,
button.primary {
color: white;
background: var(--button-bg-primary);
box-shadow: 0 5px 6px var(--button-bg-primary-box-shadow-color);
}
a.button.secondary,
button.secondary {
color: white;
background: var(--button-bg-secondary);
box-shadow: 0 5px 6px var(--button-bg-secondary-box-shadow-color);
}
a.button.danger,
button.danger {
color: white;
background: var(--button-bg-danger);
box-shadow: 0 5px 6px var(--button-bg-danger-box-shadow-color);
}
a.button:focus,
a.button:hover,
button:focus,
button:hover {
box-shadow: 0 6px 15px var(--button-bg-box-shadow-color);
transform: translateY(-2px) scale(1.02, 1.02);
}
a.button.primary:focus,
a.button.primary:hover,
button.primary:focus,
button.primary:hover {
box-shadow: 0 6px 15px var(--button-bg-primary-box-shadow-color);
}
a.button.secondary:focus,
a.button.secondary:hover,
button.secondary:focus,
button.secondary:hover {
box-shadow: 0 6px 15px var(--button-bg-secondary-box-shadow-color);
}
a.button.danger:focus,
a.button.danger:hover,
button.danger:focus,
button.danger:hover {
box-shadow: 0 6px 15px var(--button-bg-danger-box-shadow-color);
}
input {
filter: none;
}
input[type="text"],
input[type="email"],
input[type="password"] {
box-sizing: border-box;
height: 40px;
padding: 0 24px;
font-size: 15px;
letter-spacing: 0.05em;
color: #444;
background-color: white;
border: none;
border-radius: 100px;
border-bottom: 5px solid #f5f5f5;
border-bottom-width: 5px;
box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
transition: all 0.5s ease-out;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus {
outline: none;
box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
}
input[type="text"]::placeholder,
input[type="email"]::placeholder,
input[type="password"]::placeholder {
font-size: 14px;
letter-spacing: 0.05em;
color: #888;
}
input[type="checkbox"] {
position: relative;
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: white;
box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
margin: 0;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
input[type="checkbox"]:focus {
outline: 3px solid rgba(65, 164, 245, 0.5);
}
input[type="checkbox"]::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 80%;
height: 80%;
display: block;
border-radius: 2px;
background-color: #9575cd;
box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
cursor: pointer;
opacity: 0;
transform: translate(-50%, -50%) scale(0);
transition: all 0.1s ease-in-out;
}
input[type="checkbox"]:checked:after {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
label.checkbox {
display: flex;
align-items: center;
cursor: pointer;
}
label.checkbox input[type="checkbox"] {
margin: 0 0.75rem 2px 0;
}
label {
color: #555;
}
/* DISTINCT */
.main-wrapper {
min-height: 100vh;
width: 100%;
display: flex;
flex: 0 0 auto;
align-items: center;
flex-direction: column;
}
/* HEADER */
header {
box-sizing: border-box;
margin: 0;
width: 1232px;
max-width: 100%;
padding: 0 32px;
height: 102px;
justify-content: space-between;
align-items: center;
display: flex;
}
header .logo-wrapper {
display: flex;
align-items: center;
}
header a.logo {
position: relative;
display: flex;
align-items: center;
font-size: 22px;
font-weight: bold;
text-decoration: none;
border: none;
margin: 0;
padding: 0;
}
header a.logo:hover { border: none; color: inherit; }
header .logo img {
margin: 0 12px 0 0;
padding: 0;
}
header ul.logo-links {
list-style: none;
display: flex;
align-items: flex-end;
margin: 0 0 0 0.5rem;
padding: 0;
}
header ul.logo-links li {
padding: 2px 0 0;
margin: 0 0 0 32px;
}
header ul.logo-links li a {
font-size: 16px;
}
header nav ul {
display: flex;
flex-direction: row-reverse;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
header nav ul li {
margin: 0 0 0 32px;
padding: 0;
}
header nav ul li:last-child { margin-left: 0; }
/* SHORTENER */
main {
width: 800px;
max-width: 100%;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 1rem;
margin-top: 1rem;
}
main #shorturl {
display: flex;
align-items: center;
margin-bottom: 3rem;
}
main #shorturl h1 {
border-bottom: 1px dotted transparent;
font-weight: 300;
font-size: 2rem;
}
main #shorturl h1.link {
cursor: pointer;
border-bottom-color: hsl(200, 35%, 65%);
transition: opacity 0.2s ease-in-out;
}
main #shorturl h1.link:hover {
opacity: 0.8;
}
main #shorturl .clipboard {
width: 35px;
display: flex;
margin-right: 1rem;
}
main #shorturl button {
width: 100%;
display: flex;
margin: 0;
padding: 7px;
box-shadow: none;
outline: none;
border: none;
background: none;
border-radius: 100%;
background-color: var(--copy-icon-bg-color);
transition: transform 0.4s ease-out;
box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
cursor: pointer;
--keyframe-slidey-offset: 10px;
animation: slidey 0.2s ease-in-out;
}
main #shorturl button:hover,
main #shorturl button:focus {
transform: translateY(-2px) scale(1.02, 1.02);
}
main #shorturl button:focus {
outline: 3px solid rgba(65, 164, 245, 0.5);
}
main #shorturl svg {
stroke: var(--copy-icon-color);
width: 100%;
height: auto;
}
main #shorturl svg.copy {
stroke-width: 2.5;
}
main #shorturl svg.check {
display: none;
padding: 3px;
stroke-width: 3;
--keyframe-slidey-offset: -10px;
animation: slidey 0.2s ease-in-out;
}
main #shorturl.copied button {
background-color: transparent;
box-shadow: none;
}
main #shorturl.copied button { display: none; }
main #shorturl.copied svg.check { display: block; }
main #shorturl h1 span {
border-bottom: 1px dotted #999;
}
main form {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
}
main form input#target {
position: relative;
width: 100%;
height: 72px;
display: flex;
padding: 0 84px 0 40px;
font-size: 20px;
}
main form input#target::placeholder {
font-size: 17px;
}
main form .target-wrapper {
position: relative;
width: 100%;
height: auto;
}
main form button.submit {
box-sizing: content-box;
position: absolute;
cursor: pointer;
width: 28px;
height: auto;
right: 0;
top: 50%;
padding: 4px;
margin: 0 2rem 0;
background: none;
box-shadow: none;
outline: none;
border: none;
transform: translateY(-52%);
}
main form button.submit:focus,
main form button.submit:hover {
outline: none;
}
main form button.submit svg.send {
fill: #aaa;
animation: fadein 0.3s ease-in-out;
transition: fill 0.2s ease-in-out;
}
main form button.submit:hover svg.send {
fill: var(--send-icon-hover-color);
}
main form button.submit svg.spinner {
display: none;
fill: none;
stroke: var(--send-spinner-icon-color);
stroke-width: 2;
animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
}
main form.htmx-request button.submit svg.send {
display: none;
}
main form.htmx-request button.submit svg.spinner {
display: block;
}
main form label#advanced {
margin-top: 2rem;
}
/* INTRO */
.introduction {
width: 1200px;
max-width: 98%;
display: flex;
align-items: center;
margin: 150px 0 0;
}
.introduction .text-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: flex-start;
margin-top: -32px;
}
.introduction h2 {
font-weight: 300;
font-size: 28px;
padding-right: 2rem;
margin-bottom: 2.5rem
}
.introduction img {
width: 60%;
flex: 0 0 60%;
max-width: 100%;
height: auto;
}
/* FEATURES */
.features {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex: 0 0 auto;
padding: 5rem 0;
margin: 0;
background-color: var(--features-bg);
}
.features h3 {
font-weight: 300;
font-size: 28px;
margin-bottom: 72px;
}
.features ul {
width: 1200px;
max-width: 100%;
flex: 1 1 auto;
justify-content: center;
flex-wrap: nowrap;
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.features ul li {
max-width: 25%;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 1.5rem;
margin: 0;
}
.features ul li .icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
background-color: var(--color-primary);
}
.features ul li .icon svg {
width: 16px;
height: auto;
stroke: white;
stroke-width: 2px;
}
.features ul li h4 {
margin: 1rem;
padding: 0;
font-size: 15px;
}
.features ul li p {
margin: 0;
padding: 0;
font-size: 14px;
font-weight: 300;
text-align: center;
}
/* EXTENSIONS */
.extensions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
flex: 0 0 auto;
flex-wrap: nowrap;
padding: 5rem 0;
background-color: var(--extensions-bg);
color: white;
}
.extensions h3 {
font-size: 28px;
font-weight: 300;
margin-bottom: 4rem;
}
.extensions .extenstions-wrapper {
width: 1200px;
max-width: 100%;
display: flex;
flex: 1 1 auto;
justify-content: center;
flex-wrap: nowrap;
}
.extensions a.extension-button {
display: flex;
align-items: center;
justify-content: center;
margin: 0 1rem;
padding: 0.75rem 1.75rem;
background-color: #eee;
border: 1px solid #aaa;
font-size: 14px;
font-weight: bold;
text-decoration: none;
border-radius: 4px;
outline: none;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease-out;
cursor: pointer;
}
.extensions a.extension-button:hover {
transform: translateY(-2px);
}
.extensions a.extension-button.chrome { color: #4285f4; }
.extensions a.extension-button.firefox { color: #e0890f; }
.extensions a.extension-button svg {
width: 18px;
height: auto;
margin: 0 1rem 2px 0;
}
.extensions a.extension-button.chrome svg { fill: #4285f4; }
.extensions a.extension-button.firefox svg { fill: #e0890f; }
/* LOGIN & SIGNUP */
form#login-signup {
max-width: 100%;
flex: 1 1 auto;
display: flex;
flex-direction: column;
width: 400px;
margin: 3rem 0 0;
}
form#login-signup label {
display: flex;
flex-direction: column;
font-size: 16px;
font-weight: bold;
margin-bottom: 2rem;
}
form#login-signup input {
width: 100%;
height: 72px;
margin-top: 1rem;
padding: 0 3rem;
font-size: 16px;
}
form#login-signup .buttons-wrapper {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
}
form#login-signup .buttons-wrapper button {
height: 56px;
flex: 1 1 auto;
padding: 0 1rem 2px;
margin-right: 1rem;
}
form#login-signup .buttons-wrapper button:last-child { margin-right: 0; }
form#login-signup .buttons-wrapper button svg {
width: 16px;
height: auto;
margin-right: 0.5rem;
stroke: white;
stroke-width: 2;
}
form#login-signup a.forgot-password {
align-self: flex-start;
font-size: 14px;
}
form#login-signup svg.spinner {
display: none;
animation: fadein 0.3s ease-in-out, spin 1s linear infinite;
}
form#login-signup.htmx-request:not(.signup) .login svg.spinner { display: block; }
form#login-signup.htmx-request:not(.signup) .login svg.icon { display: none; }
form#login-signup.htmx-request.signup .signup svg.spinner { display: block; }
form#login-signup.htmx-request.signup .signup svg.icon { display: none; }
form#login-signup.htmx-request .error { opacity: 0; }
form#login-signup .error {
color: red;
animation: fadein 0.3s ease-in-out;
}
.login-signup-message {
flex: 1 1 auto;
margin-top: 3rem;
}
.login-signup-message h1 {
font-weight: 300;
font-size: 24px;
}
/* FOOTER */
footer {
width: 100%;
display: flex;
justify-content: center;
padding: 1rem 0;
font-size: 13px;
text-align: center;
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 31 KiB

1
static/libs/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

21
static/scripts/main.js Normal file
View File

@ -0,0 +1,21 @@
// add text/html accept header to receive html instead of json for the requests
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers["Accept"] = "text/html,*/*";
console.log(evt.detail.headers);
});
// copy the link to clipboard
function handleCopyLink(element) {
navigator.clipboard.writeText(element.dataset.url);
}
// copy the link and toggle copy button style
function handleShortURLCopyLink(element) {
handleCopyLink(element);
const parent = document.querySelector("#shorturl");
if (!parent || parent.classList.contains("copied")) return;
parent.classList.add("copied");
setTimeout(function() {
parent.classList.remove("copied");
}, 1000);
}

View File

@ -4,7 +4,7 @@
"module": "commonjs",
"sourceMap": true,
"outDir": "production-server",
"noUnusedLocals": true,
"noUnusedLocals": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": false,