├── .eslintrc.cjs ├── .github └── workflows │ ├── dockerhub.yml │ └── images.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── Dockerfile.api ├── Dockerfile.api.dockerignore ├── Dockerfile.app ├── Dockerfile.app.dockerignore ├── LICENSE.md ├── README.md ├── api ├── .env.example ├── README.md ├── config.example.yml ├── docs │ ├── docker.md │ ├── kubernetes.md │ └── manual.md ├── http │ ├── auth │ │ ├── login.http │ │ └── logout.http │ ├── code.http │ ├── codes │ │ ├── delete.http │ │ ├── list.http │ │ ├── make.http │ │ └── patch.http │ └── config │ │ └── patch.http ├── nodemon.json ├── notes │ └── database.md ├── package-lock.json ├── package.json ├── src │ ├── app.ts │ ├── config │ │ ├── index.ts │ │ └── interface.ts │ ├── database │ │ ├── config.ts │ │ ├── houseKeeping │ │ │ ├── createSearchIndex │ │ │ │ └── index.ts │ │ │ └── reflectSortedList │ │ │ │ └── index.ts │ │ └── index.ts │ ├── houseKeeping.ts │ ├── index.ts │ ├── logger.ts │ └── server │ │ ├── cors.ts │ │ ├── index.ts │ │ ├── plugins │ │ └── auth.ts │ │ ├── routes.ts │ │ └── routes │ │ ├── auth │ │ ├── login │ │ │ └── index.ts │ │ └── logout │ │ │ └── index.ts │ │ ├── code │ │ └── index.ts │ │ ├── codes │ │ ├── delete │ │ │ └── index.ts │ │ ├── list │ │ │ └── index.ts │ │ └── make │ │ │ └── index.ts │ │ └── config │ │ ├── index.ts │ │ └── validate.ts └── tsconfig.json ├── app ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── App.tsx │ ├── components │ │ ├── CodeCard │ │ │ ├── CodeCard.tsx │ │ │ └── index.ts │ │ ├── CodeModal │ │ │ ├── CodeModal.tsx │ │ │ └── index.ts │ │ ├── Sidebar │ │ │ └── Sidebar.tsx │ │ └── Topbar │ │ │ └── Topbar.tsx │ ├── index.css │ ├── index.html │ ├── index.tsx │ ├── nprogress.css │ ├── pages │ │ ├── Dash │ │ │ ├── Content.tsx │ │ │ ├── Dash.tsx │ │ │ └── codes.ts │ │ └── Login │ │ │ ├── Login.tsx │ │ │ └── index.ts │ ├── public │ │ ├── cover.png │ │ ├── icon.png │ │ ├── icon.svg │ │ └── siteicon.svg │ ├── store │ │ ├── auth.ts │ │ ├── codes.ts │ │ └── index.ts │ └── util │ │ ├── hotkeys.ts │ │ ├── logout.ts │ │ ├── scrolling.ts │ │ └── searchAPI.ts ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.ts ├── docker-compose.yml ├── docs ├── README.md ├── md │ ├── README.md │ ├── api │ │ ├── README.md │ │ └── docs │ │ │ ├── docker.md │ │ │ ├── kubernetes.md │ │ │ └── manual.md │ ├── app │ │ └── README.md │ └── docs │ │ └── README.md ├── media │ ├── cover.png │ ├── logo_dark.svg │ └── logo_light.svg ├── notes │ └── todo.md ├── package-lock.json ├── package.json ├── src │ ├── build.ts │ ├── dev.ts │ ├── helpers │ │ ├── alpa.ts │ │ ├── api.ts │ │ ├── generic.ts │ │ ├── index.ts │ │ ├── projects.ts │ │ └── twitter.ts │ ├── layout.ts │ ├── logger.ts │ └── md.ts └── tsconfig.json ├── lerna.json ├── package-lock.json ├── package.json ├── prettier.config.cjs ├── tsconfig.base.json └── tsconfig.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * ESLint run control for alpa project. 3 | * Created On 26 April 2022 4 | */ 5 | 6 | module.exports = { 7 | plugins: ['prettier', 'simple-import-sort', 'import'], 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:prettier/recommended', 11 | 'plugin:react/recommended', 12 | 'plugin:react/jsx-runtime', 13 | ], 14 | env: { 15 | es2021: true, 16 | node: true, 17 | }, 18 | parserOptions: { 19 | ecmaVersion: 12, 20 | sourceType: 'module', 21 | ecmaFeatures: { 22 | jsx: true, 23 | }, 24 | }, 25 | settings: { 26 | 'import/extensions': ['.js'], 27 | react: { 28 | version: '17', 29 | }, 30 | }, 31 | ignorePatterns: ['**/dist'], 32 | overrides: [ 33 | { 34 | files: ['*.ts', '*.tsx'], 35 | parser: '@typescript-eslint/parser', 36 | plugins: [ 37 | 'prettier', 38 | 'simple-import-sort', 39 | '@typescript-eslint', 40 | 'import', 41 | ], 42 | extends: [ 43 | 'eslint:recommended', 44 | 'plugin:prettier/recommended', 45 | 'plugin:@typescript-eslint/recommended', 46 | 'plugin:react/recommended', 47 | 'plugin:react/jsx-runtime', 48 | ], 49 | rules: { 50 | '@typescript-eslint/no-explicit-any': 'off', 51 | }, 52 | }, 53 | ], 54 | rules: { 55 | 'linebreak-style': ['error', 'unix'], 56 | quotes: ['off', 'single'], 57 | semi: ['error', 'never'], 58 | 'prettier/prettier': 'error', 59 | 'simple-import-sort/imports': 'error', 60 | 'sort-imports': 'off', 61 | 'import/order': 'off', 62 | 'react/jsx-uses-react': 'error', 63 | 'react/jsx-uses-vars': 'error', 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: DockerHub README 2 | on: 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | sync: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Cloning project 12 | uses: actions/checkout@master 13 | 14 | - name: "@alpa/api" 15 | uses: ms-jpq/sync-dockerhub-readme@v1 16 | with: 17 | username: ${{ secrets.DOCKER_USERNAME }} 18 | password: ${{ secrets.DOCKER_PASSWORD }} 19 | repository: vsnthdev/alpa-api 20 | readme: ./api/README.md 21 | 22 | - name: "@alpa/app" 23 | uses: ms-jpq/sync-dockerhub-readme@v1 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | repository: vsnthdev/alpa-app 28 | readme: ./app/README.md 29 | -------------------------------------------------------------------------------- /.github/workflows/images.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Keeps the images on DockerHub updated, by automatically building 3 | # a new Docker image, and pushing it to DockerHub. 4 | # Created On 19 February 2022 5 | # 6 | 7 | name: Docker Images 8 | on: 9 | workflow_dispatch: {} 10 | push: 11 | branches: 12 | - main 13 | jobs: 14 | api: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Cloning project 18 | uses: actions/checkout@master 19 | 20 | - name: Setting up Node.js 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: "17.4.0" 24 | 25 | - name: Installing dependencies 26 | run: npm install --dev 27 | 28 | - name: Building API 29 | run: npm run build 30 | 31 | - name: Reading API version 32 | id: package-version 33 | uses: martinbeentjes/npm-get-version-action@master 34 | with: 35 | path: api 36 | 37 | - name: Creating Docker image metadata 38 | id: meta 39 | uses: docker/metadata-action@v3 40 | with: 41 | # list of Docker images to use as base name for tags 42 | images: | 43 | vsnthdev/alpa-api 44 | 45 | - name: Setting up QEMU 46 | uses: docker/setup-qemu-action@v1 47 | 48 | - name: Setting up Docker Buildx 49 | uses: docker/setup-buildx-action@v1 50 | 51 | - name: Logging in to DockerHub 52 | uses: docker/login-action@v1 53 | with: 54 | username: ${{ secrets.DOCKER_USERNAME }} 55 | password: ${{ secrets.DOCKER_TOKEN }} 56 | 57 | - name: Building & pushing to DockerHub 58 | uses: docker/build-push-action@v2 59 | with: 60 | push: true 61 | context: . 62 | file: Dockerfile.api 63 | tags: "vsnthdev/alpa-api:latest,vsnthdev/alpa-api:v${{ steps.package-version.outputs.current-version}}" 64 | labels: ${{ steps.meta.outputs.labels }} 65 | 66 | app: 67 | runs-on: ubuntu-latest 68 | steps: 69 | - name: Cloning project 70 | uses: actions/checkout@master 71 | 72 | - name: Setting up Node.js 73 | uses: actions/setup-node@v2 74 | with: 75 | node-version: "17.4.0" 76 | 77 | - name: Installing dependencies 78 | run: npm install --dev 79 | 80 | - name: Building the app 81 | run: npm run build 82 | 83 | - name: Reading the app version 84 | id: package-version 85 | uses: martinbeentjes/npm-get-version-action@master 86 | with: 87 | path: app 88 | 89 | - name: Creating Docker image metadata 90 | id: meta 91 | uses: docker/metadata-action@v3 92 | with: 93 | # list of Docker images to use as base name for tags 94 | images: | 95 | vsnthdev/alpa-app 96 | 97 | - name: Setting up QEMU 98 | uses: docker/setup-qemu-action@v1 99 | 100 | - name: Setting up Docker Buildx 101 | uses: docker/setup-buildx-action@v1 102 | 103 | - name: Logging in to DockerHub 104 | uses: docker/login-action@v1 105 | with: 106 | username: ${{ secrets.DOCKER_USERNAME }} 107 | password: ${{ secrets.DOCKER_TOKEN }} 108 | 109 | - name: Building & pushing 110 | uses: docker/build-push-action@v2 111 | with: 112 | push: true 113 | context: . 114 | file: Dockerfile.app 115 | tags: "vsnthdev/alpa-app:latest,vsnthdev/alpa-app:v${{ steps.package-version.outputs.current-version}}" 116 | labels: ${{ steps.meta.outputs.labels }} 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,visualstudiocode 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | ### VisualStudioCode ### 145 | .vscode/* 146 | !.vscode/settings.json 147 | !.vscode/tasks.json 148 | !.vscode/launch.json 149 | !.vscode/extensions.json 150 | !.vscode/*.code-snippets 151 | 152 | # Local History for Visual Studio Code 153 | .history/ 154 | 155 | # Built Visual Studio Code Extensions 156 | *.vsix 157 | 158 | ### VisualStudioCode Patch ### 159 | # Ignore all local history of files 160 | .history 161 | .ionide 162 | 163 | # Support for Project snippet scope 164 | 165 | # End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode 166 | 167 | www/ 168 | api/config.yml 169 | .redis 170 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run --silent lint 5 | npm run --silent build:docs 6 | git add --all 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # 2 | # NPM run control for alpa project. 3 | # Created On 31 March 2022 4 | # 5 | 6 | # only allow Node.js version specified 7 | # in package.json file 8 | engine-strict=true 9 | -------------------------------------------------------------------------------- /Dockerfile.api: -------------------------------------------------------------------------------- 1 | # 2 | # Instructions to build vsnthdev/alpa-api docker image. 3 | # Created On 18 February 2022 4 | # 5 | 6 | # This image only contains the API, an image of the 7 | # frontend can be found at vsnthdev/alpa-app. 8 | 9 | # small & updated base image 10 | FROM node:17.8.0-alpine3.15 11 | 12 | # run Node.js in production so the API 13 | # can take the necessary security measures 14 | ENV NODE_ENV=production 15 | 16 | # where the API source code will be 17 | WORKDIR /opt/alpa 18 | 19 | # copy this directory to the image 20 | COPY . /opt/alpa 21 | 22 | # install dependencies 23 | RUN npm install --prod && \ 24 | rm -rf /var/cache/apk/* 25 | 26 | # run @alpa/api on contianer boot 27 | CMD node api/dist/index.js --verbose 28 | -------------------------------------------------------------------------------- /Dockerfile.api.dockerignore: -------------------------------------------------------------------------------- 1 | app 2 | **/Dockerfile* 3 | **/node_modules 4 | **/tsconfig* 5 | **/.git* 6 | api/config.yml 7 | api/http 8 | api/nodemon* 9 | api/notes 10 | api/src 11 | docker-compose.yml 12 | -------------------------------------------------------------------------------- /Dockerfile.app: -------------------------------------------------------------------------------- 1 | # 2 | # Instructions to build vsnthdev/alpa-app docker image. 3 | # Created On 18 February 2022 4 | # 5 | 6 | # This image only contains the frontend app, an image of the 7 | # API can be found at vsnthdev/alpa-api. 8 | 9 | # small & updated base image 10 | FROM node:17.8.0-alpine3.15 11 | 12 | # run Node.js in production 13 | ENV NODE_ENV=production 14 | 15 | # where the API source code will be 16 | WORKDIR /opt/alpa 17 | 18 | # copy this directory to the image 19 | COPY . /opt/alpa 20 | 21 | # install sirv static file server 22 | RUN npm install --global sirv-cli && \ 23 | rm -rf /var/cache/apk/* 24 | 25 | # run @alpa/app using sirv on contianer boot 26 | CMD cd app/dist && sirv . --port 3000 --host "0.0.0.0" --single --no-clear 27 | -------------------------------------------------------------------------------- /Dockerfile.app.dockerignore: -------------------------------------------------------------------------------- 1 | api 2 | **/Dockerfile* 3 | **/node_modules 4 | **/tsconfig* 5 | **/.git* 6 | app/vite* 7 | app/tailwind* 8 | app/src 9 | api/README.md 10 | api/.env.example 11 | docker-compose.yml 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | alpa 5 | 6 | 7 | alpa 8 | 9 |

10 | 11 | cover 12 | 13 |

( अल्प ) — A fast ⚡ self-hosted link 🔗 shortener.

14 | 15 |

16 | 17 | issues 18 | 19 | 20 | commits 22 | 23 | 24 | docker 25 | 26 | 27 | dashboard status 28 | 29 |

30 | 31 |
32 | 33 | 34 | 35 | **alpa** is a self-hosted _(you run it on your servers)_ URL shortener which is fast and provides full control of the short links you create. 36 | 37 | It takes this 👇 38 | 39 | ```plaintext 40 | https://vasanthdeveloper.com/migrating-from-vps-to-kubernetes 41 | ``` 42 | 43 | and converts it into something like this 👇 44 | 45 | ```plaintext 46 | https://vas.cx/fjby 47 | ``` 48 | 49 | Which is easier to remember and share across the internet. 50 | 51 | ## ✨ Features 52 | 53 | - **It is 🚀 super fast** 54 | - **Your domain, your branding** 👌 55 | - **Privacy friendly 🤗 & configurable** 56 | - **Simple & 🎮 intuitive dashboard** 57 | 58 | ## 💡 Why I built it? 59 | 60 | I was using goo.gl back in 2016 and I was very impressed by it. It's simple dashboard & fast redirection were two things that were really attractive to me. **alpa** is inspired by goo.gl URL shortener. 61 | 62 | Along with that, most popular URL shorteners are not _self-hosted_, which means that you'll share your data with others that use the service. To me, it was a concern about **reliability**, **privacy** and **performance**. 63 | 64 | ## 🚀 Quick start 65 | 66 | The quickest way to run **alpa** is through Docker Compose using only **3 steps**: 67 | 68 | **STEP 1️⃣** Getting alpa 69 | 70 | Once you have Docker Compose installed, clone this repository by running the following command 👇 71 | 72 | ``` 73 | git clone https://github.com/vsnthdev/alpa.git 74 | ``` 75 | 76 | **STEP 2️⃣** Creating a configuration file 77 | 78 | Enter into the **alpa** directory and create an API config by running 👇 79 | 80 | ``` 81 | cd ./alpa 82 | cp ./api/config.example.yml ./api/config.yml 83 | ``` 84 | 85 | **⚠️ Warning:** The example config file is only meant for development and testing purposes, a proper config file is required to securely run **alpa** in production. 86 | 87 | **STEP 3️⃣** Starting alpa 88 | 89 | Now all you need to do is, run the following command to start both **alpa**'s [app](https://github.com/vsnthdev/alpa/tree/main/app) & the [API](https://github.com/vsnthdev/alpa/tree/main/api). 90 | 91 | ``` 92 | docker-compose up -d 93 | ``` 94 | 95 | ## ⚡ Support & funding 96 | 97 | Financial funding would really help this project go forward as I will be able to spend more hours working on the project to maintain & add more features into it. 98 | 99 | Please get in touch with me on [Discord](https://discord.com/users/492205153198407682) or [Twitter](https://vas.cx/twitter) to get fund the project even if it is a small amount 🙏 100 | 101 | ## 🤝 Troubleshooting & help 102 | 103 | If you face trouble setting up **alpa**, or have any questions, or even a bug report, feel free to contact me through Discord. I provide support for **alpa** on [my Discord server](https://vas.cx/discord). 104 | 105 | I will be happy to consult & personally assist you 😊 106 | 107 | ## 💖 Code & contribution 108 | 109 | **Pull requests are always welcome** 👏 110 | 111 | But it will be better if you can get in touch with me before contributing or [raise an issue](https://github.com/vsnthdev/alpa/issues/new/choose) to see if the contribution aligns with the vision of the project. 112 | 113 | > **ℹ️ Note:** This project follows [Vasanth's Commit Style](https://vas.cx/commits) for commit messages. We highly encourage you to use this commit style for contributions to this project. 114 | 115 | ## 💻 Building & Dev Setup 116 | 117 | This is a [monorepo](https://monorepo.tools/#what-is-a-monorepo) containing multiple projects. Below is a list of all the projects in this repository, what they do, and docs to building them 👇 118 | 119 | | Name | Description | 120 | | --- | --- | 121 | | [@alpa/api](./api) | The core RESTful API 🛠️ that handles redirection in alpa. | 122 | | [@alpa/app](./app) | Dashboard ✨ to interact with alpa's API. | 123 | | [@alpa/docs](./docs) | Programmatically ⚡ builds docs 📚 of all projects 📂 under alpa. | 124 | 125 | ### 🛠️ Building all projects 126 | 127 | You need to be at least on **Node.js v17.4.0 or above** and follow the below instructions to build all the projects 👇 128 | 129 | - **STEP 1️⃣** Clone this repository & enter into it (`cd ./alpa`) 130 | - **STEP 2️⃣** Run **`npm install`** to get all dependencies & link projects together 131 | - **STEP 3️⃣** To build all the projects & docs run **`npm run build`** 132 | 133 | ### 🐳 Building Docker images 134 | 135 | Instead of pulling Docker images from DockerHub, you can build yourself by running 👇 136 | 137 | ``` 138 | npm run build:docker 139 | ``` 140 | 141 | > ⚠️ **Warning:** Make sure to delete Docker images pulled from DockerHub or a previous build, to prevent conflicts before running the above command. 142 | 143 | ### 🍃 Cleaning project 144 | 145 | Building the project generates artifacts on several places in the project. To delete all those artifacts **(including docs)**, run the below command 👇 146 | 147 | ``` 148 | npm run clean 149 | ``` 150 | 151 | 152 | 153 | ## 📰 License 154 | > The **alpa** project is released under the [AGPL-3.0-only](https://github.com/vsnthdev/alpa/blob/main/LICENSE.md).
Developed & maintained By Vasanth Srivatsa. Copyright 2022 © Vasanth Developer. 155 |
156 | 157 | > vsnth.dev  ·  158 | > YouTube @vasanthdeveloper  ·  159 | > Twitter @vsnthdev  ·  160 | > Discord Vasanth Developer 161 | -------------------------------------------------------------------------------- /api/.env.example: -------------------------------------------------------------------------------- 1 | # 2 | # Example environment variables file. 3 | # Created On 02 February 2022 4 | # 5 | 6 | TOKEN= 7 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | alpa 5 | 6 | 7 | alpa 8 | 9 |

10 | 11 | 12 | 13 |

( अल्प ) — A fast ⚡ self-hosted link 🔗 shortener.

14 | 15 |

16 | 17 | issues 18 | 19 | 20 | commits 22 | 23 | 24 | docker 25 | 26 | 27 | dashboard status 28 | 29 |

30 | 31 |
32 | 33 | This is the core of the project, it the RESTful API that performs redirection, communicates with the database and provides **alpa** it's functionality. 34 | 35 | ## ⚙️ Configuration 36 | 37 | Refer to the [config example file](https://github.com/vsnthdev/alpa/blob/main/api/config.example.yml) for all possible configuration keys, and their detailed explanation. If you still have any doubts, feel free to shoot a tweet at me [@vsnthdev](https://vas.cx/@me). 38 | 39 | ## 🔭 API Routes 40 | 41 | | Method | Path | Description | Protected | 42 | |---|---|---|---| 43 | | `POST` | `/api/auth/login` | Takes username, password and responds with a JWT token. | ❌ | 44 | | `DELETE` | `/api/auth/logout` | Blacklists the token until expired to prevent usage. | ❌ | 45 | | `GET` | `/:code & /` | Redirects if short code is found or returns 404. | ❌ | 46 | | `DELETE` | `/api/codes/:code` | Deletes a short code from the database. | ❌ | 47 | | `GET` | `/api/codes` | List out all short codes with pagination. | ❌ | 48 | | `POST` | `/api/codes` | Creates a new short code. | ❌ | 49 | | `POST` | `/api/config` | Creates, updates an existing, or deletes configuration. | ❌ | 50 | 51 | ## 🔮 Tech stack 52 | 53 | | Name | Description | 54 | | --- | --- | 55 | | **Fastify** | HTTP server focussed on speed designed to build RESTful APIs. | 56 | | **JSON Web Tokens** | For user authentication. | 57 | | **Redis** | Key-value pair database known for it's speed. | 58 | | **RedisJSON** | Redis database plugin to store JSON documents. | 59 | | **RediSearch** | Redis database plugin that facilitates full text search. | 60 | | **Docker** | For easy installation & seamless updates. | 61 | | **Kubernetes** | For scalable deployments to production. | 62 | 63 | ## 💻 Building & Dev Setup 64 | 65 | You need to be at least on **Node.js v17.4.0 or above** and follow the below instructions to build this project 👇 66 | 67 | - **STEP 1️⃣** Clone this repository & enter into it (`cd ./alpa`) 68 | - **STEP 2️⃣** Run **`npm install`** to get all dependencies & link projects together 69 | - **STEP 3️⃣** Enter in the project directory (`cd api`) 70 | - **STEP 4️⃣** To build this project run **`npm run build`** 71 | 72 | Upon building `@alpa/api` a `dist` folder is created with the transpiled JavaScript files. 73 | 74 | ## 📰 License 75 | > The **alpa** project is released under the [AGPL-3.0-only](https://github.com/vsnthdev/alpa/blob/main/LICENSE.md).
Developed & maintained By Vasanth Srivatsa. Copyright 2022 © Vasanth Developer. 76 |
77 | 78 | > vsnth.dev  ·  79 | > YouTube @vasanthdeveloper  ·  80 | > Twitter @vsnthdev  ·  81 | > Discord Vasanth Developer 82 | -------------------------------------------------------------------------------- /api/config.example.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Example configuration file to config @alpa/api project. 3 | # Created On 30 January 2022 4 | # 5 | 6 | # authentication of alpa's API 7 | auth: 8 | # username and password used to login to 9 | # alpa's API 10 | username: alpa 11 | password: short_lived 12 | 13 | # (not used currently) will be used to fetch 14 | # an avatar from Gravatar later 15 | email: alpa@example.com 16 | 17 | # database configuration 18 | database: 19 | # the database connection URL in the redis:// or rediss:// protocol 20 | connection: redis://redis:6379 21 | 22 | # different redis databases used for different purposes 23 | channels: 24 | # storing short codes 25 | codes: 0 26 | 27 | # storing authentication token blacklist 28 | tokens: 1 29 | 30 | # storing additional critical configuration 31 | config: 2 32 | -------------------------------------------------------------------------------- /api/docs/docker.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | alpa 5 | 6 | 7 | alpa 8 | 9 |

10 | 11 | 12 | 13 |

( अल्प ) — A fast ⚡ self-hosted link 🔗 shortener.

14 | 15 |

16 | 17 | issues 18 | 19 | 20 | commits 22 | 23 | 24 | docker 25 | 26 | 27 | dashboard status 28 | 29 |

30 | 31 |
32 | 33 | # 🐳 Deploying with Docker Compose 34 | 35 | There are mainly 3 ways to deploy `@alpa/api` onto production. For personal usage deploying through 🐳 **Docker Compose** is the easiest & recommended way. For advanced use cases or high intensity workload read about [manual deployment](./manual.md) & [Kubernetes deployment](./kubernetes.md). 36 | 37 | Deploying **alpa**'s API using Docker is easy and straightforward by following the below steps: 38 | 39 | ## 🔍 Prerequisites 40 | 41 | 1. [Docker v20.10.13](https://docs.docker.com/engine/install) or higher 42 | 2. [Docker Compose v2.3.2](https://docs.docker.com/compose/cli-command) or [`docker-compose` v1.29.2](https://docs.docker.com/compose/install) 43 | 44 | ## 🚀 Deployment process 45 | 46 | Once you've satisfied the prerequisites, follow the below steps to configure `@alpa/api` to run in production. 47 | 48 | ### 📂 Create a new folder 49 | 50 | Create a new folder named `alpa` and enter into it, this is where we'll store the `docker-compose.yml` and other artifacts generated for running all the services we need, by running 👇 the following command: 51 | 52 | ``` 53 | mkdir ./alpa && cd ./alpa 54 | ``` 55 | 56 | ### 📃 Creating `docker-compose.yml` file 57 | 58 | The first thing we'll do in the newly created folder is create a `docker-compose.yml` file which defines all the services that are required for `@alpa/api`. 59 | 60 | Open your favourite text editor (preferably [VSCode](https://code.visualstudio.com)), copy the below code block 👇 and save it as `docker-compose.yml` in the `alpa` directory. 61 | 62 | ```yaml 63 | version: "3.8" 64 | services: 65 | # the redis Docker image with RedisJSON and RediSearch 66 | # modules pre-configured & enabled along with persistance 67 | redis: 68 | image: redislabs/redisearch:2.4.0 69 | container_name: redis 70 | command: redis-server --loadmodule /usr/lib/redis/modules/redisearch.so --loadmodule /usr/lib/redis/modules/rejson.so --appendonly yes 71 | volumes: 72 | - .redis:/data 73 | 74 | # @alpa/api Docker image 75 | alpa-api: 76 | image: vsnthdev/alpa-api:v1.1.1 77 | container_name: alpa-api 78 | ports: 79 | - 1727:1727 80 | volumes: 81 | - ./config.yml:/opt/alpa/api/config.yml 82 | ``` 83 | 84 | > ℹ️ **Info:** We're intensionally using a versioned images, to mitigate the risk of accidentally updating the image and breaking everything. 85 | 86 | ### ⚙️ Mounting configuration file 87 | 88 | An example config file will all possible values already exists in this repository. Simply right click on [this link](https://raw.githubusercontent.com/vsnthdev/alpa/main/api/config.example.yml) and select "_Save as_". 89 | 90 | Now save it with the name `config.yml` in the `alpa` folder where `docker-compose.yml` is. Once done your `alpa` folder should contain two files 91 | 92 | ``` 93 | docker-compose.yml 94 | config.yml 95 | ``` 96 | 97 | ### ⚡ Configuring for production 98 | 99 | Provided example config file is best suitable for development & testing purposes only. We need to make some changes to the config file to make `@alpa/api` suitable for production environments. 100 | 101 | These exact changes have been specified in the manual deployment docs **[click here to view them](./manual.md#-production-configuration).** 102 | 103 | > ⚠️ **Warning:** Do not use `@alpa/api` in production without following the production configuration steps. It will lead to serious security risks and instabilities. 104 | 105 | ### ✨ Starting `@alpa/api` 106 | 107 | With the above mentioned changes being done to the configuration file, `@alpa/api` is now ready to be started in a production environment safely. 108 | 109 | To start all the services defined in our `docker-compose.yml` run 👇 one of the below commands depending on your Docker Compose version: 110 | 111 | ```bash 112 | # if you're on Docker Compose v2 113 | docker compose up 114 | 115 | # if you're on docker-compose v1 116 | docker-compose up 117 | ``` 118 | 119 | After following the above steps you should be able to login from the configured client and start enjoying **alpa**. 120 | 121 | **If you're still facing issues, refer the [troubleshooting & help section](https://github.com/vsnthdev/alpa#-troubleshooting--help) for further information.** 122 | 123 | 124 | 125 | ## 📰 License 126 | > The **alpa** project is released under the [AGPL-3.0-only](https://github.com/vsnthdev/alpa/blob/main/LICENSE.md).
Developed & maintained By Vasanth Srivatsa. Copyright 2022 © Vasanth Developer. 127 |
128 | 129 | > vsnth.dev  ·  130 | > YouTube @vasanthdeveloper  ·  131 | > Twitter @vsnthdev  ·  132 | > Discord Vasanth Developer 133 | -------------------------------------------------------------------------------- /api/docs/kubernetes.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | alpa 5 | 6 | 7 | alpa 8 | 9 |

10 | 11 | 12 | 13 |

( अल्प ) — A fast ⚡ self-hosted link 🔗 shortener.

14 | 15 |

16 | 17 | issues 18 | 19 | 20 | commits 22 | 23 | 24 | docker 25 | 26 | 27 | dashboard status 28 | 29 |

30 | 31 |
32 | 33 | # 🏅 Deploying with Kubernetes 34 | 35 | There are mainly 3 ways to deploy `@alpa/api` onto production. For personal usage deploying through [🐳 Docker Compose](./docker.md) is the most easiest & recommended way. For advanced use cases read about [manual deployment](./manual.md). 36 | 37 | Deploying **alpa**'s API into a Kubernetes Cluster is easy and straightforward by following the below steps: 38 | 39 | ## 🔍 Prerequisites 40 | 41 | 1. [Docker v20.10.13](https://docs.docker.com/engine/install) or higher 42 | 2. [Kubernetes 1.22.5](https://kubernetes.io/docs/setup) or higher 43 | 44 | ## 🚀 Deployment process 45 | 46 | Once you've satisfied the prerequisites, follow the below steps to configure `@alpa/api` to run in production. 47 | 48 | ### 📂 Creating folder structure 49 | 50 | Create a new folder named `alpa` with two sub-folders named `alpa`, `redis`, this is where we'll store the Kubernetes files. 51 | 52 | ``` 53 | mkdir alpa && mkdir alpa/redis && mkdir alpa/alpa && cd alpa 54 | ``` 55 | 56 | ### 🏝️ Creating a namespace 57 | 58 | Using your favorite text editor, create a new file named `0-namespace.yml` file and paste the below contents 👇 59 | 60 | ```yml 61 | apiVersion: v1 62 | kind: Namespace 63 | metadata: 64 | name: alpa 65 | ``` 66 | 67 | Save the `0-namespace.yml` file in the `alpa` folder we created above. 68 | 69 | ### 🪛 Setting up Redis database 70 | 71 | To setup Redis database in a Kubernetes cluster, we need to create a few files. Lets create them one by one while going through each one. 72 | 73 | #### 🧳 Redis database volume 74 | 75 | Create a file named `1-volumes.yml` and save the below contents 👇 in the `alpa/redis` folder we created. 76 | 77 | ```yml 78 | apiVersion: v1 79 | kind: PersistentVolumeClaim 80 | metadata: 81 | name: redis-claim 82 | namespace: alpa 83 | spec: 84 | resources: 85 | requests: 86 | storage: 1G 87 | volumeMode: Filesystem 88 | accessModes: 89 | - ReadWriteOnce 90 | ``` 91 | 92 | This file creates a [claim for a persistent volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes) which can later be used to create an actual volume to store data from our Redis database. 93 | 94 | #### 📌 Redis database deployment 95 | 96 | Create a file named `2-deploys.yml` and save the below contents 👇 in the `alpa/redis` folder we created. 97 | 98 | ```yml 99 | apiVersion: apps/v1 100 | kind: Deployment 101 | metadata: 102 | name: redis 103 | namespace: alpa 104 | spec: 105 | selector: 106 | matchLabels: 107 | app: redis 108 | template: 109 | metadata: 110 | labels: 111 | app: redis 112 | spec: 113 | hostname: redis 114 | volumes: 115 | - name: redis 116 | persistentVolumeClaim: 117 | claimName: redis-claim 118 | containers: 119 | - name: redis 120 | image: redislabs/redisearch:2.4.0 121 | imagePullPolicy: IfNotPresent 122 | args: 123 | - "redis-server" 124 | - "--loadmodule" 125 | - "/usr/lib/redis/modules/redisearch.so" 126 | - "--loadmodule" 127 | - "/usr/lib/redis/modules/rejson.so" 128 | - "--appendonly" 129 | - "yes" 130 | volumeMounts: 131 | - mountPath: /data 132 | name: redis 133 | resources: 134 | limits: 135 | memory: 128Mi 136 | cpu: 100m 137 | ports: 138 | - containerPort: 6379 139 | ``` 140 | 141 | This is the actual deployment file that tells Kubernetes which Docker container to run and how to link it with our Persistent Volume Claim and mount the data directory. 142 | 143 | This file also specifies how much CPU & memory is allocated to the Redis database. 144 | 145 | #### 🔦 Redis database service 146 | 147 | Create a file named `3-services.yml` and save the below contents 👇 in the `alpa/redis` folder we created. 148 | 149 | ```yml 150 | apiVersion: v1 151 | kind: Service 152 | metadata: 153 | name: redis 154 | namespace: alpa 155 | spec: 156 | type: NodePort 157 | selector: 158 | app: redis 159 | ports: 160 | - port: 6379 161 | targetPort: 6379 162 | ``` 163 | 164 | Redis service exposes the Redis database on port 6379 to be accessed by `@alpa/api` and other deployments in this namespace. 165 | 166 | > ℹ️ **Note:** For security purposes, it is recommended that you change this port number `6379` to a random 5 digit number below 60,000. 167 | 168 | ### ⚙️ Creating configuration file 169 | 170 | Create a file named `1-configs.yml` and save the below contents 👇 in the `alpa/alpa` folder we created. 171 | 172 | ```yml 173 | apiVersion: v1 174 | kind: ConfigMap 175 | metadata: 176 | name: alpa-api-config 177 | namespace: alpa 178 | data: 179 | config: | 180 | auth: 181 | username: alpa 182 | password: short_lived 183 | email: alpa@example.com 184 | database: 185 | connection: redis://redis:6379 186 | channels: 187 | codes: 0 188 | tokens: 1 189 | config: 2 190 | 191 | ``` 192 | 193 | This creates a [ConfigMap](https://kubernetes.io/docs/concepts/configuration/configmap) in Kubernetes which stores the config file for `@alpa/api` which will be mounted as a volume later. 194 | 195 | ### ⚡ Configuring for production 196 | 197 | Provided example config file is best suitable for development & testing purposes only. We need to make some changes to the config file to make `@alpa/api` suitable for production environments. 198 | 199 | These exact changes have been specified in the manual deployment docs **[click here to view them](./manual.md#-production-configuration).** 200 | 201 | > ⚠️ **Warning:** Do not use `@alpa/api` in production without following the production configuration steps. It will lead to serious security risks and instabilities. 202 | 203 | #### 📌 Deploying `@alpa/api` 204 | 205 | Create a file named `2-deploys.yml` and save the below contents 👇 in the `alpa/alpa` folder we created. 206 | 207 | ```yml 208 | apiVersion: apps/v1 209 | kind: Deployment 210 | metadata: 211 | name: alpa 212 | namespace: alpa 213 | spec: 214 | selector: 215 | matchLabels: 216 | app: alpa 217 | template: 218 | metadata: 219 | labels: 220 | app: alpa 221 | spec: 222 | hostname: alpa 223 | volumes: 224 | - name: alpa-api-config 225 | configMap: 226 | name: alpa-api-config 227 | containers: 228 | - name: alpa 229 | image: vsnthdev/alpa-api:v1.1.1 230 | imagePullPolicy: Always 231 | volumeMounts: 232 | - mountPath: /opt/alpa/api/config.yml 233 | name: alpa-api-config 234 | subPath: config 235 | readOnly: true 236 | resources: 237 | limits: 238 | memory: 256Mi 239 | cpu: 100m 240 | ports: 241 | - containerPort: 1727 242 | ``` 243 | 244 | > ℹ️ **Info:** We're intensionally using a versioned images, to mitigate the risk of accidentally updating the image and breaking everything. 245 | 246 | This file tells Kubernetes to pull and run `@alpa/api` on Kubernetes along with how much memory and CPU should be allocated. 247 | 248 | ### 🌏 Creating `@alpa/api` service 249 | 250 | Create a file named `3-services.yml` and save the below contents 👇 in the `alpa/alpa` folder we created. 251 | 252 | ```yml 253 | apiVersion: v1 254 | kind: Service 255 | metadata: 256 | name: alpa 257 | namespace: alpa 258 | spec: 259 | type: NodePort 260 | selector: 261 | app: alpa 262 | ports: 263 | - port: 48878 264 | targetPort: 1727 265 | ``` 266 | 267 | A [service](https://kubernetes.io/docs/concepts/services-networking/service) will allow you to access `@alpa/api` outside the Kubernetes cluster network on port `48878`. 268 | 269 | > ℹ️ **Note:** For security purposes, it is recommended that you change this port number `48878` to a random 5 digit number below 60,000. 270 | 271 | ### 🔨 Creating `kustomization.yml` file 272 | 273 | Create a file named `kustomization.yml` and save the below contents 👇 in the `alpa` folder we created. 274 | 275 | ```yml 276 | apiVersion: kustomize.config.k8s.io/v1beta1 277 | resources: 278 | # deleting the name will delete everything 279 | - 0-namespace.yml 280 | 281 | # redis database for primarily for alpa 282 | - redis/1-volumes.yml 283 | - redis/2-deploys.yml 284 | - redis/3-services.yml 285 | 286 | # @alpa/api service 287 | - alpa/1-configs.yml 288 | - alpa/2-deploys.yml 289 | - alpa/3-services.yml 290 | ``` 291 | 292 | Once all the required files are created, the completed directory structure should look something like 👇 293 | 294 | ```js 295 | alpa 296 | /alpa 297 | 1-configs.yml 298 | 2-deploys.yml 299 | 3-services.yml 300 | /redis 301 | 1-volumes.yml 302 | 2-deploys.yml 303 | 3-services.yml 304 | 0-namespace.yml 305 | kustomization.yml 306 | ``` 307 | 308 | ### ✨ Starting `@alpa/api` 309 | 310 | With the above mentioned changes being done to the configuration file, `@alpa/api` is now ready to be started in a production environment safely. 311 | 312 | To start all the services defined in our `kustomization.yml` run 👇 the below command: 313 | 314 | ```bash 315 | kubectl apply -k . 316 | ``` 317 | 318 | **If you're still facing issues, refer the [troubleshooting & help section](https://github.com/vsnthdev/alpa#-troubleshooting--help) for further information.** 319 | 320 | ## 📰 License 321 | > The **alpa** project is released under the [AGPL-3.0-only](https://github.com/vsnthdev/alpa/blob/main/LICENSE.md).
Developed & maintained By Vasanth Srivatsa. Copyright 2022 © Vasanth Developer. 322 |
323 | 324 | > vsnth.dev  ·  325 | > YouTube @vasanthdeveloper  ·  326 | > Twitter @vsnthdev  ·  327 | > Discord Vasanth Developer 328 | -------------------------------------------------------------------------------- /api/docs/manual.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | alpa 5 | 6 | 7 | alpa 8 | 9 |

10 | 11 | 12 | 13 |

( अल्प ) — A fast ⚡ self-hosted link 🔗 shortener.

14 | 15 |

16 | 17 | issues 18 | 19 | 20 | commits 22 | 23 | 24 | docker 25 | 26 | 27 | dashboard status 28 | 29 |

30 | 31 |
32 | 33 | # 🧰 Manually deploying 34 | 35 | There are mainly 3 ways to deploy `@alpa/api` onto production. For personal usage deploying through [🐳 Docker Compose](./docker.md) is the most easiest & recommended way. For high intensity workloads read about [Kubernetes deployment](./kubernetes.md). 36 | 37 | Deploying **alpa**'s API is easy and straightforward by following the below steps: 38 | 39 | Manually deploying will allow you to run `@alpa/api` on a production server without additional layers of abstraction. 40 | 41 | > **⚠️ Warning:** This method is good for advanced use cases & updating alpa may not be straightforward always. 42 | 43 | ## 🔍 Prerequisites 44 | 45 | 1. Node.js version v17.4.0 or higher ([Windows](https://youtu.be/sHGz607fsVA) / [Linux](https://github.com/nodesource/distributions#readme) / [macOS](https://github.com/nvm-sh/nvm#readme)) 46 | 2. [Redis database](https://redis.io) 47 | 3. [RedisJSON plugin](https://redis.io/docs/stack/json/) for Redis database 48 | 4. [RediSearch plugin](https://redis.io/docs/stack/search) for Redis database 49 | 50 | ## 🚀 Deployment process 51 | 52 | As said in this [README.md](https://github.com/vsnthdev/alpa/tree/main#readme) file **alpa** is a monorepo containing multiple projects, follow the below steps to configure `@alpa/api` to run in production. 53 | 54 | ### 💾 Getting `@alpa/api` 55 | 56 | Instead of normally cloning entire repository here are the commands to only clone `@alpa/api` project & the root project 👇 57 | 58 | **STEP 1️⃣** Clone only the root project 59 | 60 | ``` 61 | git clone --single-branch --branch main --depth 1 --filter=blob:none --sparse https://github.com/vsnthdev/alpa 62 | ``` 63 | 64 | **STEP 2️⃣** Enter the freshly cloned root project 65 | 66 | ``` 67 | cd ./alpa 68 | ``` 69 | 70 | **STEP 3️⃣** Initialize Git sparse checkout 71 | 72 | ``` 73 | git sparse-checkout init --cone 74 | ``` 75 | 76 | **STEP 4️⃣** Pull only `@alpa/api` project while ignoring other projects 77 | 78 | ``` 79 | git sparse-checkout set api 80 | ``` 81 | 82 | ### 🪄 Installing dependencies 83 | 84 | Dependency libraries for both the root project & `@alpa/api` can be installed & setup by running the following command 👇 85 | 86 | ``` 87 | npm install 88 | ``` 89 | 90 | ### 💻 Building `@alpa/api` 91 | 92 | We only store TypeScript source code in this repository so before we can start `@alpa/api` server, we need to build (_transpile TypeScript into JavaScript_) the project using the following command 👇 93 | 94 | ``` 95 | npm run build 96 | ``` 97 | 98 | ### ⚙️ Creating configuration file 99 | 100 | An [example config file](../../api/config.example.yml) is already present with all configurable values and their defaults. We'll copy that and make some necessary changes to prepare `@alpa/api` to work in production 👇 101 | 102 | ``` 103 | cp api/config.example.yml api/config.yml 104 | ``` 105 | 106 | ### ⚡ Configuring for production 107 | 108 | Provided example config file is best suitable for development & testing purposes only. We need to make some changes to the config file to make `@alpa/api` suitable for production environments. 109 | 110 | 1. 🔒 **Changing username & password** 111 | 112 | The default username (`alpa`) & password (`short_lived`) are extremely insecure. Change both the `auth.username` and `auth.password` fields with better values. And avoid setting the username to commonly guessable values like `admin`, `alpa`, `sudo`, `root` etc. 113 | 114 |
115 | 116 | 2. 🔌 **Changing database connection URL** 117 | 118 | The default database connection URL (`redis://redis:6379`) is mainly for connecting to an internal Docker container. 119 | 120 | Change the value of `database.connection` field to a Redis database connection URL without a database number pre-selected. Preferably to an empty Redis database exclusively to be used with `@alpa/api`. Using a shared database is also possible with additional configuration. 121 | 122 | > ⚠️ **Warning:** The Redis database must have RedisJSON & RediSearch plugins enabled & working. 123 | 124 |
125 | 126 | 3. 🔑 **Changing server's secret key** 127 | 128 | This secret key is used to sign the JWT authentication tokens, since the default (``) is already known to everyone. It is insecure to use it. 129 | 130 | Preferably use a password generator to generate a 64 character long random string here. 131 | 132 | > ⚠️ **Warning:** Failing to change the secret key, or using a small secret key will get you into the risk of getting `@alpa/api` hacked. A minimum of 64 characters is recommended. 133 | 134 |
135 | 136 | 4. 🔗 **Changing allowed domains** 137 | 138 | [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) are sent by `@alpa/api` to prevent misuse & accessing the API from unauthorized origins. 139 | 140 | Remove `localhost` entries from `server.cors` field to prevent everyone from self-deploying `@alpa/app` and accessing your instance of `@alpa/api` from their own computer. 141 | 142 | Finally, if you're not using the universal deployment of `@alpa/app` at https://alpa.vercel.app then also remove that entry as a safety measure while adding the full URL of `@alpa/app` hosted by you to allow, programmatic communication from that URL. 143 | 144 | ### ✨ Starting `@alpa/api` 145 | 146 | With the above mentioned changes being done to the configuration file, `@alpa/api` is now ready to be started in a production environment safely. 147 | 148 | On Linux & macOS operating systems run the below command 👇 149 | 150 | ```bash 151 | NODE_ENV=production node api/dist/index.js 152 | ``` 153 | 154 | If you're on Windows (_but seriously? why!_ 🤷‍♂️) then use [cross-env](https://www.npmjs.com/package/cross-env) to set the `NODE_ENV` to production 👇 and start `@alpa/api`: 155 | 156 | ```bash 157 | npx cross-env NODE_ENV=production node api/dist/index.js 158 | ``` 159 | 160 | > ℹ️ **Info:** During this process npm may ask you whether to install `cross-env` depending on if you already have it. 161 | 162 | After following the above steps you should be able to login from the configured client and start enjoying **alpa**. 163 | 164 | **If you're still facing issues, refer the [troubleshooting & help section](https://github.com/vsnthdev/alpa#-troubleshooting--help) for further information.** 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | ## 📰 License 173 | > The **alpa** project is released under the [AGPL-3.0-only](https://github.com/vsnthdev/alpa/blob/main/LICENSE.md).
Developed & maintained By Vasanth Srivatsa. Copyright 2022 © Vasanth Developer. 174 |
175 | 176 | > vsnth.dev  ·  177 | > YouTube @vasanthdeveloper  ·  178 | > Twitter @vsnthdev  ·  179 | > Discord Vasanth Developer 180 | -------------------------------------------------------------------------------- /api/http/auth/login.http: -------------------------------------------------------------------------------- 1 | # 2 | # Logs into the API by generating a token stored within 🍪. 3 | # Created On 30 January 2022 4 | # 5 | 6 | POST http://localhost:1727/api/auth/login 7 | Content-Type: application/json 8 | 9 | { 10 | "username": "alpa", 11 | "password": "short_lived" 12 | } 13 | -------------------------------------------------------------------------------- /api/http/auth/logout.http: -------------------------------------------------------------------------------- 1 | # 2 | # Blacklists the JWT if the user is already logged in. 3 | # Created On 02 February 2022 4 | # 5 | 6 | DELETE http://localhost:1727/api/auth/logout 7 | Authorization: Bearer {{$dotenv TOKEN}} 8 | -------------------------------------------------------------------------------- /api/http/code.http: -------------------------------------------------------------------------------- 1 | GET http://localhost:1727/twitter 2 | -------------------------------------------------------------------------------- /api/http/codes/delete.http: -------------------------------------------------------------------------------- 1 | DELETE http://localhost:1727/api/codes/twitter 2 | Authorization: Bearer {{$dotenv TOKEN}} 3 | -------------------------------------------------------------------------------- /api/http/codes/list.http: -------------------------------------------------------------------------------- 1 | GET http://localhost:1727/api/codes 2 | Authorization: Bearer {{$dotenv TOKEN}} 3 | 4 | ### 5 | 6 | GET http://localhost:1727/api/codes?page=1 7 | Authorization: Bearer {{$dotenv TOKEN}} 8 | 9 | ### 10 | 11 | GET http://localhost:1727/api/codes?search=official 12 | Authorization: Bearer {{$dotenv TOKEN}} 13 | -------------------------------------------------------------------------------- /api/http/codes/make.http: -------------------------------------------------------------------------------- 1 | # 2 | # Creates a new short code. 3 | # Created On 02 February 2022 4 | # 5 | 6 | POST http://localhost:1727/api/codes 7 | Content-Type: application/json 8 | Authorization: Bearer {{$dotenv TOKEN}} 9 | 10 | { 11 | "code": "twitter", 12 | "tags": "Official;Social", 13 | "links": [ 14 | { 15 | "url": "https://twitter.com/vsnthdev" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /api/http/codes/patch.http: -------------------------------------------------------------------------------- 1 | # 2 | # Updates an existing short code. 3 | # Created On 02 February 2022 4 | # 5 | 6 | POST http://localhost:1727/api/codes?force=true 7 | Content-Type: application/json 8 | Authorization: Bearer {{$dotenv TOKEN}} 9 | 10 | { 11 | "code": "twitter", 12 | "links": [ 13 | { 14 | "url": "https://github.com/vsnthdev" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /api/http/config/patch.http: -------------------------------------------------------------------------------- 1 | # 2 | # Creates, updates an existing, or deletes configuration. 3 | # Created On 10 May 2022 4 | # 5 | 6 | POST http://localhost:1727/api/config 7 | Content-Type: application/json 8 | Authorization: Bearer {{$dotenv TOKEN}} 9 | 10 | { 11 | "server": { 12 | "host": "0.0.0.0", 13 | "port": 1727, 14 | "cors": ["https://alpa.link"] 15 | } 16 | } 17 | 18 | ### 19 | 20 | # to delete a config key, simply pass it with 21 | # the value set to null 22 | 23 | POST http://localhost:1727/api/config 24 | Content-Type: application/json 25 | Authorization: Bearer {{$dotenv TOKEN}} 26 | 27 | { 28 | "server": { 29 | "port": null 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "exec": "node dist/index.js --verbose", 3 | "ext": "js,json", 4 | "verbose": false, 5 | "quiet": true, 6 | "ignore": [ 7 | "./src" 8 | ], 9 | "watch": [ 10 | "./dist" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /api/notes/database.md: -------------------------------------------------------------------------------- 1 | ## Preparing to migrate to RedisJSON and RediSearch 2 | 3 | Firstly, run the docker container of RediSearch with RedisJSON 👇 4 | 5 | ```bash 6 | docker run -it -p 6379:6379 --rm --name redis redislabs/redisearch:2.4.0 7 | ``` 8 | 9 | then, execute `redis-cli` within the running docker container 👇 10 | 11 | ```bash 12 | docker exec -it redis redis-cli 13 | ``` 14 | 15 | Now list all the indexes using the following RediSearch command 👇 16 | 17 | ``` 18 | FT._LIST 19 | ``` 20 | 21 | If the `codes` index isn't found, we execute this command 👇 to create a new index with that name. 22 | 23 | ``` 24 | FT.CREATE userIdx ON JSON SCHEMA $.user.name AS name TEXT $.user.tags AS tags TAG SEPARATOR ";" 25 | ``` 26 | 27 | Then we can normally insert data using the following RedisJSON schema 👇 28 | 29 | ``` 30 | JSON.SET myDoc $ '{"user":{"name":"John Smith","tags":"foo;bar","hp":1000, "dmg":150}}' 31 | ``` 32 | 33 | Example search queries which can used within the Websocket 👇 34 | 35 | ``` 36 | FT.SEARCH userIdx '@name:(John)' 37 | FT.SEARCH userIdx '@tags:{foo | bar}' 38 | ``` 39 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alpa/api", 3 | "description": "The core RESTful API 🛠️ that handles redirection in alpa.", 4 | "version": "1.1.1", 5 | "type": "module", 6 | "scripts": { 7 | "clean": "rimraf dist tsconfig.tsbuildinfo", 8 | "build": "tsc --incremental false", 9 | "build:docker": "cd .. && docker build . --tag vsnthdev/alpa-api:latest -f Dockerfile.api", 10 | "watch": "tsc --watch", 11 | "start": "tsc --incremental false && nodemon" 12 | }, 13 | "dependencies": { 14 | "@node-redis/json": "^1.0.2", 15 | "@node-redis/search": "^1.0.2", 16 | "ajv": "^8.11.0", 17 | "boom": "^7.3.0", 18 | "deepmerge": "^4.2.2", 19 | "dot-object": "^2.1.4", 20 | "fastify": "^3.27.0", 21 | "fastify-boom": "^1.0.0", 22 | "fastify-cors": "^6.0.2", 23 | "fastify-jwt": "^4.1.3", 24 | "fastify-plugin": "^3.0.1", 25 | "gravatar": "^1.8.2", 26 | "redis": "^4.0.3" 27 | }, 28 | "devDependencies": { 29 | "@types/boom": "^7.3.1", 30 | "@types/dot-object": "^2.1.2", 31 | "@types/gravatar": "^1.8.3", 32 | "nodemon": "^2.0.15" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/src/app.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Reads the package.json file and exposes the values 3 | * as variables to be accessed in the program. 4 | * Created On 17 February 2022 5 | */ 6 | 7 | import dirname from 'es-dirname' 8 | import { readFile } from 'fs/promises' 9 | import { join } from 'path' 10 | 11 | interface PackageJSON { 12 | name: string 13 | description: string 14 | version: string 15 | } 16 | 17 | export let app: PackageJSON 18 | 19 | export default async () => { 20 | app = JSON.parse( 21 | await readFile(join(dirname(), '..', 'package.json'), 'utf-8'), 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /api/src/config/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Reads the configuration file in the root directory. 3 | * Created On 30 January 2022 4 | */ 5 | 6 | import { promise } from '@vsnthdev/utilities-node' 7 | import chalk from 'chalk' 8 | import dirname from 'es-dirname' 9 | import fs from 'fs/promises' 10 | import path from 'path' 11 | import { parse } from 'yaml' 12 | 13 | import { log } from '../logger.js' 14 | import type { AlpaAPIConfig } from './interface.js' 15 | 16 | export let config: AlpaAPIConfig 17 | 18 | export default async (): Promise => { 19 | const loc = path.join(dirname(), '..', '..', 'config.yml') 20 | 21 | const { err, returned: str } = await promise.handle( 22 | fs.readFile(loc, 'utf-8'), 23 | ) 24 | 25 | err && 26 | log.error( 27 | `Could not read config file at :point_down:\n${chalk.gray.underline( 28 | loc, 29 | )}\n`, 30 | 2, 31 | ) 32 | 33 | try { 34 | config = parse(str) as AlpaAPIConfig 35 | } catch { 36 | log.error( 37 | 'Failed to parse config file. Read from :point_down:\n${chalk.gray.underline(loc)}\n', 38 | 2, 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /api/src/config/interface.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Contains interface for a configuration file. 3 | * Created On 30 January 2022 4 | */ 5 | 6 | export interface AlpaAPIConfig { 7 | auth: { 8 | username: string 9 | email: string 10 | password: string 11 | } 12 | database: { 13 | connection: string 14 | channels: { 15 | codes: number 16 | tokens: number 17 | config: number 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/src/database/config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Contains default values for different configs stored in the database. 3 | * Created On 10 May 2022 4 | */ 5 | 6 | import merge from 'deepmerge' 7 | import dot from 'dot-object' 8 | 9 | import { db } from './index.js' 10 | 11 | const defaults = { 12 | server: { 13 | host: process.env.NODE_ENV != 'production' ? '0.0.0.0' : 'localhost', 14 | port: 1727, 15 | secret: '3PWSzUzBRA722PdnyFwzVrXbangmFsQkLe98jjaEnDw9o8cW7fcWNkURc92GB5SF', 16 | cors: [], 17 | }, 18 | } 19 | 20 | const get = async (key: string): Promise => { 21 | try { 22 | const inDb = (await db.config.json.get('config', { 23 | path: [`.${key}`], 24 | })) as string 25 | 26 | return inDb == null ? dot.pick(key, defaults) : inDb 27 | } catch { 28 | return dot.pick(key, defaults) 29 | } 30 | } 31 | 32 | const deleteKeys = (change: any, current: any) => { 33 | const dotted = dot.dot(change) 34 | const toDelete = Object.keys(dotted).filter(key => dotted[key] == null) 35 | 36 | for (const key of toDelete) { 37 | dot.delete(key, current) 38 | dot.delete(key, change) 39 | } 40 | } 41 | 42 | const set = async (change: any): Promise => { 43 | // fetch existing config 44 | let current = await db.config.json.get('config') 45 | 46 | // handle when there's no existing config 47 | if (current == null) current = {} 48 | 49 | // remove any nulls, and undefined values 50 | deleteKeys(change, current) 51 | 52 | // merge both the updated one with full config 53 | // to get the final config object 54 | const overwriteMerge = (destinationArray, sourceArray) => sourceArray 55 | current = merge(change, current, { 56 | arrayMerge: overwriteMerge, 57 | }) 58 | 59 | // write back the updated config 60 | await db.config.json.set('config', '$', current) 61 | } 62 | 63 | export default { get, set } 64 | -------------------------------------------------------------------------------- /api/src/database/houseKeeping/createSearchIndex/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Creates a search index every time the program starts. 3 | * Created On 20 May 2022 4 | */ 5 | 6 | import { SchemaFieldTypes } from 'redis' 7 | 8 | import { log } from '../../../logger.js' 9 | import { db } from '../../index.js' 10 | 11 | export default async () => { 12 | const { codes } = db 13 | 14 | const existing = await codes.ft._list() 15 | if (existing.includes('codes') == false) { 16 | // clear all existing indexes 17 | for (const key of existing) await codes.ft.dropIndex(key) 18 | 19 | await codes.ft.create( 20 | 'codes', 21 | { 22 | '$.tags': { 23 | type: SchemaFieldTypes.TAG, 24 | AS: 'tags', 25 | SEPARATOR: ';', 26 | }, 27 | }, 28 | { 29 | ON: 'JSON', 30 | }, 31 | ) 32 | 33 | log.info('Updating search index for codes') 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /api/src/database/houseKeeping/reflectSortedList/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Primarily does two things: 3 | * 1. Makes sure every short code is added into the sorted array 4 | * for querying. 5 | * 2. Makes sure that non-existing short codes are removed from sorted 6 | * array to maintain a smaller footprint. 7 | * Created On 20 May 2022 8 | */ 9 | 10 | import { log } from '../../../logger.js' 11 | import { db } from '../../index.js' 12 | 13 | // picked up from 👇 14 | // https://stackoverflow.com/questions/44855276/how-do-i-split-a-number-into-chunks-of-max-100-in-javascript 15 | const getChunks = (total: number, chunkSize: number) => 16 | Array.from( 17 | { length: Math.ceil(total / chunkSize) }, 18 | (_: unknown, k: number) => { 19 | const leftOver = total - k * 100 20 | return leftOver > 100 ? 100 : leftOver 21 | }, 22 | ) 23 | 24 | // goes through every sorted array and checks if a key of that 25 | // name actually exists, if not it deletes the entry from sorted array 26 | const cleanUpSortedArray = async (): Promise => { 27 | const total: number = await db.config.zCount('codes', '-inf', '+inf') 28 | const chunks = getChunks(total, 100) 29 | 30 | let start = 0 31 | for (const chunk of chunks) { 32 | const end = start + chunk 33 | 34 | const keys = await db.config.zRange('codes', start, end) 35 | 36 | for (const key of keys) { 37 | const exists = Boolean(await db.codes.exists(key)) 38 | 39 | if (!exists) { 40 | await db.config.zRem('codes', key) 41 | } 42 | } 43 | 44 | start += chunk 45 | } 46 | } 47 | 48 | const populateSortedArray = async (): Promise => { 49 | // fetch the section from the database 50 | let cursor = 100 51 | const previousCursors: number[] = [] 52 | // eslint-disable-next-line no-constant-condition 53 | while (1) { 54 | const { keys, cursor: nextCursor } = await db.codes.scan(cursor) 55 | cursor = nextCursor 56 | 57 | if (previousCursors.includes(cursor)) break 58 | 59 | for (const key of keys) { 60 | // fetch the last value in sorted list and it's score 61 | let lastScore: number 62 | try { 63 | const [last] = await db.config.zRangeWithScores('codes', 0, 0, { 64 | REV: true, 65 | }) 66 | 67 | lastScore = last.score 68 | } catch { 69 | lastScore = 0 70 | } 71 | 72 | // add the newly created code to our sorted set 73 | await db.config.zAdd('codes', { score: lastScore + 1, value: key }) 74 | } 75 | 76 | previousCursors.push(cursor) 77 | } 78 | } 79 | 80 | export default async (): Promise => { 81 | // perform both operations at the same time in async 82 | await Promise.all([cleanUpSortedArray(), populateSortedArray()]) 83 | 84 | log.info('Sorted array reflection finished') 85 | } 86 | -------------------------------------------------------------------------------- /api/src/database/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Connects to the codes Redis database. 3 | * Created On 30 January 2022 4 | */ 5 | 6 | import { createClient, RedisClientType } from 'redis' 7 | 8 | import { config } from '../config/index.js' 9 | import { log } from '../logger.js' 10 | 11 | export interface ConnectionsList { 12 | codes: any | null 13 | tokens: RedisClientType | null 14 | config: any | null 15 | } 16 | 17 | export let db: ConnectionsList 18 | 19 | export default async () => { 20 | const failedConnecting = () => 21 | log.error('Failed connecting to the database.', 2) 22 | 23 | const { database } = config 24 | 25 | db = { 26 | codes: null, 27 | tokens: null, 28 | config: null, 29 | } 30 | 31 | for (const key in db) { 32 | db[key] = createClient({ 33 | url: database.connection, 34 | database: database.channels[key], 35 | }) 36 | 37 | db[key].on('error', failedConnecting) 38 | 39 | await db[key].connect() 40 | await db[key].info() 41 | } 42 | 43 | log.success(`Connected with the Redis database`) 44 | } 45 | -------------------------------------------------------------------------------- /api/src/houseKeeping.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Performs maintenance tasks to maintain database & it's data 3 | * as @alpa/api grows and changes how database is handled. 4 | * Created On 20 May 2022 5 | */ 6 | 7 | import createSearchIndex from './database/houseKeeping/createSearchIndex/index.js' 8 | import reflectSortedList from './database/houseKeeping/reflectSortedList/index.js' 9 | 10 | export default async (): Promise => { 11 | await Promise.all([createSearchIndex(), reflectSortedList()]) 12 | } 13 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Entryfile for @alpa/api project. 3 | * Created On 30 January 2022 4 | */ 5 | 6 | import getApp from './app.js' 7 | import getConfig from './config/index.js' 8 | import getDatabase from './database/index.js' 9 | import houseKeeping from './houseKeeping.js' 10 | import getLog from './logger.js' 11 | import startServer from './server/index.js' 12 | 13 | await getApp() 14 | await getLog() 15 | await getConfig() 16 | await getDatabase() 17 | await houseKeeping() 18 | await startServer() 19 | -------------------------------------------------------------------------------- /api/src/logger.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Configures itivrutaha logger module for @alpa/api project. 3 | * Created On 30 January 2022 4 | */ 5 | 6 | import chalk from 'chalk' 7 | import itivrutaha from 'itivrutaha' 8 | import { Logger } from 'itivrutaha/dist/class' 9 | 10 | export let log: Logger 11 | 12 | export default async (): Promise => { 13 | log = await itivrutaha.createNewLogger({ 14 | appName: '@alpa/api', 15 | }) 16 | 17 | log.note( 18 | `Running in ${chalk.whiteBright.bold( 19 | process.env.NODE_ENV ? process.env.NODE_ENV : 'development', 20 | )} mode`, 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /api/src/server/cors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Contains utility function to fetch the CORS domains. 3 | * Created On 12 May 2022 4 | */ 5 | 6 | import config from '../database/config.js' 7 | 8 | // fetches all the Cross-Origin allowed 9 | // domains, so that we can allow requests from them 10 | export default async (): Promise => { 11 | const allowed = ((await config.get('server.cors')) || []) as string[] 12 | 13 | // automatically allow localhost:3000 during development 14 | const prod = process.env.NODE_ENV == 'production' 15 | if (prod == false) allowed.push('http://localhost:3000') 16 | 17 | return allowed 18 | } 19 | -------------------------------------------------------------------------------- /api/src/server/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Sets up & starts Fastify API server. 3 | * Created On 30 January 2022 4 | */ 5 | 6 | import chalk from 'chalk' 7 | import Fastify, { FastifyInstance } from 'fastify' 8 | import boom from 'fastify-boom' 9 | import cors from 'fastify-cors' 10 | import jwt from 'fastify-jwt' 11 | 12 | import config from '../database/config.js' 13 | import { log } from '../logger.js' 14 | import getCors from './cors.js' 15 | import getRoutes from './routes.js' 16 | 17 | export let fastify: FastifyInstance 18 | 19 | const listen = (port, host): Promise => 20 | new Promise(resolve => { 21 | fastify.listen(port, host, err => { 22 | // log the error and terminate execution 23 | err && log.error(err, 2) 24 | 25 | // log the success and resolve promise 26 | log.success( 27 | `${chalk.whiteBright.bold( 28 | '@alpa/api', 29 | )} listening at ${chalk.gray.underline( 30 | `http://${host}:${port}`, 31 | )}`, 32 | ) 33 | resolve() 34 | }) 35 | }) 36 | 37 | export default async (): Promise => { 38 | // get required variables to start the server 39 | const host = (await config.get('server.host')) as string 40 | const port = (await config.get('server.port')) as number 41 | const secret = (await config.get('server.secret')) as string 42 | 43 | // create a new Fastify server 44 | fastify = Fastify({ 45 | // todo: implement a custom logger, and attach it here 46 | logger: false, 47 | }) 48 | 49 | // register the JWT plugin 50 | fastify.register(jwt, { 51 | secret: secret, 52 | }) 53 | 54 | // register the Cross-Origin Resource Policy plugin 55 | fastify.register(cors, { 56 | methods: ['GET', 'POST', 'DELETE'], 57 | credentials: true, 58 | origin: await getCors(), 59 | allowedHeaders: ['Authorization', 'Content-Type'], 60 | }) 61 | 62 | // register the error handling plugin 63 | fastify.register(boom) 64 | 65 | // dynamically load our routes 66 | await getRoutes(fastify) 67 | 68 | // start listening for requests from the created Fastify server 69 | await listen(port, host) 70 | } 71 | -------------------------------------------------------------------------------- /api/src/server/plugins/auth.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * JWT token verification plugin to protect routes that require login. 3 | * Created On 02 February 2022 4 | */ 5 | 6 | import boom from 'boom' 7 | import { FastifyRequest } from 'fastify' 8 | import fp from 'fastify-plugin' 9 | 10 | import { db } from '../../database/index.js' 11 | 12 | const func: any = async (req: FastifyRequest) => { 13 | try { 14 | // check if that token exists in our black list 15 | // if yes, then we simply throw an error, so it gets 16 | // caught by the try/catch and throws 401 Unauthorized 17 | const token = req.headers.authorization?.slice(7) as string 18 | if (await db.tokens?.exists(token)) throw new Error('Unauthorized') 19 | 20 | // if the token isn't in our black list then we check for validity 21 | await req.jwtVerify() 22 | } catch (err) { 23 | throw boom.unauthorized() 24 | } 25 | } 26 | 27 | export default fp(func) 28 | -------------------------------------------------------------------------------- /api/src/server/routes.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Contains function to dynamically load the routes. 3 | * Created On 12 May 2022 4 | */ 5 | 6 | import dirname from 'es-dirname' 7 | import { 8 | FastifyInstance, 9 | FastifyLoggerInstance, 10 | FastifySchema, 11 | RouteOptions, 12 | } from 'fastify' 13 | import { RouteGenericInterface } from 'fastify/types/route' 14 | import glob from 'glob' 15 | import { IncomingMessage, Server, ServerResponse } from 'http' 16 | import path from 'path' 17 | 18 | type FastifyImpl = FastifyInstance< 19 | Server, 20 | IncomingMessage, 21 | ServerResponse, 22 | FastifyLoggerInstance 23 | > 24 | 25 | interface RouteOptionsImpl 26 | extends Omit< 27 | RouteOptions< 28 | Server, 29 | IncomingMessage, 30 | ServerResponse, 31 | RouteGenericInterface, 32 | unknown, 33 | FastifySchema 34 | >, 35 | 'url' 36 | > { 37 | url: string[] 38 | } 39 | 40 | const addRoute = async (fastify: FastifyImpl, file: string) => { 41 | // dynamically import the route file 42 | const { default: route }: { default: RouteOptionsImpl } = await import( 43 | `file://${file}` 44 | ) 45 | 46 | for (const url of route.url) { 47 | fastify.route({ ...route, ...{ url } }) 48 | } 49 | } 50 | 51 | export default async (fastify: FastifyImpl): Promise => { 52 | // get all the route files 53 | const dir = path.join(dirname(), 'routes', '**', 'index.js') 54 | const files = glob.sync(dir, { 55 | nodir: true, 56 | noext: true, 57 | }) 58 | 59 | // load each single route by processing it 60 | // before attaching to the fastify server 61 | const promises: Promise[] = [] 62 | for (const file of files) { 63 | // process each route in async 64 | // for better startup 65 | promises.push(addRoute(fastify, file)) 66 | } 67 | 68 | // wait until all the route adding promises are finished 69 | await Promise.all(promises) 70 | } 71 | -------------------------------------------------------------------------------- /api/src/server/routes/auth/login/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Takes username, password and responds with a JWT token. 3 | * Created On 02 February 2022 4 | */ 5 | 6 | import boom from 'boom' 7 | import { FastifyReply, FastifyRequest } from 'fastify' 8 | 9 | import { config } from '../../../../config/index.js' 10 | 11 | interface BodyImpl { 12 | username: string 13 | password: string 14 | } 15 | 16 | const handler = async (req: FastifyRequest, rep: FastifyReply) => { 17 | const body = req.body as BodyImpl 18 | 19 | if ( 20 | !body || 21 | [ 22 | body.username, 23 | body.password, 24 | body.username == config.auth.username, 25 | body.password == config.auth.password, 26 | ] 27 | .map(elm => Boolean(elm)) 28 | .includes(false) 29 | ) 30 | throw boom.unauthorized() 31 | 32 | const token = await rep.jwtSign( 33 | { 34 | username: config.auth.username, 35 | email: config.auth.email, 36 | }, 37 | { 38 | sign: { 39 | expiresIn: 259200, 40 | }, 41 | }, 42 | ) 43 | 44 | return rep.status(200).send({ 45 | message: 'Login was successful', 46 | token, 47 | }) 48 | } 49 | 50 | export default { 51 | handler, 52 | method: 'POST', 53 | url: ['/api/auth/login'], 54 | } 55 | -------------------------------------------------------------------------------- /api/src/server/routes/auth/logout/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Blacklists the token until expired to prevent usage. 3 | * Created On 02 February 2022 4 | */ 5 | 6 | import { FastifyReply, FastifyRequest } from 'fastify' 7 | import { DecodePayloadType } from 'fastify-jwt' 8 | 9 | import { db } from '../../../../database/index.js' 10 | import { fastify } from '../../../index.js' 11 | import auth from '../../../plugins/auth.js' 12 | 13 | const handler = async (req: FastifyRequest, rep: FastifyReply) => { 14 | const token = req.headers.authorization?.slice(7) as string 15 | const decoded = fastify.jwt.decode(token) as DecodePayloadType 16 | const secondsRemaining = 17 | decoded['exp'] - Math.round(new Date().getTime() / 1000) 18 | 19 | await db.tokens?.set(token, 1, { 20 | EX: secondsRemaining, 21 | }) 22 | 23 | return rep.status(204).send('') 24 | } 25 | 26 | export default { 27 | handler, 28 | method: 'DELETE', 29 | url: ['/api/auth/logout'], 30 | preValidation: [auth], 31 | } 32 | -------------------------------------------------------------------------------- /api/src/server/routes/code/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Redirects if short code is found or returns 404. 3 | * Created On 03 February 2022 4 | */ 5 | 6 | import boom from 'boom' 7 | import { FastifyReply, FastifyRequest } from 'fastify' 8 | 9 | import { db } from '../../../database/index.js' 10 | import { CodeLink } from '../codes/make' 11 | 12 | export interface ParamsImpl { 13 | code: string 14 | } 15 | 16 | const handler = async (req: FastifyRequest, rep: FastifyReply) => { 17 | // read the URL parameters 18 | const params = req.params as ParamsImpl 19 | 20 | // determine the code, or use _root if no code is provided 21 | const links = (await db.codes.json.get(params.code || '_root', { 22 | path: ['links'], 23 | })) as CodeLink[] 24 | 25 | // handle when links don't exist 26 | if (!links) throw boom.notFound() 27 | 28 | // apply caching headers 29 | rep.header('Cache-Control', 'max-age=60') 30 | 31 | // only single link redirection has been implemented till now 32 | // for multiple, we either send a JavaScript response 33 | // or a full HTML page rendered on the server 34 | if (links.length == 1) { 35 | return rep.redirect(301, links[0].url) 36 | } else { 37 | throw boom.notImplemented() 38 | } 39 | } 40 | 41 | export default { 42 | handler, 43 | method: 'GET', 44 | url: ['/:code', '/'], 45 | } 46 | -------------------------------------------------------------------------------- /api/src/server/routes/codes/delete/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Deletes a short code from the database. 3 | * Created On 03 February 2022 4 | */ 5 | 6 | import boom from 'boom' 7 | import { FastifyReply, FastifyRequest } from 'fastify' 8 | 9 | import { db } from '../../../../database/index.js' 10 | import auth from '../../../plugins/auth.js' 11 | 12 | export interface ParamsImpl { 13 | code: string 14 | } 15 | 16 | const handler = async (req: FastifyRequest, rep: FastifyReply) => { 17 | const params = req.params as ParamsImpl 18 | 19 | // check if the given code exists 20 | const exists = await db.codes.exists(params.code) 21 | if (!exists) throw boom.notFound() 22 | 23 | // remove it from our codes database 24 | await db.codes.del(params.code) 25 | 26 | // and also from our sorted array 27 | await db.config.zRem('codes', params.code) 28 | 29 | return rep.status(204).send('') 30 | } 31 | 32 | export default { 33 | handler, 34 | method: 'DELETE', 35 | url: ['/api/codes/:code'], 36 | preValidation: [auth], 37 | } 38 | -------------------------------------------------------------------------------- /api/src/server/routes/codes/list/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * List out all short codes with pagination. 3 | * Created On 03 February 2022 4 | */ 5 | 6 | import { FastifyReply, FastifyRequest } from 'fastify' 7 | 8 | import { db } from '../../../../database/index.js' 9 | import auth from '../../../plugins/auth.js' 10 | import { Code } from '../make/index.js' 11 | 12 | interface RequestQuery { 13 | page: string 14 | search: string 15 | } 16 | 17 | interface ResponseImpl { 18 | pages: number 19 | codes: Code[] 20 | } 21 | 22 | const keysToCodes = async (keys: string[]): Promise => { 23 | const codes: any[] = [] 24 | 25 | for (const key of keys) { 26 | const code = await db.codes.json.get(key) 27 | codes.push({ ...{ code: key }, ...code }) 28 | } 29 | 30 | return codes 31 | } 32 | 33 | const documentsToCodes = async (docs: any[]) => { 34 | const codes: any[] = [] 35 | 36 | for (const doc of docs) { 37 | doc.value['code'] = doc.id 38 | codes.push(doc.value) 39 | } 40 | 41 | return codes 42 | } 43 | 44 | const getRecentList = async (query: RequestQuery): Promise => { 45 | // get the number of total keys in the database 46 | const total: number = await db.codes.dbSize() 47 | 48 | // create a response skeleton object 49 | const res: ResponseImpl = { 50 | pages: -1, 51 | codes: [], 52 | } 53 | 54 | // handle when there are no codes in the database 55 | if (total == 0) return { ...res, ...{ pages: 0 } } 56 | 57 | // initialize the cursor variable 58 | if (typeof query.page != 'string') query.page = '0' 59 | 60 | // now fetch keys from our sorted set in Redis 61 | const count = 10 62 | const start = count * parseInt(query.page) 63 | const end = start + (count - 1) 64 | 65 | const keys = await db.config.zRange('codes', start, end, { 66 | REV: true, 67 | }) 68 | 69 | // convert database keys to actual codes 70 | const codes = await keysToCodes(keys) 71 | 72 | return { ...res, ...{ pages: Math.round(total / count), codes } } 73 | } 74 | 75 | const executeQuery = async ({ search }: RequestQuery) => { 76 | // remove any special characters since 77 | // that crashes the server 78 | search = search.replace(/[^a-zA-Z0-9 ]/g, '') 79 | 80 | // the result 81 | const results = { codes: [] } 82 | 83 | // don't perform a search, if input is nothing 84 | if (!search) return results 85 | 86 | // search for direct keys 87 | const { keys } = await db.codes.scan(0, { 88 | MATCH: `"${search}*"`, 89 | }) 90 | 91 | results.codes = results.codes.concat((await keysToCodes(keys)) as any) 92 | 93 | // search for tags 94 | const { documents } = await db.codes.ft.search( 95 | 'codes', 96 | `@tags:{ ${search 97 | .split(' ') 98 | .map(tag => `${tag}*`) 99 | .join(' | ') 100 | .trim()} }`, 101 | ) 102 | 103 | results.codes = results.codes.concat( 104 | (await documentsToCodes(documents)) as any, 105 | ) 106 | 107 | return results 108 | } 109 | 110 | const handler = async (req: FastifyRequest, rep: FastifyReply) => { 111 | const query = req.query as RequestQuery 112 | const toSend = rep.status(200) 113 | 114 | if (query.search) { 115 | return toSend.send(await executeQuery(query)) 116 | } else { 117 | return toSend.send(await getRecentList(query)) 118 | } 119 | } 120 | 121 | export default { 122 | handler, 123 | method: 'GET', 124 | url: ['/api/codes'], 125 | preValidation: [auth], 126 | } 127 | -------------------------------------------------------------------------------- /api/src/server/routes/codes/make/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Creates a new short code. 3 | * Created On 03 February 2022 4 | */ 5 | 6 | import boom from 'boom' 7 | import { FastifyReply, FastifyRequest } from 'fastify' 8 | 9 | import { db } from '../../../../database/index.js' 10 | import auth from '../../../plugins/auth.js' 11 | 12 | export interface CodeLink { 13 | title: string 14 | icon: string 15 | image: string 16 | url: string 17 | } 18 | 19 | export interface Code { 20 | code?: string 21 | tags: string 22 | links: CodeLink[] 23 | } 24 | 25 | const handler = async (req: FastifyRequest, rep: FastifyReply) => { 26 | const body = req.body as Code 27 | const code = body.code 28 | const query = req.query as any 29 | delete body.code 30 | 31 | if (code == 'api') 32 | throw boom.notAcceptable('A code named api cannot be created.') 33 | 34 | const exists = await db.codes.exists(code) 35 | if (exists && Boolean(query['force']) == false) 36 | throw boom.conflict('That code already exists') 37 | 38 | await db.codes.json.set(code, '$', body) 39 | 40 | if (exists) { 41 | return rep.status(200).send({ 42 | message: 'Updated the code', 43 | }) 44 | } else { 45 | // fetch the last value in sorted list and it's score 46 | let lastScore: number 47 | try { 48 | const [last] = await db.config.zRangeWithScores('codes', 0, 0, { 49 | REV: true, 50 | }) 51 | 52 | lastScore = last.score 53 | } catch { 54 | lastScore = 0 55 | } 56 | 57 | // add the newly created code to our sorted set 58 | await db.config.zAdd('codes', { score: lastScore + 1, value: code }) 59 | 60 | return rep.status(201).send({ 61 | message: 'Created a new code', 62 | }) 63 | } 64 | } 65 | 66 | export default { 67 | handler, 68 | method: 'POST', 69 | url: ['/api/codes'], 70 | preValidation: [auth], 71 | } 72 | -------------------------------------------------------------------------------- /api/src/server/routes/config/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Creates, updates an existing, or deletes configuration. 3 | * Created On 10 May 2022 4 | */ 5 | 6 | import boom from 'boom' 7 | import { FastifyReply, FastifyRequest } from 'fastify' 8 | 9 | import config from '../../../database/config.js' 10 | import auth from '../../plugins/auth.js' 11 | import validate from './validate.js' 12 | 13 | export interface ResponseImpl { 14 | message: string 15 | data?: any 16 | } 17 | 18 | const ajvErrorResponseTransform = (func: any, err: any) => { 19 | err.output.payload['data'] = func.errors?.map((e: any) => { 20 | delete e.schemaPath 21 | return e 22 | }) 23 | throw err 24 | } 25 | 26 | const handler = async (req: FastifyRequest, rep: FastifyReply) => { 27 | // validate user input 28 | if (!validate(req.body as any)) { 29 | const err = boom.badRequest('Invalid config request') 30 | ajvErrorResponseTransform(validate, err) 31 | } 32 | 33 | // write the changes to the database 34 | await config.set(req.body) 35 | 36 | return rep.status(201).send({ 37 | message: 'Updated config accordingly', 38 | }) 39 | } 40 | 41 | export default { 42 | handler, 43 | method: 'POST', 44 | url: ['/api/config'], 45 | preValidation: [auth], 46 | } 47 | -------------------------------------------------------------------------------- /api/src/server/routes/config/validate.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Validates the user input to only accept allowed config keys. 3 | * Created On 11 May 2022 4 | */ 5 | 6 | import Ajv, { JSONSchemaType } from 'ajv' 7 | 8 | export interface Schema { 9 | server?: { 10 | host?: string 11 | port?: number 12 | cors?: string[] 13 | } 14 | } 15 | 16 | const schema: JSONSchemaType = { 17 | type: 'object', 18 | // additionalProperties: false, 19 | properties: { 20 | server: { 21 | type: 'object', 22 | nullable: true, 23 | additionalProperties: false, 24 | properties: { 25 | host: { 26 | type: 'string', 27 | nullable: true, 28 | }, 29 | port: { 30 | type: 'number', 31 | nullable: true, 32 | }, 33 | cors: { 34 | type: 'array', 35 | nullable: true, 36 | minItems: 1, 37 | items: { 38 | type: 'string', 39 | minLength: 4, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | } 46 | 47 | export default new Ajv().compile(schema) 48 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "strictNullChecks": true, 5 | "rootDir": "src", 6 | "outDir": "dist" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | alpa 5 | 6 | 7 | alpa 8 | 9 |

10 | 11 | 12 | 13 |

( अल्प ) — A fast ⚡ self-hosted link 🔗 shortener.

14 | 15 |

16 | 17 | issues 18 | 19 | 20 | commits 22 | 23 | 24 | docker 25 | 26 | 27 | dashboard status 28 | 29 |

30 | 31 |
32 | 33 | This project contains a friendly dashboard deployed at https://alpa.vercel.app which can be used to control **alpa's API hosted anywhere**. 34 | 35 | ## 🔮 Tech stack 36 | 37 | | Name | Description | 38 | | --- | --- | 39 | | **React.js** | Frontend framework of choice. | 40 | | **Redux** | Store management for React. | 41 | | **TailwindCSS** | CSS framework for rapid UI building. | 42 | | **Vite.js** | For bundling JavaScript. | 43 | | **Vercel** | For deploying frontend. | 44 | | **nanoid** | For creating short codes. | 45 | 46 | ## 💻 Building & Dev Setup 47 | 48 | You need to be at least on **Node.js v17.4.0 or above** and follow the below instructions to build this project 👇 49 | 50 | - **STEP 1️⃣** Clone this repository & enter into it (`cd ./alpa`) 51 | - **STEP 2️⃣** Run **`npm install`** to get all dependencies & link projects together 52 | - **STEP 3️⃣** Enter in the project directory (`cd app`) 53 | - **STEP 4️⃣** To build this project run **`npm run build`** 54 | 55 | Upon building `@alpa/app` a production optimized bundle of React.js app is generated in the `dist` folder within the project. 56 | 57 | ## 📰 License 58 | > The **alpa** project is released under the [AGPL-3.0-only](https://github.com/vsnthdev/alpa/blob/main/LICENSE.md).
Developed & maintained By Vasanth Srivatsa. Copyright 2022 © Vasanth Developer. 59 |
60 | 61 | > vsnth.dev  ·  62 | > YouTube @vasanthdeveloper  ·  63 | > Twitter @vsnthdev  ·  64 | > Discord Vasanth Developer 65 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alpa/app", 3 | "description": "Dashboard ✨ to interact with alpa's API.", 4 | "version": "1.1.1", 5 | "type": "module", 6 | "scripts": { 7 | "clean": "rimraf dist tsconfig.tsbuildinfo", 8 | "start": "vite", 9 | "build": "vite build --emptyOutDir", 10 | "preview": "vite build --emptyOutDir && sirv dist --port 5000", 11 | "build:docker": "cd .. && docker build . --tag vsnthdev/alpa-app:latest -f Dockerfile.app", 12 | "vercel": "vite build --emptyOutDir && vercel --prod && rimraf dist tsconfig.tsbuildinfo" 13 | }, 14 | "devDependencies": { 15 | "@reduxjs/toolkit": "^1.7.2", 16 | "@tippyjs/react": "^4.2.6", 17 | "@types/nprogress": "^0.2.0", 18 | "@types/react": "^17.0.39", 19 | "@types/react-dom": "^17.0.11", 20 | "@types/react-tag-input": "^6.1.3", 21 | "@types/tailwindcss": "^3.0.7", 22 | "@vitejs/plugin-react": "^1.2.0", 23 | "autoprefixer": "^10.4.2", 24 | "is-mobile": "^3.0.0", 25 | "nanoid": "^3.3.0", 26 | "nprogress": "^0.2.0", 27 | "postcss": "^8.4.6", 28 | "react-dnd": "^14.0.5", 29 | "react-dnd-html5-backend": "^14.1.0", 30 | "react-hotkeys": "^2.0.0", 31 | "react-redux": "^7.2.6", 32 | "react-router-dom": "^6.2.1", 33 | "react-tag-input": "^6.8.0", 34 | "sirv-cli": "^2.0.2", 35 | "tailwindcss": "^3.0.19", 36 | "use-debounce": "^7.0.1", 37 | "vercel": "^24.0.0", 38 | "vite": "^2.7.13", 39 | "vite-plugin-html": "^3.0.3", 40 | "vite-plugin-pwa": "^0.11.13" 41 | }, 42 | "dependencies": { 43 | "react": "^17.0.2", 44 | "react-dom": "^17.0.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/App.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * The App shell that encapsulating the entire application. 3 | * Created On 08 February 2022 4 | */ 5 | 6 | import { ReactElement, StrictMode, useState } from 'react' 7 | import { HotKeys } from 'react-hotkeys' 8 | import { Provider } from 'react-redux' 9 | import { BrowserRouter, Route, Routes } from 'react-router-dom' 10 | 11 | import { prepareModalState } from './components/CodeModal' 12 | import { Sidebar } from './components/Sidebar/Sidebar' 13 | import { Topbar } from './components/Topbar/Topbar' 14 | import { Dash } from './pages/Dash/Dash' 15 | import { Login } from './pages/Login/Login' 16 | import { store } from './store/index.js' 17 | import { hotkeyHandlers, hotkeyMap } from './util/hotkeys' 18 | 19 | export const App = (): ReactElement => { 20 | const [loading, setLoading] = useState(true) 21 | const [quickText, setQuickText] = useState('') 22 | 23 | // prepare modal's required state 24 | const modalState = prepareModalState() 25 | 26 | return ( 27 | 28 | 29 | 30 | 35 | {/* the sidebar */} 36 | 37 | 38 | {/* the routes link to their pages */} 39 |
40 | {/* the topbar */} 41 | 47 | 48 | 49 | } 52 | > 53 | 63 | } 64 | > 65 | 66 |
67 |
68 |
69 |
70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /app/src/components/CodeCard/CodeCard.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * A single code block, which can be edited or modified. 3 | * Created On 11 February 2022 4 | */ 5 | 6 | import Tippy from '@tippyjs/react' 7 | import { Dispatch, ReactElement, useState } from 'react' 8 | import { useDispatch, useSelector } from 'react-redux' 9 | 10 | import { AppState } from '../../store' 11 | import { Code } from '../../store/codes' 12 | import { searchAPI } from '../../util/searchAPI' 13 | import { CodeModalStateReturns, openCodeModal } from '../CodeModal' 14 | import { copyShortURL, del, getColorFromTag } from './index' 15 | 16 | export const CodeCard = ({ 17 | code, 18 | modalState, 19 | quickText, 20 | setQuickText, 21 | }: { 22 | code: Code 23 | quickText: string 24 | modalState: CodeModalStateReturns 25 | setQuickText: Dispatch> 26 | }): ReactElement => { 27 | const [showCopiedTooltip, setShowCopiedToolTip] = useState(false) 28 | const auth = useSelector((state: AppState) => state.auth) 29 | const dispatch = useDispatch() 30 | 31 | return ( 32 |
33 | {/* the code of the item */} 34 |
35 | 36 | {code.code} 37 | 38 |
39 | 40 | {/* the link */} 41 | 51 | 52 | {/* tags & card actions */} 53 |
54 | {/* render tags if exist */} 55 | {code.tags ? ( 56 |
57 | {code.tags.split(';').map(tag => ( 58 | { 61 | setQuickText(tag) 62 | searchAPI({ 63 | auth, 64 | dispatch, 65 | quickText, 66 | }) 67 | }} 68 | className="cursor-pointer text-black px-3 py-1 mr-2 mb-2 rounded-full lg:mb-0" 69 | style={{ 70 | backgroundColor: getColorFromTag(tag), 71 | }} 72 | > 73 | {tag} 74 | 75 | ))} 76 |
77 | ) : ( 78 |

79 | Not tagged 80 |

81 | )} 82 | 83 | {/* the card actions */} 84 |
85 | {/* copy short URL button */} 86 | {Boolean(navigator.clipboard) && ( 87 | 92 | 95 | setShowCopiedToolTip(false) 96 | } 97 | content="✅ Copied short URL" 98 | theme="primary" 99 | animation="shift-away" 100 | inertia={true} 101 | > 102 | 127 | 128 | 129 | )} 130 | 131 | {/* edit button */} 132 | 133 | 154 | 155 | 156 | {/* delete button */} 157 | 162 | 188 | 189 |
190 |
191 |
192 | ) 193 | } 194 | -------------------------------------------------------------------------------- /app/src/components/CodeCard/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Contains additional importable functions to work with short codes. 3 | * Created On 12 February 2022 4 | */ 5 | 6 | import { Dispatch } from '@reduxjs/toolkit' 7 | import axios from 'axios' 8 | 9 | import { Code, del as _del } from '../../store/codes' 10 | 11 | export const del = ({ 12 | apiHost, 13 | apiToken, 14 | code, 15 | dispatch, 16 | }: { 17 | apiHost: string 18 | apiToken: string 19 | code: string 20 | dispatch: Dispatch 21 | }) => 22 | axios({ 23 | method: 'DELETE', 24 | url: `${apiHost}/api/codes/${code}`, 25 | headers: { 26 | Authorization: `Bearer ${apiToken}`, 27 | }, 28 | }).then(() => { 29 | // update our application state 30 | dispatch(_del(code)) 31 | }) 32 | 33 | export const copyShortURL = ({ 34 | code, 35 | apiHost, 36 | setShowCopiedToolTip, 37 | }: { 38 | code: Code 39 | apiHost: string 40 | setShowCopiedToolTip: React.Dispatch> 41 | }) => 42 | navigator.clipboard.writeText(`${apiHost}/${code.code}`).then(() => { 43 | setShowCopiedToolTip(true) 44 | setTimeout(() => setShowCopiedToolTip(false), 1000) 45 | }) 46 | 47 | // copied from 👇 48 | // https://stackoverflow.com/questions/3426404/create-a-hexadecimal-colour-based-on-a-string-with-javascript 49 | export const getColorFromTag = (tag: string) => { 50 | const stringUniqueHash = [...tag].reduce((acc, char) => { 51 | return char.charCodeAt(0) + ((acc << 5) - acc) 52 | }, 0) 53 | 54 | return `hsla(${stringUniqueHash % 360}, 95%, 35%, 0.15)` 55 | } 56 | -------------------------------------------------------------------------------- /app/src/components/CodeModal/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file contains functions, to prepare and handle the state 3 | * of the CodeModal component. But these functions should be invoked 4 | * in the parent component. Not in CodeModal itself. 5 | * Created On 13 February 2022 6 | */ 7 | 8 | import axios from 'axios' 9 | import isMobile from 'is-mobile' 10 | import { customAlphabet } from 'nanoid' 11 | import { Dispatch, useState } from 'react' 12 | 13 | import { AuthState } from '../../store/auth' 14 | import { Code, patch } from '../../store/codes' 15 | 16 | const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 4) 17 | 18 | export interface CodeModalStateReturns { 19 | code: Code 20 | isOpen: boolean 21 | isCreatingNew: boolean 22 | setCode: React.Dispatch> 23 | setIsOpen: React.Dispatch> 24 | setIsCreatingNew: React.Dispatch> 25 | } 26 | 27 | export const prepareModalState = (): CodeModalStateReturns => { 28 | const [isOpen, setIsOpen] = useState(false) 29 | const [isCreatingNew, setIsCreatingNew] = useState(false) 30 | 31 | const [code, setCode] = useState({ 32 | code: '', 33 | links: [ 34 | { 35 | url: '', 36 | }, 37 | ], 38 | tags: '', 39 | } as Code) 40 | 41 | return { code, setCode, isOpen, setIsOpen, isCreatingNew, setIsCreatingNew } 42 | } 43 | 44 | export const openCodeModal = ( 45 | code: Code | null, 46 | state: CodeModalStateReturns, 47 | ) => { 48 | // function to focus on our target input field 49 | const focus = () => 50 | (document.querySelector('#target') as HTMLInputElement).focus() 51 | 52 | // set the code if we're editing 53 | // an existing one, or else set as a new code dialog 54 | if (code) { 55 | state.setIsCreatingNew(false) 56 | state.setCode({ 57 | ...code, 58 | ...{ 59 | tags: code.tags 60 | .split(';') 61 | .map(tag => tag.trim()) 62 | .filter(tag => Boolean(tag)) 63 | .join('; '), 64 | }, 65 | } as Code) 66 | } else { 67 | state.setCode({ 68 | ...state.code, 69 | ...{ code: generateCodeString() }, 70 | } as Code) 71 | state.setIsCreatingNew(true) 72 | } 73 | 74 | // show the modal & set focus on target 75 | state.setIsOpen(true) 76 | isMobile() ? (document.activeElement as HTMLElement).blur() : focus() 77 | } 78 | 79 | const closeModal = (state: CodeModalStateReturns) => state.setIsOpen(false) 80 | 81 | const clearState = (state: CodeModalStateReturns) => 82 | state.setCode({ 83 | code: '', 84 | links: [{ url: '' }], 85 | tags: '', 86 | }) 87 | 88 | export const cancelAction = (state: CodeModalStateReturns) => { 89 | closeModal(state) 90 | clearState(state) 91 | ;(document.querySelector('#btnNew') as any).focus() 92 | } 93 | 94 | export const applyAction = ( 95 | state: CodeModalStateReturns, 96 | dispatch: Dispatch, 97 | auth: AuthState, 98 | ) => { 99 | closeModal(state) 100 | 101 | // prepare a final new Code object 102 | const getTags = (tags: string) => 103 | tags 104 | .split(';') 105 | .map(tag => tag.trim()) 106 | .filter(tag => tag.length > 0) 107 | .join(';') 108 | const final = { ...state.code, ...{ tags: getTags(state.code.tags) } } 109 | 110 | // send HTTP request 111 | axios({ 112 | method: 'POST', 113 | url: `${auth.apiHost}/api/codes?force=true`, 114 | headers: { 115 | Authorization: `Bearer ${auth.apiToken}`, 116 | }, 117 | data: final, 118 | }).then(() => { 119 | // dispatch a app store change 120 | dispatch(patch(final)) 121 | }) 122 | 123 | clearState(state) 124 | } 125 | 126 | export const generateCodeString = (): string => nanoid() 127 | -------------------------------------------------------------------------------- /app/src/components/Topbar/Topbar.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Site-wide header component contains mainly the 3 | * logo & user profile menu. 4 | * Created On 08 February 2022 5 | */ 6 | 7 | import { Dispatch, ReactElement } from 'react' 8 | import { useDispatch, useSelector } from 'react-redux' 9 | import { useNavigate } from 'react-router-dom' 10 | import { useDebouncedCallback } from 'use-debounce' 11 | 12 | import { AppState } from '../../store/index' 13 | import logout from '../../util/logout' 14 | import { searchAPI } from '../../util/searchAPI' 15 | 16 | export const Topbar = ({ 17 | loading, 18 | quickText, 19 | setQuickText, 20 | setLoading, 21 | }: { 22 | loading: boolean 23 | quickText: string 24 | setQuickText: Dispatch> 25 | setLoading: React.Dispatch> 26 | }): ReactElement => { 27 | const navigate = useNavigate() 28 | const dispatch = useDispatch() 29 | 30 | const auth = useSelector((state: AppState) => state.auth) 31 | 32 | const triggerSearchAPI = useDebouncedCallback(async () => { 33 | if (Boolean(quickText) == false) return 34 | 35 | // call search api api 36 | searchAPI({ 37 | auth, 38 | quickText, 39 | dispatch, 40 | }) 41 | }, 200) 42 | 43 | return ( 44 |
45 |
46 | {/* search bar */} 47 |
48 | {auth.isLoggedIn && loading == false && ( 49 | { 56 | setQuickText(e.target.value) 57 | triggerSearchAPI() 58 | }} 59 | /> 60 | )} 61 |
62 | 63 | {/* logout button */} 64 | {auth.isLoggedIn ? ( 65 | 67 | logout({ auth, navigate, dispatch, setLoading }) 68 | } 69 | > 70 | 86 | 87 | ) : ( 88 | '' 89 | )} 90 |
91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Entry Cascading Stylesheet file. Contains mainly TailwindCSS imports. 3 | * Created On 08 February 2022 4 | */ 5 | 6 | @tailwind base; 7 | @tailwind components; 8 | @tailwind utilities; 9 | 10 | /* hide scrollbar */ 11 | 12 | ::-webkit-scrollbar { 13 | display: none; 14 | } 15 | 16 | /* prevent blue color highlight on clicking in mobile devices */ 17 | 18 | * { 19 | -webkit-tap-highlight-color: transparent; 20 | } 21 | 22 | /* prevent black outline on focus */ 23 | 24 | * { 25 | outline: none !important; 26 | } 27 | 28 | /* Tippy.js styles */ 29 | 30 | .tippy-box[data-theme~='primary'] { 31 | @apply bg-primary border-2 border-solid border-primary font-medium; 32 | } 33 | 34 | .tippy-box[data-theme~='primary'] .tippy-arrow { 35 | @apply text-primary; 36 | } 37 | 38 | .tippy-box[data-theme~='light'] { 39 | @apply bg-white text-neutral-900 border-2 border-solid border-neutral-300 font-medium; 40 | } 41 | 42 | .tippy-box[data-theme~='light'] .tippy-arrow { 43 | @apply text-white; 44 | } 45 | -------------------------------------------------------------------------------- /app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | alpa — A fast ⚡ self-hosted link 🔗 shortener. 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | 53 | 56 | 59 | 60 | 61 |
62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /app/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Entry TypeScript file for @alpa/app. 3 | * Created On 04 February 2022 4 | */ 5 | 6 | import './index.css' 7 | import './nprogress.css' 8 | import 'tippy.js/dist/tippy.css' 9 | import 'tippy.js/animations/shift-away.css' 10 | import 'tippy.js/dist/border.css' 11 | 12 | import ReactDOM from 'react-dom' 13 | 14 | import { App } from './App' 15 | 16 | ReactDOM.render(, document.querySelector('#app')) 17 | 18 | const { registerSW } = await import('virtual:pwa-register') 19 | registerSW({ 20 | immediate: true, 21 | }) 22 | -------------------------------------------------------------------------------- /app/src/nprogress.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | } 5 | 6 | #nprogress .bar { 7 | background: #EF233C; 8 | position: fixed; 9 | z-index: 1031; 10 | top: 0; 11 | left: 0; 12 | width: 100%; 13 | height: 2px; 14 | } 15 | 16 | /* Fancy blur effect */ 17 | #nprogress .peg { 18 | display: block; 19 | position: absolute; 20 | right: 0px; 21 | width: 100px; 22 | height: 100%; 23 | box-shadow: 0 0 10px #EF233C, 0 0 5px #EF233C; 24 | opacity: 1.0; 25 | 26 | -webkit-transform: rotate(3deg) translate(0px, -4px); 27 | -ms-transform: rotate(3deg) translate(0px, -4px); 28 | transform: rotate(3deg) translate(0px, -4px); 29 | } 30 | 31 | /* Remove these to get rid of the spinner */ 32 | #nprogress .spinner { 33 | display: block; 34 | position: fixed; 35 | z-index: 1031; 36 | top: 15px; 37 | right: 15px; 38 | } 39 | 40 | #nprogress .spinner-icon { 41 | width: 18px; 42 | height: 18px; 43 | box-sizing: border-box; 44 | border: solid 2px transparent; 45 | border-top-color: #EF233C; 46 | border-left-color: #EF233C; 47 | border-radius: 50%; 48 | -webkit-animation: nprogress-spinner 400ms linear infinite; 49 | animation: nprogress-spinner 400ms linear infinite; 50 | } 51 | 52 | .nprogress-custom-parent { 53 | overflow: hidden; 54 | position: relative; 55 | } 56 | 57 | .nprogress-custom-parent #nprogress .spinner, 58 | .nprogress-custom-parent #nprogress .bar { 59 | position: absolute; 60 | } 61 | 62 | @-webkit-keyframes nprogress-spinner { 63 | 0% { 64 | -webkit-transform: rotate(0deg); 65 | } 66 | 67 | 100% { 68 | -webkit-transform: rotate(360deg); 69 | } 70 | } 71 | 72 | @keyframes nprogress-spinner { 73 | 0% { 74 | transform: rotate(0deg); 75 | } 76 | 77 | 100% { 78 | transform: rotate(360deg); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/pages/Dash/Dash.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * A container component that will check if JWT is valid and redirect 3 | * to login if not, or else loads the Dashboard content. 4 | * Created On 08 February 2022 5 | */ 6 | 7 | import { Dispatch, ReactElement, useEffect, useState } from 'react' 8 | import { useDispatch } from 'react-redux' 9 | import { useNavigate } from 'react-router-dom' 10 | 11 | import { CodeModalStateReturns } from '../../components/CodeModal' 12 | import getCodes from './codes' 13 | import { Content } from './Content' 14 | 15 | export const Dash = ({ 16 | loading, 17 | quickText, 18 | setQuickText, 19 | setLoading, 20 | modalState, 21 | }: { 22 | loading: boolean 23 | quickText: string 24 | setQuickText: Dispatch> 25 | setLoading: React.Dispatch> 26 | modalState: CodeModalStateReturns 27 | }): ReactElement => { 28 | const navigate = useNavigate() 29 | const dispatch = useDispatch() 30 | 31 | // create any states, helper functions required for functioning 32 | const page = useState(0) 33 | 34 | useEffect(() => { 35 | getCodes({ navigate, dispatch, page, setLoading }) 36 | }, []) 37 | 38 | return ( 39 |
40 | 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/src/pages/Dash/codes.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Fetches the short codes accordingly to show on dash. 3 | * Created On 18 May 2022 4 | */ 5 | 6 | import { Dispatch } from '@reduxjs/toolkit' 7 | import axios from 'axios' 8 | import progress from 'nprogress' 9 | import { NavigateFunction } from 'react-router-dom' 10 | 11 | import { login } from '../../store/auth' 12 | import { insert, setPages } from '../../store/codes' 13 | import logout from '../../util/logout' 14 | import scrolling from '../../util/scrolling' 15 | import { parseJWTPayload } from '../Login/index' 16 | 17 | const fetchCodes = async ({ 18 | getCodesURL, 19 | apiToken, 20 | apiHost, 21 | dispatch, 22 | navigate, 23 | setLoading, 24 | }: { 25 | getCodesURL: () => string 26 | apiToken: string 27 | apiHost: string 28 | dispatch: Dispatch 29 | navigate: NavigateFunction 30 | setLoading: React.Dispatch> 31 | }): Promise => { 32 | try { 33 | // fetch the codes 34 | const { data } = await axios({ 35 | method: 'GET', 36 | url: getCodesURL(), 37 | headers: { 38 | Authorization: `Bearer ${apiToken}`, 39 | }, 40 | }) 41 | 42 | // if (status != 200) throw new Error('Invalid response status') 43 | dispatch(setPages(data.pages)) 44 | dispatch(insert(data.codes)) 45 | setLoading(false) 46 | progress.done() 47 | 48 | return data.pages 49 | } catch { 50 | // 51 | logout({ 52 | auth: { 53 | apiHost, 54 | apiToken, 55 | }, 56 | dispatch, 57 | navigate, 58 | setLoading, 59 | }) 60 | 61 | return -1 62 | } 63 | } 64 | 65 | export default async ({ 66 | navigate, 67 | dispatch, 68 | page, 69 | setLoading, 70 | }: { 71 | dispatch: Dispatch 72 | navigate: NavigateFunction 73 | page: [number, any] 74 | setLoading: React.Dispatch> 75 | }) => { 76 | // start the progress bar 77 | progress.start() 78 | 79 | // fetch required variables from localStorage 80 | const apiToken = localStorage.getItem('apiToken') as string 81 | const apiHost = localStorage.getItem('apiHost') as string 82 | 83 | // handle when there's isn't an apiHost 84 | if (Boolean(apiHost) == false) { 85 | navigate('/login') 86 | progress.done() 87 | return 88 | } 89 | 90 | const getCodesURL = () => `${apiHost}/api/codes?page=${page[0]}` 91 | 92 | const pages = await fetchCodes({ 93 | apiToken, 94 | getCodesURL, 95 | apiHost, 96 | dispatch, 97 | navigate, 98 | setLoading, 99 | }) 100 | 101 | // set user's details into the store 102 | const { username, email } = parseJWTPayload(apiToken) 103 | dispatch( 104 | login({ 105 | apiHost, 106 | apiToken, 107 | username, 108 | email, 109 | isLoggedIn: true, 110 | }), 111 | ) 112 | 113 | // attach intersection observer 114 | scrolling({ 115 | dispatch, 116 | apiHost, 117 | apiToken, 118 | pages, 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /app/src/pages/Login/Login.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * The login page which redirects to the dashboard 3 | * if the user is already logged in with a valid token. 4 | * Created On 08 February 2022 5 | */ 6 | 7 | import { ReactElement, useEffect, useState } from 'react' 8 | import { useNavigate } from 'react-router-dom' 9 | 10 | import login, { openDashboard } from './index' 11 | 12 | export const Login = (): ReactElement => { 13 | const navigate = useNavigate() 14 | 15 | const [password, setPassword] = useState('') 16 | const [username, updateUsername] = useState( 17 | localStorage.getItem('username') || '', 18 | ) 19 | const [apiHost, updateApiHost] = useState( 20 | localStorage.getItem('apiHost') || '', 21 | ) 22 | 23 | const setUsername = (username: string) => { 24 | // set username in state 25 | updateUsername(username) 26 | 27 | // set username in localStorage 28 | localStorage.setItem('username', username) 29 | } 30 | 31 | const setApiHost = (host: string) => { 32 | // set API host in state 33 | updateApiHost(host) 34 | 35 | // set API host in localStorage 36 | localStorage.setItem('apiHost', host) 37 | } 38 | 39 | const submit = (e: any) => { 40 | // prevent page refresh 41 | e.preventDefault() 42 | 43 | // trigger the login function 44 | login({ 45 | apiHost, 46 | navigate, 47 | credentials: { 48 | username, 49 | password, 50 | }, 51 | }) 52 | 53 | return false 54 | } 55 | 56 | // check if an existing token exists 57 | useEffect(() => { 58 | if (localStorage.getItem('apiToken')) openDashboard(navigate) 59 | }, []) 60 | 61 | return ( 62 |
63 |
64 | {/* login card */} 65 |
66 | {/* card information */} 67 |

68 | Log in 69 |

70 |

71 | Welcome to{' '} 72 | 78 | alpa 79 | 80 | , please input the configured login credentials to 81 | manage your short links. 82 |

83 | 84 | {/* input fields */} 85 |
86 | {/* host */} 87 |
88 | 91 | setApiHost(e.target.value)} 98 | autoFocus={!apiHost} 99 | required 100 | /> 101 |
102 | 103 | {/* username */} 104 |
105 | 108 | setUsername(e.target.value)} 116 | required 117 | /> 118 |
119 | 120 | {/* password */} 121 |
122 | 125 | setPassword(e.target.value)} 133 | onKeyUp={e => { 134 | if (e.key == 'Enter') 135 | document 136 | .querySelector('form') 137 | ?.requestSubmit() 138 | }} 139 | required 140 | /> 141 |
142 | 143 | {/* login button */} 144 | 154 |
155 |
156 |
157 |
158 | ) 159 | } 160 | -------------------------------------------------------------------------------- /app/src/pages/Login/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Unified procedure to login a user with the given credentials. 3 | * Created On 12 February 2022 4 | */ 5 | 6 | import axios from 'axios' 7 | import progress from 'nprogress' 8 | import { NavigateFunction } from 'react-router-dom' 9 | 10 | interface LoginOptions { 11 | navigate: NavigateFunction 12 | apiHost: string 13 | credentials: { 14 | username: string 15 | password: string 16 | } 17 | } 18 | 19 | export const openDashboard = (navigate: NavigateFunction) => 20 | navigate('/', { 21 | replace: true, 22 | }) 23 | 24 | export const parseJWTPayload = (token: string) => { 25 | const base64Url: string = token.split('.')[1] 26 | const base64: string = base64Url.replace(/-/g, '+').replace(/_/g, '/') 27 | const jsonPayload: any = decodeURIComponent( 28 | atob(base64) 29 | .split('') 30 | .map(c => { 31 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) 32 | }) 33 | .join(''), 34 | ) 35 | 36 | return JSON.parse(jsonPayload) 37 | } 38 | 39 | export default async ({ apiHost, credentials, navigate }: LoginOptions) => { 40 | const { username, password } = credentials 41 | 42 | progress.start() 43 | 44 | try { 45 | const { status, data } = await axios({ 46 | method: 'POST', 47 | url: `${apiHost}/api/auth/login`, 48 | data: { 49 | username, 50 | password, 51 | }, 52 | }) 53 | 54 | if (status == 200) { 55 | localStorage.setItem('apiToken', data.token) 56 | openDashboard(navigate) 57 | } 58 | } catch { 59 | console.log('failed login attempt') 60 | progress.done() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsnthdev/alpa/e999519e5a6f2343ac596ac036ac549b9ae66ff5/app/src/public/cover.png -------------------------------------------------------------------------------- /app/src/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsnthdev/alpa/e999519e5a6f2343ac596ac036ac549b9ae66ff5/app/src/public/icon.png -------------------------------------------------------------------------------- /app/src/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/public/siteicon.svg: -------------------------------------------------------------------------------- 1 | 5 | 22 | 23 | 28 | 33 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/store/auth.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | export interface AuthState { 4 | username: string 5 | email: string 6 | isLoggedIn: boolean 7 | apiHost: string 8 | apiToken: string 9 | } 10 | 11 | const initialState = { 12 | isLoggedIn: false, 13 | } 14 | 15 | const user = createSlice({ 16 | initialState, 17 | name: 'user', 18 | reducers: { 19 | login: (state, action) => ({ ...state, ...action.payload }), 20 | logout: () => initialState, 21 | }, 22 | }) 23 | 24 | export const { login, logout } = user.actions 25 | export default user.reducer 26 | -------------------------------------------------------------------------------- /app/src/store/codes.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | interface CodeLink { 4 | url: string 5 | } 6 | 7 | export interface Code { 8 | code: string 9 | links: CodeLink[] 10 | tags: string 11 | } 12 | 13 | interface InitialState { 14 | pages: number 15 | codes: Code[] 16 | } 17 | 18 | const codes = createSlice({ 19 | name: 'codes', 20 | initialState: { 21 | pages: 0, 22 | codes: [], 23 | } as InitialState, 24 | reducers: { 25 | // sets the total number of pages while 26 | // querying the API for infinite scrolling 27 | setPages: (state, action) => { 28 | const { codes } = state 29 | 30 | return { 31 | codes, 32 | pages: action.payload, 33 | } 34 | }, 35 | 36 | // inserts the initial codes into the app store 37 | insert: (state, action) => { 38 | const { pages, codes } = state 39 | 40 | return { 41 | pages, 42 | codes: codes.concat(action.payload), 43 | } 44 | }, 45 | 46 | // deletes a given short code given it's code string 47 | del: (state, action) => { 48 | const { pages, codes } = state 49 | 50 | return { 51 | pages, 52 | codes: codes.filter( 53 | (code: Code) => code.code != action.payload, 54 | ), 55 | } 56 | }, 57 | 58 | // used to mutate an individual code object 59 | patch: (state, action) => { 60 | const { pages, codes } = state 61 | 62 | const index = codes.indexOf( 63 | codes.find( 64 | (code: Code) => code.code == action.payload.code, 65 | ) as any, 66 | ) 67 | 68 | const newCodes = codes.filter( 69 | (code: Code) => code.code != action.payload.code, 70 | ) 71 | 72 | newCodes.splice(index, 0, action.payload) 73 | 74 | return { 75 | pages, 76 | codes: newCodes, 77 | } 78 | }, 79 | 80 | // used to update the entire codes array at once 81 | update: (state, { payload }) => { 82 | const { pages, codes } = state 83 | 84 | return { 85 | pages, 86 | codes: codes.concat( 87 | payload.filter( 88 | (code: Code) => 89 | Boolean( 90 | codes.find((c: Code) => c.code == code.code), 91 | ) == false, 92 | ), 93 | ), 94 | } 95 | }, 96 | 97 | // resets the state to initial and deletes all the 98 | // data from the frontend 99 | clear: () => { 100 | return { 101 | pages: 0, 102 | codes: [], 103 | } 104 | }, 105 | }, 106 | }) 107 | 108 | export const { insert, clear, del, patch, update, setPages } = codes.actions 109 | export default codes.reducer 110 | -------------------------------------------------------------------------------- /app/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | 3 | import auth, { AuthState } from './auth.js' 4 | import codes, { Code } from './codes.js' 5 | 6 | export interface AppState { 7 | auth: AuthState 8 | codes: { 9 | pages: number 10 | codes: Code[] 11 | } 12 | } 13 | 14 | export const store = configureStore({ 15 | reducer: { auth, codes }, 16 | }) 17 | -------------------------------------------------------------------------------- /app/src/util/hotkeys.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Contains hotkey map and their handlers. 3 | * Created On 14 May 2022 4 | */ 5 | 6 | export const hotkeyMap = { 7 | SEARCH: '/', 8 | NEWCODE: 'n', 9 | } 10 | 11 | export const hotkeyHandlers = { 12 | SEARCH: (e: any) => { 13 | // eslint-disable-next-line prettier/prettier 14 | (document.querySelector('#txtSearch') as any).focus() 15 | e.preventDefault() 16 | return false 17 | }, 18 | NEWCODE: (e: any) => { 19 | // eslint-disable-next-line prettier/prettier 20 | (document.querySelector('#btnNew') as any).click() 21 | e.preventDefault() 22 | return false 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /app/src/util/logout.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Unified procedure to logout the authenticated user. 3 | * Created On 12 February 2022 4 | */ 5 | 6 | import { Dispatch } from '@reduxjs/toolkit' 7 | import axios from 'axios' 8 | import progress from 'nprogress' 9 | import { NavigateFunction } from 'react-router-dom' 10 | 11 | import { logout } from '../store/auth' 12 | import { clear } from '../store/codes' 13 | 14 | interface LogoutOptions { 15 | auth: { 16 | apiHost: string 17 | apiToken: string 18 | } 19 | navigate: NavigateFunction 20 | dispatch: Dispatch 21 | setLoading: React.Dispatch> 22 | } 23 | 24 | export default ({ auth, navigate, dispatch, setLoading }: LogoutOptions) => { 25 | const { apiHost, apiToken } = auth 26 | 27 | // the logout procedure 28 | const procedure = () => { 29 | // delete the token from the browser 30 | localStorage.removeItem('apiToken') 31 | 32 | // reset our app store 33 | dispatch(logout()) 34 | dispatch(clear()) 35 | 36 | // go back to login page 37 | navigate('/login') 38 | progress.done() 39 | 40 | // set the loading state back to true 41 | setLoading(true) 42 | } 43 | 44 | progress.start() 45 | axios({ 46 | method: 'DELETE', 47 | url: `${apiHost}/api/auth/logout`, 48 | headers: { 49 | Authorization: `Bearer ${apiToken}`, 50 | }, 51 | }) 52 | .then(() => procedure()) 53 | .catch(e => { 54 | // if the token is no longer authorized, we simply 55 | // clean up the token and redirect to login page 56 | if (JSON.parse(JSON.stringify(e)).status == 401) procedure() 57 | if (JSON.parse(JSON.stringify(e)).status == 500) procedure() 58 | }) 59 | .finally(() => { 60 | progress.done() 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /app/src/util/scrolling.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from '@reduxjs/toolkit' 2 | import axios from 'axios' 3 | 4 | import { insert } from '../store/codes' 5 | 6 | export default async ({ 7 | apiHost, 8 | apiToken, 9 | pages, 10 | dispatch, 11 | }: { 12 | pages: number 13 | apiHost: string 14 | apiToken: string 15 | dispatch: Dispatch 16 | }) => { 17 | let currentPage = 0 18 | let loading = false 19 | 20 | const lastOne = document.querySelector( 21 | '#codes > div:last-child', 22 | ) as HTMLDivElement 23 | 24 | const observer = new IntersectionObserver(async entries => { 25 | const entry = entries[0] 26 | 27 | if ( 28 | entry.intersectionRatio > 0 && 29 | loading == false && 30 | currentPage < pages 31 | ) { 32 | loading = true 33 | currentPage = currentPage + 1 34 | 35 | const { data } = await axios({ 36 | method: 'GET', 37 | url: `${apiHost}/api/codes?page=${currentPage}`, 38 | headers: { 39 | Authorization: `Bearer ${apiToken}`, 40 | }, 41 | }) 42 | 43 | if (data.codes.length != 0) { 44 | dispatch(insert(data.codes)) 45 | 46 | const newLastOne = document.querySelector( 47 | '#codes > div:last-child', 48 | ) as HTMLDivElement 49 | 50 | observer.observe(newLastOne) 51 | } 52 | 53 | observer.unobserve(lastOne) 54 | loading = false 55 | } 56 | }) 57 | 58 | try { 59 | observer.observe(lastOne) 60 | } catch { 61 | return 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/util/searchAPI.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Searches the api provided authentication details. 3 | * Created On 14 May 2022 4 | */ 5 | 6 | import axios from 'axios' 7 | import progress from 'nprogress' 8 | 9 | import { AuthState } from '../store/auth' 10 | import { update } from '../store/codes' 11 | 12 | // fetch new codes upon searching 13 | export const searchAPI = ({ 14 | auth, 15 | dispatch, 16 | quickText, 17 | }: { 18 | auth: AuthState 19 | quickText: string 20 | dispatch: any 21 | }): Promise => 22 | new Promise((resolve, reject) => { 23 | progress.start() 24 | axios({ 25 | method: 'GET', 26 | url: `${auth.apiHost}/api/codes?search=${encodeURIComponent( 27 | quickText, 28 | )}`, 29 | headers: { 30 | Authorization: `Bearer ${auth.apiToken}`, 31 | }, 32 | }) 33 | .then(({ data }) => { 34 | dispatch(update(data.codes)) 35 | resolve(true) 36 | }) 37 | .catch(err => reject(err)) 38 | .finally(() => progress.done()) 39 | }) 40 | -------------------------------------------------------------------------------- /app/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * TailwindCSS configuration for @alpa/app project. 3 | * Created On 08 February 2022 4 | */ 5 | 6 | module.exports = { 7 | darkMode: 'class', 8 | content: ['./src/**/*.{js,jsx,ts,tsx,html}'], 9 | theme: { 10 | fontFamily: { 11 | sans: [ 12 | 'Plus Jakarta Sans', 13 | 'ui-sans-serif', 14 | 'system-ui', 15 | '-apple-system', 16 | 'BlinkMacSystemFont', 17 | '"Segoe UI"', 18 | 'Roboto', 19 | '"Helvetica Neue"', 20 | 'Arial', 21 | '"Noto Sans"', 22 | 'sans-serif', 23 | ], 24 | mono: [ 25 | 'IBM Plex Mono', 26 | 'ui-monospace', 27 | 'SFMono-Regular', 28 | 'Menlo', 29 | 'Monaco', 30 | 'Consolas', 31 | '"Liberation Mono"', 32 | '"Courier New"', 33 | 'monospace', 34 | ], 35 | }, 36 | extend: { 37 | colors: { 38 | primary: '#EF233C', 39 | 'primary-hover': '#D11026', 40 | secondary: '#1C1917', 41 | 'secondary-hover': '#5A5049', 42 | }, 43 | }, 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "outDir": "dist", 6 | "jsx": "preserve", 7 | "target": "ESNext", 8 | "module": "ESNext", 9 | "esModuleInterop": true, 10 | "moduleResolution": "node", 11 | "allowSyntheticDefaultImports": true, 12 | "types": [ 13 | "vite/client" 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/vite.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Vite bundler configuration for @alpa/app project. 3 | * Created On 04 February 2022 4 | */ 5 | 6 | import react from '@vitejs/plugin-react' 7 | import autoprefixer from 'autoprefixer' 8 | import tailwindcss from 'tailwindcss' 9 | import { defineConfig } from 'vite' 10 | import { createHtmlPlugin } from 'vite-plugin-html' 11 | import { VitePWA } from 'vite-plugin-pwa' 12 | 13 | export default defineConfig(() => { 14 | return { 15 | clearScreen: false, 16 | root: 'src', 17 | build: { 18 | outDir: '../dist', 19 | minify: 'esbuild', 20 | target: 'esnext', 21 | polyfillModulePreload: false, 22 | }, 23 | server: { 24 | fs: { 25 | strict: false, 26 | }, 27 | }, 28 | css: { 29 | postcss: { 30 | plugins: [autoprefixer(), tailwindcss()], 31 | }, 32 | }, 33 | plugins: [ 34 | react(), 35 | createHtmlPlugin({ 36 | minify: true, 37 | }), 38 | VitePWA({ 39 | manifest: { 40 | name: 'alpa', 41 | short_name: 'alpa', 42 | id: 'dev.vsnth.alpa', 43 | description: 'A fast ⚡ self-hosted link 🔗 shortener.', 44 | orientation: 'portrait-primary', 45 | theme_color: '#FFFFFF', 46 | start_url: '/app', 47 | icons: [ 48 | { 49 | src: 'https://alpa.link/app/icon.svg', 50 | sizes: '791x791', 51 | type: 'image/svg', 52 | purpose: 'maskable', 53 | }, 54 | { 55 | src: 'https://alpa.link/app/icon.png', 56 | sizes: '791x791', 57 | type: 'image/png', 58 | }, 59 | ], 60 | }, 61 | includeAssets: ['/cover.png', '/.well-known/assetlinks.json'], 62 | }), 63 | ], 64 | } 65 | }) 66 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | # the redis Docker image with RedisJSON and RediSearch 4 | # modules enabled along with persistance 5 | redis: 6 | image: redislabs/redisearch:2.4.0 7 | container_name: redis 8 | command: redis-server --loadmodule /usr/lib/redis/modules/redisearch.so --loadmodule /usr/lib/redis/modules/rejson.so --appendonly yes 9 | volumes: 10 | - .redis:/data 11 | 12 | # the API backend which is the actual 13 | # redirection service 14 | alpa-api: 15 | image: vsnthdev/alpa-api 16 | container_name: alpa-api 17 | ports: 18 | - 1727:1727 19 | volumes: 20 | - ./api/config.yml:/opt/alpa/api/config.yml 21 | 22 | # the frontend app to interface with the API 23 | alpa-app: 24 | image: vsnthdev/alpa-app 25 | container_name: alpa-app 26 | ports: 27 | - 3000:3000 28 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | alpa 5 | 6 | 7 | alpa 8 | 9 |

10 | 11 | 12 | 13 |

( अल्प ) — A fast ⚡ self-hosted link 🔗 shortener.

14 | 15 |

16 | 17 | issues 18 | 19 | 20 | commits 22 | 23 | 24 | docker 25 | 26 | 27 | dashboard status 28 | 29 |

30 | 31 |
32 | 33 | Reads the TypeScript code in this repository and programmatically generates documentation markdown files. 34 | 35 | ## 🔮 Tech stack 36 | 37 | | Name | Description | 38 | | --- | --- | 39 | | **Handlebars** | Templating engine to inject values into template markdown files. | 40 | | **Chokidar** | Watches for file changes and rebuilds docs. | 41 | 42 | ## 💻 Building & Dev Setup 43 | 44 | You need to be at least on **Node.js v17.4.0 or above** and follow the below instructions to build this project 👇 45 | 46 | - **STEP 1️⃣** Clone this repository & enter into it (`cd ./alpa`) 47 | - **STEP 2️⃣** Run **`npm install`** to get all dependencies & link projects together 48 | - **STEP 3️⃣** Enter in the project directory (`cd docs`) 49 | - **STEP 4️⃣** To build this project run **`npm run build`** 50 | 51 | Upon building `@alpa/docs` will rerender all markdown files within all the projects in this repository. 52 | 53 | > **ℹ️ Info:** You can also run `npm run clean` to delete existing documentation from the project to avoid overwriting & purge dangling documents. While running the `build` script, old docs are first deleted before getting overwritten. 54 | 55 | ## 📰 License 56 | > The **alpa** project is released under the [AGPL-3.0-only](https://github.com/vsnthdev/alpa/blob/main/LICENSE.md).
Developed & maintained By Vasanth Srivatsa. Copyright 2022 © Vasanth Developer. 57 |
58 | 59 | > vsnth.dev  ·  60 | > YouTube @vasanthdeveloper  ·  61 | > Twitter @vsnthdev  ·  62 | > Discord Vasanth Developer 63 | -------------------------------------------------------------------------------- /docs/md/README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | alpa 5 | 6 | 7 | alpa 8 | 9 |

10 | 11 | {{#if isIndex}}cover{{/if}} 12 | 13 |

{{desc}}

14 | 15 |

16 | 17 | issues 18 | 19 | 20 | commits 22 | 23 | 24 | docker 25 | 26 | 27 | dashboard status 28 | 29 |

30 | 31 |
32 | 33 | 34 | 35 | **alpa** is a self-hosted _(you run it on your servers)_ URL shortener which is fast and provides full control of the short links you create. 36 | 37 | It takes this 👇 38 | 39 | ```plaintext 40 | https://vasanthdeveloper.com/migrating-from-vps-to-kubernetes 41 | ``` 42 | 43 | and converts it into something like this 👇 44 | 45 | ```plaintext 46 | https://vas.cx/fjby 47 | ``` 48 | 49 | Which is easier to remember and share across the internet. 50 | 51 | ## ✨ Features 52 | 53 | - **It is 🚀 super fast** 54 | - **Your domain, your branding** 👌 55 | - **Privacy friendly 🤗 & configurable** 56 | - **Simple & 🎮 intuitive dashboard** 57 | 58 | ## 💡 Why I built it? 59 | 60 | I was using goo.gl back in 2016 and I was very impressed by it. It's simple dashboard & fast redirection were two things that were really attractive to me. **alpa** is inspired by goo.gl URL shortener. 61 | 62 | Along with that, most popular URL shorteners are not _self-hosted_, which means that you'll share your data with others that use the service. To me, it was a concern about **reliability**, **privacy** and **performance**. 63 | 64 | ## 🚀 Quick start 65 | 66 | The quickest way to run **alpa** is through Docker Compose using only **3 steps**: 67 | 68 | **STEP 1️⃣** Getting alpa 69 | 70 | Once you have Docker Compose installed, clone this repository by running the following command 👇 71 | 72 | ``` 73 | git clone https://github.com/vsnthdev/alpa.git 74 | ``` 75 | 76 | **STEP 2️⃣** Creating a configuration file 77 | 78 | Enter into the **alpa** directory and create an API config by running 👇 79 | 80 | ``` 81 | cd ./alpa 82 | cp ./api/config.example.yml ./api/config.yml 83 | ``` 84 | 85 | **⚠️ Warning:** The example config file is only meant for development and testing purposes, a proper config file is required to securely run **alpa** in production. 86 | 87 | **STEP 3️⃣** Starting alpa 88 | 89 | Now all you need to do is, run the following command to start both **alpa**'s [app](https://github.com/vsnthdev/alpa/tree/main/app) & the [API](https://github.com/vsnthdev/alpa/tree/main/api). 90 | 91 | ``` 92 | docker-compose up -d 93 | ``` 94 | 95 | ## ⚡ Support & funding 96 | 97 | Financial funding would really help this project go forward as I will be able to spend more hours working on the project to maintain & add more features into it. 98 | 99 | Please get in touch with me on [Discord](https://discord.com/users/492205153198407682) or [Twitter](https://vas.cx/twitter) to get fund the project even if it is a small amount 🙏 100 | 101 | ## 🤝 Troubleshooting & help 102 | 103 | If you face trouble setting up **alpa**, or have any questions, or even a bug report, feel free to contact me through Discord. I provide support for **alpa** on [my Discord server](https://vas.cx/discord). 104 | 105 | I will be happy to consult & personally assist you 😊 106 | 107 | ## 💖 Code & contribution 108 | 109 | **Pull requests are always welcome** 👏 110 | 111 | But it will be better if you can get in touch with me before contributing or [raise an issue](https://github.com/vsnthdev/alpa/issues/new/choose) to see if the contribution aligns with the vision of the project. 112 | 113 | > **ℹ️ Note:** This project follows [Vasanth's Commit Style](https://vas.cx/commits) for commit messages. We highly encourage you to use this commit style for contributions to this project. 114 | 115 | ## 💻 Building & Dev Setup 116 | 117 | This is a [monorepo](https://monorepo.tools/#what-is-a-monorepo) containing multiple projects. Below is a list of all the projects in this repository, what they do, and docs to building them 👇 118 | 119 | | Name | Description | 120 | | --- | --- | 121 | {{#each projects}} 122 | | [{{this.name}}](./{{this.projectName}}) | {{this.description}} | 123 | {{/each}} 124 | 125 | ### 🛠️ Building all projects 126 | 127 | You need to be at least on **Node.js v{{nodeVersion}} or above** and follow the below instructions to build all the projects 👇 128 | 129 | - **STEP 1️⃣** Clone this repository & enter into it (`cd ./alpa`) 130 | - **STEP 2️⃣** Run **`npm install`** to get all dependencies & link projects together 131 | - **STEP 3️⃣** To build all the projects & docs run **`npm run build`** 132 | 133 | ### 🐳 Building Docker images 134 | 135 | Instead of pulling Docker images from DockerHub, you can build yourself by running 👇 136 | 137 | ``` 138 | npm run build:docker 139 | ``` 140 | 141 | > ⚠️ **Warning:** Make sure to delete Docker images pulled from DockerHub or a previous build, to prevent conflicts before running the above command. 142 | 143 | ### 🍃 Cleaning project 144 | 145 | Building the project generates artifacts on several places in the project. To delete all those artifacts **(including docs)**, run the below command 👇 146 | 147 | ``` 148 | npm run clean 149 | ``` 150 | 151 | 152 | 153 | ## 📰 License 154 | > The **alpa** project is released under the [{{license}}](https://github.com/vsnthdev/alpa/blob/main/LICENSE.md).
Developed & maintained By Vasanth Srivatsa. Copyright {{year}} © Vasanth Developer. 155 |
156 | 157 | > vsnth.dev  ·  158 | > YouTube @vasanthdeveloper  ·  159 | > Twitter @vsnthdev  ·  160 | > Discord Vasanth Developer 161 | -------------------------------------------------------------------------------- /docs/md/api/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: ../README.md 3 | --- 4 | 5 | This is the core of the project, it the RESTful API that performs redirection, communicates with the database and provides **alpa** it's functionality. 6 | 7 | ## ⚙️ Configuration 8 | 9 | Refer to the [config example file](https://github.com/vsnthdev/alpa/blob/main/api/config.example.yml) for all possible configuration keys, and their detailed explanation. If you still have any doubts, feel free to shoot a tweet at me [@vsnthdev](https://vas.cx/@me). 10 | 11 | ## 🔭 API Routes 12 | 13 | | Method | Path | Description | Protected | 14 | |---|---|---|---| 15 | {{#each api.routes}} 16 | | `{{this.method}}` | `{{this.path}}` | {{this.description}} | {{#if this.authRequired}}✅{{else}}❌{{/if}} | 17 | {{/each}} 18 | 19 | ## 🔮 Tech stack 20 | 21 | | Name | Description | 22 | | --- | --- | 23 | | **Fastify** | HTTP server focussed on speed designed to build RESTful APIs. | 24 | | **JSON Web Tokens** | For user authentication. | 25 | | **Redis** | Key-value pair database known for it's speed. | 26 | | **RedisJSON** | Redis database plugin to store JSON documents. | 27 | | **RediSearch** | Redis database plugin that facilitates full text search. | 28 | | **Docker** | For easy installation & seamless updates. | 29 | | **Kubernetes** | For scalable deployments to production. | 30 | 31 | ## 💻 Building & Dev Setup 32 | 33 | You need to be at least on **Node.js v{{nodeVersion}} or above** and follow the below instructions to build this project 👇 34 | 35 | - **STEP 1️⃣** Clone this repository & enter into it (`cd ./alpa`) 36 | - **STEP 2️⃣** Run **`npm install`** to get all dependencies & link projects together 37 | - **STEP 3️⃣** Enter in the project directory (`cd api`) 38 | - **STEP 4️⃣** To build this project run **`npm run build`** 39 | 40 | Upon building `@alpa/api` a `dist` folder is created with the transpiled JavaScript files. 41 | -------------------------------------------------------------------------------- /docs/md/api/docs/docker.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: ../../README.md 3 | --- 4 | 5 | # 🐳 Deploying with Docker Compose 6 | 7 | There are mainly 3 ways to deploy `@alpa/api` onto production. For personal usage deploying through 🐳 **Docker Compose** is the easiest & recommended way. For advanced use cases or high intensity workload read about [manual deployment](./manual.md) & [Kubernetes deployment](./kubernetes.md). 8 | 9 | Deploying **alpa**'s API using Docker is easy and straightforward by following the below steps: 10 | 11 | ## 🔍 Prerequisites 12 | 13 | 1. [Docker v20.10.13](https://docs.docker.com/engine/install) or higher 14 | 2. [Docker Compose v2.3.2](https://docs.docker.com/compose/cli-command) or [`docker-compose` v1.29.2](https://docs.docker.com/compose/install) 15 | 16 | ## 🚀 Deployment process 17 | 18 | Once you've satisfied the prerequisites, follow the below steps to configure `@alpa/api` to run in production. 19 | 20 | ### 📂 Create a new folder 21 | 22 | Create a new folder named `alpa` and enter into it, this is where we'll store the `docker-compose.yml` and other artifacts generated for running all the services we need, by running 👇 the following command: 23 | 24 | ``` 25 | mkdir ./alpa && cd ./alpa 26 | ``` 27 | 28 | ### 📃 Creating `docker-compose.yml` file 29 | 30 | The first thing we'll do in the newly created folder is create a `docker-compose.yml` file which defines all the services that are required for `@alpa/api`. 31 | 32 | Open your favourite text editor (preferably [VSCode](https://code.visualstudio.com)), copy the below code block 👇 and save it as `docker-compose.yml` in the `alpa` directory. 33 | 34 | ```yaml 35 | version: "3.8" 36 | services: 37 | # the redis Docker image with RedisJSON and RediSearch 38 | # modules pre-configured & enabled along with persistance 39 | redis: 40 | image: redislabs/redisearch:2.4.0 41 | container_name: redis 42 | command: redis-server --loadmodule /usr/lib/redis/modules/redisearch.so --loadmodule /usr/lib/redis/modules/rejson.so --appendonly yes 43 | volumes: 44 | - .redis:/data 45 | 46 | # @alpa/api Docker image 47 | alpa-api: 48 | image: vsnthdev/alpa-api:v{{api.app.version}} 49 | container_name: alpa-api 50 | ports: 51 | - 1727:1727 52 | volumes: 53 | - ./config.yml:/opt/alpa/api/config.yml 54 | ``` 55 | 56 | > ℹ️ **Info:** We're intensionally using a versioned images, to mitigate the risk of accidentally updating the image and breaking everything. 57 | 58 | ### ⚙️ Mounting configuration file 59 | 60 | An example config file will all possible values already exists in this repository. Simply right click on [this link](https://raw.githubusercontent.com/vsnthdev/alpa/main/api/config.example.yml) and select "_Save as_". 61 | 62 | Now save it with the name `config.yml` in the `alpa` folder where `docker-compose.yml` is. Once done your `alpa` folder should contain two files 63 | 64 | ``` 65 | docker-compose.yml 66 | config.yml 67 | ``` 68 | 69 | ### ⚡ Configuring for production 70 | 71 | Provided example config file is best suitable for development & testing purposes only. We need to make some changes to the config file to make `@alpa/api` suitable for production environments. 72 | 73 | These exact changes have been specified in the manual deployment docs **[click here to view them](./manual.md#-production-configuration).** 74 | 75 | > ⚠️ **Warning:** Do not use `@alpa/api` in production without following the production configuration steps. It will lead to serious security risks and instabilities. 76 | 77 | ### ✨ Starting `@alpa/api` 78 | 79 | With the above mentioned changes being done to the configuration file, `@alpa/api` is now ready to be started in a production environment safely. 80 | 81 | To start all the services defined in our `docker-compose.yml` run 👇 one of the below commands depending on your Docker Compose version: 82 | 83 | ```bash 84 | # if you're on Docker Compose v2 85 | docker compose up 86 | 87 | # if you're on docker-compose v1 88 | docker-compose up 89 | ``` 90 | 91 | After following the above steps you should be able to login from the configured client and start enjoying **alpa**. 92 | 93 | **If you're still facing issues, refer the [troubleshooting & help section](https://github.com/vsnthdev/alpa#-troubleshooting--help) for further information.** 94 | 95 | 96 | -------------------------------------------------------------------------------- /docs/md/api/docs/kubernetes.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: ../../README.md 3 | --- 4 | 5 | # 🏅 Deploying with Kubernetes 6 | 7 | There are mainly 3 ways to deploy `@alpa/api` onto production. For personal usage deploying through [🐳 Docker Compose](./docker.md) is the most easiest & recommended way. For advanced use cases read about [manual deployment](./manual.md). 8 | 9 | Deploying **alpa**'s API into a Kubernetes Cluster is easy and straightforward by following the below steps: 10 | 11 | ## 🔍 Prerequisites 12 | 13 | 1. [Docker v20.10.13](https://docs.docker.com/engine/install) or higher 14 | 2. [Kubernetes 1.22.5](https://kubernetes.io/docs/setup) or higher 15 | 16 | ## 🚀 Deployment process 17 | 18 | Once you've satisfied the prerequisites, follow the below steps to configure `@alpa/api` to run in production. 19 | 20 | ### 📂 Creating folder structure 21 | 22 | Create a new folder named `alpa` with two sub-folders named `alpa`, `redis`, this is where we'll store the Kubernetes files. 23 | 24 | ``` 25 | mkdir alpa && mkdir alpa/redis && mkdir alpa/alpa && cd alpa 26 | ``` 27 | 28 | ### 🏝️ Creating a namespace 29 | 30 | Using your favorite text editor, create a new file named `0-namespace.yml` file and paste the below contents 👇 31 | 32 | ```yml 33 | apiVersion: v1 34 | kind: Namespace 35 | metadata: 36 | name: alpa 37 | ``` 38 | 39 | Save the `0-namespace.yml` file in the `alpa` folder we created above. 40 | 41 | ### 🪛 Setting up Redis database 42 | 43 | To setup Redis database in a Kubernetes cluster, we need to create a few files. Lets create them one by one while going through each one. 44 | 45 | #### 🧳 Redis database volume 46 | 47 | Create a file named `1-volumes.yml` and save the below contents 👇 in the `alpa/redis` folder we created. 48 | 49 | ```yml 50 | apiVersion: v1 51 | kind: PersistentVolumeClaim 52 | metadata: 53 | name: redis-claim 54 | namespace: alpa 55 | spec: 56 | resources: 57 | requests: 58 | storage: 1G 59 | volumeMode: Filesystem 60 | accessModes: 61 | - ReadWriteOnce 62 | ``` 63 | 64 | This file creates a [claim for a persistent volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes) which can later be used to create an actual volume to store data from our Redis database. 65 | 66 | #### 📌 Redis database deployment 67 | 68 | Create a file named `2-deploys.yml` and save the below contents 👇 in the `alpa/redis` folder we created. 69 | 70 | ```yml 71 | apiVersion: apps/v1 72 | kind: Deployment 73 | metadata: 74 | name: redis 75 | namespace: alpa 76 | spec: 77 | selector: 78 | matchLabels: 79 | app: redis 80 | template: 81 | metadata: 82 | labels: 83 | app: redis 84 | spec: 85 | hostname: redis 86 | volumes: 87 | - name: redis 88 | persistentVolumeClaim: 89 | claimName: redis-claim 90 | containers: 91 | - name: redis 92 | image: redislabs/redisearch:2.4.0 93 | imagePullPolicy: IfNotPresent 94 | args: 95 | - "redis-server" 96 | - "--loadmodule" 97 | - "/usr/lib/redis/modules/redisearch.so" 98 | - "--loadmodule" 99 | - "/usr/lib/redis/modules/rejson.so" 100 | - "--appendonly" 101 | - "yes" 102 | volumeMounts: 103 | - mountPath: /data 104 | name: redis 105 | resources: 106 | limits: 107 | memory: 128Mi 108 | cpu: 100m 109 | ports: 110 | - containerPort: 6379 111 | ``` 112 | 113 | This is the actual deployment file that tells Kubernetes which Docker container to run and how to link it with our Persistent Volume Claim and mount the data directory. 114 | 115 | This file also specifies how much CPU & memory is allocated to the Redis database. 116 | 117 | #### 🔦 Redis database service 118 | 119 | Create a file named `3-services.yml` and save the below contents 👇 in the `alpa/redis` folder we created. 120 | 121 | ```yml 122 | apiVersion: v1 123 | kind: Service 124 | metadata: 125 | name: redis 126 | namespace: alpa 127 | spec: 128 | type: NodePort 129 | selector: 130 | app: redis 131 | ports: 132 | - port: 6379 133 | targetPort: 6379 134 | ``` 135 | 136 | Redis service exposes the Redis database on port 6379 to be accessed by `@alpa/api` and other deployments in this namespace. 137 | 138 | > ℹ️ **Note:** For security purposes, it is recommended that you change this port number `6379` to a random 5 digit number below 60,000. 139 | 140 | ### ⚙️ Creating configuration file 141 | 142 | Create a file named `1-configs.yml` and save the below contents 👇 in the `alpa/alpa` folder we created. 143 | 144 | ```yml 145 | apiVersion: v1 146 | kind: ConfigMap 147 | metadata: 148 | name: alpa-api-config 149 | namespace: alpa 150 | data: 151 | config: | 152 | {{{yaml api.config 8}}} 153 | ``` 154 | 155 | This creates a [ConfigMap](https://kubernetes.io/docs/concepts/configuration/configmap) in Kubernetes which stores the config file for `@alpa/api` which will be mounted as a volume later. 156 | 157 | ### ⚡ Configuring for production 158 | 159 | Provided example config file is best suitable for development & testing purposes only. We need to make some changes to the config file to make `@alpa/api` suitable for production environments. 160 | 161 | These exact changes have been specified in the manual deployment docs **[click here to view them](./manual.md#-production-configuration).** 162 | 163 | > ⚠️ **Warning:** Do not use `@alpa/api` in production without following the production configuration steps. It will lead to serious security risks and instabilities. 164 | 165 | #### 📌 Deploying `@alpa/api` 166 | 167 | Create a file named `2-deploys.yml` and save the below contents 👇 in the `alpa/alpa` folder we created. 168 | 169 | ```yml 170 | apiVersion: apps/v1 171 | kind: Deployment 172 | metadata: 173 | name: alpa 174 | namespace: alpa 175 | spec: 176 | selector: 177 | matchLabels: 178 | app: alpa 179 | template: 180 | metadata: 181 | labels: 182 | app: alpa 183 | spec: 184 | hostname: alpa 185 | volumes: 186 | - name: alpa-api-config 187 | configMap: 188 | name: alpa-api-config 189 | containers: 190 | - name: alpa 191 | image: vsnthdev/alpa-api:v{{api.app.version}} 192 | imagePullPolicy: Always 193 | volumeMounts: 194 | - mountPath: /opt/alpa/api/config.yml 195 | name: alpa-api-config 196 | subPath: config 197 | readOnly: true 198 | resources: 199 | limits: 200 | memory: 256Mi 201 | cpu: 100m 202 | ports: 203 | - containerPort: 1727 204 | ``` 205 | 206 | > ℹ️ **Info:** We're intensionally using a versioned images, to mitigate the risk of accidentally updating the image and breaking everything. 207 | 208 | This file tells Kubernetes to pull and run `@alpa/api` on Kubernetes along with how much memory and CPU should be allocated. 209 | 210 | ### 🌏 Creating `@alpa/api` service 211 | 212 | Create a file named `3-services.yml` and save the below contents 👇 in the `alpa/alpa` folder we created. 213 | 214 | ```yml 215 | apiVersion: v1 216 | kind: Service 217 | metadata: 218 | name: alpa 219 | namespace: alpa 220 | spec: 221 | type: NodePort 222 | selector: 223 | app: alpa 224 | ports: 225 | - port: 48878 226 | targetPort: 1727 227 | ``` 228 | 229 | A [service](https://kubernetes.io/docs/concepts/services-networking/service) will allow you to access `@alpa/api` outside the Kubernetes cluster network on port `48878`. 230 | 231 | > ℹ️ **Note:** For security purposes, it is recommended that you change this port number `48878` to a random 5 digit number below 60,000. 232 | 233 | ### 🔨 Creating `kustomization.yml` file 234 | 235 | Create a file named `kustomization.yml` and save the below contents 👇 in the `alpa` folder we created. 236 | 237 | ```yml 238 | apiVersion: kustomize.config.k8s.io/v1beta1 239 | resources: 240 | # deleting the name will delete everything 241 | - 0-namespace.yml 242 | 243 | # redis database for primarily for alpa 244 | - redis/1-volumes.yml 245 | - redis/2-deploys.yml 246 | - redis/3-services.yml 247 | 248 | # @alpa/api service 249 | - alpa/1-configs.yml 250 | - alpa/2-deploys.yml 251 | - alpa/3-services.yml 252 | ``` 253 | 254 | Once all the required files are created, the completed directory structure should look something like 👇 255 | 256 | ```js 257 | alpa 258 | /alpa 259 | 1-configs.yml 260 | 2-deploys.yml 261 | 3-services.yml 262 | /redis 263 | 1-volumes.yml 264 | 2-deploys.yml 265 | 3-services.yml 266 | 0-namespace.yml 267 | kustomization.yml 268 | ``` 269 | 270 | ### ✨ Starting `@alpa/api` 271 | 272 | With the above mentioned changes being done to the configuration file, `@alpa/api` is now ready to be started in a production environment safely. 273 | 274 | To start all the services defined in our `kustomization.yml` run 👇 the below command: 275 | 276 | ```bash 277 | kubectl apply -k . 278 | ``` 279 | 280 | **If you're still facing issues, refer the [troubleshooting & help section](https://github.com/vsnthdev/alpa#-troubleshooting--help) for further information.** 281 | -------------------------------------------------------------------------------- /docs/md/api/docs/manual.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: ../../README.md 3 | --- 4 | 5 | # 🧰 Manually deploying 6 | 7 | There are mainly 3 ways to deploy `@alpa/api` onto production. For personal usage deploying through [🐳 Docker Compose](./docker.md) is the most easiest & recommended way. For high intensity workloads read about [Kubernetes deployment](./kubernetes.md). 8 | 9 | Deploying **alpa**'s API is easy and straightforward by following the below steps: 10 | 11 | Manually deploying will allow you to run `@alpa/api` on a production server without additional layers of abstraction. 12 | 13 | > **⚠️ Warning:** This method is good for advanced use cases & updating alpa may not be straightforward always. 14 | 15 | ## 🔍 Prerequisites 16 | 17 | 1. Node.js version v{{nodeVersion}} or higher ([Windows](https://youtu.be/sHGz607fsVA) / [Linux](https://github.com/nodesource/distributions#readme) / [macOS](https://github.com/nvm-sh/nvm#readme)) 18 | 2. [Redis database](https://redis.io) 19 | 3. [RedisJSON plugin](https://redis.io/docs/stack/json/) for Redis database 20 | 4. [RediSearch plugin](https://redis.io/docs/stack/search) for Redis database 21 | 22 | ## 🚀 Deployment process 23 | 24 | As said in this [README.md](https://github.com/vsnthdev/alpa/tree/main#readme) file **alpa** is a monorepo containing multiple projects, follow the below steps to configure `@alpa/api` to run in production. 25 | 26 | ### 💾 Getting `@alpa/api` 27 | 28 | Instead of normally cloning entire repository here are the commands to only clone `@alpa/api` project & the root project 👇 29 | 30 | **STEP 1️⃣** Clone only the root project 31 | 32 | ``` 33 | git clone --single-branch --branch main --depth 1 --filter=blob:none --sparse https://github.com/vsnthdev/alpa 34 | ``` 35 | 36 | **STEP 2️⃣** Enter the freshly cloned root project 37 | 38 | ``` 39 | cd ./alpa 40 | ``` 41 | 42 | **STEP 3️⃣** Initialize Git sparse checkout 43 | 44 | ``` 45 | git sparse-checkout init --cone 46 | ``` 47 | 48 | **STEP 4️⃣** Pull only `@alpa/api` project while ignoring other projects 49 | 50 | ``` 51 | git sparse-checkout set api 52 | ``` 53 | 54 | ### 🪄 Installing dependencies 55 | 56 | Dependency libraries for both the root project & `@alpa/api` can be installed & setup by running the following command 👇 57 | 58 | ``` 59 | npm install 60 | ``` 61 | 62 | ### 💻 Building `@alpa/api` 63 | 64 | We only store TypeScript source code in this repository so before we can start `@alpa/api` server, we need to build (_transpile TypeScript into JavaScript_) the project using the following command 👇 65 | 66 | ``` 67 | npm run build 68 | ``` 69 | 70 | ### ⚙️ Creating configuration file 71 | 72 | An [example config file](../../api/config.example.yml) is already present with all configurable values and their defaults. We'll copy that and make some necessary changes to prepare `@alpa/api` to work in production 👇 73 | 74 | ``` 75 | cp api/config.example.yml api/config.yml 76 | ``` 77 | 78 | ### ⚡ Configuring for production 79 | 80 | Provided example config file is best suitable for development & testing purposes only. We need to make some changes to the config file to make `@alpa/api` suitable for production environments. 81 | 82 | 1. 🔒 **Changing username & password** 83 | 84 | The default username (`{{api.config.auth.username}}`) & password (`{{api.config.auth.password}}`) are extremely insecure. Change both the `auth.username` and `auth.password` fields with better values. And avoid setting the username to commonly guessable values like `admin`, `alpa`, `sudo`, `root` etc. 85 | 86 |
87 | 88 | 2. 🔌 **Changing database connection URL** 89 | 90 | The default database connection URL (`{{api.config.database.connection}}`) is mainly for connecting to an internal Docker container. 91 | 92 | Change the value of `database.connection` field to a Redis database connection URL without a database number pre-selected. Preferably to an empty Redis database exclusively to be used with `@alpa/api`. Using a shared database is also possible with additional configuration. 93 | 94 | > ⚠️ **Warning:** The Redis database must have RedisJSON & RediSearch plugins enabled & working. 95 | 96 |
97 | 98 | 3. 🔑 **Changing server's secret key** 99 | 100 | This secret key is used to sign the JWT authentication tokens, since the default (`{{api.config.server.secret}}`) is already known to everyone. It is insecure to use it. 101 | 102 | Preferably use a password generator to generate a 64 character long random string here. 103 | 104 | > ⚠️ **Warning:** Failing to change the secret key, or using a small secret key will get you into the risk of getting `@alpa/api` hacked. A minimum of 64 characters is recommended. 105 | 106 |
107 | 108 | 4. 🔗 **Changing allowed domains** 109 | 110 | [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) are sent by `@alpa/api` to prevent misuse & accessing the API from unauthorized origins. 111 | 112 | Remove `localhost` entries from `server.cors` field to prevent everyone from self-deploying `@alpa/app` and accessing your instance of `@alpa/api` from their own computer. 113 | 114 | Finally, if you're not using the universal deployment of `@alpa/app` at https://alpa.vercel.app then also remove that entry as a safety measure while adding the full URL of `@alpa/app` hosted by you to allow, programmatic communication from that URL. 115 | 116 | ### ✨ Starting `@alpa/api` 117 | 118 | With the above mentioned changes being done to the configuration file, `@alpa/api` is now ready to be started in a production environment safely. 119 | 120 | On Linux & macOS operating systems run the below command 👇 121 | 122 | ```bash 123 | NODE_ENV=production node api/dist/index.js 124 | ``` 125 | 126 | If you're on Windows (_but seriously? why!_ 🤷‍♂️) then use [cross-env](https://www.npmjs.com/package/cross-env) to set the `NODE_ENV` to production 👇 and start `@alpa/api`: 127 | 128 | ```bash 129 | npx cross-env NODE_ENV=production node api/dist/index.js 130 | ``` 131 | 132 | > ℹ️ **Info:** During this process npm may ask you whether to install `cross-env` depending on if you already have it. 133 | 134 | After following the above steps you should be able to login from the configured client and start enjoying **alpa**. 135 | 136 | **If you're still facing issues, refer the [troubleshooting & help section](https://github.com/vsnthdev/alpa#-troubleshooting--help) for further information.** 137 | 138 | 139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /docs/md/app/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: ../README.md 3 | --- 4 | 5 | This project contains a friendly dashboard deployed at https://alpa.vercel.app which can be used to control **alpa's API hosted anywhere**. 6 | 7 | ## 🔮 Tech stack 8 | 9 | | Name | Description | 10 | | --- | --- | 11 | | **React.js** | Frontend framework of choice. | 12 | | **Redux** | Store management for React. | 13 | | **TailwindCSS** | CSS framework for rapid UI building. | 14 | | **Vite.js** | For bundling JavaScript. | 15 | | **Vercel** | For deploying frontend. | 16 | | **nanoid** | For creating short codes. | 17 | 18 | ## 💻 Building & Dev Setup 19 | 20 | You need to be at least on **Node.js v{{nodeVersion}} or above** and follow the below instructions to build this project 👇 21 | 22 | - **STEP 1️⃣** Clone this repository & enter into it (`cd ./alpa`) 23 | - **STEP 2️⃣** Run **`npm install`** to get all dependencies & link projects together 24 | - **STEP 3️⃣** Enter in the project directory (`cd app`) 25 | - **STEP 4️⃣** To build this project run **`npm run build`** 26 | 27 | Upon building `@alpa/app` a production optimized bundle of React.js app is generated in the `dist` folder within the project. 28 | -------------------------------------------------------------------------------- /docs/md/docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: ../README.md 3 | --- 4 | 5 | Reads the TypeScript code in this repository and programmatically generates documentation markdown files. 6 | 7 | ## 🔮 Tech stack 8 | 9 | | Name | Description | 10 | | --- | --- | 11 | | **Handlebars** | Templating engine to inject values into template markdown files. | 12 | | **Chokidar** | Watches for file changes and rebuilds docs. | 13 | 14 | ## 💻 Building & Dev Setup 15 | 16 | You need to be at least on **Node.js v{{nodeVersion}} or above** and follow the below instructions to build this project 👇 17 | 18 | - **STEP 1️⃣** Clone this repository & enter into it (`cd ./alpa`) 19 | - **STEP 2️⃣** Run **`npm install`** to get all dependencies & link projects together 20 | - **STEP 3️⃣** Enter in the project directory (`cd docs`) 21 | - **STEP 4️⃣** To build this project run **`npm run build`** 22 | 23 | Upon building `@alpa/docs` will rerender all markdown files within all the projects in this repository. 24 | 25 | > **ℹ️ Info:** You can also run `npm run clean` to delete existing documentation from the project to avoid overwriting & purge dangling documents. While running the `build` script, old docs are first deleted before getting overwritten. 26 | -------------------------------------------------------------------------------- /docs/media/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsnthdev/alpa/e999519e5a6f2343ac596ac036ac549b9ae66ff5/docs/media/cover.png -------------------------------------------------------------------------------- /docs/media/logo_dark.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /docs/media/logo_light.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /docs/notes/todo.md: -------------------------------------------------------------------------------- 1 | api 2 | docs/deploy.md 3 | ❌ manual deploy 4 | ❌ docker deploy 5 | ❌ kubernetes deploy 6 | README.md 7 | ✅ configuration 8 | ✅ api routes 9 | ✅ tech stack 10 | 11 | app 12 | README.md 13 | ✅ tech stack 14 | 15 | README.md 16 | ✅ header 17 | ✅ short overview 18 | ✅ features 19 | ✅ why I built it 20 | ✅ quick start 21 | ✅ support & funding 22 | ✅ code & contribution 23 | ✅ building 24 | ✅ license 25 | ✅ footer 26 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alpa/docs", 3 | "description": "Programmatically ⚡ builds docs 📚 of all projects 📂 under alpa.", 4 | "version": "1.1.1", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "clean": "rimraf ./README.md ../README.md ../app/README.md ../api/README.md ../api/docs", 9 | "build": "node --no-warnings --loader ts-node/esm src/build.ts", 10 | "start": "node --no-warnings --loader ts-node/esm src/dev.ts" 11 | }, 12 | "dependencies": { 13 | "chokidar": "^3.5.3", 14 | "gray-matter": "^4.0.3", 15 | "handlebars": "^4.7.7", 16 | "mkdirp": "^1.0.4", 17 | "ts-node": "^10.7.0" 18 | }, 19 | "devDependencies": { 20 | "@types/handlebars": "^4.1.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/src/build.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Entryfile to build documentation for alpa project. 3 | * Created On 08 March 2022 4 | */ 5 | 6 | import dirname from 'es-dirname' 7 | import glob from 'glob' 8 | import path from 'path' 9 | 10 | import getData from './helpers/index.js' 11 | import { log } from './logger.js' 12 | import handleMarkdownFile from './md.js' 13 | 14 | const getMD = async () => { 15 | log.info('Estimating markdown files') 16 | let files = glob.sync(path.join(dirname(), '..', 'md', '**', '**.md')) 17 | files = files.concat(glob.sync(path.join(dirname(), '..', 'md', '**.md'))) 18 | 19 | return files 20 | } 21 | 22 | const md = await getMD() 23 | const data = await getData() 24 | 25 | const promises = md.map(file => handleMarkdownFile(file, data)) 26 | 27 | await Promise.all(promises) 28 | log.success('Finished generating docs') 29 | -------------------------------------------------------------------------------- /docs/src/dev.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * A dev command to watch for file changes and re-build changed 3 | * changed files. 4 | * Created On 10 March 2022 5 | */ 6 | 7 | import chokidar from 'chokidar' 8 | import dirname from 'es-dirname' 9 | import fs from 'fs' 10 | import path from 'path' 11 | 12 | import getData from './helpers/index.js' 13 | import handleMarkdownFile from './md.js' 14 | 15 | const dir = path.join(dirname(), '..', 'md') 16 | const data = await getData() 17 | 18 | const onChange = (p: string) => { 19 | handleMarkdownFile(p, data) 20 | } 21 | 22 | chokidar 23 | .watch(dir, { 24 | ignored: p => { 25 | const stat = fs.statSync(p) 26 | 27 | // allow directories to be watched 28 | if (stat.isDirectory()) return false 29 | 30 | // only allow markdown files, and rest 31 | // everything should be ignored 32 | return path.parse(p).ext != '.md' 33 | }, 34 | }) 35 | .on('add', p => onChange(p)) 36 | .on('change', p => onChange(p)) 37 | -------------------------------------------------------------------------------- /docs/src/helpers/alpa.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Gets application information for docs to be rendered. 3 | * Created On 09 March 2022 4 | */ 5 | 6 | import dirname from 'es-dirname' 7 | import fs from 'fs/promises' 8 | import path from 'path' 9 | 10 | export default async () => { 11 | const packageJSON = await fs.readFile( 12 | path.join(dirname(), '..', '..', '..', 'package.json'), 13 | 'utf-8', 14 | ) 15 | const { description, license, engines } = JSON.parse(packageJSON) 16 | 17 | return { 18 | license, 19 | desc: description, 20 | nodeVersion: engines.node.slice(2), 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/src/helpers/api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Returns an object, by reading parts of the @alpa/api project. 3 | * Created On 31 March 2022 4 | */ 5 | 6 | import dirname from 'es-dirname' 7 | import fs from 'fs/promises' 8 | import glob from 'glob' 9 | import path from 'path' 10 | import { parse } from 'yaml' 11 | 12 | const getRoutePath = (code: string) => { 13 | const lines = code 14 | .split('export default {')[1] 15 | .split('\n') 16 | .map(line => line.trim()) 17 | .filter(line => Boolean(line)) 18 | 19 | lines.pop() 20 | 21 | let line = lines.find(line => line.match(/url: /g)) 22 | line = line.slice(5, -1).trim() 23 | 24 | const value = eval(line) as string[] 25 | return value.length == 0 ? value[0] : value.join(' & ') 26 | } 27 | 28 | const getRouteMethod = (code: string) => { 29 | const lines = code 30 | .split('export default {')[1] 31 | .split('\n') 32 | .map(line => line.trim()) 33 | .filter(line => Boolean(line)) 34 | 35 | lines.pop() 36 | 37 | let line = lines.find(line => line.match(/method:/g)) 38 | line = line.slice(5, -1).trim() 39 | 40 | const value = eval(line) 41 | 42 | return typeof value == 'string' ? value : value[0] 43 | } 44 | 45 | const getRouteDescription = (code: string) => { 46 | let lines = code.split(' */')[0].split('\n') 47 | lines.shift() 48 | lines = lines.filter(line => Boolean(line)) 49 | 50 | return lines[0].slice(2).trim() 51 | } 52 | 53 | const isAuthRequired = (code: string) => { 54 | const lines = code 55 | .split('export default {')[1] 56 | .split('\n') 57 | .map(line => line.trim()) 58 | .filter(line => Boolean(line)) 59 | 60 | lines.pop() 61 | 62 | if (lines.find(line => line.includes('opts: {'))) { 63 | const others = lines.join(' ').split('opts: {')[1].split('},')[0] 64 | return others.match(/auth/g) ? true : false 65 | } else { 66 | return false 67 | } 68 | } 69 | 70 | const readDefaultConfig = async (api: string): Promise => { 71 | const str = await fs.readFile(path.join(api, 'config.example.yml'), 'utf-8') 72 | return parse(str) 73 | } 74 | 75 | const getApp = async (api: string): Promise => { 76 | const str = await fs.readFile(path.join(api, 'package.json'), 'utf-8') 77 | return JSON.parse(str) 78 | } 79 | 80 | export default async () => { 81 | const api = path.join(dirname(), '..', '..', '..', 'api') 82 | const routeFiles = glob.sync( 83 | path.join(api, 'src', 'server', 'routes', '**', '**', 'index.ts'), 84 | ) 85 | const routes = [] 86 | 87 | for (const file of routeFiles) { 88 | const code = await fs.readFile(file, 'utf-8') 89 | 90 | routes.push({ 91 | path: getRoutePath(code), 92 | method: getRouteMethod(code), 93 | description: getRouteDescription(code), 94 | authRequired: isAuthRequired(code), 95 | }) 96 | } 97 | 98 | return { 99 | api: { 100 | routes, 101 | app: await getApp(api), 102 | config: await readDefaultConfig(api), 103 | }, 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /docs/src/helpers/generic.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Contains some generic variables. 3 | * Created On 09 March 2022 4 | */ 5 | 6 | export default async () => { 7 | const date = new Date() 8 | 9 | return { 10 | year: date.getFullYear(), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Loads all the helpers and returns the data object. 3 | * Created On 09 March 2022 4 | */ 5 | 6 | // helpers directory would contain folders containing 7 | // TypeScript modules, that would dynamically interpret 8 | // parts of the codebase to create variables to be used dynamically 9 | // throughout the markdown files 10 | 11 | import chalk from 'chalk' 12 | import dirname from 'es-dirname' 13 | import glob from 'glob' 14 | import path from 'path' 15 | 16 | import { log } from '../logger.js' 17 | 18 | export default async () => { 19 | // get all the files in this directory 20 | log.info('Starting to fetch data') 21 | let files = glob.sync(path.join(dirname(), '*.ts')) 22 | files = files.filter( 23 | file => file != files.find(file => path.parse(file).base == 'index.ts'), 24 | ) 25 | 26 | let data = {} 27 | 28 | for (const file of files) { 29 | log.info(`Fetching for ${chalk.gray(path.parse(file).base)}`) 30 | const { default: ts } = await import(`file://${file}`) 31 | data = { ...data, ...(await ts()) } 32 | } 33 | 34 | return data 35 | } 36 | -------------------------------------------------------------------------------- /docs/src/helpers/projects.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Reads all the projects in this repo to be used within README.md. 3 | * Created On 31 March 2022 4 | */ 5 | 6 | import dirname from 'es-dirname' 7 | import fs from 'fs/promises' 8 | import path from 'path' 9 | 10 | const excludes = ['node_modules'] 11 | 12 | export default async () => { 13 | const root = path.join(dirname(), '..', '..', '..') 14 | const files = await fs.readdir(root, { 15 | withFileTypes: true, 16 | }) 17 | 18 | const projects = files 19 | .filter(file => { 20 | // only allow folder 21 | const outcomes = [ 22 | file.isDirectory(), 23 | !excludes.includes(file.name), 24 | file.name.charAt(0) != '.', 25 | ] 26 | 27 | return !outcomes.includes(false) 28 | }) 29 | .map(file => file.name) 30 | 31 | const returnable = [] 32 | 33 | for (const project of projects) { 34 | const data = await fs.readFile( 35 | path.join(root, project, 'package.json'), 36 | 'utf-8', 37 | ) 38 | returnable.push({ ...JSON.parse(data), ...{ projectName: project } }) 39 | } 40 | 41 | return { 42 | projects: returnable, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/src/helpers/twitter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Fetches my actual Twitter username using alpa & mahat. 3 | * Created On 10 March 2022 4 | */ 5 | 6 | import axios from 'axios' 7 | import path from 'path' 8 | 9 | export default async () => { 10 | try { 11 | await axios({ 12 | method: 'GET', 13 | url: `https://vas.cx/twitter`, 14 | maxRedirects: 0, 15 | }) 16 | } catch ({ response: { status, headers } }) { 17 | if (status == 307) 18 | return { 19 | twitterUsername: path.parse(headers.location).base, 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/src/layout.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Fetches the layout if specified. 3 | * Created On 09 March 2022 4 | */ 5 | 6 | import fs from 'fs/promises' 7 | import path from 'path' 8 | 9 | export default async (md: string, context: any, content: string) => { 10 | if (!context.layout) return content 11 | 12 | const layoutFile = path.join(path.dirname(md), context.layout) 13 | const layout = await fs.readFile(layoutFile, 'utf-8') 14 | 15 | return layout 16 | .split('')[0] 17 | .trim() 18 | .concat('\n\n') 19 | .concat(content.trim()) 20 | .concat(layout.split('')[1]) 21 | .trim() 22 | .concat('\n') 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/logger.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Initializes itivrutaha logger for @alpa/docs project. 3 | * Created On 08 March 2022 4 | */ 5 | 6 | import itivrutaha from 'itivrutaha' 7 | 8 | export const log = await itivrutaha.createNewLogger({ 9 | bootLog: false, 10 | shutdownLog: false, 11 | theme: { 12 | string: ':emoji :type :message', 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /docs/src/md.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Processes each single markdown file. 3 | * Created On 09 March 2022 4 | */ 5 | 6 | import chalk from 'chalk' 7 | import dirname from 'es-dirname' 8 | import fs from 'fs/promises' 9 | import gm from 'gray-matter' 10 | import handlebars from 'handlebars' 11 | import mkdir from 'mkdirp' 12 | import path from 'path' 13 | import { stringify } from 'yaml' 14 | 15 | import getLayout from './layout.js' 16 | import { log } from './logger.js' 17 | 18 | const getIsIndex = (md: string) => { 19 | const relative = path.relative(path.join(dirname(), '..'), md) 20 | return path.dirname(relative) == 'md' && 21 | path.basename(relative) == 'README.md' 22 | ? true 23 | : false 24 | } 25 | 26 | export default async (md: string, data: any) => { 27 | // read the file 28 | const src = await fs.readFile(md, 'utf-8') 29 | 30 | // read the front matter 31 | const doc = gm(src) 32 | 33 | // fetch the layout if specified 34 | doc.content = await getLayout(md, doc.data, doc.content) 35 | 36 | // create a YAML template 37 | handlebars.registerHelper('yaml', (data, indent) => 38 | stringify(data) 39 | .split('\n') 40 | .map(line => ' '.repeat(indent) + line) 41 | .join('\n') 42 | .substring(indent), 43 | ) 44 | 45 | // create a handlebars template 46 | const template = handlebars.compile(doc.content, { 47 | noEscape: true, 48 | }) 49 | 50 | // render it 51 | const render = template({ ...data, ...{ isIndex: getIsIndex(md) } }) 52 | 53 | // write to the destination 54 | const dest = path.join( 55 | dirname(), 56 | '..', 57 | '..', 58 | path.normalize(md).split(path.join('docs', 'md'))[1], 59 | ) 60 | await mkdir(path.dirname(dest)) 61 | await fs.writeFile(dest, render, 'utf-8') 62 | 63 | // tell the user, we're finished with this file 64 | log.info(`Finished writing ${chalk.gray.underline(dest)}`) 65 | } 66 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "*" 4 | ], 5 | "version": "1.1.1" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alpa", 3 | "description": "( अल्प ) — A fast ⚡ self-hosted link 🔗 shortener.", 4 | "license": "AGPL-3.0-only", 5 | "type": "module", 6 | "bugs": "https://github.com/vsnthdev/alpa/issues", 7 | "author": { 8 | "name": "Vasanth Developer", 9 | "email": "vasanth@vasanthdeveloper.com", 10 | "url": "https://vsnth.dev" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/vsnthdev/alpa.git" 15 | }, 16 | "engines": { 17 | "node": ">=17.4.0" 18 | }, 19 | "scripts": { 20 | "postinstall": "lerna bootstrap", 21 | "lint": "eslint --fix --ext cjs,mjs,js,ts,tsx -c ./.eslintrc.cjs .", 22 | "clean": "lerna run clean", 23 | "build": "lerna run --parallel build", 24 | "build:docker": "lerna run --stream build:docker", 25 | "build:docs": "lerna run --loglevel silent --stream --scope @alpa/docs clean && lerna run --loglevel silent --stream --scope @alpa/docs build", 26 | "prepare": "husky install" 27 | }, 28 | "dependencies": { 29 | "@vsnthdev/utilities-node": "^2.0.1", 30 | "axios": "^0.25.0", 31 | "chalk": "^5.0.0", 32 | "es-dirname": "^0.1.0", 33 | "glob": "^7.2.0", 34 | "husky": "^7.0.4", 35 | "itivrutaha": "^2.0.13", 36 | "lerna": "^4.0.0", 37 | "yaml": "^2.0.1" 38 | }, 39 | "devDependencies": { 40 | "@types/glob": "^7.2.0", 41 | "@types/node": "^17.0.33", 42 | "@typescript-eslint/eslint-plugin": "^5.23.0", 43 | "concurrently": "^7.1.0", 44 | "eslint": "^8.15.0", 45 | "eslint-config-prettier": "^8.5.0", 46 | "eslint-plugin-import": "^2.26.0", 47 | "eslint-plugin-prettier": "^4.0.0", 48 | "eslint-plugin-react": "^7.29.4", 49 | "eslint-plugin-simple-import-sort": "^7.0.0", 50 | "prettier": "^2.6.2", 51 | "rimraf": "^3.0.2", 52 | "typescript": "^4.6.4" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Prettier run control for alpa project. 3 | * Created On 26 April 2022 4 | */ 5 | 6 | module.exports = { 7 | semi: false, 8 | tabWidth: 4, 9 | useTabs: false, 10 | endOfLine: 'lf', 11 | singleQuote: true, 12 | trailingComma: 'all', 13 | bracketSpacing: true, 14 | arrowParens: 'avoid', 15 | parser: 'typescript', 16 | quoteProps: 'as-needed', 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "alwaysStrict": true, 5 | "charset": "UTF-8", 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "module": "ESNext", 9 | "newLine": "LF", 10 | "preserveConstEnums": true, 11 | "removeComments": true, 12 | "target": "ESNext", 13 | "incremental": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./api" 6 | }, 7 | ] 8 | } 9 | --------------------------------------------------------------------------------