├── cypress.json ├── store └── index.js ├── static ├── rick.gif ├── snail.png ├── trophy.png ├── favicon.ico └── purple-heart.png ├── plugins └── vercel.js ├── layouts ├── empty.vue └── default.vue ├── .prettierrc ├── pages ├── login │ └── index.vue ├── redirect.vue ├── cheeky.vue ├── s │ └── _slug.vue ├── index.vue ├── leaderboard │ └── index.vue └── mysnails │ └── index.vue ├── vercel.json ├── .editorconfig ├── .babelrc ├── .eslintrc.js ├── components ├── AppFooter.vue ├── AppBody.vue ├── LeaderboardCard.vue ├── SnailDisplay.vue ├── SnailCard.vue ├── SnailCreation.vue └── AppHeader.vue ├── utils ├── icons.js └── validators.ts ├── jest.config.js ├── tsconfig.json ├── api ├── owners │ └── index.ts ├── index.ts ├── jwtCheck │ └── index.ts ├── generateRandomSlug │ └── index.ts ├── rateLimiter │ └── index.ts └── snails │ └── index.ts ├── LICENSE ├── README.md ├── .gitignore ├── cypress └── integration │ └── main │ └── main.spec.js ├── .prettierignore ├── package.json └── nuxt.config.js /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /store/index.js: -------------------------------------------------------------------------------- 1 | // activate the store 2 | -------------------------------------------------------------------------------- /static/rick.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrozaalex/tiny_snails/HEAD/static/rick.gif -------------------------------------------------------------------------------- /static/snail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrozaalex/tiny_snails/HEAD/static/snail.png -------------------------------------------------------------------------------- /static/trophy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrozaalex/tiny_snails/HEAD/static/trophy.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrozaalex/tiny_snails/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/purple-heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrozaalex/tiny_snails/HEAD/static/purple-heart.png -------------------------------------------------------------------------------- /plugins/vercel.js: -------------------------------------------------------------------------------- 1 | import { inject } from '@vercel/analytics'; 2 | 3 | export default () => { 4 | inject(); 5 | }; 6 | -------------------------------------------------------------------------------- /layouts/empty.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "trailingComma": "none", 7 | "semi": true 8 | } 9 | -------------------------------------------------------------------------------- /pages/login/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "nuxt.config.js", 6 | "use": "@nuxtjs/vercel-builder", 7 | "config": { 8 | "serverFiles": ["api/**", "utils/**"] 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "targets": { 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pages/redirect.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | extends: [ 8 | '@nuxtjs/eslint-config-typescript', 9 | 'plugin:nuxt/recommended', 10 | 'prettier' 11 | ], 12 | plugins: [], 13 | // add your custom rules here 14 | rules: {} 15 | }; 16 | -------------------------------------------------------------------------------- /components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 17 | -------------------------------------------------------------------------------- /pages/cheeky.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /utils/icons.js: -------------------------------------------------------------------------------- 1 | export const extend = { 2 | github: { 3 | path: '', 4 | viewBox: '0 0 24 24' 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '^@/(.*)$': '/$1', 4 | '^~/(.*)$': '/$1', 5 | '^vue$': 'vue/dist/vue.common.js' 6 | }, 7 | moduleFileExtensions: ['ts', 'js', 'vue', 'json'], 8 | transform: { 9 | '^.+\\.ts$': 'ts-jest', 10 | '^.+\\.js$': 'babel-jest', 11 | '.*\\.(vue)$': 'vue-jest' 12 | }, 13 | collectCoverage: true, 14 | collectCoverageFrom: [ 15 | '/components/**/*.vue', 16 | '/pages/**/*.vue' 17 | ], 18 | testEnvironment: 'jsdom' 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["ESNext", "ESNext.AsyncIterable", "DOM"], 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "experimentalDecorators": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "~/*": ["./*"], 16 | "@/*": ["./*"] 17 | }, 18 | "types": ["@nuxt/types", "@types/node", "@nuxtjs/axios"] 19 | }, 20 | "exclude": ["node_modules", ".nuxt", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /pages/s/_slug.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 27 | -------------------------------------------------------------------------------- /api/owners/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import faunadb, { query as q } from 'faunadb'; 3 | import jwtCheck from '../jwtCheck'; 4 | 5 | const router = Router(); 6 | const faunaClient = new faunadb.Client({ 7 | secret: process.env.FAUNA_SECRET_KEY ?? '' 8 | }); 9 | 10 | // returns all of an owner's snails 11 | router.get('/', jwtCheck(), async (req: any, res) => { 12 | console.debug(req); 13 | console.debug(res); 14 | 15 | try { 16 | const docs: any = await faunaClient.query( 17 | q.Map( 18 | q.Paginate(q.Match(q.Index('alias_by_owner'), req.user.sub)), 19 | q.Lambda('alias', q.Get(q.Var('alias'))) 20 | ) 21 | ); 22 | res.status(200).send(docs.data); 23 | } catch (error) { 24 | console.error(error); 25 | res.status(400).send("Couldn't find your snails :("); 26 | } 27 | }); 28 | 29 | export default router; 30 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import morgan from 'morgan'; 3 | import helmet from 'helmet'; 4 | import rateLimiter from './rateLimiter'; 5 | 6 | // routers 7 | import snails from './snails'; 8 | import owners from './owners'; 9 | 10 | const app = express(); 11 | 12 | app.use(helmet()); 13 | app.use(express.json()); 14 | app.use(morgan('dev')); 15 | 16 | app.use(rateLimiter); 17 | 18 | app.use('/snails', snails); 19 | app.use('/owners', owners); 20 | 21 | app.use( 22 | ( 23 | err: { message: any; stack: any }, 24 | _req: any, 25 | res: { json: (arg0: { message: any; stack: any }) => void }, 26 | _next: any 27 | ) => { 28 | res.json({ 29 | message: err.message, 30 | stack: process.env.NODE_ENV === 'production' ? '🥞' : err.stack 31 | }); 32 | } 33 | ); 34 | 35 | export default { 36 | path: '/api', 37 | handler: app 38 | }; 39 | -------------------------------------------------------------------------------- /components/AppBody.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 45 | -------------------------------------------------------------------------------- /api/jwtCheck/index.ts: -------------------------------------------------------------------------------- 1 | import { expressjwt, GetVerificationKey } from 'express-jwt'; 2 | import { expressJwtSecret } from 'jwks-rsa'; 3 | 4 | /** 5 | * Returns a middleware function that will check the authorization header 6 | * The middleware will call next() if the authorization header is valid 7 | * If credentialsRequired is set to false, the middleware will call next(), 8 | * even if the authorization header is not present 9 | * */ 10 | const jwtCheck = (credentialsRequired = true) => 11 | expressjwt({ 12 | secret: expressJwtSecret({ 13 | cache: true, 14 | rateLimit: true, 15 | jwksRequestsPerMinute: 5, 16 | jwksUri: 'https://dev-vz68qmuc.us.auth0.com/.well-known/jwks.json' 17 | }) as GetVerificationKey, 18 | audience: 'https://tny-snls.xyz/api/', 19 | issuer: 'https://dev-vz68qmuc.us.auth0.com/', 20 | algorithms: ['RS256'], 21 | credentialsRequired 22 | }); 23 | 24 | export default jwtCheck; 25 | -------------------------------------------------------------------------------- /api/generateRandomSlug/index.ts: -------------------------------------------------------------------------------- 1 | import faunadb, { query as q } from 'faunadb'; 2 | 3 | const faunaClient = new faunadb.Client({ 4 | secret: process.env.FAUNA_SECRET_KEY ?? '' 5 | }); 6 | 7 | const slugExists = async (s: string) => 8 | await faunaClient.query(q.Exists(q.Match(q.Index('aliases'), s))); 9 | 10 | const generateRandomSlug = async (tries: number = 0): Promise => { 11 | const chars = process.env.SLUG_CHARS ?? 'abcdefghijklmnopqrstuvwxyz'; 12 | const length = process.env.SLUG_LENGTH ?? 6; 13 | const maxTries = process.env.SLUG_MAX_TRIES ?? 5; 14 | 15 | const slug = [...Array(length).keys()] 16 | .map(() => chars[Math.floor(Math.random() * chars.length)]) 17 | .join(''); 18 | 19 | const exists = await slugExists(slug); 20 | 21 | if (!exists) return slug; 22 | else if (tries < maxTries) return generateRandomSlug(tries + 1); 23 | 24 | throw new Error('Could not generate unique slug'); 25 | }; 26 | 27 | export default generateRandomSlug; 28 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 15 | 35 | 36 | 50 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | 24 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexandre Pedroza 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /utils/validators.ts: -------------------------------------------------------------------------------- 1 | import { string } from 'yup'; 2 | 3 | export default function containsBaseUrl(url?: string | null) { 4 | return ( 5 | /tny-snls.xyz\/s\/\w+/.test(url ?? '') || 6 | /tny-snls.xyz\/s\/\w+/.test(decodeURI(url ?? '')) 7 | ); 8 | } 9 | 10 | const containsSlur = (alias?: string | null) => { 11 | return /(fag(g|got|tard)?\b|cocks?sucker(s|ing)?|ni((g{2,}|q)+|[gq]{2,})[e3r]+(s|z)?|mudslime?s?|kikes?|\bspi(c|k)s?\b|\bchinks?|gooks?|bitch(es|ing|y)?|whor(es?|ing)|\btr(a|@)nn?(y|ies?)|\b(b|re|r)tard(ed)?s?)/i.test(alias ?? ''); 12 | }; 13 | 14 | export const urlValidator = string() 15 | .trim() 16 | .url() 17 | .required() 18 | .nullable() 19 | .test( 20 | 'base url', 21 | "you can't create a recursive url, you cheeky bastard", 22 | (url) => !containsBaseUrl(url) 23 | ); 24 | 25 | export const snailOwnerValidator = string().trim().nullable(); 26 | 27 | export const aliasValidator = string() 28 | .trim() 29 | .matches( 30 | /^[\w-]+$/i, 31 | 'slug can only contain letters, numbers, hyphens (-) and underscores (_)' 32 | ) 33 | .nullable() 34 | .min(3) 35 | .max(20) 36 | .test('slur', "don't use slurs", (alias) => !containsSlur(alias)); 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | tiny snails tiny snails 6 |

