├── .env.sample ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── commitlint.config.js ├── cspell.config.js ├── nuxt.config.ts ├── package.json ├── src ├── app.config.ts ├── app.vue ├── assets │ └── toast.css ├── components │ ├── atoms │ │ ├── button │ │ │ ├── icon-button.vue │ │ │ ├── primary-button.vue │ │ │ └── secondary-button.vue │ │ ├── input │ │ │ ├── password-input.vue │ │ │ └── text-input.vue │ │ ├── logo.vue │ │ └── tooltip.vue │ ├── molecules │ │ ├── cards │ │ │ └── link-card.vue │ │ ├── menus │ │ │ ├── link-menu.vue │ │ │ ├── profile-menu.vue │ │ │ └── stats-menu.vue │ │ ├── navigation │ │ │ └── dashboard-navigation.vue │ │ ├── panels │ │ │ ├── link-panel.vue │ │ │ └── qr-code-panel.vue │ │ └── switch │ │ │ ├── drawer-switch.vue │ │ │ └── theme-switch.vue │ └── organisms │ │ ├── app-drawer.vue │ │ ├── app-footer.vue │ │ ├── app-header.vue │ │ ├── dashboard-drawer.vue │ │ └── dashboard-header.vue ├── composables │ ├── use-drawer.ts │ ├── use-theme.ts │ └── use-token.ts ├── index.d.ts ├── interfaces │ └── error.interface.ts ├── layouts │ ├── default.vue │ └── public.vue ├── middleware │ └── auth.global.ts ├── modules.d.ts ├── pages │ ├── [alias].vue │ ├── auth │ │ ├── github.vue │ │ ├── login.vue │ │ ├── register.vue │ │ └── verify-email.vue │ ├── dashboard │ │ ├── index │ │ │ ├── [alias] │ │ │ │ ├── index.vue │ │ │ │ └── stats.vue │ │ │ └── index.vue │ │ ├── settings.vue │ │ └── stats.vue │ ├── index.vue │ └── protected │ │ └── [alias].vue ├── plugin.d.ts ├── plugins │ ├── api.plugin.ts │ ├── directives.ts │ └── extend-pinia.plugin.ts ├── public │ ├── favicon.ico │ └── features │ │ ├── analytics.svg │ │ ├── free.svg │ │ ├── host-your-own.svg │ │ ├── open-source.svg │ │ ├── powerful-link-builder.svg │ │ └── qr-code.svg ├── server │ ├── app.ts │ ├── common │ │ ├── classes │ │ │ └── base-repository.class.ts │ │ ├── configs │ │ │ ├── dev.config.ts │ │ │ ├── index.ts │ │ │ └── production.config.ts │ │ ├── exceptions │ │ │ ├── http.exception.ts │ │ │ └── index.ts │ │ ├── helpers │ │ │ ├── mongo.helper.ts │ │ │ └── validator.helper.ts │ │ ├── middlewares │ │ │ ├── auth.middleware.ts │ │ │ ├── error.middleware.ts │ │ │ ├── statistics.middleware.ts │ │ │ └── verifcation.middleware.ts │ │ ├── types │ │ │ ├── config.type.ts │ │ │ ├── index.ts │ │ │ ├── module.d.ts │ │ │ ├── response.interface.ts │ │ │ ├── routes.interface.ts │ │ │ ├── statistics.interface.ts │ │ │ └── use-case.type.ts │ │ └── utils │ │ │ ├── cookie.ts │ │ │ └── logger.ts │ ├── modules │ │ ├── email │ │ │ ├── services │ │ │ │ ├── email.service.ts │ │ │ │ └── index.ts │ │ │ └── types │ │ │ │ ├── email-templates.type.ts │ │ │ │ └── index.ts │ │ ├── links │ │ │ ├── controllers │ │ │ │ ├── index.ts │ │ │ │ └── link.controller.ts │ │ │ ├── dto │ │ │ │ ├── create-link.dto.ts │ │ │ │ ├── index.ts │ │ │ │ └── link.dto.ts │ │ │ ├── helpers │ │ │ │ ├── index.ts │ │ │ │ └── validation.helper.ts │ │ │ ├── models │ │ │ │ ├── index.ts │ │ │ │ └── link.model.ts │ │ │ ├── repositories │ │ │ │ ├── index.ts │ │ │ │ └── link.repository.ts │ │ │ ├── routes │ │ │ │ ├── index.ts │ │ │ │ └── link.route.ts │ │ │ ├── services │ │ │ │ ├── index.ts │ │ │ │ └── link.service.ts │ │ │ ├── types │ │ │ │ ├── index.ts │ │ │ │ └── pagination.type.ts │ │ │ ├── use-cases │ │ │ │ ├── create-link │ │ │ │ │ └── create-link.use-case.ts │ │ │ │ ├── generate-alias │ │ │ │ │ └── generate-alias.use-case.ts │ │ │ │ ├── index.ts │ │ │ │ └── redirect-link │ │ │ │ │ └── redirect-link.use-case.ts │ │ │ └── utils │ │ │ │ ├── index.ts │ │ │ │ └── link.util.ts │ │ ├── statistics │ │ │ ├── controllers │ │ │ │ └── statistics.controller.ts │ │ │ ├── models │ │ │ │ └── statistics.model.ts │ │ │ ├── routes │ │ │ │ └── statistics.route.ts │ │ │ └── services │ │ │ │ └── statistics.service.ts │ │ └── users │ │ │ ├── controllers │ │ │ ├── auth.controller.ts │ │ │ ├── index.ts │ │ │ └── user.controller.ts │ │ │ ├── dto │ │ │ ├── index.ts │ │ │ ├── login.dto.ts │ │ │ ├── register.dto.ts │ │ │ ├── token.dto.ts │ │ │ └── user.dto.ts │ │ │ ├── helpers │ │ │ ├── github.helper.ts │ │ │ ├── index.ts │ │ │ └── validation.helper.ts │ │ │ ├── models │ │ │ ├── index.ts │ │ │ └── user.model.ts │ │ │ ├── repositories │ │ │ ├── index.ts │ │ │ └── user.repository.ts │ │ │ ├── routes │ │ │ ├── auth.route.ts │ │ │ ├── index.ts │ │ │ └── user.route.ts │ │ │ ├── services │ │ │ ├── auth.service.ts │ │ │ ├── index.ts │ │ │ └── user.service.ts │ │ │ ├── types │ │ │ ├── github.type.ts │ │ │ └── index.ts │ │ │ ├── use-cases │ │ │ ├── index.ts │ │ │ ├── login │ │ │ │ ├── login-with-github.use-case.ts │ │ │ │ └── login.use-case.ts │ │ │ ├── me │ │ │ │ └── me.use-case.ts │ │ │ ├── refresh-token │ │ │ │ └── refresh-token.use-case.ts │ │ │ ├── register │ │ │ │ └── register.use-case.ts │ │ │ ├── resend-verification-email │ │ │ │ └── resend-verification-email.use-case.ts │ │ │ ├── send-verification-email │ │ │ │ └── send-verification-email.use-case.ts │ │ │ └── verify-account │ │ │ │ └── verify-account.use-case.ts │ │ │ └── utils │ │ │ ├── index.ts │ │ │ └── token.util.ts │ └── server.ts ├── services │ ├── api.service.ts │ ├── auth.service.ts │ ├── link.service.ts │ └── statistics.service.ts ├── store │ ├── auth.store.ts │ └── link.store.ts └── utils │ └── logger.ts ├── tailwind.config.ts ├── tsconfig.json └── yarn.lock /.env.sample: -------------------------------------------------------------------------------- 1 | API_BASE_URL = 2 | DB_URL = 3 | DB_NAME = 4 | ACCESS_TOKEN_SECRET= 5 | ACCESS_TOKEN_EXPIRATION = 6 | REFRESH_TOKEN_SECRET= 7 | REFRESH_TOKEN_EXPIRATION = 8 | ACCOUNT_VERIFICATION_TOKEN_SECRET= 9 | ACCOUNT_VERIFICATION_TOKEN_EXPIRATION= 10 | SIB_EMAIL_API_KEY= 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@kolhe/eslint-config"], 4 | "rules": { 5 | "padding-line-between-statements": [ 6 | "error", 7 | { "blankLine": "always", "prev": "*", "next": "return" } 8 | ], 9 | 10 | "vue/component-name-in-template-casing": [ 11 | "error", 12 | "kebab-case", 13 | { 14 | "registeredComponentsOnly": false, 15 | "ignores": [] 16 | } 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .vercel 10 | .DS_store 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "plugins": ["prettier-plugin-tailwindcss"] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.validate": false, 3 | "tailwindCSS.includeLanguages": { 4 | "vue": "html", 5 | "vue-html": "html" 6 | }, 7 | "editor.quickSuggestions": { 8 | "strings": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kut 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /cspell.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | words: [ 3 | 'foldr', 4 | 'consola', 5 | 'ofetch', 6 | 'pinia', 7 | 'nuxt', 8 | 'nuxthq', 9 | 'nuxtjs', 10 | 'vueuse', 11 | 'tailwindcss', 12 | 'Segoe', 13 | 'Roboto', 14 | 'typecheck', 15 | ], 16 | version: '0.2', 17 | language: 'en', 18 | ignorePaths: [ 19 | '**/node_modules/**', 20 | 'dist', 21 | 'tests-output', 22 | 'playwright-report', 23 | '.vscode', 24 | 'src/locales', 25 | 'nuxt.config.ts', 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url' 2 | 3 | export default defineNuxtConfig({ 4 | app: { 5 | head: { 6 | title: 'Kut - A modern link management tool', 7 | htmlAttrs: { 8 | lang: 'en', 9 | }, 10 | charset: 'utf-8', 11 | viewport: 'width=device-width, initial-scale=1', 12 | meta: [ 13 | { hid: 'og:title', name: 'og:title', content: 'Kut - A modern link management tool' }, 14 | { name: 'format-detection', content: 'telephone=no' }, 15 | ], 16 | link: [ 17 | { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, 18 | { 19 | rel: 'stylesheet', 20 | type: 'text/css', 21 | href: 'https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap', 22 | }, 23 | ], 24 | }, 25 | pageTransition: { 26 | name: 'fade-in', 27 | mode: 'out-in', 28 | }, 29 | layoutTransition: { 30 | name: 'fade-in', 31 | mode: 'out-in', 32 | }, 33 | }, 34 | 35 | typescript: { 36 | typeCheck: true, 37 | }, 38 | 39 | srcDir: 'src', 40 | serverDir: 'server', 41 | 42 | css: ['~/assets/toast.css'], 43 | 44 | ui: { 45 | icons: ['ph'], 46 | }, 47 | 48 | modules: [ 49 | '@pinia/nuxt', 50 | '@vueuse/nuxt', 51 | 'nuxt-icon', 52 | '@pinia-plugin-persistedstate/nuxt', 53 | '@nuxt/devtools', 54 | '@nuxt/ui', 55 | ], 56 | 57 | serverHandlers: [ 58 | { route: '/api', handler: '~/server/server.ts' }, 59 | { route: '/api/**', handler: '~/server/server.ts' }, 60 | ], 61 | 62 | nitro: { 63 | plugins: ['~/server/common/helpers/mongo.helper.ts'], 64 | esbuild: { 65 | options: { 66 | tsconfigRaw: { 67 | compilerOptions: { 68 | experimentalDecorators: true, 69 | }, 70 | }, 71 | }, 72 | }, 73 | }, 74 | 75 | build: { 76 | transpile: ['@headlessui/vue', 'vue-tiny-validate'], 77 | }, 78 | 79 | alias: { 80 | components: fileURLToPath(new URL('./src/components', import.meta.url)), 81 | composables: fileURLToPath(new URL('./src/composables', import.meta.url)), 82 | interfaces: fileURLToPath(new URL('./src/interfaces', import.meta.url)), 83 | services: fileURLToPath(new URL('./src/services', import.meta.url)), 84 | store: fileURLToPath(new URL('./src/store', import.meta.url)), 85 | utils: fileURLToPath(new URL('./src/utils', import.meta.url)), 86 | server: fileURLToPath(new URL('./src/server', import.meta.url)), 87 | }, 88 | 89 | runtimeConfig: { 90 | public: { 91 | apiBaseUrl: process.env.API_BASE_URL, 92 | githubClientId: process.env.GITHUB_CLIENT_ID, 93 | }, 94 | dbUrl: process.env.DB_URL, 95 | dbName: process.env.DB_NAME, 96 | accessTokenSecret: process.env.ACCESS_TOKEN_SECRET, 97 | accessTokenExpiration: process.env.ACCESS_TOKEN_EXPIRATION, 98 | refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET, 99 | refreshTokenExpiration: process.env.REFRESH_TOKEN_EXPIRATION, 100 | accountVerificationTokenSecret: process.env.ACCOUNT_VERIFICATION_TOKEN_SECRET, 101 | accountVerificationTokenExpiration: process.env.ACCOUNT_VERIFICATION_TOKEN_EXPIRATION, 102 | emailApiKey: process.env.SIB_EMAIL_API_KEY, 103 | githubClientSecret: process.env.GITHUB_CLIENT_SECRET, 104 | }, 105 | }) 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kut", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "yarn run format && yarn run lint && nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "start": "node -C production .output/server/index.mjs", 11 | "lint": "eslint . --ext \".ts,.js,.vue,.json\" --fix", 12 | "format": "prettier --write \"src/**/*.{js,ts,json,vue}\"", 13 | "spell-check": "cspell .", 14 | "typecheck": "nuxt typecheck", 15 | "postinstall": "npx simple-git-hooks && nuxt prepare" 16 | }, 17 | "simple-git-hooks": { 18 | "pre-commit": "yarn run lint && yarn run format", 19 | "commit-msg": "yarn run commitlint --edit $1" 20 | }, 21 | "dependencies": { 22 | "@headlessui/vue": "1.7.16", 23 | "@pinia/nuxt": "0.5.1", 24 | "@typegoose/typegoose": "12.0.0", 25 | "bcryptjs": "2.4.3", 26 | "celebrate": "15.0.3", 27 | "cors": "2.8.5", 28 | "dayjs": "1.11.10", 29 | "express": "4.18.2", 30 | "express-timeout-handler": "2.2.2", 31 | "express-useragent": "1.0.15", 32 | "jsonwebtoken": "9.0.2", 33 | "jwt-decode": "3.1.2", 34 | "mongoose": "8.0.2", 35 | "morgan": "1.10.0", 36 | "open-graph-scraper": "6.3.2", 37 | "pinia": "2.1.7", 38 | "prettier-plugin-tailwindcss": "0.5.7", 39 | "qrcode": "1.5.3", 40 | "vue-command-palette": "0.2.3", 41 | "vue-frappe-chart": "0.1.10", 42 | "vue-tiny-validate": "0.2.4", 43 | "winston": "3.11.0" 44 | }, 45 | "devDependencies": { 46 | "@commitlint/cli": "18.4.3", 47 | "@commitlint/config-conventional": "18.4.3", 48 | "@iconify-json/ph": "1.1.8", 49 | "@iconify-json/tabler": "1.1.100", 50 | "@kolhe/eslint-config": "1.2.6", 51 | "@nuxt/devtools": "1.0.4", 52 | "@nuxt/ui": "npm:@nuxt/ui-edge@latest", 53 | "@pinia-plugin-persistedstate/nuxt": "1.2.0", 54 | "@types/bcryptjs": "2.4.6", 55 | "@types/cors": "2.8.17", 56 | "@types/express": "4.17.21", 57 | "@types/express-useragent": "1.0.5", 58 | "@types/jsonwebtoken": "9.0.5", 59 | "@types/morgan": "1.9.9", 60 | "@types/node": "20.10.1", 61 | "@vueuse/core": "10.6.1", 62 | "@vueuse/integrations": "10.6.1", 63 | "@vueuse/nuxt": "10.6.1", 64 | "cspell": "8.1.0", 65 | "eslint": "8.54.0", 66 | "eslint-plugin-prettier": "5.0.1", 67 | "nuxt": "3.8.2", 68 | "nuxt-icon": "0.6.6", 69 | "prettier": "3.1.0", 70 | "simple-git-hooks": "2.9.0", 71 | "typescript": "5.3.2", 72 | "vue-tsc": "1.8.24" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | primary: 'midnight', 4 | gray: 'neutral', 5 | 6 | container: { 7 | base: 'mx-auto w-full', 8 | padding: 'px-6', 9 | constrained: 'max-w-screen-5xl', 10 | }, 11 | 12 | button: { 13 | base: 'focus:outline-none focus-visible:outline-0 disabled:cursor-not-allowed disabled:opacity-75 flex-shrink-0', 14 | font: 'font-normal', 15 | rounded: 'rounded', 16 | size: { 17 | '2xs': 'text-xs', 18 | xs: 'text-xs', 19 | sm: 'text-sm', 20 | md: 'text-sm', 21 | lg: 'text-sm', 22 | xl: 'text-base', 23 | }, 24 | gap: { 25 | '2xs': 'gap-x-1', 26 | xs: 'gap-x-1.5', 27 | sm: 'gap-x-1.5', 28 | md: 'gap-x-2', 29 | lg: 'gap-x-2.5', 30 | xl: 'gap-x-2.5', 31 | }, 32 | padding: { 33 | '2xs': 'px-2 py-1', 34 | xs: 'px-2.5 py-1.5', 35 | sm: 'px-3 py-1.5', 36 | md: 'px-3 py-2', 37 | lg: 'px-3.5 py-2.5', 38 | xl: 'px-3.5 py-2.5', 39 | }, 40 | square: { 41 | '2xs': 'p-1', 42 | xs: 'p-1.5', 43 | sm: 'p-1.5', 44 | md: 'p-2', 45 | lg: 'p-2.5', 46 | xl: 'p-2.5', 47 | }, 48 | variant: { 49 | solid: 50 | 'border shadow-sm border-primary-900 bg-primary-900 text-primary-50 transition hover:border-primary-500 hover:bg-primary-700 hover:text-primary-50 disabled:cursor-not-allowed disabled:border-primary-200 disabled:bg-primary-100 disabled:text-primary-300 dark:border-primary-100 dark:bg-primary-50 dark:text-primary-900 dark:hover:border-primary-200 dark:hover:bg-primary-200 dark:hover:text-primary-800 disabled:dark:border-primary-700 disabled:dark:bg-primary-800 disabled:dark:text-primary-400', 51 | outline: 52 | 'shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-700 text-gray-700 dark:text-gray-200 bg-gray-50 hover:bg-gray-100 disabled:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50 dark:disabled:bg-gray-800 focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400', 53 | soft: 'text-primary-500 dark:text-primary-400 bg-primary-50 hover:bg-primary-100 dark:bg-primary-950 dark:hover:bg-primary-900 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400', 54 | ghost: 55 | 'text-primary-500 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-950 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400', 56 | link: 'text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-500 underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400', 57 | }, 58 | icon: { 59 | base: 'flex-shrink-0', 60 | size: { 61 | '2xs': 'h-4 w-4', 62 | xs: 'h-4 w-4', 63 | sm: 'h-5 w-5', 64 | md: 'h-5 w-5', 65 | lg: 'h-5 w-5', 66 | xl: 'h-6 w-6', 67 | }, 68 | }, 69 | default: { 70 | size: 'md', 71 | variant: 'solid', 72 | color: 'primary', 73 | loadingIcon: 'i-heroicons-arrow-path-20-solid', 74 | }, 75 | }, 76 | 77 | formGroup: { 78 | wrapper: '', 79 | label: { 80 | wrapper: 'flex content-center justify-between', 81 | base: 'block text-sm font-regular text-primary-700 dark:text-primary-500', 82 | required: "after:content-['*'] after:ml-0.5 after:text-red-500 dark:after:text-red-400", 83 | }, 84 | description: 'text-sm text-primary-500 dark:text-primary-400', 85 | container: 'mt-1 relative', 86 | hint: 'text-sm text-primary-500 dark:text-primary-400', 87 | help: 'mt-2 text-sm text-primary-500 dark:text-primary-400', 88 | error: 'mt-2 text-sm text-red-500 dark:text-red-400', 89 | }, 90 | 91 | input: { 92 | base: 'relative block w-full disabled:cursor-not-allowed disabled:opacity-75 focus:outline-none border-0 font-normal', 93 | rounded: 'rounded', 94 | placeholder: 95 | 'placeholder-primary-400 dark:placeholder-primary-500 placeholder-font-light text-sm', 96 | color: { 97 | white: { 98 | outline: 99 | 'bg-primary-50 dark:bg-primary-900 text-primary-900 dark:text-primary-50 ring-1 ring-inset ring-primary-200 dark:ring-primary-700 focus:ring-1 focus:ring-primary-900 dark:focus:ring-primary-400', 100 | }, 101 | }, 102 | 103 | default: { 104 | loadingIcon: 'i-tabler-loader-2', 105 | }, 106 | }, 107 | 108 | slideover: { 109 | base: 'relative flex-1 flex flex-col w-full h-fit min-h-screen focus:outline-none', 110 | width: 'w-screen max-w-lg', 111 | wrapper: 'fixed inset-0 flex z-50 overflow-y-scroll', 112 | }, 113 | 114 | toggle: { 115 | active: 'bg-primary-900 dark:bg-primary-300', 116 | inactive: 'bg-primary-200 dark:bg-primary-600', 117 | }, 118 | 119 | skeleton: { 120 | base: 'animate-pulse', 121 | rounded: 'rounded', 122 | background: 'bg-primary-200 dark:bg-primary-600', 123 | }, 124 | 125 | dropdown: { 126 | shadow: 'none', 127 | }, 128 | }, 129 | }) 130 | -------------------------------------------------------------------------------- /src/app.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | -------------------------------------------------------------------------------- /src/assets/toast.css: -------------------------------------------------------------------------------- 1 | .toast-container { 2 | --radius: 5px; 3 | --stack-gap: 20px; 4 | --safe-area-gap: env(safe-area-inset-bottom); 5 | 6 | position: fixed; 7 | display: block; 8 | max-width: 468px; 9 | bottom: calc(var(--safe-area-gap, 0px) + 20px); 10 | right: 20px; 11 | z-index: 5000; 12 | transition: all 0.4s ease; 13 | 14 | & .toast { 15 | position: absolute; 16 | bottom: 0; 17 | right: 0; 18 | width: 468px; 19 | transition: all 0.4s ease; 20 | transform: translate3d(0, 86px, 0); 21 | opacity: 0; 22 | 23 | & .toast-inner { 24 | --toast-bg: none; 25 | --toast-fg: #fff; 26 | box-sizing: border-box; 27 | border-radius: var(--radius); 28 | display: flex; 29 | align-items: center; 30 | justify-content: space-between; 31 | padding: 24px; 32 | color: var(--toast-fg); 33 | background-color: var(--toast-bg); 34 | height: var(--height); 35 | transition: all 0.25s ease; 36 | 37 | &.default { 38 | --toast-fg: #000; 39 | --toast-bg: #fff; 40 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12); 41 | } 42 | 43 | &.success { 44 | --toast-bg: #0076ff; 45 | } 46 | 47 | &.error { 48 | --toast-bg: #f04; 49 | } 50 | 51 | &.warning { 52 | --toast-bg: #f5a623; 53 | } 54 | 55 | &.dark { 56 | --toast-bg: #000; 57 | --toast-fg: #fff; 58 | box-shadow: 0 0 0 1px #333; 59 | 60 | & .toast-button { 61 | --button-fg: #000; 62 | --button-bg: #fff; 63 | --button-border: #fff; 64 | --button-border-hover: #fff; 65 | --button-fg-hover: #fff; 66 | 67 | &.cancel-button { 68 | --cancel-button-bg: #000; 69 | --cancel-button-fg: #888; 70 | --cancel-button-border: #333; 71 | 72 | &:hover { 73 | color: #fff; 74 | border-color: var(--button-border); 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | & .toast-text { 82 | width: 100%; 83 | height: 100%; 84 | font-size: 14px; 85 | margin-top: -1px; 86 | margin-right: 24px; 87 | transition: all 0.3s ease-in; 88 | } 89 | 90 | & .toast-button { 91 | --button-fg: #000; 92 | --button-bg: #fff; 93 | --button-border: #fff; 94 | --button-border-hover: #fff; 95 | --button-fg-hover: #fff; 96 | min-width: auto; 97 | height: 24px; 98 | line-height: 22px; 99 | padding: 0 10px; 100 | font-size: 14px; 101 | background-color: var(--button-bg); 102 | color: var(--button-fg); 103 | white-space: nowrap; 104 | user-select: none; 105 | cursor: pointer; 106 | vertical-align: middle; 107 | border-radius: var(--radius); 108 | outline: none; 109 | border: 1px solid var(--button-border); 110 | transition: all 0.2s ease; 111 | 112 | &:hover { 113 | border-color: var(--button-border-hover); 114 | background-color: transparent; 115 | color: var(--button-fg-hover); 116 | } 117 | 118 | &.cancel-button { 119 | --cancel-button-bg: #fff; 120 | --cancel-button-fg: #666; 121 | --cancel-button-border: #eaeaea; 122 | margin-right: 10px; 123 | color: var(--cancel-button-fg); 124 | border-color: var(--cancel-button-border); 125 | background-color: var(--cancel-button-bg); 126 | 127 | &:hover { 128 | --cancel-button-fg: #000; 129 | --cancel-button-border: #000; 130 | } 131 | } 132 | } 133 | 134 | & .default .toast-button { 135 | --button-fg: #fff; 136 | --button-bg: #000; 137 | --button-border: #000; 138 | --button-border-hover: #000; 139 | --button-fg-hover: #000; 140 | } 141 | 142 | &:after { 143 | content: ''; 144 | position: absolute; 145 | left: 0; 146 | right: 0; 147 | top: calc(100% + 1px); 148 | width: 100%; 149 | /* This for destroy the middle toast, still keep `spread` */ 150 | height: 1000px; 151 | background: transparent; 152 | } 153 | 154 | &.toast-1 { 155 | transform: translate3d(0, 0, 0); 156 | opacity: 1; 157 | } 158 | 159 | &:not(:last-child) { 160 | --i: calc(var(--index) - 1); 161 | transform: translate3d(0, calc(1px - (var(--stack-gap) * var(--i))), 0) scale(calc(1 - 0.05 * var(--i))); 162 | opacity: 1; 163 | 164 | & .toast-inner { 165 | height: var(--front-height); 166 | 167 | & .toast-text { 168 | opacity: 0; 169 | } 170 | } 171 | } 172 | 173 | &.toast-4 { 174 | opacity: 0; 175 | } 176 | } 177 | } 178 | 179 | .toast-container:hover { 180 | bottom: calc(var(--safe-area-gap, 0px) + 20px); 181 | 182 | & .toast { 183 | transform: translate3d(0, calc(var(--hover-offset-y) - var(--stack-gap) * (var(--index) - 1)), 0); 184 | 185 | & .toast-inner { 186 | height: var(--height); 187 | } 188 | 189 | & .toast-text { 190 | opacity: 1 !important; 191 | } 192 | } 193 | } 194 | 195 | @media (max-width: 800px) { 196 | .toast-container { 197 | max-width: 90vw; 198 | right: 5vw; 199 | 200 | & .toast { 201 | width: 90vw; 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/components/atoms/button/icon-button.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /src/components/atoms/button/primary-button.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 50 | -------------------------------------------------------------------------------- /src/components/atoms/button/secondary-button.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 47 | -------------------------------------------------------------------------------- /src/components/atoms/input/password-input.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 61 | -------------------------------------------------------------------------------- /src/components/atoms/input/text-input.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 99 | -------------------------------------------------------------------------------- /src/components/atoms/logo.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/components/atoms/tooltip.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | -------------------------------------------------------------------------------- /src/components/molecules/cards/link-card.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 122 | -------------------------------------------------------------------------------- /src/components/molecules/menus/link-menu.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 131 | -------------------------------------------------------------------------------- /src/components/molecules/menus/profile-menu.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 64 | -------------------------------------------------------------------------------- /src/components/molecules/menus/stats-menu.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 148 | -------------------------------------------------------------------------------- /src/components/molecules/navigation/dashboard-navigation.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 63 | -------------------------------------------------------------------------------- /src/components/molecules/panels/link-panel.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 181 | -------------------------------------------------------------------------------- /src/components/molecules/panels/qr-code-panel.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 69 | -------------------------------------------------------------------------------- /src/components/molecules/switch/drawer-switch.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /src/components/molecules/switch/theme-switch.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /src/components/organisms/app-drawer.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 60 | 61 | 75 | -------------------------------------------------------------------------------- /src/components/organisms/app-footer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /src/components/organisms/app-header.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 53 | -------------------------------------------------------------------------------- /src/components/organisms/dashboard-drawer.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 66 | 67 | 82 | -------------------------------------------------------------------------------- /src/components/organisms/dashboard-header.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 66 | -------------------------------------------------------------------------------- /src/composables/use-drawer.ts: -------------------------------------------------------------------------------- 1 | export const useDrawer = createSharedComposable(() => { 2 | const isDrawerVisible = ref(false) 3 | 4 | const toggleDrawer = () => { 5 | isDrawerVisible.value = !isDrawerVisible.value 6 | } 7 | 8 | return { toggleDrawer, isDrawerVisible } 9 | }) 10 | -------------------------------------------------------------------------------- /src/composables/use-theme.ts: -------------------------------------------------------------------------------- 1 | export const useTheme = () => { 2 | const colorMode = useColorMode() 3 | 4 | const isDarkTheme = computed({ 5 | get() { 6 | return colorMode.value === 'dark' 7 | }, 8 | set() { 9 | colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark' 10 | }, 11 | }) 12 | 13 | const changeTheme = () => { 14 | isDarkTheme.value = !isDarkTheme.value 15 | } 16 | 17 | return { 18 | changeTheme, 19 | isDarkTheme, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/composables/use-token.ts: -------------------------------------------------------------------------------- 1 | export const useToken = () => { 2 | const accessTokenCookie = useCookie('accessToken') 3 | const refreshTokenCookie = useCookie('refreshToken') 4 | 5 | const accessToken = useState('accessToken', () => accessTokenCookie.value) 6 | const refreshToken = useState('refreshToken', () => refreshTokenCookie.value) 7 | 8 | watch( 9 | accessToken, 10 | () => { 11 | accessTokenCookie.value = accessToken.value 12 | }, 13 | { deep: true } 14 | ) 15 | 16 | watch( 17 | refreshToken, 18 | () => { 19 | refreshTokenCookie.value = refreshToken.value 20 | }, 21 | { deep: true } 22 | ) 23 | 24 | return { accessToken, refreshToken } 25 | } 26 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | // workaround for https://github.com/nuxt-modules/icon/pull/63 3 | import * as _nuxt_schema from '@nuxt/schema' 4 | 5 | interface NuxtIconModuleOptions { 6 | size?: string | false 7 | class?: string 8 | aliases?: { [alias: string]: string } 9 | } 10 | 11 | declare module '@nuxt/schema' { 12 | interface AppConfig { 13 | nuxtIcon?: NuxtIconModuleOptions 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/interfaces/error.interface.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorType { 2 | unauthorised = 'unauthorised', 3 | accountNotVerified = 'account not verified', 4 | userBanned = 'user is banned', 5 | invalidAuthenticationToken = 'invalid authentication token', 6 | invalidRefreshToken = 'invalid refresh token', 7 | invalidVerifyToken = 'invalid email verification token', 8 | userAlreadyExists = 'user is already registered', 9 | userNotFound = 'user not found', 10 | incorrectLoginCredentials = 'incorrect login credentials', 11 | aliasAlreadyUsed = 'alias is already used', 12 | linkNotFound = 'link not found', 13 | linkInactive = 'link is inactive', 14 | linkViewLimitReached = 'link view limit reached', 15 | incorrectlinkPassword = 'incorrect link password', 16 | linkPasswordProtected = 'link is password protected', 17 | somethingWentWrong = 'something went wrong', 18 | } 19 | -------------------------------------------------------------------------------- /src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 20 | 21 | 56 | -------------------------------------------------------------------------------- /src/layouts/public.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 51 | -------------------------------------------------------------------------------- /src/middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | import { useJwt } from '@vueuse/integrations/useJwt' 2 | import { storeToRefs } from 'pinia' 3 | import { useAuthStore } from 'store/auth.store' 4 | 5 | export default defineNuxtRouteMiddleware(async (to) => { 6 | if (to && to.meta.auth && to.meta.auth === 'guest') return 7 | 8 | const { logout, fetchUser, refreshAccessToken } = useAuthStore() 9 | const { accessToken, user, refreshToken } = storeToRefs(useAuthStore()) 10 | 11 | // if access token is not present, logout the user 12 | if (!accessToken.value) return logout() 13 | 14 | // decode the token to get expiration time. if token is expired, logout. 15 | const { payload } = useJwt(accessToken.value) 16 | 17 | if (payload.value && payload.value.exp! < Date.now() / 1000) { 18 | await refreshAccessToken(refreshToken.value) 19 | 20 | return 21 | } 22 | 23 | if (!user.value || !user.value?.email) { 24 | await fetchUser() 25 | 26 | return 27 | } 28 | 29 | return 30 | }) 31 | -------------------------------------------------------------------------------- /src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vue-frappe-chart' 2 | declare module 'vue-command-palette' 3 | -------------------------------------------------------------------------------- /src/pages/[alias].vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 48 | -------------------------------------------------------------------------------- /src/pages/auth/github.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 48 | -------------------------------------------------------------------------------- /src/pages/auth/login.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 158 | -------------------------------------------------------------------------------- /src/pages/auth/register.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 154 | -------------------------------------------------------------------------------- /src/pages/auth/verify-email.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 70 | -------------------------------------------------------------------------------- /src/pages/dashboard/index/[alias]/index.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 80 | -------------------------------------------------------------------------------- /src/pages/dashboard/index/[alias]/stats.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 108 | -------------------------------------------------------------------------------- /src/pages/dashboard/settings.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/pages/dashboard/stats.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 60 | -------------------------------------------------------------------------------- /src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 129 | -------------------------------------------------------------------------------- /src/pages/protected/[alias].vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 68 | -------------------------------------------------------------------------------- /src/plugin.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import 'pinia' 3 | import type { AuthService } from 'services/auth.service' 4 | import type { LinkService } from 'services/link.service' 5 | import type { StatisticsService } from 'services/statistics.service' 6 | 7 | declare module 'pinia' { 8 | export interface PiniaCustomProperties { 9 | $http: { auth: AuthService; link: LinkService; statistics: StatisticsService } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/plugins/api.plugin.ts: -------------------------------------------------------------------------------- 1 | import { ApiService } from 'services/api.service' 2 | 3 | export default defineNuxtPlugin(() => { 4 | const { 5 | public: { apiBaseUrl }, 6 | } = useRuntimeConfig() 7 | 8 | return { 9 | provide: { apiService: new ApiService(apiBaseUrl) }, 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /src/plugins/directives.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin((nuxtApp) => { 2 | nuxtApp.vueApp.directive('slice', (el, binding) => { 3 | const initial = 0 4 | const final = binding.value 5 | 6 | el.textContent = `${el.textContent.slice(initial, final)}${ 7 | el.textContent.length > final ? '...' : '' 8 | }` 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/plugins/extend-pinia.plugin.ts: -------------------------------------------------------------------------------- 1 | import { AuthService } from 'services/auth.service' 2 | import { LinkService } from 'services/link.service' 3 | import { StatisticsService } from 'services/statistics.service' 4 | import type { PiniaPluginContext } from 'pinia' 5 | 6 | function HttpServicePlugin({ store }: PiniaPluginContext) { 7 | store.$http = { 8 | auth: markRaw(new AuthService()), 9 | link: markRaw(new LinkService()), 10 | statistics: markRaw(new StatisticsService()), 11 | } 12 | } 13 | 14 | export default defineNuxtPlugin(({ $pinia }) => { 15 | ;($pinia as any).use(HttpServicePlugin) 16 | }) 17 | -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sumitkolhe/kut/09de5bca3d24d286e2d7725180546428ab4ee8e0/src/public/favicon.ico -------------------------------------------------------------------------------- /src/server/app.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | import cors from 'cors' 3 | import express from 'express' 4 | import morgan from 'morgan' 5 | import timeout from 'express-timeout-handler' 6 | import useragent from 'express-useragent' 7 | import { useConfig } from 'server/common/configs' 8 | import { errorMiddleware } from 'server/common/middlewares/error.middleware' 9 | import { LogLevels, setLogLevel } from '@typegoose/typegoose' 10 | import type { Statistics } from '~/server/common/types/statistics.interface' 11 | import type { Response } from 'express' 12 | import type { Config } from 'server/common/types/config.type' 13 | import type { Routes } from 'server/common/types/routes.interface' 14 | 15 | declare global { 16 | namespace Express { 17 | interface Request { 18 | auth: { 19 | userId: string 20 | email: string 21 | isVerified: boolean 22 | isBanned: boolean 23 | } 24 | timedout: boolean 25 | statistics?: Statistics | undefined 26 | } 27 | } 28 | } 29 | 30 | export class App { 31 | public app: express.Application 32 | public config: Config 33 | public env: string 34 | 35 | constructor(routes: Routes[]) { 36 | this.config = useConfig() 37 | this.env = this.config.env 38 | this.app = express() 39 | this.initializeMiddlewares() 40 | this.initializeRoutes(routes) 41 | this.initializeRouteFallback() 42 | this.initializeErrorHandler() 43 | setLogLevel(LogLevels.DEBUG) 44 | } 45 | 46 | private initializeMiddlewares() { 47 | const middlewares = [ 48 | useragent.express(), 49 | morgan(this.config.log.format), 50 | cors({ 51 | origin: this.config.cors.origin, 52 | credentials: this.config.cors.credentials, 53 | }), 54 | express.json(), 55 | express.urlencoded({ extended: true }), 56 | timeout.handler({ 57 | timeout: 9000, 58 | onTimeout(req: Request, res: Response) { 59 | res.status(503).json({ 60 | status: 'FAILED', 61 | message: 'request timeout', 62 | data: null, 63 | }) 64 | }, 65 | }), 66 | ] 67 | 68 | this.app.use(middlewares) 69 | } 70 | 71 | private initializeRoutes(routes: Routes[]) { 72 | routes.forEach((route) => { 73 | this.app.use('/api/v1', route.router) 74 | }) 75 | } 76 | 77 | private initializeRouteFallback() { 78 | this.app.use((_req, res) => { 79 | res.status(404).json({ 80 | status: 'FAILED', 81 | message: 'route not found', 82 | data: null, 83 | }) 84 | }) 85 | } 86 | 87 | private initializeErrorHandler() { 88 | this.app.use(errorMiddleware) 89 | } 90 | 91 | public getServer() { 92 | return this.app 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/server/common/classes/base-repository.class.ts: -------------------------------------------------------------------------------- 1 | import type { FilterQuery, Model, UpdateQuery } from 'mongoose' 2 | 3 | export class BaseRepository { 4 | constructor(readonly model: Model) { 5 | this.model = model 6 | } 7 | 8 | // create 9 | async create(input: Request) { 10 | const response = await this.model.create(input) 11 | 12 | return response.toObject() 13 | } 14 | 15 | createMany(input: Request[]): Promise { 16 | return this.model.insertMany(input.map((item) => item)) as Promise 17 | } 18 | 19 | // read 20 | getById(id: string): Promise { 21 | return this.model.findById(id).lean().exec() as Promise 22 | } 23 | 24 | getByIds(ids: string[]): Promise { 25 | return this.model.find({ _id: { $in: ids } }).exec() 26 | } 27 | 28 | async exists(filter: FilterQuery): Promise { 29 | const exists = await this.model.exists(filter) 30 | if (typeof exists === 'boolean') return exists 31 | 32 | return !!exists?._id 33 | } 34 | 35 | existsById(id: string): Promise { 36 | return this.exists({ _id: id }) 37 | } 38 | 39 | async existsIds(ids: string[]): Promise { 40 | const count = await this.model.countDocuments({ _id: { $in: ids } }).exec() 41 | 42 | return count === ids.length 43 | } 44 | 45 | count(filter?: Partial): Promise { 46 | return this.model.countDocuments(filter).exec() 47 | } 48 | 49 | // update 50 | update(id: string, item: UpdateQuery): Promise { 51 | return this.model.findByIdAndUpdate(id, item, { new: true }).lean().exec() as Promise 52 | } 53 | 54 | updateByQuery(query: FilterQuery, item: UpdateQuery): Promise { 55 | return this.model 56 | .findOneAndUpdate(query, item, { new: true }) 57 | .lean() 58 | .exec() as Promise 59 | } 60 | 61 | async updateMany(ids: string[], item: UpdateQuery): Promise { 62 | await this.model.updateMany({ _id: { $in: ids } }, item).exec() 63 | } 64 | 65 | // delete 66 | delete(id: string): Promise { 67 | return this.model.findByIdAndDelete(id).lean().exec() as Promise 68 | } 69 | 70 | async deleteById(id: string): Promise { 71 | const docDeleted = await this.model.deleteOne({ _id: id }).exec() 72 | 73 | return docDeleted?.deletedCount === 1 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/server/common/configs/dev.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'server/common/types/config.type' 2 | 3 | const config = useRuntimeConfig() 4 | 5 | export const devConfig: Config = { 6 | env: 'development', 7 | database: { 8 | dbName: 'kut-db-dev', 9 | dbUrl: 'mongodb://localhost:27017', 10 | }, 11 | cors: { 12 | origin: true, 13 | credentials: true, 14 | }, 15 | log: { 16 | format: 'dev', 17 | level: 'debug', 18 | }, 19 | token: { 20 | access: { 21 | secret: 'erpigepigerpipr34634643', 22 | expiresIn: '1d', 23 | }, 24 | refresh: { 25 | secret: 'soeugh8350238230oighwiogh43803480', 26 | expiresIn: '3d', 27 | }, 28 | accountVerification: { 29 | secret: 'ifnew9823rh9283hf208g3h2308gh203gh2038gh230', 30 | expiresIn: '7d', 31 | }, 32 | }, 33 | email: { 34 | apiKey: config.emailApiKey, 35 | senderEmail: 'no-reply@kut.sh', 36 | senderName: 'Kut', 37 | }, 38 | domain: { 39 | protocol: 'http', 40 | url: '0.0.0.0:3000', 41 | }, 42 | github: { 43 | clientId: config.public.githubClientId, 44 | clientSecret: config.githubClientSecret, 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /src/server/common/configs/index.ts: -------------------------------------------------------------------------------- 1 | import { devConfig } from '~/server/common/configs/dev.config' 2 | import { productionConfig } from '~/server/common/configs/production.config' 3 | 4 | export const useConfig = () => { 5 | if (process.env.NODE_ENV === 'production') return productionConfig 6 | 7 | return devConfig 8 | } 9 | -------------------------------------------------------------------------------- /src/server/common/configs/production.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'server/common/types/config.type' 2 | 3 | const config = useRuntimeConfig() 4 | 5 | export const productionConfig: Config = { 6 | env: 'production', 7 | database: { 8 | dbName: config.dbName, 9 | dbUrl: config.dbUrl, 10 | }, 11 | cors: { 12 | origin: true, 13 | credentials: true, 14 | }, 15 | log: { 16 | format: 'tiny', 17 | level: 'info', 18 | }, 19 | token: { 20 | access: { 21 | secret: config.accessTokenSecret, 22 | expiresIn: config.accessTokenExpiration, 23 | }, 24 | refresh: { 25 | secret: config.refreshTokenSecret, 26 | expiresIn: config.refreshTokenExpiration, 27 | }, 28 | accountVerification: { 29 | secret: config.accountVerificationTokenSecret, 30 | expiresIn: config.accountVerificationTokenExpiration, 31 | }, 32 | }, 33 | email: { 34 | apiKey: config.emailApiKey, 35 | senderEmail: 'no-reply@kut.sh', 36 | senderName: 'Kut', 37 | }, 38 | domain: { 39 | protocol: 'https', 40 | url: 'kut.sh', 41 | }, 42 | github: { 43 | clientId: config.public.githubClientId, 44 | clientSecret: config.githubClientSecret, 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /src/server/common/exceptions/http.exception.ts: -------------------------------------------------------------------------------- 1 | export type Status = 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | 503 2 | 3 | export class HttpExceptionError extends Error { 4 | public status: Status 5 | 6 | constructor(status: Status, message: string) { 7 | super(message) 8 | this.status = status 9 | this.name = 'HttpExceptionError' 10 | Object.setPrototypeOf(this, new.target.prototype) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/server/common/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http.exception' 2 | -------------------------------------------------------------------------------- /src/server/common/helpers/mongo.helper.ts: -------------------------------------------------------------------------------- 1 | import { connect, set } from 'mongoose' 2 | import { logger } from 'server/common/utils/logger' 3 | import { useConfig } from 'server/common/configs' 4 | import type { ConnectionStates } from 'mongoose' 5 | 6 | const config = useConfig() 7 | 8 | let isConnected: ConnectionStates 9 | 10 | export default async () => { 11 | try { 12 | if (isConnected) { 13 | logger.info('connected using cached db') 14 | 15 | return Promise.resolve() 16 | } 17 | 18 | set('strictQuery', true) 19 | 20 | const connection = await connect(config.database.dbUrl, { 21 | dbName: config.database.dbName, 22 | }) 23 | 24 | isConnected = connection.connections[0].readyState 25 | 26 | logger.info('connected using new db connection') 27 | 28 | return isConnected 29 | } catch (error) { 30 | logger.error('database connection failed.', error) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/server/common/helpers/validator.helper.ts: -------------------------------------------------------------------------------- 1 | import { Joi, Modes, Segments, celebrate } from 'celebrate' 2 | 3 | export const registrationSchema = celebrate( 4 | { 5 | [Segments.BODY]: Joi.object().keys({ 6 | firstName: Joi.string().optional(), 7 | lastName: Joi.string().optional(), 8 | email: Joi.string().email(), 9 | password: Joi.string().min(6), 10 | }), 11 | }, 12 | { abortEarly: false }, 13 | { mode: Modes.FULL } 14 | ) 15 | 16 | export const loginSchema = celebrate( 17 | { 18 | [Segments.BODY]: Joi.object().keys({ 19 | email: Joi.string().email().required(), 20 | password: Joi.string().min(6).required(), 21 | }), 22 | }, 23 | { abortEarly: false }, 24 | { mode: Modes.FULL } 25 | ) 26 | 27 | export const refreshTokenSchema = celebrate( 28 | { 29 | [Segments.BODY]: Joi.object().keys({ 30 | refreshToken: Joi.string().required(), 31 | }), 32 | }, 33 | { abortEarly: false }, 34 | { mode: Modes.FULL } 35 | ) 36 | 37 | export const verifyAccountSchema = celebrate( 38 | { 39 | [Segments.QUERY]: Joi.object().keys({ 40 | token: Joi.string().required(), 41 | }), 42 | }, 43 | { abortEarly: false }, 44 | { mode: Modes.FULL } 45 | ) 46 | 47 | export const allLinksSchema = celebrate( 48 | { 49 | [Segments.QUERY]: Joi.object().keys({ 50 | offset: Joi.number().default(0), 51 | limit: Joi.number().default(10), 52 | search: Joi.optional(), 53 | sort: Joi.string() 54 | .optional() 55 | .default('date') 56 | .custom((value, helpers) => { 57 | if (value === 'views') return { visitCount: -1 } 58 | else if (value === 'date') return { createdAt: -1 } 59 | 60 | return helpers.message({ custom: 'sort must have a value of [views] or [date]' }) 61 | }), 62 | }), 63 | }, 64 | { abortEarly: false }, 65 | { mode: Modes.FULL } 66 | ) 67 | 68 | export const statisticsVisitsSchema = celebrate( 69 | { 70 | [Segments.QUERY]: Joi.object().keys({ 71 | period: Joi.string().default('1d'), 72 | }), 73 | }, 74 | { abortEarly: false }, 75 | { mode: Modes.FULL } 76 | ) 77 | -------------------------------------------------------------------------------- /src/server/common/middlewares/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import Jwt from 'jsonwebtoken' 2 | import { ErrorType } from 'interfaces/error.interface' 3 | import { HttpExceptionError } from 'server/common/exceptions/http.exception' 4 | import { useConfig } from 'server/common/configs' 5 | import type { RequestHandler } from 'express' 6 | import { userRepository } from '~/server/modules/users/repositories/user.repository' 7 | 8 | const config = useConfig() 9 | 10 | export const checkAuthentication: RequestHandler = async (req, _res, next) => { 11 | const authHeader = req.headers['authorization'] 12 | const accessToken = authHeader && authHeader.split(' ')[1] 13 | 14 | // if no access token provided 15 | if (!accessToken) return next(new HttpExceptionError(401, ErrorType.unauthorised)) 16 | 17 | try { 18 | const tokenDetails = Jwt.verify(accessToken, config.token.access.secret) as Jwt.JwtPayload 19 | 20 | const user = await userRepository.getById(tokenDetails.id) 21 | 22 | if (!user) { 23 | return next(new HttpExceptionError(401, ErrorType.userNotFound)) 24 | } 25 | 26 | req.auth = { 27 | // @ts-expect-error 28 | userId: user._id, 29 | email: user.email, 30 | isVerified: user.isVerified!, 31 | isBanned: user.isBanned!, 32 | } 33 | 34 | return next() 35 | } catch (error) { 36 | let errorMessage = error 37 | 38 | if (error instanceof Error) { 39 | if (error.name === 'TokenExpiredError' || error.name === 'JsonWebTokenError') 40 | errorMessage = ErrorType.invalidAuthenticationToken 41 | else errorMessage = error.message 42 | } 43 | next(new HttpExceptionError(401, errorMessage as string)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/server/common/middlewares/error.middleware.ts: -------------------------------------------------------------------------------- 1 | import { isCelebrateError } from 'celebrate' 2 | import { logger } from 'server/common/utils/logger' 3 | import type { NextFunction, Request, Response } from 'express' 4 | import type { HttpExceptionError } from 'server/common/exceptions/http.exception' 5 | 6 | interface ValidationError { 7 | error: string 8 | location: string | undefined 9 | } 10 | 11 | export const errorMiddleware = ( 12 | error: HttpExceptionError, 13 | req: Request, 14 | res: Response, 15 | next: NextFunction 16 | ) => { 17 | try { 18 | if (isCelebrateError(error)) { 19 | const message: ValidationError[] = [] 20 | const status = 400 21 | 22 | for (const value of error.details.values()) { 23 | value.details.forEach((err) => { 24 | message.push({ 25 | location: err.context?.key, 26 | error: err.message.replaceAll('"', ''), 27 | }) 28 | }) 29 | } 30 | 31 | logger.error(`[${req.method}] [${req.path}] [${status}] ${JSON.stringify(message)}`) 32 | 33 | res.status(status).json({ 34 | status: 'FAILED', 35 | message, 36 | data: null, 37 | }) 38 | } else { 39 | const status = error.status || 500 40 | const message = error.message || 'Something went wrong' 41 | 42 | logger.error(`[${req.method}] [${req.path}] [${status}] ${JSON.stringify(message)}`) 43 | 44 | res.status(status).json({ 45 | status: 'FAILED', 46 | message, 47 | data: null, 48 | }) 49 | } 50 | } catch (error) { 51 | next(error) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/server/common/middlewares/statistics.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express' 2 | 3 | export const statisticsHandler = (req: Request, _res: Response, next: NextFunction) => { 4 | req.statistics = { 5 | os: { 6 | windows: req?.useragent?.isWindows, 7 | linux: req?.useragent?.isLinux, 8 | mac: req?.useragent?.isMac, 9 | android: req?.useragent?.isAndroid, 10 | }, 11 | browser: { 12 | opera: req?.useragent?.isOpera, 13 | ie: req?.useragent?.isIE, 14 | edge: req?.useragent?.isEdge, 15 | safari: req?.useragent?.isSafari, 16 | firefox: req?.useragent?.isFirefox, 17 | chrome: req?.useragent?.isChrome, 18 | }, 19 | details: { 20 | os: req?.useragent?.os, 21 | browser: req?.useragent?.browser, 22 | version: req?.useragent?.version, 23 | platform: req?.useragent?.platform, 24 | source: req?.useragent?.source, 25 | }, 26 | // only available on vercel 27 | location: { 28 | country: req.headers['x-vercel-ip-country'] as string, 29 | city: req.headers['x-vercel-ip-city'] as string, 30 | timezone: req.headers['x-vercel-ip-timezone'] as string, 31 | latitude: req.headers['x-vercel-ip-latitude'] as string, 32 | longitude: req.headers['x-vercel-ip-longitude'] as string, 33 | region: req.headers['x-vercel-ip-region'] as string, 34 | }, 35 | } 36 | 37 | next() 38 | } 39 | -------------------------------------------------------------------------------- /src/server/common/middlewares/verifcation.middleware.ts: -------------------------------------------------------------------------------- 1 | import { ErrorType } from 'interfaces/error.interface' 2 | import type { RequestHandler } from 'express' 3 | import { HttpExceptionError } from '~/server/common/exceptions/http.exception' 4 | 5 | export const checkEmailVerification: RequestHandler = async (req, _res, next) => { 6 | try { 7 | const { isVerified, isBanned } = req.auth 8 | 9 | if (!isVerified) { 10 | return next(new HttpExceptionError(403, ErrorType.accountNotVerified)) 11 | } else if (isBanned) { 12 | return next(new HttpExceptionError(403, ErrorType.userBanned)) 13 | } 14 | 15 | return next() 16 | } catch (error) { 17 | next(new HttpExceptionError(403, error as string)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/server/common/types/config.type.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | env: string 3 | database: { 4 | dbName: string 5 | dbUrl: string 6 | } 7 | cors: { 8 | origin: string | boolean 9 | credentials: boolean 10 | } 11 | log: { 12 | format: 'combined' | 'common' | 'dev' | 'short' | 'tiny' 13 | level: 'error' | 'warn' | 'info' | 'http' | 'debug' 14 | } 15 | token: { 16 | access: { 17 | secret: string 18 | expiresIn: string 19 | } 20 | refresh: { 21 | secret: string 22 | expiresIn: string 23 | } 24 | accountVerification: { 25 | secret: string 26 | expiresIn: string 27 | } 28 | } 29 | email: { 30 | apiKey: string 31 | senderName: string 32 | senderEmail: string 33 | } 34 | domain: { 35 | protocol: 'http' | 'https' 36 | url: string 37 | } 38 | github: { 39 | clientId: string 40 | clientSecret: string 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/server/common/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config.type' 2 | export * from './response.interface' 3 | export * from './routes.interface' 4 | export * from './use-case.type' 5 | -------------------------------------------------------------------------------- /src/server/common/types/module.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'express-timeout-handler' 2 | -------------------------------------------------------------------------------- /src/server/common/types/response.interface.ts: -------------------------------------------------------------------------------- 1 | export interface CustomResponse { 2 | status: 'SUCCESS' | 'FAILED' 3 | message: string | null 4 | data: T | null 5 | } 6 | -------------------------------------------------------------------------------- /src/server/common/types/routes.interface.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from 'express' 2 | 3 | export interface Routes { 4 | path?: string 5 | router: Router 6 | } 7 | -------------------------------------------------------------------------------- /src/server/common/types/statistics.interface.ts: -------------------------------------------------------------------------------- 1 | export type StatisticsPeriod = '1h' | '24h' | '7d' | '30d' | '180d' | 'all' 2 | 3 | export interface Statistics { 4 | visitDate?: Date 5 | os: { 6 | windows: Boolean | undefined 7 | linux: Boolean | undefined 8 | mac: Boolean | undefined 9 | android: Boolean | undefined 10 | } 11 | browser: { 12 | opera: Boolean | undefined 13 | ie: Boolean | undefined 14 | edge: Boolean | undefined 15 | safari: Boolean | undefined 16 | firefox: Boolean | undefined 17 | chrome: Boolean | undefined 18 | } 19 | details: { 20 | os: String | undefined 21 | browser: String | undefined 22 | version: String | undefined 23 | platform: String | undefined 24 | source: String | undefined 25 | } 26 | location: { 27 | country: String | undefined 28 | city: String | undefined 29 | timezone: String | undefined 30 | latitude: String | undefined 31 | longitude: String | undefined 32 | region: String | undefined 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/server/common/types/use-case.type.ts: -------------------------------------------------------------------------------- 1 | interface Obj { 2 | [key: string]: any 3 | } 4 | 5 | export interface IUseCase { 6 | execute(params: T): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/server/common/utils/cookie.ts: -------------------------------------------------------------------------------- 1 | export const getCookies = function (rawCookie: string | undefined): Record { 2 | const cookies: Record = {} 3 | rawCookie && 4 | rawCookie.split(';').forEach((cookie: string) => { 5 | const parts: RegExpMatchArray | null = cookie.match(/(.*?)=(.*)$/) 6 | if (parts && parts.length > 0) { 7 | cookies[parts[1].trim()] = (parts[2] || '').trim() 8 | } 9 | }) 10 | 11 | return cookies 12 | } 13 | -------------------------------------------------------------------------------- /src/server/common/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston' 2 | import { useConfig } from 'server/common/configs' 3 | 4 | const config = useConfig() 5 | 6 | const levels = { 7 | error: 0, 8 | warn: 1, 9 | info: 2, 10 | http: 3, 11 | debug: 4, 12 | } 13 | 14 | const colors = { 15 | error: 'red', 16 | warn: 'yellow', 17 | info: 'green', 18 | http: 'magenta', 19 | debug: 'white', 20 | } 21 | 22 | winston.addColors(colors) 23 | 24 | const format = winston.format.combine( 25 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), 26 | winston.format.colorize({ all: true }), 27 | winston.format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`) 28 | ) 29 | 30 | const transports = [new winston.transports.Console()] 31 | 32 | export const logger = winston.createLogger({ 33 | level: config.log.level, 34 | levels, 35 | format, 36 | transports, 37 | }) 38 | -------------------------------------------------------------------------------- /src/server/modules/email/services/email.service.ts: -------------------------------------------------------------------------------- 1 | import { $fetch } from 'ofetch' 2 | import { useConfig } from 'server/common/configs' 3 | 4 | interface SendEmailArgs { 5 | templateId: number 6 | toEmail: string 7 | params: Record 8 | } 9 | 10 | export class EmailService { 11 | public sendEmail = async ({ templateId, toEmail, params }: SendEmailArgs) => { 12 | const { 13 | email: { apiKey, senderEmail, senderName }, 14 | } = useConfig() 15 | 16 | return $fetch('https://api.brevo.com/v3/smtp/email', { 17 | retry: 2, 18 | headers: { 19 | Accept: 'application/json', 20 | 'api-key': apiKey, 21 | 'content-type': 'application/json', 22 | }, 23 | body: { 24 | templateId, 25 | sender: { 26 | name: senderName, 27 | email: senderEmail, 28 | }, 29 | to: [{ email: toEmail }], 30 | params: { ...params }, 31 | }, 32 | method: 'POST', 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/server/modules/email/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './email.service' 2 | -------------------------------------------------------------------------------- /src/server/modules/email/types/email-templates.type.ts: -------------------------------------------------------------------------------- 1 | export enum EmailTemplate { 2 | accountVerification = 2, 3 | passwordReset = 3, 4 | } 5 | -------------------------------------------------------------------------------- /src/server/modules/email/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './email-templates.type' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './link.controller' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/controllers/link.controller.ts: -------------------------------------------------------------------------------- 1 | import { LinkService } from 'server/modules/links/services' 2 | import type { NextFunction, Request, RequestHandler, Response } from 'express' 3 | import type { CustomResponse } from 'server/common/types' 4 | import type { CreateLinkDto, LinkDto } from 'server/modules/links/dto' 5 | import type { Paginator } from 'server/modules/links/types' 6 | 7 | export class LinkController { 8 | private linkService: LinkService 9 | 10 | constructor() { 11 | this.linkService = new LinkService() 12 | } 13 | 14 | public createLink: RequestHandler = async ( 15 | req: Request, 16 | res: Response>, 17 | next: NextFunction 18 | ) => { 19 | try { 20 | const { userId } = req.auth 21 | const { 22 | alias, 23 | target, 24 | description, 25 | meta: { password, validFrom, validTill, maxVisits, active } = {}, 26 | }: CreateLinkDto = req.body 27 | 28 | const shortenedLink = await this.linkService.createLink({ 29 | userId, 30 | alias, 31 | target, 32 | description, 33 | meta: { password, validFrom, validTill, maxVisits, active }, 34 | }) 35 | 36 | return res.json({ status: 'SUCCESS', message: null, data: shortenedLink }) 37 | } catch (error) { 38 | next(error) 39 | } 40 | } 41 | 42 | public getLinks: RequestHandler = async ( 43 | req: Request, 44 | res: Response>, 45 | next: NextFunction 46 | ) => { 47 | try { 48 | const { userId } = req.auth 49 | const { offset, limit, search, sort } = req.query 50 | 51 | const paginator: Paginator = { 52 | offset: Number(offset), 53 | limit: Number(limit), 54 | search: search?.toString(), 55 | sort: sort as Paginator['sort'], 56 | } 57 | 58 | const allLinks = await this.linkService.getAllLinks(userId, paginator) 59 | 60 | return res.json({ status: 'SUCCESS', message: null, data: allLinks }) 61 | } catch (error) { 62 | next(error) 63 | } 64 | } 65 | 66 | public getLink: RequestHandler = async ( 67 | req: Request, 68 | res: Response>, 69 | next: NextFunction 70 | ) => { 71 | try { 72 | const { userId } = req.auth 73 | const { alias } = req.params 74 | 75 | const link = await this.linkService.getLink(userId, alias.toString()) 76 | 77 | return res.json({ status: 'SUCCESS', message: null, data: link }) 78 | } catch (error) { 79 | next(error) 80 | } 81 | } 82 | 83 | public updateLink: RequestHandler = async ( 84 | req: Request, 85 | res: Response>, 86 | next: NextFunction 87 | ) => { 88 | try { 89 | // const { email } = req.auth 90 | // const { alias } = req.params 91 | // const linkPayload: Link = req.body 92 | // const shortenedLink = await this.linkService.updateLink(email, alias, linkPayload) 93 | // return res.json({ status: 'SUCCESS', message: null, data: shortenedLink }) 94 | } catch (error) { 95 | next(error) 96 | } 97 | } 98 | 99 | public deleteLink: RequestHandler = async ( 100 | req: Request, 101 | res: Response>, 102 | next: NextFunction 103 | ) => { 104 | try { 105 | const { userId } = req.auth 106 | const { alias } = req.params 107 | 108 | await this.linkService.deleteLink(userId, alias) 109 | 110 | return res.json({ status: 'SUCCESS', message: 'link deleted successfully', data: null }) 111 | } catch (error) { 112 | next(error) 113 | } 114 | } 115 | 116 | public redirectLink: RequestHandler = async ( 117 | req: Request, 118 | res: Response>, 119 | next: NextFunction 120 | ) => { 121 | try { 122 | const { alias } = req.params 123 | const { password } = req.body 124 | const { statistics } = req 125 | 126 | const link = await this.linkService.redirectLink(alias, statistics!, password) 127 | 128 | return res.json({ status: 'SUCCESS', message: null, data: link }) 129 | } catch (error) { 130 | next(error) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/server/modules/links/dto/create-link.dto.ts: -------------------------------------------------------------------------------- 1 | import type { LinkClass } from 'server/modules/links/models' 2 | 3 | export type CreateLinkDto = Pick & 4 | Partial> 5 | -------------------------------------------------------------------------------- /src/server/modules/links/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './link.dto' 2 | export * from './create-link.dto' 3 | -------------------------------------------------------------------------------- /src/server/modules/links/dto/link.dto.ts: -------------------------------------------------------------------------------- 1 | import type { LinkClass } from 'server/modules/links/models' 2 | 3 | export type LinkDto = LinkClass 4 | -------------------------------------------------------------------------------- /src/server/modules/links/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validation.helper' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/helpers/validation.helper.ts: -------------------------------------------------------------------------------- 1 | import { Joi, Modes, Segments, celebrate } from 'celebrate' 2 | 3 | export const createLinkSchema = celebrate( 4 | { 5 | [Segments.BODY]: Joi.object() 6 | .keys({ 7 | target: Joi.string().required(), 8 | }) 9 | .unknown(true), 10 | }, 11 | { abortEarly: false }, 12 | { mode: Modes.FULL } 13 | ) 14 | -------------------------------------------------------------------------------- /src/server/modules/links/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './link.model' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/models/link.model.ts: -------------------------------------------------------------------------------- 1 | import { getModelForClass, index, plugin, prop } from '@typegoose/typegoose' 2 | import { TimeStamps } from '@typegoose/typegoose/lib/defaultClasses.js' 3 | 4 | class Meta { 5 | @prop({ required: false, default: null, type: String }) 6 | password?: string | null 7 | 8 | @prop({ required: false, default: Date.now(), type: Date }) 9 | validFrom?: Date 10 | 11 | @prop({ required: false, default: null, type: Date }) 12 | validTill?: Date | null 13 | 14 | @prop({ required: false, default: null, type: Number }) 15 | maxVisits?: number | null 16 | 17 | @prop({ required: false, default: true, type: Boolean }) 18 | active?: boolean 19 | } 20 | 21 | @plugin(index, { alias: 'text', target: 'text', shortUrl: 'text', description: 'text' }) 22 | class LinkClass extends TimeStamps { 23 | @prop({ required: true, type: String }) 24 | userId!: string 25 | 26 | @prop({ required: true, unique: true, type: String }) 27 | alias!: string 28 | 29 | @prop({ required: true, type: String }) 30 | target!: string 31 | 32 | @prop({ required: true, type: String }) 33 | shortUrl!: string 34 | 35 | @prop({ default: 0, type: Number }) 36 | visitCount?: number 37 | 38 | @prop({ type: String }) 39 | description?: string | null 40 | 41 | @prop({ _id: false, type: () => Meta }) 42 | meta?: Meta 43 | } 44 | 45 | const LinkModel = getModelForClass(LinkClass, { 46 | schemaOptions: { collection: 'links' }, 47 | }) 48 | 49 | export { LinkModel, LinkClass } 50 | -------------------------------------------------------------------------------- /src/server/modules/links/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './link.repository' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/repositories/link.repository.ts: -------------------------------------------------------------------------------- 1 | import { BaseRepository } from 'server/common/classes/base-repository.class' 2 | import { LinkModel } from 'server/modules/links/models/link.model' 3 | import type { LinkDto } from 'server/modules/links/dto' 4 | import type { Paginator } from 'server/modules/links/types/pagination.type' 5 | 6 | export class LinkRepository extends BaseRepository { 7 | constructor() { 8 | super(LinkModel) 9 | } 10 | 11 | async getAllLinks(userId: string, { offset, limit, search, sort }: Paginator) { 12 | return this.model 13 | .find({ 14 | $and: [{ userId }, search ? { $text: { $search: search } } : {}], 15 | }) 16 | .limit(limit) 17 | .skip(offset) 18 | .sort(sort) 19 | .lean() 20 | .exec() 21 | } 22 | 23 | async getUserLinkByAlias(userId: string, alias: string) { 24 | return this.model 25 | .findOne({ $and: [{ userId }, { alias }] }) 26 | .lean() 27 | .exec() 28 | } 29 | 30 | async getLinkByAlias(alias: string) { 31 | return this.model.findOne({ alias }).lean().exec() 32 | } 33 | 34 | async getTotalLinks(userId: string) { 35 | return this.model.countDocuments({ userId }) 36 | } 37 | 38 | async deleteLink(userId: string, alias: string) { 39 | return this.model.deleteOne({ $and: [{ userId }, { alias }] }) 40 | } 41 | 42 | async incrementLinkVisits(id: string) { 43 | return this.model.updateOne({ _id: id }, { $inc: { visitCount: 1 } }) 44 | } 45 | } 46 | 47 | export const linkRepository = new LinkRepository() 48 | -------------------------------------------------------------------------------- /src/server/modules/links/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './link.route' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/routes/link.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { checkAuthentication } from 'server/common/middlewares/auth.middleware' 3 | import { statisticsHandler } from 'server/common/middlewares/statistics.middleware' 4 | import { checkEmailVerification } from 'server/common/middlewares/verifcation.middleware' 5 | import { StatisticsController } from 'server/modules/statistics/controllers/statistics.controller' 6 | import { allLinksSchema } from 'server/common/helpers/validator.helper' 7 | import { LinkController } from 'server/modules/links/controllers/link.controller' 8 | import { createLinkSchema } from 'server/modules/links/helpers/validation.helper' 9 | import type { Routes } from 'server/common/types/routes.interface' 10 | 11 | export class LinkRoute implements Routes { 12 | public path = '/links' 13 | public router = Router() 14 | public linkController = new LinkController() 15 | public statisticsController = new StatisticsController() 16 | 17 | constructor() { 18 | this.initializeRoutes() 19 | } 20 | 21 | private initializeRoutes() { 22 | this.router.get( 23 | `${this.path}`, 24 | checkAuthentication, 25 | checkEmailVerification, 26 | allLinksSchema, 27 | this.linkController.getLinks 28 | ) 29 | this.router.post( 30 | `${this.path}/shorten`, 31 | checkAuthentication, 32 | checkEmailVerification, 33 | createLinkSchema, 34 | this.linkController.createLink 35 | ) 36 | this.router.post( 37 | `${this.path}/redirect/:alias`, 38 | statisticsHandler, 39 | this.linkController.redirectLink 40 | ) 41 | this.router.get( 42 | `${this.path}/:alias`, 43 | checkAuthentication, 44 | checkEmailVerification, 45 | this.linkController.getLink 46 | ) 47 | this.router.put( 48 | `${this.path}/:alias`, 49 | checkAuthentication, 50 | checkEmailVerification, 51 | this.linkController.updateLink 52 | ) 53 | this.router.delete( 54 | `${this.path}/:alias`, 55 | checkAuthentication, 56 | checkEmailVerification, 57 | this.linkController.deleteLink 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/server/modules/links/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './link.service' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/services/link.service.ts: -------------------------------------------------------------------------------- 1 | import { LinkRepository } from 'server/modules/links/repositories/link.repository' 2 | import { CreateLinkUseCase } from 'server/modules/links/use-cases/create-link/create-link.use-case' 3 | import { RedirectLinkUseCase } from 'server/modules/links/use-cases/redirect-link/redirect-link.use-case' 4 | import type { Statistics } from 'server/common/types/statistics.interface' 5 | import type { CreateLinkDto } from 'server/modules/links/dto' 6 | import type { Paginator } from 'server/modules/links/types/pagination.type' 7 | 8 | export class LinkService { 9 | private linkRepository: LinkRepository 10 | private createLinkUseCase: CreateLinkUseCase 11 | private redirectLinkUseCase: RedirectLinkUseCase 12 | 13 | constructor() { 14 | this.linkRepository = new LinkRepository() 15 | this.createLinkUseCase = new CreateLinkUseCase() 16 | this.redirectLinkUseCase = new RedirectLinkUseCase() 17 | } 18 | 19 | public createLink = async (createLinkInput: CreateLinkDto) => { 20 | return this.createLinkUseCase.execute(createLinkInput) 21 | } 22 | 23 | public getAllLinks = async (userId: string, { offset, limit, search, sort }: Paginator) => { 24 | const [allLinks, totalCount] = await Promise.all([ 25 | this.linkRepository.getAllLinks(userId, { offset, limit, search, sort }), 26 | this.linkRepository.getTotalLinks(userId), 27 | ]) 28 | 29 | return { links: allLinks, total: totalCount } 30 | } 31 | 32 | public getLink = async (userId: string, alias: string) => { 33 | return this.linkRepository.getUserLinkByAlias(userId, alias) 34 | } 35 | 36 | public deleteLink = async (userId: string, alias: string) => { 37 | return this.linkRepository.deleteLink(userId, alias) 38 | } 39 | 40 | public redirectLink = async (alias: string, statistics: Statistics, password?: string) => { 41 | return this.redirectLinkUseCase.execute(alias, password) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/server/modules/links/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pagination.type' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/types/pagination.type.ts: -------------------------------------------------------------------------------- 1 | export interface Paginator { 2 | limit: number 3 | offset: number 4 | search?: string 5 | sort?: 'views' | 'date' 6 | } 7 | -------------------------------------------------------------------------------- /src/server/modules/links/use-cases/create-link/create-link.use-case.ts: -------------------------------------------------------------------------------- 1 | import { generateShortLink, sanitizeTargetLink } from 'server/modules/links/utils' 2 | import { LinkRepository } from 'server/modules/links/repositories' 3 | import { GenerateAliasUseCase } from 'server/modules/links/use-cases' 4 | import { ErrorType } from 'interfaces/error.interface' 5 | import { HttpExceptionError } from 'server/common/exceptions' 6 | import type { IUseCase } from 'server/common/types' 7 | import type { CreateLinkDto } from 'server/modules/links/dto' 8 | 9 | export class CreateLinkUseCase implements IUseCase { 10 | private linkRepository: LinkRepository 11 | private generateAliasUseCase: GenerateAliasUseCase 12 | 13 | constructor() { 14 | this.linkRepository = new LinkRepository() 15 | this.generateAliasUseCase = new GenerateAliasUseCase() 16 | } 17 | 18 | async execute(linkInput: CreateLinkDto) { 19 | const linkAlreadyExists = await this.linkRepository.exists({ alias: linkInput.alias }) 20 | 21 | if (linkAlreadyExists) throw new HttpExceptionError(400, ErrorType.aliasAlreadyUsed) 22 | 23 | const { userId, target, description, meta } = linkInput 24 | 25 | const uniqueAlias = linkInput.alias 26 | ? linkInput.alias 27 | : await this.generateAliasUseCase.execute() 28 | 29 | const verifiedTarget = sanitizeTargetLink(target) 30 | 31 | const shortUrl = generateShortLink(uniqueAlias) 32 | 33 | return this.linkRepository.create({ 34 | userId: userId!, 35 | alias: uniqueAlias, 36 | target: verifiedTarget, 37 | shortUrl, 38 | description, 39 | meta, 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/server/modules/links/use-cases/generate-alias/generate-alias.use-case.ts: -------------------------------------------------------------------------------- 1 | import { LinkRepository } from 'server/modules/links/repositories/link.repository' 2 | import type { IUseCase } from 'server/common/types/use-case.type' 3 | import { HttpExceptionError } from '~/server/common/exceptions/http.exception' 4 | 5 | export class GenerateAliasUseCase implements IUseCase { 6 | private linkRepository: LinkRepository 7 | 8 | constructor() { 9 | this.linkRepository = new LinkRepository() 10 | } 11 | 12 | async execute(): Promise { 13 | const characterSet = 'abcdefghijklmnopqrstuvwxyz1234567890' 14 | const maxAttempts = 10 // Maximum number of attempts to find a unique alias 15 | 16 | for (let attempt = 0; attempt < maxAttempts; attempt++) { 17 | let randomAlias = '' 18 | 19 | for (let i = 0; i < 6; i++) { 20 | randomAlias += characterSet.charAt(Math.floor(Math.random() * characterSet.length)) 21 | } 22 | 23 | const linkAlreadyExists = await this.linkRepository.getLinkByAlias(randomAlias) 24 | 25 | if (!linkAlreadyExists) { 26 | return randomAlias 27 | } 28 | } 29 | 30 | throw new HttpExceptionError(500, 'Error creating a new link. Please try again.') 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/server/modules/links/use-cases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-link/create-link.use-case' 2 | export * from './generate-alias/generate-alias.use-case' 3 | export * from './redirect-link/redirect-link.use-case' 4 | -------------------------------------------------------------------------------- /src/server/modules/links/use-cases/redirect-link/redirect-link.use-case.ts: -------------------------------------------------------------------------------- 1 | import { LinkRepository } from 'server/modules/links/repositories/link.repository' 2 | import { ErrorType } from 'interfaces/error.interface' 3 | import { HttpExceptionError } from 'server/common/exceptions/http.exception' 4 | import type { IUseCase } from 'server/common/types/use-case.type' 5 | 6 | export class RedirectLinkUseCase implements IUseCase { 7 | private linkRepository: LinkRepository 8 | 9 | constructor() { 10 | this.linkRepository = new LinkRepository() 11 | } 12 | 13 | async execute(alias: string, password?: string) { 14 | const link = await this.linkRepository.getLinkByAlias(alias) 15 | 16 | if (!link) { 17 | throw new HttpExceptionError(404, ErrorType.linkNotFound) 18 | } 19 | 20 | if (link.meta?.active === false) { 21 | throw new HttpExceptionError(403, ErrorType.linkInactive) 22 | } 23 | 24 | const currentDate = new Date() 25 | const validFrom = link.meta?.validFrom ? new Date(link.meta.validFrom) : null 26 | const validTill = link.meta?.validTill ? new Date(link.meta.validTill) : null 27 | 28 | if ((validFrom && validFrom > currentDate) || (validTill && validTill < currentDate)) { 29 | throw new HttpExceptionError(403, ErrorType.linkInactive) 30 | } 31 | 32 | if (link.meta?.maxVisits && link.meta.maxVisits <= link.visitCount!) { 33 | throw new HttpExceptionError(403, ErrorType.linkViewLimitReached) 34 | } 35 | 36 | if (link.meta?.password) { 37 | if (!password) { 38 | throw new HttpExceptionError(403, ErrorType.linkPasswordProtected) 39 | } 40 | if (link.meta.password !== password) { 41 | throw new HttpExceptionError(400, ErrorType.incorrectlinkPassword) 42 | } 43 | } 44 | 45 | await this.linkRepository.incrementLinkVisits(link._id.toString()) 46 | 47 | // Add statistics for link visit 48 | // const newStats = new StatisticsModel({ linkId: link.id, ...statistics }) TODO: add after statistics model is refactored 49 | 50 | // await newStats.save().catch(() => { 51 | // logger.error('cannot update link statistics'); 52 | // }); 53 | 54 | return link.target 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/server/modules/links/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './link.util' 2 | -------------------------------------------------------------------------------- /src/server/modules/links/utils/link.util.ts: -------------------------------------------------------------------------------- 1 | import { useConfig } from 'server/common/configs' 2 | import { HttpExceptionError } from 'server/common/exceptions/http.exception' 3 | 4 | const config = useConfig() 5 | 6 | export const generateShortLink = (alias: string) => { 7 | return `${config.domain.protocol}://${config.domain.url}/${alias}` 8 | } 9 | 10 | export const sanitizeTargetLink = (target: string) => { 11 | const link = target.trim() 12 | const linkWithProtocol = 13 | /^(https?|ftp|file):\/\/(www\.)?[\w#%+.:=@~-]{1,256}\.[\d()A-Za-z]{1,6}\b([\w#%&()+./:=?@~-]*)/ 14 | const linkWithoutProtocol = /^[\w#%+.:=@~-]{1,256}\.[\d()A-Za-z]{1,6}\b([\w#%&()+./:=?@~-]*)/ 15 | 16 | // valid URL including http/https and domain 17 | if (linkWithProtocol.test(link)) { 18 | return link 19 | // valid URL but http/https protocol not present 20 | } else if (linkWithoutProtocol.test(link)) { 21 | return `https://${link}` 22 | } else { 23 | // Invalid Url 24 | throw new HttpExceptionError(400, 'Invalid target link') 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/server/modules/statistics/controllers/statistics.controller.ts: -------------------------------------------------------------------------------- 1 | import { StatisticsService } from 'server/modules/statistics/services/statistics.service' 2 | import type { NextFunction, Request, RequestHandler, Response } from 'express' 3 | import type { CustomResponse } from 'server/common/types/response.interface' 4 | import type { StatisticsPeriod } from '~/server/common/types/statistics.interface' 5 | 6 | export class StatisticsController { 7 | private statisticsService: StatisticsService 8 | 9 | constructor() { 10 | this.statisticsService = new StatisticsService() 11 | } 12 | 13 | public overviewStats: RequestHandler = async ( 14 | req: Request, 15 | res: Response>, 16 | next: NextFunction 17 | ) => { 18 | try { 19 | const { userId } = req.auth 20 | 21 | const visitStats = await this.statisticsService.getOverviewStats(userId) 22 | 23 | return res.json({ status: 'SUCCESS', message: null, data: visitStats }) 24 | } catch (error) { 25 | next(error) 26 | } 27 | } 28 | 29 | public visitStats: RequestHandler = async ( 30 | req: Request, 31 | res: Response>, 32 | next: NextFunction 33 | ) => { 34 | try { 35 | const { userId } = req.auth 36 | const { alias } = req.params 37 | const { period } = req.query 38 | 39 | const visitStats = await this.statisticsService.getVisitStats( 40 | userId, 41 | alias, 42 | period as StatisticsPeriod 43 | ) 44 | 45 | return res.json({ status: 'SUCCESS', message: null, data: visitStats }) 46 | } catch (error) { 47 | next(error) 48 | } 49 | } 50 | 51 | public deviceStats: RequestHandler = async ( 52 | req: Request, 53 | res: Response>, 54 | next: NextFunction 55 | ) => { 56 | try { 57 | const { userId } = req.auth 58 | 59 | const { alias } = req.params 60 | const { period } = req.query 61 | 62 | const visitStats = await this.statisticsService.getDeviceStats( 63 | userId, 64 | alias, 65 | period as StatisticsPeriod 66 | ) 67 | 68 | return res.json({ status: 'SUCCESS', message: null, data: visitStats }) 69 | } catch (error) { 70 | next(error) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/server/modules/statistics/models/statistics.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import type { Document, Schema } from 'mongoose' 3 | 4 | interface StatisticsDocument extends Document { 5 | linkId: Schema.Types.ObjectId 6 | visitDate: { type: Date; required: boolean } 7 | os: { 8 | windows: boolean 9 | linux: boolean 10 | mac: boolean 11 | android: boolean 12 | } 13 | browser: { 14 | opera: boolean 15 | ie: boolean 16 | edge: boolean 17 | safari: boolean 18 | firefox: boolean 19 | chrome: boolean 20 | } 21 | details: { 22 | os: string 23 | browser: string 24 | version: string 25 | platform: string 26 | source: string 27 | } 28 | location: { 29 | country: string 30 | city: string 31 | timezone: string 32 | latitude: string 33 | longitude: string 34 | region: string 35 | } 36 | } 37 | 38 | const StatisticsSchema: Schema = new mongoose.Schema({ 39 | linkId: { type: mongoose.Schema.Types.ObjectId, required: true, unique: false }, 40 | visitDate: { type: Date, default: Date.now, required: true }, 41 | os: { 42 | windows: Boolean, 43 | linux: Boolean, 44 | mac: Boolean, 45 | android: Boolean, 46 | }, 47 | browser: { 48 | opera: Boolean, 49 | ie: Boolean, 50 | edge: Boolean, 51 | safari: Boolean, 52 | firefox: Boolean, 53 | chrome: Boolean, 54 | }, 55 | details: { 56 | os: String, 57 | browser: String, 58 | version: String, 59 | platform: String, 60 | source: String, 61 | }, 62 | location: { 63 | country: String, 64 | city: String, 65 | timezone: String, 66 | latitude: String, 67 | longitude: String, 68 | region: String, 69 | }, 70 | }) 71 | 72 | export const StatisticsModel = mongoose.model('statistics', StatisticsSchema) 73 | -------------------------------------------------------------------------------- /src/server/modules/statistics/routes/statistics.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { checkAuthentication } from 'server/common/middlewares/auth.middleware' 3 | import { checkEmailVerification } from 'server/common/middlewares/verifcation.middleware' 4 | import { StatisticsController } from 'server/modules/statistics/controllers/statistics.controller' 5 | import { statisticsVisitsSchema } from 'server/common/helpers/validator.helper' 6 | import type { Routes } from 'server/common/types/routes.interface' 7 | 8 | export class StatisticsRoute implements Routes { 9 | public path = '/stats' 10 | public router = Router() 11 | 12 | public statisticsController = new StatisticsController() 13 | 14 | constructor() { 15 | this.initializeRoutes() 16 | } 17 | 18 | private initializeRoutes() { 19 | this.router.get( 20 | `${this.path}/:alias/visits`, 21 | checkAuthentication, 22 | checkEmailVerification, 23 | statisticsVisitsSchema, 24 | this.statisticsController.visitStats 25 | ) 26 | this.router.get( 27 | `${this.path}/:alias/device`, 28 | checkAuthentication, 29 | checkEmailVerification, 30 | statisticsVisitsSchema, 31 | this.statisticsController.deviceStats 32 | ) 33 | this.router.get( 34 | `${this.path}/overview`, 35 | checkAuthentication, 36 | checkEmailVerification, 37 | this.statisticsController.overviewStats 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/server/modules/users/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { AuthService } from 'server/modules/users/services' 2 | import type { NextFunction, Request, RequestHandler, Response } from 'express' 3 | import type { CustomResponse } from 'server/common/types/response.interface' 4 | import type { AccessTokenDto, AuthTokenDto, UserRegisterDto } from 'server/modules/users/dto' 5 | 6 | export class AuthController { 7 | private readonly authService: AuthService 8 | constructor() { 9 | this.authService = new AuthService() 10 | } 11 | 12 | public register: RequestHandler = async ( 13 | req: Request, 14 | res: Response>, 15 | next: NextFunction 16 | ) => { 17 | try { 18 | const { email, password }: UserRegisterDto = req.body 19 | 20 | await this.authService.register({ email, password }) 21 | 22 | return res.json({ 23 | status: 'SUCCESS', 24 | message: 'user registered successfully', 25 | data: null, 26 | }) 27 | } catch (error) { 28 | next(error) 29 | } 30 | } 31 | 32 | public login: RequestHandler = async ( 33 | req: Request, 34 | res: Response>, 35 | next: NextFunction 36 | ) => { 37 | try { 38 | const { email, password } = req.body 39 | 40 | const { accessToken, refreshToken } = await this.authService.login({ 41 | email, 42 | password, 43 | }) 44 | 45 | return res.json({ 46 | status: 'SUCCESS', 47 | message: null, 48 | data: { accessToken, refreshToken }, 49 | }) 50 | } catch (error) { 51 | next(error) 52 | } 53 | } 54 | 55 | public loginWithGithub: RequestHandler = async ( 56 | req: Request, 57 | res: Response>, 58 | next: NextFunction 59 | ) => { 60 | try { 61 | const { code } = req.body 62 | 63 | const { accessToken, refreshToken } = await this.authService.loginWithGithub({ code }) 64 | 65 | return res.json({ 66 | status: 'SUCCESS', 67 | message: 'user registered successfully', 68 | data: { accessToken, refreshToken }, 69 | }) 70 | } catch (error) { 71 | next(error) 72 | } 73 | } 74 | 75 | public refreshToken: RequestHandler = async ( 76 | req: Request, 77 | res: Response>, 78 | next: NextFunction 79 | ) => { 80 | try { 81 | const { refreshToken } = req.body 82 | 83 | const { accessToken } = await this.authService.refreshToken({ refreshToken }) 84 | 85 | return res.json({ status: 'SUCCESS', message: null, data: { accessToken } }) 86 | } catch (error) { 87 | next(error) 88 | } 89 | } 90 | 91 | public verifyAccount: RequestHandler = async ( 92 | req: Request, 93 | res: Response>, 94 | next: NextFunction 95 | ) => { 96 | try { 97 | const { token } = req.query 98 | 99 | await this.authService.verifyAccount({ verificationToken: token as string }) 100 | 101 | return res.json({ 102 | status: 'SUCCESS', 103 | message: 'account verified successfully', 104 | data: null, 105 | }) 106 | } catch (error) { 107 | next(error) 108 | } 109 | } 110 | 111 | public resendVerificationEmail: RequestHandler = async ( 112 | req: Request, 113 | res: Response>, 114 | next: NextFunction 115 | ) => { 116 | try { 117 | const { userId } = req.auth 118 | 119 | await this.authService.resendEmailVerification(userId) 120 | 121 | return res.json({ 122 | status: 'SUCCESS', 123 | message: 'verification email sent successfully', 124 | data: null, 125 | }) 126 | } catch (error) { 127 | next(error) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/server/modules/users/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.controller' 2 | export * from './user.controller' 3 | -------------------------------------------------------------------------------- /src/server/modules/users/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from 'server/modules/users/services' 2 | import type { NextFunction, Request, RequestHandler, Response } from 'express' 3 | import type { CustomResponse } from 'server/common/types/response.interface' 4 | import type { UserDto } from 'server/modules/users/dto' 5 | 6 | export class UserController { 7 | private readonly userService: UserService 8 | constructor() { 9 | this.userService = new UserService() 10 | } 11 | 12 | public me: RequestHandler = async ( 13 | req: Request, 14 | res: Response>, 15 | next: NextFunction 16 | ) => { 17 | try { 18 | const { userId } = req.auth 19 | 20 | const userDetails = await this.userService.me(userId) 21 | 22 | return res.json({ status: 'SUCCESS', message: null, data: userDetails }) 23 | } catch (error) { 24 | next(error) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/server/modules/users/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login.dto' 2 | export * from './register.dto' 3 | export * from './user.dto' 4 | export * from './token.dto' 5 | -------------------------------------------------------------------------------- /src/server/modules/users/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import type { UserClass } from 'server/modules/users/models' 2 | 3 | export type UserLoginDto = Pick 4 | 5 | export interface UserGithubLoginDto { 6 | code: string 7 | } 8 | -------------------------------------------------------------------------------- /src/server/modules/users/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import type { UserClass } from 'server/modules/users/models' 2 | 3 | export type UserRegisterDto = Pick 4 | -------------------------------------------------------------------------------- /src/server/modules/users/dto/token.dto.ts: -------------------------------------------------------------------------------- 1 | export interface AccessTokenDto { 2 | accessToken: string 3 | } 4 | 5 | export interface RefreshTokenDto { 6 | refreshToken: string 7 | } 8 | 9 | export interface VerificationTokenDto { 10 | verificationToken: string 11 | } 12 | 13 | export type AuthTokenDto = AccessTokenDto & RefreshTokenDto 14 | -------------------------------------------------------------------------------- /src/server/modules/users/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import type { UserClass } from 'server/modules/users/models' 2 | 3 | export type UserDto = UserClass 4 | -------------------------------------------------------------------------------- /src/server/modules/users/helpers/github.helper.ts: -------------------------------------------------------------------------------- 1 | import { useConfig } from 'server/common/configs' 2 | import type { GithubUser, GithubUserEmail } from 'server/modules/users/types' 3 | 4 | const { 5 | github: { clientId, clientSecret }, 6 | } = useConfig() 7 | 8 | export const getGithubAccessToken = (code: string) => { 9 | return $fetch<{ access_token: string; token_type: string; scope: string }>( 10 | 'https://github.com/login/oauth/access_token', 11 | { 12 | method: 'POST', 13 | headers: { 14 | accept: 'application/json', 15 | }, 16 | params: { 17 | client_id: clientId, 18 | client_secret: clientSecret, 19 | code, 20 | }, 21 | } 22 | ) 23 | } 24 | 25 | export const getGithubUser = (accessToken: string) => { 26 | return $fetch('https://api.github.com/user', { 27 | headers: { 28 | accept: 'application/json', 29 | Authorization: `token ${accessToken}`, 30 | }, 31 | }) 32 | } 33 | 34 | export const getGithubUserEmails = (accessToken: string) => { 35 | return $fetch('https://api.github.com/user/emails', { 36 | headers: { 37 | accept: 'application/json', 38 | Authorization: `token ${accessToken}`, 39 | }, 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /src/server/modules/users/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github.helper' 2 | export * from './validation.helper' 3 | -------------------------------------------------------------------------------- /src/server/modules/users/helpers/validation.helper.ts: -------------------------------------------------------------------------------- 1 | import { Joi, Modes, Segments, celebrate } from 'celebrate' 2 | 3 | export const registrationSchema = celebrate( 4 | { 5 | [Segments.BODY]: Joi.object().keys({ 6 | firstName: Joi.string().optional(), 7 | lastName: Joi.string().optional(), 8 | email: Joi.string().email(), 9 | password: Joi.string().min(6), 10 | }), 11 | }, 12 | { abortEarly: false }, 13 | { mode: Modes.FULL } 14 | ) 15 | 16 | export const loginSchema = celebrate( 17 | { 18 | [Segments.BODY]: Joi.object().keys({ 19 | email: Joi.string().email().required(), 20 | password: Joi.string().min(6).required(), 21 | }), 22 | }, 23 | { abortEarly: false }, 24 | { mode: Modes.FULL } 25 | ) 26 | 27 | export const refreshTokenSchema = celebrate( 28 | { 29 | [Segments.BODY]: Joi.object().keys({ 30 | refreshToken: Joi.string().required(), 31 | }), 32 | }, 33 | { abortEarly: false }, 34 | { mode: Modes.FULL } 35 | ) 36 | 37 | export const verifyAccountSchema = celebrate( 38 | { 39 | [Segments.QUERY]: Joi.object().keys({ 40 | token: Joi.string().required(), 41 | }), 42 | }, 43 | { abortEarly: false }, 44 | { mode: Modes.FULL } 45 | ) 46 | -------------------------------------------------------------------------------- /src/server/modules/users/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.model' 2 | -------------------------------------------------------------------------------- /src/server/modules/users/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { getModelForClass, modelOptions, prop } from '@typegoose/typegoose' 2 | import { TimeStamps } from '@typegoose/typegoose/lib/defaultClasses.js' 3 | 4 | class UserProfile { 5 | @prop({ type: String, required: false, lowercase: true }) 6 | public firstName?: string 7 | 8 | @prop({ type: String, required: false, lowercase: true }) 9 | public lastName?: string 10 | 11 | @prop({ type: String, required: false }) 12 | public picture?: string 13 | } 14 | 15 | @modelOptions({ 16 | schemaOptions: { 17 | _id: false, 18 | }, 19 | }) 20 | class SocialAuthProviders { 21 | @prop({ type: Boolean, required: true, default: false }) 22 | public google?: boolean 23 | 24 | @prop({ type: Boolean, required: true, default: false }) 25 | public github?: boolean 26 | 27 | @prop({ type: Boolean, required: true, default: false }) 28 | public credentials?: boolean 29 | } 30 | 31 | @modelOptions({ 32 | schemaOptions: { 33 | _id: false, 34 | }, 35 | }) 36 | class UserApiKeys { 37 | @prop({ type: String, required: true, index: true, default: '9876hvkyvifiuui-jvyviucut' }) 38 | public key?: string 39 | 40 | @prop({ type: Date, required: true, default: Date.now }) 41 | public issuedOn?: Date 42 | 43 | @prop({ type: Date, required: false, default: null }) 44 | public expirationDate?: Date 45 | 46 | @prop({ type: String, required: true, default: 'api-key' }) 47 | public name?: string 48 | 49 | @prop({ type: Date, required: false, default: null }) 50 | public lastUsedOn?: Date 51 | } 52 | 53 | class UserClass extends TimeStamps { 54 | @prop({ type: UserProfile }) 55 | public profile?: UserProfile 56 | 57 | @prop({ type: SocialAuthProviders }) 58 | public authProviders?: SocialAuthProviders 59 | 60 | @prop({ type: UserApiKeys }) 61 | public apiKeys?: UserApiKeys 62 | 63 | @prop({ type: String, required: true, lowercase: true, unique: true, index: true }) 64 | public email!: string 65 | 66 | @prop({ type: String, required: false, minlength: 6, select: false }) 67 | public password?: string 68 | 69 | @prop({ type: Boolean, required: false, default: false }) 70 | public isBanned?: boolean 71 | 72 | @prop({ type: Boolean, required: false, default: false }) 73 | public isVerified?: boolean 74 | } 75 | 76 | const UserModel = getModelForClass(UserClass, { 77 | schemaOptions: { collection: 'users' }, 78 | }) 79 | 80 | export { UserModel, UserClass } 81 | -------------------------------------------------------------------------------- /src/server/modules/users/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.repository' 2 | -------------------------------------------------------------------------------- /src/server/modules/users/repositories/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { UserModel } from 'server/modules/users/models' 2 | import { BaseRepository } from 'server/common/classes/base-repository.class' 3 | import type { UserDto } from 'server/modules/users/dto' 4 | 5 | export class UserRepository extends BaseRepository { 6 | constructor() { 7 | super(UserModel) 8 | } 9 | 10 | async findByEmail(email: string) { 11 | return this.model.findOne({ email }).select('email password authProviders').lean().exec() 12 | } 13 | 14 | updateVerificationById(id: string, isVerified: boolean) { 15 | return this.model.findOneAndUpdate({ _id: id }, { $set: { isVerified } }).lean().exec() 16 | } 17 | } 18 | 19 | export const userRepository = new UserRepository() 20 | -------------------------------------------------------------------------------- /src/server/modules/users/routes/auth.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { checkAuthentication } from 'server/common/middlewares/auth.middleware' 3 | import { 4 | loginSchema, 5 | refreshTokenSchema, 6 | registrationSchema as registrationSchema, 7 | verifyAccountSchema, 8 | } from 'server/modules/users/helpers' 9 | import { AuthController } from 'server/modules/users/controllers' 10 | import type { Routes } from 'server/common/types' 11 | 12 | export class AuthRoute implements Routes { 13 | public path = '/auth' 14 | public router = Router() 15 | public authController = new AuthController() 16 | constructor() { 17 | this.initializeRoutes() 18 | } 19 | 20 | private initializeRoutes() { 21 | this.router.post(`${this.path}/register`, registrationSchema, this.authController.register) 22 | this.router.post(`${this.path}/github`, this.authController.loginWithGithub) 23 | this.router.post(`${this.path}/login`, loginSchema, this.authController.login) 24 | this.router.post( 25 | `${this.path}/refresh-token`, 26 | refreshTokenSchema, 27 | this.authController.refreshToken 28 | ) 29 | this.router.post( 30 | `${this.path}/resend-verification-email`, 31 | checkAuthentication, 32 | this.authController.resendVerificationEmail 33 | ) 34 | this.router.post( 35 | `${this.path}/verify-email`, 36 | verifyAccountSchema, 37 | this.authController.verifyAccount 38 | ) 39 | this.router.post(`${this.path}/github`, this.authController.verifyAccount) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/server/modules/users/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.route' 2 | export * from './auth.route' 3 | -------------------------------------------------------------------------------- /src/server/modules/users/routes/user.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { checkAuthentication } from 'server/common/middlewares/auth.middleware' 3 | import { UserController } from 'server/modules/users/controllers' 4 | import type { Routes } from 'server/common/types' 5 | 6 | export class UserRoute implements Routes { 7 | public path = '/user' 8 | public router = Router() 9 | public userController = new UserController() 10 | constructor() { 11 | this.initializeRoutes() 12 | } 13 | 14 | private initializeRoutes() { 15 | this.router.get(`${this.path}/me`, checkAuthentication, this.userController.me) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/server/modules/users/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoginUserUseCase, 3 | LoginWithGithubUseCase, 4 | RefreshTokenUseCase, 5 | RegisterUserUseCase, 6 | ResendAccountVerificationEmailUseCase, 7 | VerifyAccountUseCase, 8 | } from 'server/modules/users/use-cases' 9 | import type { 10 | RefreshTokenDto, 11 | UserGithubLoginDto, 12 | UserLoginDto, 13 | UserRegisterDto, 14 | VerificationTokenDto, 15 | } from 'server/modules/users/dto' 16 | 17 | export class AuthService { 18 | private readonly registerUseCase: RegisterUserUseCase 19 | private readonly loginWithGithubUseCase: LoginWithGithubUseCase 20 | private readonly loginUseCase: LoginUserUseCase 21 | private readonly refreshTokenUseCase: RefreshTokenUseCase 22 | private readonly resendAccountVerificationEmailUseCase: ResendAccountVerificationEmailUseCase 23 | private readonly verifyAccountUseCase: VerifyAccountUseCase 24 | 25 | constructor() { 26 | this.registerUseCase = new RegisterUserUseCase() 27 | this.loginUseCase = new LoginUserUseCase() 28 | this.refreshTokenUseCase = new RefreshTokenUseCase() 29 | this.resendAccountVerificationEmailUseCase = new ResendAccountVerificationEmailUseCase() 30 | this.verifyAccountUseCase = new VerifyAccountUseCase() 31 | this.loginWithGithubUseCase = new LoginWithGithubUseCase() 32 | } 33 | 34 | public register = async (user: UserRegisterDto) => { 35 | return this.registerUseCase.execute(user) 36 | } 37 | 38 | public loginWithGithub = async (code: UserGithubLoginDto) => { 39 | return this.loginWithGithubUseCase.execute(code) 40 | } 41 | 42 | public login = async (user: UserLoginDto) => { 43 | return this.loginUseCase.execute(user) 44 | } 45 | 46 | public refreshToken = async (refreshToken: RefreshTokenDto) => { 47 | return this.refreshTokenUseCase.execute(refreshToken) 48 | } 49 | 50 | public verifyAccount = async (verificationToken: VerificationTokenDto) => { 51 | return this.verifyAccountUseCase.execute(verificationToken) 52 | } 53 | 54 | public resendEmailVerification = async (userId: string) => { 55 | return this.resendAccountVerificationEmailUseCase.execute(userId) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/server/modules/users/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.service' 2 | export * from './auth.service' 3 | -------------------------------------------------------------------------------- /src/server/modules/users/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { MeUseCase } from 'server/modules/users/use-cases' 2 | 3 | export class UserService { 4 | private readonly meUseCase: MeUseCase 5 | 6 | constructor() { 7 | this.meUseCase = new MeUseCase() 8 | } 9 | 10 | public me = async (userId: string) => { 11 | return this.meUseCase.execute(userId) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/server/modules/users/types/github.type.ts: -------------------------------------------------------------------------------- 1 | export interface GithubUser { 2 | login: string 3 | id: number 4 | node_id: string 5 | avatar_url: string 6 | gravatar_id: string 7 | url: string 8 | html_url: string 9 | followers_url: string 10 | following_url: string 11 | gists_url: string 12 | starred_url: string 13 | subscriptions_url: string 14 | organizations_url: string 15 | repos_url: string 16 | events_url: string 17 | received_events_url: string 18 | type: string 19 | site_admin: boolean 20 | name: string 21 | company: string | null 22 | blog: string 23 | location: string 24 | email: string | null 25 | hireable: boolean 26 | bio: string 27 | twitter_username: string 28 | public_repos: number 29 | public_gists: number 30 | followers: number 31 | following: number 32 | created_at: string 33 | updated_at: string 34 | private_gists: number 35 | total_private_repos: number 36 | owned_private_repos: number 37 | disk_usage: number 38 | collaborators: number 39 | two_factor_authentication: boolean 40 | plan: { 41 | name: string 42 | space: number 43 | collaborators: number 44 | private_repos: number 45 | } 46 | } 47 | 48 | export interface GithubUserEmail { 49 | email: string 50 | primary: boolean 51 | verified: boolean 52 | visibility: 'private' | null 53 | } 54 | -------------------------------------------------------------------------------- /src/server/modules/users/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github.type' 2 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login/login.use-case' 2 | export * from './register/register.use-case' 3 | export * from './login/login-with-github.use-case' 4 | export * from './me/me.use-case' 5 | export * from './resend-verification-email/resend-verification-email.use-case' 6 | export * from './verify-account/verify-account.use-case' 7 | export * from './send-verification-email/send-verification-email.use-case' 8 | export * from './refresh-token/refresh-token.use-case' 9 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/login/login-with-github.use-case.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto' 2 | import { UserRepository } from 'server/modules/users/repositories' 3 | import { HttpExceptionError } from 'server/common/exceptions' 4 | import { ErrorType } from 'interfaces/error.interface' 5 | import { 6 | getGithubAccessToken, 7 | getGithubUser, 8 | getGithubUserEmails, 9 | } from 'server/modules/users/helpers' 10 | import { signAccessToken, signRefreshToken } from 'server/modules/users/utils' 11 | import type { IUseCase } from 'server/common/types/use-case.type' 12 | import type { AuthTokenDto, UserGithubLoginDto } from 'server/modules/users/dto' 13 | 14 | export class LoginWithGithubUseCase implements IUseCase { 15 | private userRepository: UserRepository 16 | 17 | constructor() { 18 | this.userRepository = new UserRepository() 19 | } 20 | 21 | async execute({ code }: UserGithubLoginDto) { 22 | const authResponse = await getGithubAccessToken(code) 23 | 24 | if (!authResponse.access_token) throw new HttpExceptionError(500, ErrorType.somethingWentWrong) 25 | 26 | const [user, emails] = await Promise.all([ 27 | getGithubUser(authResponse.access_token), 28 | getGithubUserEmails(authResponse.access_token), 29 | ]) 30 | 31 | // const githubAvatar = user.avatar_url 32 | const githubEmail = emails.find((email) => email.primary)?.email 33 | // const githubName = user.name 34 | 35 | if (!githubEmail) { 36 | logger.error(`Github email not found for user ${user.id}:${user.name}`) 37 | throw new HttpExceptionError(500, ErrorType.somethingWentWrong) 38 | } 39 | 40 | // check if user exists in db with the primary email 41 | const existingUser = await this.userRepository.findByEmail(githubEmail) 42 | 43 | if (!existingUser) { 44 | const newUser = await this.userRepository.create({ 45 | email: githubEmail, 46 | isVerified: true, 47 | authProviders: { 48 | github: true, 49 | google: false, 50 | credentials: false, 51 | }, 52 | apiKeys: { 53 | key: randomUUID(), 54 | name: 'default-api-key', 55 | }, 56 | }) 57 | 58 | const signedAccessToken = await signAccessToken({ id: newUser._id }) 59 | const signedRefreshToken = await signRefreshToken({ id: newUser._id }) 60 | 61 | return { accessToken: signedAccessToken, refreshToken: signedRefreshToken } 62 | } else if (existingUser.authProviders?.github === false) { 63 | await this.userRepository.update(existingUser._id.toString(), { 64 | $set: { 65 | 'authProviders.github': true, 66 | isVerified: true, 67 | }, 68 | }) 69 | 70 | const signedAccessToken = await signAccessToken({ id: existingUser?._id }) 71 | const signedRefreshToken = await signRefreshToken({ id: existingUser?._id }) 72 | 73 | return { accessToken: signedAccessToken, refreshToken: signedRefreshToken } 74 | } else { 75 | const signedAccessToken = await signAccessToken({ id: existingUser?._id }) 76 | const signedRefreshToken = await signRefreshToken({ id: existingUser?._id }) 77 | 78 | return { accessToken: signedAccessToken, refreshToken: signedRefreshToken } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/login/login.use-case.ts: -------------------------------------------------------------------------------- 1 | import { HttpExceptionError } from 'server/common/exceptions' 2 | import { UserRepository } from 'server/modules/users/repositories' 3 | import { ErrorType } from 'interfaces/error.interface' 4 | import bcrypt from 'bcryptjs' 5 | import { signAccessToken, signRefreshToken } from 'server/modules/users/utils' 6 | import type { IUseCase } from 'server/common/types' 7 | import type { AuthTokenDto, UserLoginDto } from 'server/modules/users/dto' 8 | 9 | export class LoginUserUseCase implements IUseCase { 10 | private userRepository: UserRepository 11 | 12 | constructor() { 13 | this.userRepository = new UserRepository() 14 | } 15 | 16 | async execute({ email, password }: UserLoginDto) { 17 | const existingUser = await this.userRepository.findByEmail(email) 18 | 19 | if (!existingUser) throw new HttpExceptionError(404, ErrorType.userNotFound) 20 | 21 | if (!existingUser.password) 22 | throw new HttpExceptionError(400, ErrorType.incorrectLoginCredentials) 23 | 24 | const doesPasswordMatch = await bcrypt.compare(password!, existingUser.password) 25 | 26 | if (!doesPasswordMatch) throw new HttpExceptionError(400, ErrorType.incorrectLoginCredentials) 27 | 28 | const signedAccessToken = await signAccessToken({ id: existingUser._id }) 29 | const signedRefreshToken = await signRefreshToken({ id: existingUser._id }) 30 | 31 | return { accessToken: signedAccessToken, refreshToken: signedRefreshToken } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/me/me.use-case.ts: -------------------------------------------------------------------------------- 1 | import { UserRepository } from 'server/modules/users/repositories' 2 | import { HttpExceptionError } from 'server/common/exceptions' 3 | import { ErrorType } from 'interfaces/error.interface' 4 | import type { IUseCase } from 'server/common/types' 5 | import type { UserDto } from 'server/modules/users/dto' 6 | 7 | export class MeUseCase implements IUseCase { 8 | private userRepository: UserRepository 9 | 10 | constructor() { 11 | this.userRepository = new UserRepository() 12 | } 13 | 14 | async execute(userId: string) { 15 | const user = await this.userRepository.getById(userId) 16 | 17 | if (!user) throw new HttpExceptionError(404, ErrorType.userNotFound) 18 | 19 | return user 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/refresh-token/refresh-token.use-case.ts: -------------------------------------------------------------------------------- 1 | import { HttpExceptionError } from 'server/common/exceptions' 2 | import { UserRepository } from 'server/modules/users/repositories' 3 | import { ErrorType } from 'interfaces/error.interface' 4 | import { signAccessToken, verifyRefreshToken } from 'server/modules/users/utils' 5 | import type { IUseCase } from 'server/common/types/use-case.type' 6 | import type { AccessTokenDto, RefreshTokenDto } from 'server/modules/users/dto' 7 | 8 | export class RefreshTokenUseCase implements IUseCase { 9 | private userRepository: UserRepository 10 | 11 | constructor() { 12 | this.userRepository = new UserRepository() 13 | } 14 | 15 | async execute({ refreshToken }: RefreshTokenDto) { 16 | const decodedToken = await verifyRefreshToken(refreshToken).catch(() => { 17 | throw new HttpExceptionError(400, ErrorType.invalidRefreshToken) 18 | }) 19 | 20 | const doesUserExist = await this.userRepository.existsById(decodedToken.id) 21 | 22 | if (!doesUserExist) throw new HttpExceptionError(404, ErrorType.userNotFound) 23 | 24 | const signedAccessToken = await signAccessToken({ 25 | email: decodedToken.email, 26 | }) 27 | 28 | return { accessToken: signedAccessToken } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/register/register.use-case.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto' 2 | import { HttpExceptionError } from 'server/common/exceptions' 3 | import { UserRepository } from 'server/modules/users/repositories' 4 | import { ErrorType } from 'interfaces/error.interface' 5 | import bcrypt from 'bcryptjs' 6 | import { AccountVerificationEmailUseCase } from 'server/modules/users/use-cases' 7 | import type { IUseCase } from 'server/common/types' 8 | import type { UserRegisterDto } from 'server/modules/users/dto' 9 | 10 | export class RegisterUserUseCase implements IUseCase { 11 | private userRepository: UserRepository 12 | private accountVerificationEmailUsecase: AccountVerificationEmailUseCase 13 | 14 | constructor() { 15 | this.userRepository = new UserRepository() 16 | this.accountVerificationEmailUsecase = new AccountVerificationEmailUseCase() 17 | } 18 | 19 | async execute({ email, password }: UserRegisterDto) { 20 | const doesUserExist = await this.userRepository.findByEmail(email) 21 | 22 | const salt = await bcrypt.genSalt(10) 23 | 24 | const hashedPassword = await bcrypt.hash(password!, salt) 25 | 26 | if (doesUserExist && doesUserExist.authProviders?.credentials === true) { 27 | throw new HttpExceptionError(409, ErrorType.userAlreadyExists) 28 | } else if (doesUserExist && doesUserExist.authProviders?.credentials === false) { 29 | return this.userRepository.update(doesUserExist._id.toString(), { 30 | $set: { 31 | password: hashedPassword, 32 | 'authProviders.credentials': true, 33 | }, 34 | }) 35 | } else { 36 | await this.userRepository.create({ 37 | email, 38 | password: hashedPassword, 39 | authProviders: { 40 | github: false, 41 | google: false, 42 | credentials: true, 43 | }, 44 | apiKeys: { 45 | key: randomUUID(), 46 | name: 'default-api-key', 47 | }, 48 | }) 49 | 50 | return this.accountVerificationEmailUsecase.execute(email) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/resend-verification-email/resend-verification-email.use-case.ts: -------------------------------------------------------------------------------- 1 | import { UserRepository } from 'server/modules/users/repositories' 2 | import { HttpExceptionError } from 'server/common/exceptions' 3 | import { ErrorType } from 'interfaces/error.interface' 4 | import { AccountVerificationEmailUseCase } from 'server/modules/users/use-cases' 5 | import type { IUseCase } from 'server/common/types' 6 | 7 | export class ResendAccountVerificationEmailUseCase implements IUseCase { 8 | private userRepository: UserRepository 9 | private accountVerificationEmailUseCase: AccountVerificationEmailUseCase 10 | 11 | constructor() { 12 | this.accountVerificationEmailUseCase = new AccountVerificationEmailUseCase() 13 | this.userRepository = new UserRepository() 14 | } 15 | 16 | async execute(userId: string) { 17 | const userDetails = await this.userRepository.getById(userId) 18 | 19 | if (!userDetails) throw new HttpExceptionError(404, ErrorType.userNotFound) 20 | 21 | return this.accountVerificationEmailUseCase.execute(userDetails.email) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/send-verification-email/send-verification-email.use-case.ts: -------------------------------------------------------------------------------- 1 | import { EmailService } from 'server/modules/email/services' 2 | import { EmailTemplate } from 'server/modules/email/types' 3 | import { useConfig } from 'server/common/configs' 4 | import { signAccountVerificationToken } from 'server/modules/users/utils' 5 | import type { Config, IUseCase } from 'server/common/types' 6 | import type { VerificationTokenDto } from 'server/modules/users/dto' 7 | 8 | export class AccountVerificationEmailUseCase implements IUseCase { 9 | private emailService: EmailService 10 | private domainConfig: Config['domain'] 11 | 12 | constructor() { 13 | this.emailService = new EmailService() 14 | this.domainConfig = useConfig().domain 15 | } 16 | 17 | async execute(email: string) { 18 | const emailVerificationToken = await signAccountVerificationToken({ email }) 19 | 20 | const verificationLink = `${this.domainConfig.protocol}://${this.domainConfig.url}/auth/verify-email?token=${emailVerificationToken}` 21 | 22 | return this.emailService.sendEmail({ 23 | templateId: EmailTemplate.accountVerification, 24 | toEmail: email, 25 | params: { 26 | VERIFICATION_LINK: verificationLink, 27 | }, 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/server/modules/users/use-cases/verify-account/verify-account.use-case.ts: -------------------------------------------------------------------------------- 1 | import { UserRepository } from 'server/modules/users/repositories' 2 | import { HttpExceptionError } from 'server/common/exceptions' 3 | import { ErrorType } from 'interfaces/error.interface' 4 | import { verifyAccountVerificationToken } from 'server/modules/users/utils' 5 | import type { IUseCase } from 'server/common/types' 6 | import type { VerificationTokenDto } from 'server/modules/users/dto' 7 | 8 | export class VerifyAccountUseCase implements IUseCase { 9 | private userRepository: UserRepository 10 | 11 | constructor() { 12 | this.userRepository = new UserRepository() 13 | } 14 | 15 | async execute({ verificationToken }: VerificationTokenDto) { 16 | let decodedToken 17 | 18 | try { 19 | decodedToken = await verifyAccountVerificationToken(verificationToken) 20 | } catch { 21 | throw new HttpExceptionError(400, ErrorType.invalidVerifyToken) 22 | } 23 | 24 | const user = await this.userRepository.existsById(decodedToken.id) 25 | 26 | if (!user) throw new HttpExceptionError(404, ErrorType.userNotFound) 27 | 28 | return this.userRepository.updateVerificationById(decodedToken.id, true) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/server/modules/users/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token.util' 2 | -------------------------------------------------------------------------------- /src/server/modules/users/utils/token.util.ts: -------------------------------------------------------------------------------- 1 | import Jwt from 'jsonwebtoken' 2 | import { useConfig } from 'server/common/configs' 3 | import type { JwtPayload } from 'jsonwebtoken' 4 | 5 | type JwtToken = string | object | Buffer 6 | 7 | const config = useConfig() 8 | 9 | export const signAccessToken = async (data: JwtToken) => { 10 | return Jwt.sign(data, config.token.access.secret, { 11 | expiresIn: config.token.access.expiresIn, 12 | }) 13 | } 14 | 15 | export const signRefreshToken = async (data: JwtToken) => { 16 | return Jwt.sign(data, config.token.refresh.secret, { 17 | expiresIn: config.token.refresh.expiresIn, 18 | }) 19 | } 20 | 21 | export const verifyRefreshToken = async (refreshToken: string): Promise => { 22 | return Jwt.verify(refreshToken, config.token.refresh.secret) as JwtPayload 23 | } 24 | 25 | export const signAccountVerificationToken = async (data: JwtToken) => { 26 | return Jwt.sign(data, config.token.accountVerification.secret, { 27 | expiresIn: config.token.accountVerification.expiresIn, 28 | }) 29 | } 30 | 31 | export const verifyAccountVerificationToken = async ( 32 | verificationToken: string 33 | ): Promise => { 34 | return Jwt.verify(verificationToken, config.token.accountVerification.secret) as JwtPayload 35 | } 36 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import { AuthRoute } from 'server/modules/users/routes/auth.route' 3 | import { LinkRoute } from 'server/modules/links/routes/link.route' 4 | import { StatisticsRoute } from 'server/modules/statistics/routes/statistics.route' 5 | import { UserRoute } from 'server/modules/users/routes/user.route' 6 | import { App } from './app' 7 | 8 | const app = new App([new AuthRoute(), new UserRoute(), new LinkRoute(), new StatisticsRoute()]) 9 | 10 | export default fromNodeMiddleware(app.getServer()) 11 | -------------------------------------------------------------------------------- /src/services/api.service.ts: -------------------------------------------------------------------------------- 1 | import { $fetch } from 'ofetch' 2 | import { storeToRefs } from 'pinia' 3 | import { useAuthStore } from 'store/auth.store' 4 | import { ErrorType } from 'interfaces/error.interface' 5 | import type { $Fetch } from 'ofetch' 6 | 7 | const reasonsToRenewSession = [ErrorType.invalidAuthenticationToken, ErrorType.unauthorised] 8 | 9 | const reasonsToLogout = [ErrorType.invalidRefreshToken, ErrorType.userNotFound] 10 | 11 | export class ApiService { 12 | public http: $Fetch 13 | 14 | constructor(baseURL: string) { 15 | this.http = $fetch.create({ 16 | baseURL: `${baseURL}/api/v1`, 17 | 18 | async onResponseError({ response }) { 19 | if (response?.status === 401 && reasonsToRenewSession.includes(response?._data?.message)) { 20 | const { refreshAccessToken } = useAuthStore() 21 | const { refreshToken } = storeToRefs(useAuthStore()) 22 | 23 | await refreshAccessToken(refreshToken.value) 24 | } else if (response?.status === 401 && reasonsToLogout.includes(response?._data?.message)) { 25 | const { logout } = useAuthStore() 26 | 27 | return logout() 28 | } 29 | }, 30 | 31 | onRequest({ options }) { 32 | const { accessToken } = storeToRefs(useAuthStore()) 33 | 34 | options.headers = new Headers(options.headers) 35 | options.headers.set('Authorization', `Bearer ${accessToken.value}`) 36 | }, 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import type { CustomResponse } from 'server/common/types/response.interface' 2 | import type { $Fetch } from 'ofetch' 3 | import type { UserRegisterDto } from 'server/modules/users/dto/register.dto' 4 | import type { AccessTokenDto, AuthTokenDto } from 'server/modules/users/dto/token.dto' 5 | import type { UserDto } from 'server/modules/users/dto/user.dto' 6 | 7 | export class AuthService { 8 | private http: $Fetch 9 | private base: string 10 | 11 | constructor() { 12 | const { 13 | $apiService: { http }, 14 | } = useNuxtApp() 15 | 16 | this.http = http 17 | this.base = '/auth' 18 | } 19 | 20 | public async register(email: string, password: string) { 21 | return this.http>(`${this.base}/register`, { 22 | body: { email, password }, 23 | method: 'POST', 24 | }) 25 | } 26 | 27 | public async login(email: string, password: string) { 28 | return this.http>(`${this.base}/login`, { 29 | body: { email, password }, 30 | method: 'POST', 31 | }) 32 | } 33 | 34 | public async loginWithGithub(code: string) { 35 | return this.http>(`${this.base}/github`, { 36 | body: { code }, 37 | method: 'POST', 38 | }) 39 | } 40 | 41 | public async fetchUser() { 42 | return this.http>(`user/me`, { 43 | method: 'GET', 44 | }) 45 | } 46 | 47 | public async refreshAccessToken(refreshToken: string) { 48 | return this.http>(`${this.base}/refresh-token`, { 49 | method: 'POST', 50 | body: { refreshToken }, 51 | }) 52 | } 53 | 54 | public async resendVerificationEmail() { 55 | return this.http>(`${this.base}/resend-verification-email`, { 56 | method: 'POST', 57 | }) 58 | } 59 | 60 | public async verifyEmail(token: string) { 61 | return this.http>(`${this.base}/verify-email`, { 62 | method: 'POST', 63 | query: { token }, 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/services/link.service.ts: -------------------------------------------------------------------------------- 1 | import type { Paginator } from 'server/modules/links/types' 2 | import type { CustomResponse } from 'server/common/types/response.interface' 3 | import type { $Fetch } from 'ofetch' 4 | import type { CreateLinkDto, LinkDto } from 'server/modules/links/dto' 5 | 6 | export class LinkService { 7 | private http: $Fetch 8 | private base: string 9 | 10 | constructor() { 11 | const { 12 | $apiService: { http }, 13 | } = useNuxtApp() 14 | 15 | this.http = http 16 | this.base = '/links' 17 | } 18 | 19 | public async shorten(linkPayload: CreateLinkDto) { 20 | return this.http>(`${this.base}/shorten`, { 21 | body: linkPayload, 22 | method: 'POST', 23 | }) 24 | } 25 | 26 | public async fetchLinks(options: Paginator) { 27 | return this.http>(`${this.base}`, { 28 | query: { ...options }, 29 | method: 'GET', 30 | }) 31 | } 32 | 33 | public async fetchLink(alias: string) { 34 | return this.http>(`${this.base}/${alias}`, { 35 | method: 'GET', 36 | }) 37 | } 38 | 39 | public async redirectProtectedLink(alias: string, useragent: string, password: string) { 40 | return this.http>(`${this.base}/redirect/${alias}`, { 41 | body: { password }, 42 | headers: { 43 | 'user-agent': useragent, 44 | }, 45 | method: 'POST', 46 | }) 47 | } 48 | 49 | public async deleteLink(alias: string) { 50 | return this.http>(`${this.base}/${alias}`, { 51 | method: 'DELETE', 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/services/statistics.service.ts: -------------------------------------------------------------------------------- 1 | import type { $Fetch } from 'ofetch' 2 | import type { CustomResponse } from 'server/common/types/response.interface' 3 | 4 | export class StatisticsService { 5 | private http: $Fetch 6 | private base: string 7 | 8 | constructor() { 9 | const { 10 | $apiService: { http }, 11 | } = useNuxtApp() 12 | 13 | this.http = http 14 | this.base = '/stats' 15 | } 16 | 17 | public async linkViews(alias: string, period?: string) { 18 | return this.http>>(`${this.base}/${alias}/visits`, { 19 | query: { period }, 20 | method: 'GET', 21 | }) 22 | } 23 | 24 | public async overview() { 25 | return this.http>>(`${this.base}/overview`, { 26 | method: 'GET', 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/store/auth.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { logger } from 'utils/logger' 3 | import type { FetchError } from 'ofetch' 4 | import type { UserDto } from 'server/modules/users/dto/user.dto' 5 | 6 | interface State { 7 | user: Omit | null 8 | isLoggedIn: boolean 9 | accessToken: string | null 10 | refreshToken: string | null 11 | } 12 | 13 | export const useAuthStore = defineStore('authentication-store', { 14 | state: (): State => ({ 15 | user: null, 16 | isLoggedIn: false, 17 | accessToken: null, 18 | refreshToken: null, 19 | }), 20 | 21 | actions: { 22 | async loginUser(email: string, password: string) { 23 | try { 24 | const response = await this.$http.auth.login(email, password) 25 | 26 | if (response.data?.accessToken) { 27 | this.accessToken = response.data.accessToken 28 | this.refreshToken = response.data.refreshToken 29 | this.isLoggedIn = true 30 | } 31 | 32 | return { error: null } 33 | } catch (err) { 34 | const error = (err as FetchError) || Error 35 | logger.error(error?.message) 36 | 37 | return { error: error?.data?.message || error?.message } 38 | } 39 | }, 40 | 41 | async loginWithGithub(code: string) { 42 | try { 43 | const response = await this.$http.auth.loginWithGithub(code) 44 | 45 | if (response.data?.accessToken) { 46 | this.accessToken = response.data.accessToken 47 | this.refreshToken = response.data.refreshToken 48 | this.isLoggedIn = true 49 | } 50 | 51 | return { error: null } 52 | } catch (err) { 53 | const error = (err as FetchError) || Error 54 | logger.error(error?.message) 55 | 56 | return { error: error?.data?.message || error?.message } 57 | } 58 | }, 59 | 60 | async registerUser(email: string, password: string) { 61 | try { 62 | await this.$http.auth.register(email, password) 63 | 64 | return { error: null } 65 | } catch (err) { 66 | const error = (err as FetchError) || Error 67 | logger.error(error?.message) 68 | 69 | return { error: error?.data?.message || error?.message } 70 | } 71 | }, 72 | 73 | async fetchUser() { 74 | try { 75 | const response = await this.$http.auth.fetchUser() 76 | 77 | this.user = response.data! 78 | this.isLoggedIn = true 79 | 80 | return { error: null } 81 | } catch (err) { 82 | const error = (err as FetchError) || Error 83 | logger.error(error?.message) 84 | 85 | return { error: error?.data?.message || error?.message } 86 | } 87 | }, 88 | 89 | logout() { 90 | try { 91 | this.isLoggedIn = false 92 | this.accessToken = null 93 | this.refreshToken = null 94 | this.user = null 95 | 96 | navigateTo('/') 97 | } catch (error) { 98 | if (error instanceof Error) logger.error(error.message) 99 | } 100 | }, 101 | 102 | async refreshAccessToken(refreshToken: string | null) { 103 | try { 104 | if (!refreshToken) return this.logout() 105 | 106 | const response = await this.$http.auth.refreshAccessToken(refreshToken) 107 | 108 | if (response.data?.accessToken) this.accessToken = response.data?.accessToken 109 | } catch (error) { 110 | if (error instanceof Error) logger.error(error.message) 111 | this.logout() 112 | } 113 | }, 114 | 115 | async resendVerificationEmail() { 116 | try { 117 | await this.$http.auth.resendVerificationEmail() 118 | 119 | return { error: null } 120 | } catch (err) { 121 | const error = (err as FetchError) || Error 122 | logger.error(error?.message) 123 | 124 | return { error: error?.data?.message || error?.message } 125 | } 126 | }, 127 | 128 | async verifyEmail(token: string) { 129 | try { 130 | await this.$http.auth.verifyEmail(token) 131 | 132 | return { error: null } 133 | } catch (err) { 134 | const error = (err as FetchError) || Error 135 | logger.error(error?.message) 136 | 137 | return { error: error?.data?.message || error?.message } 138 | } 139 | }, 140 | }, 141 | 142 | persist: [ 143 | { 144 | key: 'kut.accessToken', 145 | paths: ['accessToken'], 146 | }, 147 | { 148 | key: 'kut.refreshToken', 149 | paths: ['refreshToken'], 150 | }, 151 | { 152 | key: 'kut.isLoggedIn', 153 | paths: ['isLoggedIn'], 154 | }, 155 | ], 156 | }) 157 | -------------------------------------------------------------------------------- /src/store/link.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { FetchError } from 'ofetch' 3 | import { logger } from 'utils/logger' 4 | import type { Paginator } from 'server/modules/links/types' 5 | import type { LinkDto } from 'server/modules/links/dto/link.dto' 6 | import type { CreateLinkDto } from 'server/modules/links/dto' 7 | 8 | interface State { 9 | allLinks: LinkDto[] 10 | link: LinkDto 11 | linkViews: Record | null 12 | overviewStats: Record | null 13 | target: string | null 14 | totalCount: number 15 | } 16 | 17 | export const useLinkStore = defineStore('links', { 18 | state: (): State => ({ 19 | allLinks: [], 20 | link: null as unknown as LinkDto, 21 | linkViews: null, 22 | overviewStats: null, 23 | target: null, 24 | totalCount: 0, 25 | }), 26 | 27 | actions: { 28 | async shortenLink(linkPayload: CreateLinkDto) { 29 | try { 30 | const response = await this.$http.link.shorten(linkPayload) 31 | 32 | if (response.data) this.allLinks.unshift(response.data) 33 | 34 | return { error: null } 35 | } catch (err) { 36 | const error = (err as FetchError) || Error 37 | logger.error(error?.message) 38 | 39 | return { error: error?.data?.message || error?.message } 40 | } 41 | }, 42 | 43 | async fetchAllLinks({ offset, limit, search, sort }: Paginator) { 44 | try { 45 | const response = await this.$http.link.fetchLinks({ offset, limit, search, sort }) 46 | this.allLinks = [] 47 | this.totalCount = 0 48 | 49 | this.allLinks = response.data!.links! 50 | this.totalCount = response.data!.total! 51 | 52 | return { error: null } 53 | } catch (err) { 54 | const error = (err as FetchError) || Error 55 | logger.error(error?.message) 56 | 57 | return { error: error?.data?.message || error?.message } 58 | } 59 | }, 60 | 61 | async fetchLinkByAlias(alias: string) { 62 | try { 63 | const response = await this.$http.link.fetchLink(alias) 64 | 65 | this.link = response.data! 66 | 67 | return { error: null } 68 | } catch (err) { 69 | const error = (err as FetchError) || Error 70 | logger.error(error?.message) 71 | 72 | return { error: error?.data?.message || error?.message } 73 | } 74 | }, 75 | 76 | async redirectProtectedLink(alias: string, useragent: string, password: string) { 77 | try { 78 | const response = await this.$http.link.redirectProtectedLink(alias, useragent, password) 79 | 80 | this.target = response.data 81 | 82 | return { error: null } 83 | } catch (err) { 84 | const error = (err as FetchError) || Error 85 | logger.error(error?.message) 86 | 87 | return { error: error?.data?.message || error?.message, data: null } 88 | } 89 | }, 90 | 91 | async deleteLink(alias: string) { 92 | try { 93 | await this.$http.link.deleteLink(alias) 94 | 95 | return { error: null } 96 | } catch (err) { 97 | const error = (err as FetchError) || Error 98 | logger.error(error?.message) 99 | 100 | return { error: error?.data?.message || error?.message, data: null } 101 | } 102 | }, 103 | 104 | async fetchLinkViewStats(alias: string, period?: string) { 105 | try { 106 | const views = await this.$http.statistics.linkViews(alias, period) 107 | 108 | this.linkViews = views.data! 109 | } catch (error) { 110 | if (error instanceof FetchError) { 111 | logger.error(error.message) 112 | } 113 | } 114 | }, 115 | 116 | async fetchOverviewStats() { 117 | try { 118 | const views = await this.$http.statistics.overview() 119 | 120 | this.overviewStats = views.data! 121 | } catch (error) { 122 | if (error instanceof FetchError) { 123 | logger.error(error.message) 124 | } 125 | } 126 | }, 127 | }, 128 | }) 129 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | class Logger { 2 | public info = (args: string | object) => { 3 | return console.info(args) 4 | } 5 | 6 | public warning = (args: string | object) => { 7 | return console.warn(args) 8 | } 9 | 10 | public debug = (args: string | object) => { 11 | return console.debug(args) 12 | } 13 | 14 | public error = (args: string | object) => { 15 | return console.error(args) 16 | } 17 | } 18 | 19 | export const logger = new Logger() 20 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | export default { 4 | darkMode: 'class', 5 | content: ['./src/**/*.vue'], 6 | theme: { 7 | extend: { 8 | animation: { 9 | text: 'text 5s ease infinite', 10 | }, 11 | keyframes: { 12 | text: { 13 | '0%, 100%': { 14 | 'background-size': '200% 200%', 15 | 'background-position': 'left center', 16 | }, 17 | '50%': { 18 | 'background-size': '200% 200%', 19 | 'background-position': 'right center', 20 | }, 21 | }, 22 | }, 23 | fontFamily: { 24 | sans: ['Inter', 'sans-serif'], 25 | }, 26 | colors: { 27 | midnight: { 28 | 50: '#fff', 29 | 100: '#FAFAFA', 30 | 200: '#EAEAEA', 31 | 300: '#999', 32 | 400: '#888', 33 | 500: '#666', 34 | 600: '#444', 35 | 700: '#333', 36 | 800: '#111', 37 | 900: '#000', 38 | }, 39 | error: { 40 | lighter: '#F7D4D6', 41 | light: '#FF1A1A', 42 | DEFAULT: '#E00', 43 | dark: '#C50000', 44 | }, 45 | success: { 46 | lighter: '#D3E5FF', 47 | light: '#3291FF', 48 | DEFAULT: '#0070F3', 49 | dark: '#0761D1', 50 | }, 51 | warning: { 52 | lighter: '#FFEFCF', 53 | light: '#F7B955', 54 | DEFAULT: '#F5A623', 55 | dark: '#AB570A', 56 | }, 57 | violet: { 58 | lighter: '#D8CCF1', 59 | light: '#8A63D2', 60 | DEFAULT: '#7928CA', 61 | dark: '#4C2889', 62 | }, 63 | cyan: { 64 | lighter: '#AAFFEC', 65 | light: '#79FFE1', 66 | DEFAULT: '#50E3C2', 67 | dark: '#29BC9B', 68 | }, 69 | highlight: { 70 | lighter: '#F81CE5', 71 | light: '#EB367F', 72 | DEFAULT: '#FF0080', 73 | dark: '#FFF500', 74 | }, 75 | }, 76 | }, 77 | }, 78 | } 79 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./.nuxt/tsconfig.json", 4 | "compilerOptions": { 5 | "target": "ES2022", 6 | "module": "ES2022", 7 | "experimentalDecorators": true, 8 | "emitDecoratorMetadata": true, 9 | "skipLibCheck": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------