(wip): nextjs to htmx
This commit is contained in:
parent
041aed5ad6
commit
8fe106c2d6
@ -1,5 +0,0 @@
|
||||
.next/
|
||||
flow-typed/
|
||||
node_modules/
|
||||
client/**/__test__/
|
||||
production-server
|
26
.eslintrc
26
.eslintrc
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npm run lint:nofix
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"endOfLine": "lf"
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import env from "./server/env";
|
||||
const env = require("./server/env");
|
||||
|
||||
module.exports = {
|
||||
production: {
|
@ -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
25424
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@ -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",
|
||||
|
@ -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
|
||||
}
|
@ -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;
|
@ -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,18 +67,18 @@ 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();
|
||||
@ -79,42 +97,26 @@ 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
|
||||
@ -122,15 +124,37 @@ export const signup: Handler = async (req, res) => {
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
export const token: Handler = async (req, res) => {
|
||||
return res.status(201).send({ message: "A verification email has been sent." });
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
function login(req, res) {
|
||||
const token = utils.signToken(req.user);
|
||||
return res.status(200).send({ token });
|
||||
};
|
||||
|
||||
export const verify: Handler = async (req, res, next) => {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("express").Handler}
|
||||
*/
|
||||
async function verify(req, res, next) {
|
||||
if (!req.params.verificationToken) return next();
|
||||
|
||||
const [user] = await query.user.update(
|
||||
@ -151,9 +175,12 @@ export const verify: Handler = async (req, res, next) => {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -166,9 +193,12 @@ export const changePassword: Handler = async (req, res) => {
|
||||
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);
|
||||
@ -180,9 +210,12 @@ export const generateApiKey: Handler = async (req, res) => {
|
||||
}
|
||||
|
||||
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 },
|
||||
{
|
||||
@ -198,9 +231,12 @@ export const resetPasswordRequest: Handler = async (req, res) => {
|
||||
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) {
|
||||
@ -213,19 +249,25 @@ export const resetPassword: Handler = async (req, res, next) => {
|
||||
);
|
||||
|
||||
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);
|
||||
@ -260,9 +302,12 @@ export const changeEmailRequest: Handler = async (req, res) => {
|
||||
"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) {
|
||||
@ -288,9 +333,28 @@ export const changeEmail: Handler = async (req, res, next) => {
|
||||
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,
|
||||
}
|
@ -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;
|
||||
|
87
server/handlers/helpers.js
Normal file
87
server/handlers/helpers.js
Normal 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,
|
||||
}
|
@ -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
403
server/handlers/links.js
Normal 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,
|
||||
}
|
@ -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)
|
||||
});
|
||||
};
|
@ -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 });
|
471
server/handlers/validators.js
Normal file
471
server/handlers/validators.js
Normal 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,
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
@ -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
1
server/mail/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require("./mail");
|
@ -1 +0,0 @@
|
||||
export * from "./mail";
|
@ -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,
|
||||
@ -80,9 +78,9 @@ export const changeEmail = async (user: User) => {
|
||||
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,
|
||||
@ -100,4 +98,10 @@ export const resetPasswordToken = async (user: User) => {
|
||||
"Couldn't send reset password email. Try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
changeEmail,
|
||||
verification,
|
||||
resetPasswordToken,
|
||||
}
|
@ -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.
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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
8
server/models/index.js
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
...require("./domain"),
|
||||
...require("./host"),
|
||||
...require("./ip"),
|
||||
...require("./link"),
|
||||
...require("./user"),
|
||||
...require("./visit"),
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
export * from "./domain";
|
||||
export * from "./host";
|
||||
export * from "./ip";
|
||||
export * from "./link";
|
||||
export * from "./user";
|
||||
export * from "./visit";
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
};
|
@ -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
|
||||
}
|
@ -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
76
server/queries/domain.js
Normal 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,
|
||||
}
|
@ -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
15
server/queries/index.js
Normal 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
|
||||
};
|
@ -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
53
server/queries/ip.js
Normal 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,
|
||||
}
|
@ -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();
|
@ -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,18 +43,13 @@ 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]
|
||||
@ -63,23 +59,17 @@ export const total = async (match: Match<Link>, params: TotalParams = {}) => {
|
||||
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]
|
||||
@ -88,19 +78,19 @@ export const get = async (match: Partial<Link>, params: GetParams) => {
|
||||
|
||||
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")
|
||||
@ -108,26 +98,21 @@ export const find = async (match: Partial<Link>): Promise<Link> => {
|
||||
|
||||
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(
|
||||
{
|
||||
@ -143,29 +128,24 @@ 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]));
|
||||
@ -177,25 +157,35 @@ export const batchRemove = async (match: Match<Link>) => {
|
||||
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,
|
||||
}
|
@ -1,37 +1,33 @@
|
||||
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,
|
||||
@ -40,11 +36,11 @@ export const add = async (params: Add, user?: User) => {
|
||||
};
|
||||
|
||||
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);
|
||||
@ -53,10 +49,10 @@ export const add = async (params: Add, user?: User) => {
|
||||
...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]));
|
||||
@ -70,12 +66,19 @@ export const update = async (match: Match<User>, update: Partial<User>) => {
|
||||
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,
|
||||
}
|
@ -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";
|
||||
|
||||
|
@ -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"];
|
||||
|
@ -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
1
server/renders/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require("./renders");
|
18
server/renders/renders.js
Normal file
18
server/renders/renders.js
Normal 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
52
server/routes/auth.js
Normal 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;
|
@ -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;
|
@ -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
1
server/routes/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require("./routes");
|
@ -1 +0,0 @@
|
||||
export { default } from "./routes";
|
83
server/routes/links.js
Normal file
83
server/routes/links.js
Normal 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;
|
@ -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
17
server/routes/routes.js
Normal 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;
|
@ -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;
|
@ -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
81
server/server.js
Normal 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}`);
|
||||
});
|
@ -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
1
server/utils/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require("./utils");
|
@ -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
233
server/utils/utils.js
Normal 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
81
server/views/homepage.hbs
Normal 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
62
server/views/layout.hbs
Normal 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
3
server/views/login.hbs
Normal file
@ -0,0 +1,3 @@
|
||||
{{> header}}
|
||||
{{> login_signup}}
|
||||
{{> footer}}
|
9
server/views/partials/footer.hbs
Normal file
9
server/views/partials/footer.hbs
Normal 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>
|
40
server/views/partials/header.hbs
Normal file
40
server/views/partials/header.hbs
Normal 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>
|
50
server/views/partials/login_signup.hbs
Normal file
50
server/views/partials/login_signup.hbs
Normal 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>
|
5
server/views/partials/login_welcome.hbs
Normal file
5
server/views/partials/login_welcome.hbs
Normal 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>
|
7
server/views/partials/shorturl.hbs
Normal file
7
server/views/partials/shorturl.hbs
Normal 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>
|
5
server/views/partials/signup_verify_email.hbs
Normal file
5
server/views/partials/signup_verify_email.hbs
Normal 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
768
static/css/styles.css
Normal 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;
|
||||
}
|
BIN
static/fonts/nunito-variable.woff2
Normal file
BIN
static/fonts/nunito-variable.woff2
Normal file
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
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
21
static/scripts/main.js
Normal 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);
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
"module": "commonjs",
|
||||
"sourceMap": true,
|
||||
"outDir": "production-server",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedLocals": false,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": false,
|
||||
|
Loading…
x
Reference in New Issue
Block a user