├── .firebaserc ├── .github ├── renovate.json ├── screenshot.png ├── screenshot2.png └── workflows │ ├── codeql-analysis.yml │ ├── deploy_functions.yml │ └── deploy_hosting.yml ├── .gitignore ├── LICENSE ├── README.md ├── bors.toml ├── env.default.sh ├── firebase.json ├── functions ├── .eslintrc.json ├── .gitignore ├── index.js ├── package-lock.json ├── package.json └── yarn.lock ├── hosting ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── gatsby-browser.js ├── gatsby-config.js ├── gatsby-node.js ├── gatsby-ssr.js ├── package-lock.json ├── package.json ├── src │ ├── components │ │ ├── footer.js │ │ ├── layout.js │ │ ├── seo.js │ │ └── shorten-result.js │ ├── images │ │ └── gatsby-icon.png │ ├── pages │ │ ├── 404.js │ │ └── index.js │ └── utils │ │ └── gtag.js └── yarn.lock └── package-lock.json /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "duyet-url" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true 6 | } 7 | -------------------------------------------------------------------------------- /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duyet/firebase-shorten-url/57a96b8dc2e6b02af99955615f6e4bf7cd27c13e/.github/screenshot.png -------------------------------------------------------------------------------- /.github/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duyet/firebase-shorten-url/57a96b8dc2e6b02af99955615f6e4bf7cd27c13e/.github/screenshot2.png -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 17 * * 3' 11 | 12 | jobs: 13 | analyse: 14 | name: Analyse 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v3 33 | # Override language selection by uncommenting this and choosing your languages 34 | # with: 35 | # languages: go, javascript, csharp, python, cpp, java 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v3 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v3 55 | -------------------------------------------------------------------------------- /.github/workflows/deploy_functions.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Functions 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'functions/**' 9 | - '.github/**' 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Use Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 16.x 19 | - name: Run Eslint 20 | run: | 21 | cd functions 22 | yarn 23 | yarn lint 24 | - name: Deploy functions 25 | run: | 26 | cd functions 27 | yarn 28 | export FIREBASE_TOKEN=${{ secrets.FIREBASE_TOKEN }} 29 | yarn deploy 30 | -------------------------------------------------------------------------------- /.github/workflows/deploy_hosting.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Hostings 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'hosting/**' 9 | - '.github/**' 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 16.x 20 | - name: Run Eslint 21 | run: | 22 | cd hosting 23 | yarn 24 | yarn lint 25 | - name: Deploy hosting 26 | run: | 27 | cd hosting 28 | yarn 29 | yarn build 30 | export FIREBASE_TOKEN=${{ secrets.FIREBASE_TOKEN }} 31 | yarn deploy 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # local file 64 | *.local.* 65 | 66 | .runtimeconfig.json 67 | .firebase -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Van-Duyet Le 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase Shorten URL 2 | This is a demonstrate how to use the Firebase Dynamic Link API to build the shorten URL app. 3 | 4 | Target: 5 | - Simple 6 | - Fast 7 | - Fast 😎 8 | 9 | Live: https://s.duyet.net 10 | 11 |  12 | 13 |  14 | 15 | 16 | # Locally development 17 | 18 | 1. Set up Node.js and the Firebase CLI 19 | ``` 20 | yarn global add firebase-tools 21 | ``` 22 | 23 | To initialize project: Run `firebase login` to log in via the browser and authenticate the firebase tool. 24 | 25 | 26 | 2. Setup packages 27 | ``` 28 | cd functions/ && yarn 29 | cd hosting/ && yarn 30 | ``` 31 | 32 | 3. Go to https://console.firebase.google.com and create new project. 33 | 34 | 4. Setup env variables, copy and modify `env.default.sh` to `env.local.sh` 35 | 36 | Get the `api_key` by **Project settings > General > Web API Key** 37 | 38 | Get the `domain_uri_prefix` by **Dynamic Links**, then **Add URL prefix** 39 | 40 | ``` 41 | firebase functions:config:set config.api_key=AIzaSyC8mZm********** 42 | firebase functions:config:set config.domain_uri_prefix=duyet.page.link 43 | ``` 44 | Run: `bash ./env.local.sh` 45 | 46 | 5. Update the frontend config: `hosting/gatsby-config.js`: 47 | 48 | ```js 49 | module.exports = { 50 | siteMetadata: { 51 | title: 'duyet shorten url', 52 | description: 'Shorten URL by Firebase Dynamic Link', 53 | author: '@duyet', 54 | }, 55 | ... 56 | ``` 57 | 58 | 6. Running in local: https://firebase.google.com/docs/functions/local-emulator 59 | 60 | - Export local configs: `firebase functions:config:get > functions/.runtimeconfig.json` 61 | - Start firebase function: `firebase serve` 62 | - Start hosting local in another terminal: `cd hosting && yarn develop` 63 | - Open the UI in browser: http://localhost:8000 64 | 65 | # Deploy 66 | 67 | Deploy serverless functions and hosting to Firebase 68 | 69 | ``` 70 | firebase deploy 71 | ``` 72 | 73 | 74 | # Licence 75 | MIT 76 | -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | status = ["Analyse"] 2 | -------------------------------------------------------------------------------- /env.default.sh: -------------------------------------------------------------------------------- 1 | firebase functions:config:set config.api_key= 2 | firebase functions:config:set config.domain_uri_prefix= -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "predeploy": [ 4 | "npm --prefix \"$RESOURCE_DIR\" run lint" 5 | ], 6 | "source": "functions" 7 | }, 8 | "hosting": { 9 | "cleanUrls": true, 10 | "trailingSlash": false, 11 | "public": "hosting/public", 12 | "ignore": [ 13 | "firebase.json", 14 | "**/.*", 15 | "**/node_modules/**" 16 | ], 17 | "rewrites": [ 18 | { "source": "/api/add", "function": "addUrl" }, 19 | { "source": "/api/analytics", "function": "analytics" }, 20 | { "source": "/r/**", "dynamicLinks": true }, 21 | { 22 | "source": "**", 23 | "destination": "/index.html" 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /functions/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2017, 5 | "sourceType": "module", 6 | "requireConfigFile": false 7 | }, 8 | "env": { 9 | "es6": true, 10 | "node": true 11 | }, 12 | "extends": "eslint:recommended", 13 | "rules": { 14 | "camelcase": [ 15 | 2, 16 | { 17 | "properties": "never" 18 | } 19 | ], 20 | "no-console": 0 21 | } 22 | } -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | // The Cloud Functions for Firebase SDK to create Cloud Functions and setup triggers. 4 | const functions = require('firebase-functions'); 5 | 6 | // The Firebase Admin SDK to access the Firebase Realtime Database. 7 | const admin = require('firebase-admin'); 8 | const app = admin.initializeApp(); 9 | const db = admin.firestore() 10 | db.settings({ timestampsInSnapshots: true }) 11 | 12 | const config = functions.config().config 13 | const apiKey = process.env.API_KEY || config.api_key || app.options_.apiKey 14 | const firebaseDynamicLinkApi = `https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=${apiKey}`; 15 | const domainUriPrefix = config.domain_uri_prefix || 'https://duyeturl.page.link'; // TODO: move this `domainUriPrefix` to config 16 | const URL_COLLECTION = config.url_collection || 'urls'; 17 | 18 | exports.addUrl = functions.https.onRequest(async (req, res) => { 19 | const link = req.query.url || req.body.url || null; 20 | console.log(JSON.stringify(req.headers, null, 4)) 21 | 22 | try { 23 | console.log(`Getting shorten URL for: ${link}`); 24 | let result = await axios.post(firebaseDynamicLinkApi, { 25 | dynamicLinkInfo: { 26 | domainUriPrefix, 27 | link, 28 | }, 29 | suffix: { 30 | option: 'SHORT' 31 | } 32 | }) 33 | 34 | // Store the result to Firestore 35 | try { 36 | const data = Object.assign(result.data, { 37 | clientInformation: req.headers, 38 | originalUrl: link, 39 | created: new Date() 40 | }) 41 | const shortenKey = data.shortLink.split('/').slice(-1).pop() 42 | const docRef = await db.collection(URL_COLLECTION).doc(shortenKey).set(data); 43 | console.log("Document written with ID: ", docRef.id); 44 | } catch (error) { 45 | console.error("Error adding document: ", error); 46 | } 47 | 48 | res.json(result.data); 49 | } catch (e) { 50 | console.error(e.message); 51 | res.status(500).json('error'); 52 | } 53 | }); 54 | 55 | 56 | exports.analytics = functions.https.onRequest(async (req, res) => { 57 | const shortDynamicLink = req.query.shortLink || req.body.shortLink || null; 58 | const durationDays = req.query.durationDays || req.body.durationDays || 30; 59 | const requestUrl = `https://firebasedynamiclinks.googleapis.com/v1/${shortDynamicLink}/linkStats?durationDays=${durationDays}&key=${apiKey}`; 60 | 61 | try { 62 | console.log(`Getting statistics for: ${shortDynamicLink}`); 63 | let result = await axios.get(requestUrl) 64 | res.json(result.data); 65 | } catch (e) { 66 | console.error(e.message); 67 | res.status(500).json('error'); 68 | } 69 | }) -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "duyet-shortener-api", 3 | "description": "Backend API for duyet-shortener", 4 | "scripts": { 5 | "lint": "eslint .", 6 | "serve": "firebase serve --only functions", 7 | "shell": "firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions --token \"$FIREBASE_TOKEN\"", 10 | "logs": "firebase functions:log" 11 | }, 12 | "dependencies": { 13 | "axios": "1.8.3", 14 | "firebase-admin": "13.0.1", 15 | "firebase-functions": "6.1.1" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "7.25.8", 19 | "@babel/eslint-parser": "7.25.8", 20 | "@babel/preset-react": "7.25.7", 21 | "eslint": "8.57.1", 22 | "eslint-plugin-promise": "7.2.0", 23 | "firebase-tools": "13.22.0" 24 | }, 25 | "private": true, 26 | "engines": { 27 | "node": "22" 28 | }, 29 | "babel": { 30 | "presets": [ 31 | "@babel/preset-react" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /hosting/.eslintignore: -------------------------------------------------------------------------------- 1 | /flow-typed/* 2 | /public/* 3 | /node_modules/* 4 | firebase-messaging-sw.js 5 | gatsby-*.js -------------------------------------------------------------------------------- /hosting/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "parserOptions": { 4 | "requireConfigFile": false 5 | }, 6 | "extends": "airbnb-base", 7 | "env": { 8 | "browser": true, 9 | "node": true, 10 | "es6": true 11 | }, 12 | "plugins": [ 13 | "react", 14 | "jest", 15 | "jsx-a11y", 16 | "import" 17 | ], 18 | "globals": { 19 | "graphql": true 20 | }, 21 | "rules": { 22 | "comma-dangle": 0, 23 | "import/imports-first": 0, 24 | "global-require": 0, 25 | "class-methods-use-this": 0, 26 | "arrow-body-style": [ 27 | 2, 28 | "as-needed" 29 | ], 30 | "arrow-parens": [ 31 | "error", 32 | "always" 33 | ], 34 | "import/no-extraneous-dependencies": [ 35 | "error", 36 | { 37 | "devDependencies": true 38 | } 39 | ], 40 | "no-debugger": 0, 41 | "dot-notation": 0, 42 | "no-console": 0, 43 | "new-cap": 0, 44 | "strict": 0, 45 | "no-param-reassign": [ 46 | "error", 47 | { 48 | "props": false 49 | } 50 | ], 51 | "no-underscore-dangle": 0, 52 | "no-use-before-define": 0, 53 | "eol-last": 0, 54 | "no-shadow": 0, 55 | "quotes": [ 56 | 2, 57 | "single" 58 | ], 59 | "jsx-quotes": [ 60 | 0, 61 | "prefer-single" 62 | ], 63 | "react/jsx-no-undef": 1, 64 | "react/jsx-uses-react": 1, 65 | "react/jsx-uses-vars": 1, 66 | "max-classes-per-file": 0 67 | } 68 | } -------------------------------------------------------------------------------- /hosting/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variables file 55 | .env 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | -------------------------------------------------------------------------------- /hosting/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /hosting/README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |
You just hit a route that doesn't exist... the sadness.
11 |