=> {
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
35 |
36 |
50 |
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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
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 |
2 |
3 | {{ rank }}
4 |
5 | {{ snail.alias }}
6 |
7 |
8 | {{ removeUrlProtocol(snail.url) }}
9 |
10 |
11 | {{ snail.clicks }}
12 |
13 |
14 |
15 |
16 |
35 |
36 |
94 | >
95 |
--------------------------------------------------------------------------------
/components/SnailDisplay.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | success!
4 |
5 | your shortened url:
6 |
7 |
18 | {{ $config.baseURL }}/s/{{ snail.alias }}
19 | 📋
20 |
21 | create another one
22 |
23 |
24 |
25 |
74 |
--------------------------------------------------------------------------------
/components/SnailCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{
5 | snail.alias
6 | }}
7 |
8 |
9 | {{ removeUrlProtocol(snail.url) }}
10 |
11 |
12 | {{ snail.clicks }}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
59 |
60 |
92 | >
93 |
--------------------------------------------------------------------------------
/pages/leaderboard/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | top snails
6 |
7 |
8 |
9 |
20 |
21 |
22 |
23 |
24 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
60 |
61 |
97 |
--------------------------------------------------------------------------------
/pages/mysnails/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | An error occurred when trying to access your snails :(
5 | {{ error }}
6 |
7 |
8 |
9 | You have no snails :(
10 |
11 |
12 |
13 | your snails:
14 |
15 |
16 |
17 |
23 |
24 |
25 |
26 |
27 |
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 |
2 |
3 |
10 | your url:
11 |
21 |
22 | shortened url:
23 |
24 |
25 | {{ $config.baseURL }}/s/
26 |
27 |
38 |
39 |
40 | leave it blank to get a random one
41 |
42 |
43 | {{ request.error }}
44 |
45 |
46 |
53 | shorten it!
54 |
55 |
56 |
57 |
58 |
59 |
132 |
133 |
134 |
--------------------------------------------------------------------------------
/components/AppHeader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | tiny snails
6 |
7 |
8 |
9 |
10 |
11 |
17 |
18 |
19 |
20 |
21 | leaderboard
23 |
24 |
25 |
26 |
32 | sign in
33 |
34 |
35 |
36 |
42 | my snails
43 |
44 |
45 | sign out
46 |
47 |
48 |
49 |
56 |
57 |
58 |
59 |
65 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
86 |
87 |
88 | leaderboard
90 |
91 |
92 |
93 |
99 | sign in
100 |
101 |
102 |
103 |
109 | my snails
110 |
111 |
116 | sign out
117 |
118 |
119 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
178 |
198 |
--------------------------------------------------------------------------------