7 | 8 |

9 | generate links with tiny slugs for your links! 10 |

11 | 12 |

13 | 14 | Website 15 | 16 |

17 | 18 | ## Overview 19 | 20 | - **Unique URLs.** Use a custom shortened URL for your URL, or use a generated one. 21 | - **Leaderboard.** View the top links clicked on. 22 | - **Accounts.** Sign in and have all of your URLs in one place. 23 | - **Dark mode.** Create and manage your shortened URLs in the dark. 24 | 25 | ## Build setup 26 | 27 | [Node.js](https://nodejs.org/) and the [Yarn package manager](https://yarnpkg.com/) are required. 28 | 29 | ```sh 30 | # install dependencies 31 | yarn 32 | 33 | # server with hot reload on port 3000 34 | yarn dev 35 | 36 | # build for production and launch the server 37 | yarn build 38 | yarn start 39 | ``` 40 | 41 | The final product is also hosted temporarily [here](https://tny-snls.xyz/). 42 | 43 | ## To-do 44 | 45 | - Test components separately (unit tests) 46 | - Write more end-to-end tests 47 | 48 | Right now the basic functionality of creating shortened urls is tested. Other functionalities like login and leaderboard couldn't be tested in time. 49 | -------------------------------------------------------------------------------- /api/rateLimiter/index.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import LRU from 'lru-cache'; 3 | 4 | const tokenCache = new LRU({ 5 | max: parseInt(process.env.UNIQUE_TOKEN_PER_INTERVAL ?? '') || 500, 6 | maxAge: parseInt(process.env.RATE_LIMIT_INTERVAL ?? '') || 60 * 1000 7 | }); 8 | 9 | const rateLimit = () => ({ 10 | check: (res: Response, limit: number, token: string) => 11 | new Promise((resolve, reject) => { 12 | console.log('check', token); 13 | const tokenCount = (tokenCache.get(token) || [0]) as number[]; 14 | if (tokenCount[0] === 0) { 15 | tokenCache.set(token, tokenCount); 16 | console.log('token set'); 17 | } 18 | tokenCount[0] += 1; 19 | 20 | const currentUsage = tokenCount[0]; 21 | const isRateLimited = currentUsage >= limit; 22 | res.setHeader('X-RateLimit-Limit', limit); 23 | res.setHeader( 24 | 'X-RateLimit-Remaining', 25 | isRateLimited ? 0 : limit - currentUsage 26 | ); 27 | 28 | return isRateLimited ? reject() : resolve(); 29 | }) 30 | }); 31 | 32 | export default async function rateLimiter( 33 | req: Request, 34 | res: Response, 35 | next: NextFunction 36 | ) { 37 | try { 38 | await rateLimit().check( 39 | res, 40 | parseInt(process.env.RATE_LIMIT_REQUESTS_PER_INTERVAL ?? '') || 10, 41 | req.ip 42 | ); 43 | next(); 44 | } catch { 45 | return res.status(429).json({ error: 'Too many requests' }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | -------------------------------------------------------------------------------- /cypress/integration/main/main.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('home page', () => { 4 | beforeEach(() => { 5 | // Cypress starts out with a blank slate for each test 6 | // so we must tell it to visit our website with the `cy.visit()` command. 7 | // Since we want to visit the same URL at the start of all our tests, 8 | // we include it in our beforeEach function so that it runs before each test 9 | cy.visit('http://localhost:3000'); 10 | }); 11 | 12 | it('displays two inputs by default', () => { 13 | // the two inputs for the user to enter the url and slug 14 | cy.get('input').should('have.length', 2); 15 | 16 | cy.get('input') 17 | .first() 18 | .invoke('attr', 'placeholder') 19 | .should('contain', 'URL'); 20 | // slug should start empty 21 | cy.get('input').last().should('have.value', ''); 22 | }); 23 | 24 | describe('can add new snails', () => { 25 | it('without provided slug', () => { 26 | const url = 'https://www.google.com'; 27 | 28 | cy.get('input').first().type(url); 29 | 30 | cy.get('button').contains('shorten it').click(); 31 | 32 | cy.contains('success'); 33 | }); 34 | it('with provided slug', () => { 35 | const url = 'https://www.google.com'; 36 | // generate random string of length 20 37 | const slug = Math.random().toString(36).substring(2, 22); 38 | 39 | cy.get('input').first().type(url); 40 | cy.get('input').last().type(slug); 41 | 42 | cy.get('button').contains('shorten it').click(); 43 | 44 | cy.contains('success'); 45 | cy.contains(slug); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiny_snails", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxt --hostname 0.0.0.0", 7 | "build": "nuxt build", 8 | "start": "nuxt start", 9 | "generate": "nuxt generate", 10 | "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .", 11 | "lint": "yarn lint:js", 12 | "test": "jest", 13 | "e2e": "cypress open" 14 | }, 15 | "dependencies": { 16 | "@chakra-ui/nuxt": "^0.5.0", 17 | "@nuxtjs/auth-next": "5.0.0-1643791578.532b3d6", 18 | "@nuxtjs/axios": "^5.13.6", 19 | "@nuxtjs/emotion": "^0.1.0", 20 | "@vercel/analytics": "^0.1.5", 21 | "cookie-parser": "^1.4.6", 22 | "core-js": "^3.22.0", 23 | "express": "^4.18.0", 24 | "express-jwt": "^7.7.7", 25 | "faunadb": "^4.5.4", 26 | "helmet": "^5.0.2", 27 | "jwks-rsa": "^3.0.0", 28 | "morgan": "^1.10.0", 29 | "npm-check-updates": "^12.5.7", 30 | "nuxt": "^2.15.8", 31 | "vue-cookies": "^1.7.5", 32 | "yup": "^0.32.11" 33 | }, 34 | "devDependencies": { 35 | "@babel/eslint-parser": "^7.17.0", 36 | "@nuxt/types": "^2.15.8", 37 | "@nuxt/typescript-build": "^2.1.0", 38 | "@nuxtjs/eslint-config-typescript": "^8.0.0", 39 | "@nuxtjs/eslint-module": "^3.0.2", 40 | "@nuxtjs/google-fonts": "^1.3.0", 41 | "@types/cookie-parser": "^1.4.3", 42 | "@types/lru-cache": "^5.1.1", 43 | "@types/morgan": "^1.9.3", 44 | "@vue/test-utils": "^2.0.0-rc.17", 45 | "babel-core": "7.0.0-bridge.0", 46 | "babel-jest": "^27.5.1", 47 | "cypress": "^9.5.0", 48 | "eslint": "^8.9.0", 49 | "eslint-config-prettier": "^8.4.0", 50 | "eslint-plugin-nuxt": "^3.1.0", 51 | "eslint-plugin-vue": "^8.4.1", 52 | "jest": "^27.5.1", 53 | "prettier": "^2.5.1", 54 | "ts-jest": "^27.1.3", 55 | "vue-jest": "^3.0.7" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /components/LeaderboardCard.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 35 | 36 | 94 | > 95 | -------------------------------------------------------------------------------- /components/SnailDisplay.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 74 | -------------------------------------------------------------------------------- /components/SnailCard.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 59 | 60 | 92 | > 93 | -------------------------------------------------------------------------------- /pages/leaderboard/index.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 60 | 61 | 97 | -------------------------------------------------------------------------------- /pages/mysnails/index.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 67 | 68 | 91 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | import { extend } from './utils/icons'; 2 | 3 | export default { 4 | target: 'static', 5 | ssr: false, 6 | 7 | server: { 8 | host: '0' 9 | }, 10 | 11 | generate: { 12 | fallback: 'redirect.html' 13 | }, 14 | 15 | serverMiddleware: { 16 | '/api': '~/api' 17 | }, 18 | // Global page headers: https://go.nuxtjs.dev/config-head 19 | head: { 20 | title: 'tiny snails', 21 | htmlAttrs: { 22 | lang: 'en' 23 | }, 24 | meta: [ 25 | { charset: 'utf-8' }, 26 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 27 | { hid: 'description', name: 'description', content: '' }, 28 | { name: 'format-detection', content: 'telephone=no' } 29 | ], 30 | link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }] 31 | }, 32 | 33 | // Global CSS: https://go.nuxtjs.dev/config-css 34 | css: [], 35 | 36 | // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins 37 | plugins: [{ src: '~/plugins/vercel.js', mode: 'client' }], 38 | 39 | // Auto import components: https://go.nuxtjs.dev/config-components 40 | components: true, 41 | 42 | // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules 43 | buildModules: [ 44 | // https://go.nuxtjs.dev/typescript 45 | '@nuxt/typescript-build', 46 | [ 47 | '@nuxtjs/google-fonts', 48 | { 49 | families: { 50 | Poppins: [400, 600, 700], 51 | 'Roboto Condensed': [400] 52 | }, 53 | display: 'swap' 54 | } 55 | ] 56 | ], 57 | 58 | // Modules: https://go.nuxtjs.dev/config-modules 59 | modules: [ 60 | '@chakra-ui/nuxt', 61 | '@nuxtjs/emotion', 62 | '@nuxtjs/axios', 63 | '@nuxtjs/auth-next' 64 | ], 65 | 66 | auth: { 67 | redirect: { 68 | login: '/', 69 | logout: '/' 70 | }, 71 | strategies: { 72 | auth0: { 73 | domain: 'dev-vz68qmuc.us.auth0.com', 74 | clientId: '1PnxykiF5ILPFMoMdFLkCJhN5gGmXT4g', 75 | audience: 'https://tny-snls.xyz/api/' 76 | } 77 | } 78 | }, 79 | 80 | publicRuntimeConfig: { 81 | baseURL: process.env.BASE_URL, 82 | slugChars: 'abcdefghijklmnopqrstuvwxyz', 83 | slugLength: 4 84 | }, 85 | 86 | // Build Configuration: https://go.nuxtjs.dev/config-build 87 | build: {}, 88 | 89 | axios: { 90 | baseURL: process.env.BASE_URL 91 | }, 92 | 93 | chakra: { 94 | icons: { 95 | extend 96 | }, 97 | extendTheme: { 98 | fonts: { 99 | heading: 'Poppins', 100 | body: 'Poppins' 101 | } 102 | } 103 | } 104 | }; 105 | -------------------------------------------------------------------------------- /api/snails/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import faunadb, { query as q } from 'faunadb'; 3 | import { object } from 'yup'; 4 | import generateRandomSlug from '../generateRandomSlug'; 5 | import jwtCheck from '../jwtCheck'; 6 | import { 7 | aliasValidator, 8 | snailOwnerValidator, 9 | urlValidator 10 | } from '../../utils/validators'; 11 | 12 | const router = Router(); 13 | const faunaClient = new faunadb.Client({ 14 | secret: process.env.FAUNA_SECRET_KEY ?? '' 15 | }); 16 | 17 | type topAliasesIndexResponse = { data: [number, string, string][] }; 18 | 19 | // return 50 most popular urls for leaderboard 20 | router.get('/', async (_req, res, _next) => { 21 | try { 22 | const query = await faunaClient.query( 23 | q.Paginate(q.Match(q.Index('top_aliases_by_clicks_new')), { size: 50 }) 24 | ); 25 | const result = query.data.map((item) => { 26 | return { 27 | clicks: item[0], 28 | alias: item[1], 29 | url: item[2] 30 | }; 31 | }); 32 | res.json(result); 33 | } catch (error) { 34 | res.json(error); 35 | } 36 | }); 37 | 38 | const schema = object().shape({ 39 | owner: snailOwnerValidator, 40 | url: urlValidator, 41 | alias: aliasValidator 42 | }); 43 | 44 | // create a new shortened url 45 | router.post('/', jwtCheck(false), async (req: any, res, _next) => { 46 | const { url: urlFromRequest, slug: aliasFromRequest } = req.body; 47 | const owner = req.user?.sub ?? ''; 48 | 49 | const url = decodeURI(urlFromRequest); 50 | const alias = aliasFromRequest ?? (await generateRandomSlug()); 51 | 52 | try { 53 | await schema.validate({ owner, url, alias }); 54 | } catch (error) { 55 | return res.status(400).json({ error: 'invalid data' }); 56 | } 57 | 58 | // TODO auto generate sdk from fauna so we don't have to do "any" 59 | try { 60 | const { data }: any = await faunaClient.query( 61 | q.Create(q.Collection('snails'), { 62 | data: { 63 | url, 64 | alias, 65 | owner, 66 | clicks: 0 67 | } 68 | }) 69 | ); 70 | res.json(data); 71 | } catch (error: any) { 72 | if (error.requestResult?.statusCode === 400) 73 | return res.status(400).json({ error: 'alias already exists' }); 74 | 75 | return res.status(500).json({ error }); 76 | } 77 | }); 78 | 79 | // get the url for a given alias 80 | router.get('/:alias', async (req, res, _next) => { 81 | const alias = req.params.alias; 82 | const doc: any = await faunaClient.query( 83 | q.Get(q.Match(q.Index('aliases'), alias)) 84 | ); 85 | 86 | if (!doc.data) return res.status(404).json({ error: 'not found' }); 87 | 88 | faunaClient.query( 89 | q.Update(q.Ref(doc.ref), { data: { clicks: doc.data.clicks + 1 } }) 90 | ); 91 | 92 | res.json(doc.data); 93 | }); 94 | 95 | // destroy a shortened url 96 | router.delete('/:alias', jwtCheck(), async (req: any, res, _next) => { 97 | const alias = req.params.alias; 98 | const doc: any = await faunaClient.query( 99 | q.Get(q.Match(q.Index('aliases'), alias)) 100 | ); 101 | 102 | if (doc.data.owner === req.user.sub) { 103 | await faunaClient.query(q.Delete(q.Ref(doc.ref))); 104 | 105 | res.json({ success: true }); 106 | } else { 107 | res.status(403).json({ success: false, error: 'not authorized' }); 108 | } 109 | }); 110 | 111 | export default router; 112 | -------------------------------------------------------------------------------- /components/SnailCreation.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 134 | 135 | 178 | 198 | --------------------------------------------------------------------------------