mirror of
				https://github.com/zws-im/zws.git
				synced 2025-10-30 23:27:52 +02:00 
			
		
		
		
	feat: release API v2
BREAKING CHANGE: API v1 endpoints are no longer totally compatible, you should upgrade to v2
This commit is contained in:
		
							
								
								
									
										13
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| .github | ||||
| .vscode | ||||
| node_modules | ||||
| tsc_output | ||||
| .editorconfig | ||||
| *.env | ||||
| .gitignore | ||||
| .prettierignore | ||||
| app.json | ||||
| openapi.yml | ||||
| prettier.config.js | ||||
| renovate.json | ||||
| *.log | ||||
| @@ -1,9 +1,16 @@ | ||||
| root = true | ||||
|  | ||||
| [*] | ||||
| indent_style = space | ||||
| indent_style = tab | ||||
| indent_size = 2 | ||||
| end_of_line = lf | ||||
| charset = utf-8 | ||||
| trim_trailing_whitespace = true | ||||
| insert_final_newline = true | ||||
| insert_final_newline = true | ||||
|  | ||||
| [*.{y,ya}ml] | ||||
| indent_style = space | ||||
|  | ||||
| [*.sql] | ||||
| indent_style = space | ||||
| indent_size = 4 | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| { | ||||
|   "projects": { | ||||
|     "default": "zero-width-shortener" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										6
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,12 +1,12 @@ | ||||
| # These are supported funding model platforms | ||||
|  | ||||
| github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] | ||||
| patreon: pizzafox # Replace with a single Patreon username | ||||
| open_collective: zws # Replace with a single Open Collective username | ||||
| patreon: pizzafox | ||||
| open_collective: zws | ||||
| ko_fi: # Replace with a single Ko-fi username | ||||
| tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel | ||||
| community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry | ||||
| liberapay: # Replace with a single Liberapay username | ||||
| issuehunt: # Replace with a single IssueHunt username | ||||
| otechie: # Replace with a single Otechie username | ||||
| custom: # Replace with a single custom sponsorship URL | ||||
| custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] | ||||
|   | ||||
							
								
								
									
										142
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| name: CI | ||||
|  | ||||
| on: [push, pull_request] | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     name: Build | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout git repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Get Yarn cache directory path | ||||
|         id: yarn-cache-dir-path | ||||
|         run: echo "::set-output name=dir::$(yarn config get cacheFolder)" | ||||
|       - name: Cache dependencies | ||||
|         uses: actions/cache@v2 | ||||
|         with: | ||||
|           path: ${{ steps.yarn-cache-dir-path.outputs.dir }} | ||||
|           key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-yarn- | ||||
|       - name: Install dependencies with Yarn | ||||
|         run: yarn install --immutable | ||||
|       - name: Compile TypeScript | ||||
|         run: yarn run build | ||||
|       - name: Upload compiled TypeScript | ||||
|         uses: actions/upload-artifact@v2 | ||||
|         with: | ||||
|           name: tsc_output | ||||
|           path: tsc_output | ||||
|   lint: | ||||
|     name: Lint | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout git repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Get Yarn cache directory path | ||||
|         id: yarn-cache-dir-path | ||||
|         run: echo "::set-output name=dir::$(yarn config get cacheFolder)" | ||||
|       - name: Cache dependencies | ||||
|         uses: actions/cache@v2 | ||||
|         with: | ||||
|           path: ${{ steps.yarn-cache-dir-path.outputs.dir }} | ||||
|           key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-yarn- | ||||
|       - name: Install dependencies with Yarn | ||||
|         run: yarn install --immutable | ||||
|       - name: Lint | ||||
|         run: yarn run lint | ||||
|   lint-dockerfile: | ||||
|     name: Lint Dockerfile | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout git repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Lint Dockerfile | ||||
|         uses: brpaz/hadolint-action@v1.3.1 | ||||
|         with: | ||||
|           dockerfile: 'Dockerfile' | ||||
|   style: | ||||
|     name: Check style | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout git repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Get Yarn cache directory path | ||||
|         id: yarn-cache-dir-path | ||||
|         run: echo "::set-output name=dir::$(yarn config get cacheFolder)" | ||||
|       - name: Cache dependencies | ||||
|         uses: actions/cache@v2 | ||||
|         with: | ||||
|           path: ${{ steps.yarn-cache-dir-path.outputs.dir }} | ||||
|           key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-yarn- | ||||
|       - name: Install dependencies with Yarn | ||||
|         run: yarn install --immutable | ||||
|       - name: Check style | ||||
|         run: yarn run style | ||||
|   style-prisma: | ||||
|     name: Check style of Prisma schema | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout git repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Get yarn cache | ||||
|         id: yarn-cache | ||||
|         run: echo "::set-output name=dir::$(yarn config get cacheFolder)" | ||||
|       - name: Cache dependencies | ||||
|         uses: actions/cache@v2 | ||||
|         with: | ||||
|           path: ${{ steps.yarn-cache.outputs.dir }} | ||||
|           key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-yarn- | ||||
|       - name: Install dependencies with Yarn | ||||
|         run: yarn install --immutable | ||||
|       - name: Check style with Prisma CLI | ||||
|         run: yarn run prisma format && git diff --exit-code -s prisma/schema.prisma | ||||
|   deploy: | ||||
|     name: Deploy | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     # Don't run this job if we aren't on master branch | ||||
|     # `semantic-release` will do this automatically, but this saves us the time of building the image prior to that | ||||
|     if: ${{ github.ref  == 'refs/heads/master' }} | ||||
|  | ||||
|     needs: [build, lint, lint-dockerfile] | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout git repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Get yarn cache | ||||
|         id: yarn-cache | ||||
|         run: echo "::set-output name=dir::$(yarn config get cacheFolder)" | ||||
|       - name: Cache dependencies | ||||
|         uses: actions/cache@v2 | ||||
|         with: | ||||
|           path: ${{ steps.yarn-cache.outputs.dir }} | ||||
|           key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-yarn- | ||||
|       - name: Install dependencies with Yarn | ||||
|         run: yarn install --immutable | ||||
|       - name: Deploy | ||||
|         run: yarn run deploy | ||||
|         env: | ||||
|           DOCKER_USERNAME: pizzafox | ||||
|           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
							
								
								
									
										67
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| # For most projects, this workflow file will not need changing; you simply need | ||||
| # to commit it to your repository. | ||||
| # | ||||
| # You may wish to alter this file to override the set of languages analyzed, | ||||
| # or to provide custom queries or build logic. | ||||
| # | ||||
| # ******** NOTE ******** | ||||
| # We have attempted to detect the languages in your repository. Please check | ||||
| # the `language` matrix defined below to confirm you have the correct set of | ||||
| # supported CodeQL languages. | ||||
| # | ||||
| name: 'CodeQL' | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: [main] | ||||
|   pull_request: | ||||
|     # The branches below must be a subset of the branches above | ||||
|     branches: [main] | ||||
|   schedule: | ||||
|     - cron: '27 15 * * 5' | ||||
|  | ||||
| jobs: | ||||
|   analyze: | ||||
|     name: Analyze | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         language: ['javascript'] | ||||
|         # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] | ||||
|         # Learn more: | ||||
|         # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v2 | ||||
|  | ||||
|       # Initializes the CodeQL tools for scanning. | ||||
|       - name: Initialize CodeQL | ||||
|         uses: github/codeql-action/init@v1 | ||||
|         with: | ||||
|           languages: ${{ matrix.language }} | ||||
|           # If you wish to specify custom queries, you can do so here or in a config file. | ||||
|           # By default, queries listed here will override any specified in a config file. | ||||
|           # Prefix the list here with "+" to use these queries and those in the config file. | ||||
|           # queries: ./path/to/local/query, your-org/your-repo/queries@main | ||||
|  | ||||
|       # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java). | ||||
|       # If this step fails, then you should remove it and run the build manually (see below) | ||||
|       - name: Autobuild | ||||
|         uses: github/codeql-action/autobuild@v1 | ||||
|  | ||||
|       # ℹ️ Command-line programs to run using the OS shell. | ||||
|       # 📚 https://git.io/JvXDl | ||||
|  | ||||
|       # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines | ||||
|       #    and modify them (or add more) to build your code if your project | ||||
|       #    uses a compiled language | ||||
|  | ||||
|       #- run: | | ||||
|       #   make bootstrap | ||||
|       #   make release | ||||
|  | ||||
|       - name: Perform CodeQL Analysis | ||||
|         uses: github/codeql-action/analyze@v1 | ||||
							
								
								
									
										35
									
								
								.github/workflows/workflow.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										35
									
								
								.github/workflows/workflow.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,35 +0,0 @@ | ||||
| on: [push, pull_request] | ||||
| name: CI | ||||
| jobs: | ||||
|   test: | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|     - name: Git checkout | ||||
|       uses: actions/checkout@v1 | ||||
|       with: | ||||
|         fetch-depth: 0 | ||||
|     - name: Install Node.js | ||||
|       uses: actions/setup-node@v1 | ||||
|       with: | ||||
|         version: 10.x | ||||
|     - name: Install functions dependencies | ||||
|       run: npm --prefix ./functions install | ||||
|     - name: Lint functions | ||||
|       run: npm --prefix ./functions run lint | ||||
|     - name: Deploy Firebase Functions | ||||
|       uses: pizzafox/firebase-action@master | ||||
|       if: github.event == 'push' && github.branch == 'master' | ||||
|       env: | ||||
|         FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} | ||||
|         PROJECT_ID: zero-width-shortener | ||||
|       with: | ||||
|         args: deploy --only functions | ||||
|     - name: Deploy Firebase Firestore | ||||
|       if: github.event == 'push' && github.branch == 'master' | ||||
|       uses: pizzafox/firebase-action@master | ||||
|       env: | ||||
|         FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} | ||||
|         PROJECT_ID: zero-width-shortener | ||||
|       with: | ||||
|         args: deploy --only firestore | ||||
							
								
								
									
										87
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										87
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,15 +1,5 @@ | ||||
|  | ||||
| # Created by https://www.gitignore.io/api/node,linux,windows,firebase,visualstudiocode | ||||
| # Edit at https://www.gitignore.io/?templates=node,linux,windows,firebase,visualstudiocode | ||||
|  | ||||
| ### Firebase ### | ||||
| .idea | ||||
| **/node_modules/* | ||||
| **/.firebaserc | ||||
|  | ||||
| ### Firebase Patch ### | ||||
| .runtimeconfig.json | ||||
| .firebase/ | ||||
| # Created by https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode | ||||
| # Edit at https://www.gitignore.io/?templates=node,linux,macos,windows,visualstudiocode | ||||
|  | ||||
| ### Linux ### | ||||
| *~ | ||||
| @@ -26,6 +16,34 @@ | ||||
| # .nfs files are created when an open file is removed but is still being accessed | ||||
| .nfs* | ||||
|  | ||||
| ### macOS ### | ||||
| # General | ||||
| .DS_Store | ||||
| .AppleDouble | ||||
| .LSOverride | ||||
|  | ||||
| # Icon must end with two \r | ||||
| Icon | ||||
|  | ||||
| # Thumbnails | ||||
| ._* | ||||
|  | ||||
| # Files that might appear in the root of a volume | ||||
| .DocumentRevisions-V100 | ||||
| .fseventsd | ||||
| .Spotlight-V100 | ||||
| .TemporaryItems | ||||
| .Trashes | ||||
| .VolumeIcon.icns | ||||
| .com.apple.timemachine.donotpresent | ||||
|  | ||||
| # Directories potentially created on remote AFP share | ||||
| .AppleDB | ||||
| .AppleDesktop | ||||
| Network Trash Folder | ||||
| Temporary Items | ||||
| .apdisk | ||||
|  | ||||
| ### Node ### | ||||
| # Logs | ||||
| logs | ||||
| @@ -49,6 +67,7 @@ lib-cov | ||||
|  | ||||
| # Coverage directory used by tools like istanbul | ||||
| coverage | ||||
| *.lcov | ||||
|  | ||||
| # nyc test coverage | ||||
| .nyc_output | ||||
| @@ -72,6 +91,9 @@ jspm_packages/ | ||||
| # TypeScript v1 declaration files | ||||
| typings/ | ||||
|  | ||||
| # TypeScript cache | ||||
| *.tsbuildinfo | ||||
|  | ||||
| # Optional npm cache directory | ||||
| .npm | ||||
|  | ||||
| @@ -100,6 +122,18 @@ typings/ | ||||
| # nuxt.js build output | ||||
| .nuxt | ||||
|  | ||||
| # rollup.js default build output | ||||
| dist/ | ||||
|  | ||||
| # Uncomment the public line if your project uses Gatsby | ||||
| # https://nextjs.org/blog/next-9-1#public-directory-support | ||||
| # https://create-react-app.dev/docs/using-the-public-folder/#docsNav | ||||
| # public | ||||
|  | ||||
| # Storybook build outputs | ||||
| .out | ||||
| .storybook-out | ||||
|  | ||||
| # vuepress build output | ||||
| .vuepress/dist | ||||
|  | ||||
| @@ -112,6 +146,10 @@ typings/ | ||||
| # DynamoDB Local files | ||||
| .dynamodb/ | ||||
|  | ||||
| # Temporary folders | ||||
| tmp/ | ||||
| temp/ | ||||
|  | ||||
| ### VisualStudioCode ### | ||||
| .vscode/* | ||||
| !.vscode/settings.json | ||||
| @@ -126,6 +164,7 @@ typings/ | ||||
| ### Windows ### | ||||
| # Windows thumbnail cache files | ||||
| Thumbs.db | ||||
| Thumbs.db:encryptable | ||||
| ehthumbs.db | ||||
| ehthumbs_vista.db | ||||
|  | ||||
| @@ -148,12 +187,22 @@ $RECYCLE.BIN/ | ||||
| # Windows shortcuts | ||||
| *.lnk | ||||
|  | ||||
| # End of https://www.gitignore.io/api/node,linux,windows,firebase,visualstudiocode | ||||
| # End of https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode | ||||
|  | ||||
| # Package manager lockfiles | ||||
| package-lock.json | ||||
| pnpm-lock.yaml | ||||
| yarn.lock | ||||
| # TypeScript compiler output | ||||
| tsc_output | ||||
|  | ||||
| # Firebase authentication file | ||||
| serviceAccount.json | ||||
| # Yarn | ||||
| .yarn/* | ||||
| !.yarn/releases | ||||
| !.yarn/plugins | ||||
| !.yarn/sdks | ||||
| !.yarn/versions | ||||
| .pnp.* | ||||
|  | ||||
| # Dotenv files | ||||
| *.env | ||||
| !*example.env | ||||
|  | ||||
| # Google Cloud Service account | ||||
| service-account-key.json | ||||
|   | ||||
							
								
								
									
										1
									
								
								.node-version
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.node-version
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| 15 | ||||
							
								
								
									
										197
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										197
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,197 @@ | ||||
| # Created by https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode | ||||
| # Edit at https://www.gitignore.io/?templates=node,linux,macos,windows,visualstudiocode | ||||
|  | ||||
| ### Linux ### | ||||
| *~ | ||||
|  | ||||
| # temporary files which can be created if a process still has a handle open of a deleted file | ||||
| .fuse_hidden* | ||||
|  | ||||
| # KDE directory preferences | ||||
| .directory | ||||
|  | ||||
| # Linux trash folder which might appear on any partition or disk | ||||
| .Trash-* | ||||
|  | ||||
| # .nfs files are created when an open file is removed but is still being accessed | ||||
| .nfs* | ||||
|  | ||||
| ### macOS ### | ||||
| # General | ||||
| .DS_Store | ||||
| .AppleDouble | ||||
| .LSOverride | ||||
|  | ||||
| # Icon must end with two \r | ||||
| Icon | ||||
|  | ||||
| # Thumbnails | ||||
| ._* | ||||
|  | ||||
| # Files that might appear in the root of a volume | ||||
| .DocumentRevisions-V100 | ||||
| .fseventsd | ||||
| .Spotlight-V100 | ||||
| .TemporaryItems | ||||
| .Trashes | ||||
| .VolumeIcon.icns | ||||
| .com.apple.timemachine.donotpresent | ||||
|  | ||||
| # Directories potentially created on remote AFP share | ||||
| .AppleDB | ||||
| .AppleDesktop | ||||
| Network Trash Folder | ||||
| Temporary Items | ||||
| .apdisk | ||||
|  | ||||
| ### Node ### | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| lerna-debug.log* | ||||
|  | ||||
| # Diagnostic reports (https://nodejs.org/api/report.html) | ||||
| report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | ||||
|  | ||||
| # Runtime data | ||||
| pids | ||||
| *.pid | ||||
| *.seed | ||||
| *.pid.lock | ||||
|  | ||||
| # Directory for instrumented libs generated by jscoverage/JSCover | ||||
| lib-cov | ||||
|  | ||||
| # Coverage directory used by tools like istanbul | ||||
| coverage | ||||
| *.lcov | ||||
|  | ||||
| # nyc test coverage | ||||
| .nyc_output | ||||
|  | ||||
| # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) | ||||
| .grunt | ||||
|  | ||||
| # Bower dependency directory (https://bower.io/) | ||||
| bower_components | ||||
|  | ||||
| # node-waf configuration | ||||
| .lock-wscript | ||||
|  | ||||
| # Compiled binary addons (https://nodejs.org/api/addons.html) | ||||
| build/Release | ||||
|  | ||||
| # Dependency directories | ||||
| node_modules/ | ||||
| jspm_packages/ | ||||
|  | ||||
| # TypeScript v1 declaration files | ||||
| typings/ | ||||
|  | ||||
| # TypeScript cache | ||||
| *.tsbuildinfo | ||||
|  | ||||
| # Optional npm cache directory | ||||
| .npm | ||||
|  | ||||
| # Optional eslint cache | ||||
| .eslintcache | ||||
|  | ||||
| # Optional REPL history | ||||
| .node_repl_history | ||||
|  | ||||
| # Output of 'npm pack' | ||||
| *.tgz | ||||
|  | ||||
| # Yarn Integrity file | ||||
| .yarn-integrity | ||||
|  | ||||
| # dotenv environment variables file | ||||
| .env | ||||
| .env.test | ||||
|  | ||||
| # parcel-bundler cache (https://parceljs.org/) | ||||
| .cache | ||||
|  | ||||
| # next.js build output | ||||
| .next | ||||
|  | ||||
| # nuxt.js build output | ||||
| .nuxt | ||||
|  | ||||
| # rollup.js default build output | ||||
| dist/ | ||||
|  | ||||
| # Uncomment the public line if your project uses Gatsby | ||||
| # https://nextjs.org/blog/next-9-1#public-directory-support | ||||
| # https://create-react-app.dev/docs/using-the-public-folder/#docsNav | ||||
| # public | ||||
|  | ||||
| # Storybook build outputs | ||||
| .out | ||||
| .storybook-out | ||||
|  | ||||
| # vuepress build output | ||||
| .vuepress/dist | ||||
|  | ||||
| # Serverless directories | ||||
| .serverless/ | ||||
|  | ||||
| # FuseBox cache | ||||
| .fusebox/ | ||||
|  | ||||
| # DynamoDB Local files | ||||
| .dynamodb/ | ||||
|  | ||||
| # Temporary folders | ||||
| tmp/ | ||||
| temp/ | ||||
|  | ||||
| ### VisualStudioCode ### | ||||
| .vscode/* | ||||
| !.vscode/settings.json | ||||
| !.vscode/tasks.json | ||||
| !.vscode/launch.json | ||||
| !.vscode/extensions.json | ||||
|  | ||||
| ### VisualStudioCode Patch ### | ||||
| # Ignore all local history of files | ||||
| .history | ||||
|  | ||||
| ### Windows ### | ||||
| # Windows thumbnail cache files | ||||
| Thumbs.db | ||||
| Thumbs.db:encryptable | ||||
| ehthumbs.db | ||||
| ehthumbs_vista.db | ||||
|  | ||||
| # Dump file | ||||
| *.stackdump | ||||
|  | ||||
| # Folder config file | ||||
| [Dd]esktop.ini | ||||
|  | ||||
| # Recycle Bin used on file shares | ||||
| $RECYCLE.BIN/ | ||||
|  | ||||
| # Windows Installer files | ||||
| *.cab | ||||
| *.msi | ||||
| *.msix | ||||
| *.msm | ||||
| *.msp | ||||
|  | ||||
| # Windows shortcuts | ||||
| *.lnk | ||||
|  | ||||
| # End of https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode | ||||
|  | ||||
| # TypeScript compiler output | ||||
| tsc_output | ||||
|  | ||||
| # Yarn | ||||
| .yarn/* | ||||
| .pnp.* | ||||
							
								
								
									
										25
									
								
								.releaserc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								.releaserc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| { | ||||
| 	"plugins": [ | ||||
| 		[ | ||||
| 			"@semantic-release/commit-analyzer", | ||||
| 			{ | ||||
| 				"preset": "angular" | ||||
| 			} | ||||
| 		], | ||||
| 		"@semantic-release/release-notes-generator", | ||||
| 		"@semantic-release/github", | ||||
| 		["@semantic-release/exec", {"prepareCmd": "docker pull zwsim/zws"}], | ||||
| 		[ | ||||
| 			"@semantic-release/exec", | ||||
| 			{ | ||||
| 				"prepareCmd": "docker build -t zwsim/zws ." | ||||
| 			} | ||||
| 		], | ||||
| 		[ | ||||
| 			"semantic-release-docker", | ||||
| 			{ | ||||
| 				"name": "zwsim/zws" | ||||
| 			} | ||||
| 		] | ||||
| 	] | ||||
| } | ||||
							
								
								
									
										6
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| { | ||||
| 	"editor.codeActionsOnSave": { | ||||
| 		"source.organizeImports": false | ||||
| 	}, | ||||
| 	"typescript.tsdk": "node_modules\\typescript\\lib" | ||||
| } | ||||
							
								
								
									
										55
									
								
								.yarn/releases/yarn-2.4.0.cjs
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
							
						
						
									
										55
									
								
								.yarn/releases/yarn-2.4.0.cjs
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										2
									
								
								.yarnrc.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.yarnrc.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| yarnPath: .yarn/releases/yarn-2.4.0.cjs | ||||
| nodeLinker: node-modules | ||||
| @@ -1,76 +0,0 @@ | ||||
| # Contributor Covenant Code of Conduct | ||||
|  | ||||
| ## Our Pledge | ||||
|  | ||||
| In the interest of fostering an open and welcoming environment, we as | ||||
| contributors and maintainers pledge to making participation in our project and | ||||
| our community a harassment-free experience for everyone, regardless of age, body | ||||
| size, disability, ethnicity, sex characteristics, gender identity and expression, | ||||
| level of experience, education, socio-economic status, nationality, personal | ||||
| appearance, race, religion, or sexual identity and orientation. | ||||
|  | ||||
| ## Our Standards | ||||
|  | ||||
| Examples of behavior that contributes to creating a positive environment | ||||
| include: | ||||
|  | ||||
| * Using welcoming and inclusive language | ||||
| * Being respectful of differing viewpoints and experiences | ||||
| * Gracefully accepting constructive criticism | ||||
| * Focusing on what is best for the community | ||||
| * Showing empathy towards other community members | ||||
|  | ||||
| Examples of unacceptable behavior by participants include: | ||||
|  | ||||
| * The use of sexualized language or imagery and unwelcome sexual attention or | ||||
|  advances | ||||
| * Trolling, insulting/derogatory comments, and personal or political attacks | ||||
| * Public or private harassment | ||||
| * Publishing others' private information, such as a physical or electronic | ||||
|  address, without explicit permission | ||||
| * Other conduct which could reasonably be considered inappropriate in a | ||||
|  professional setting | ||||
|  | ||||
| ## Our Responsibilities | ||||
|  | ||||
| Project maintainers are responsible for clarifying the standards of acceptable | ||||
| behavior and are expected to take appropriate and fair corrective action in | ||||
| response to any instances of unacceptable behavior. | ||||
|  | ||||
| Project maintainers have the right and responsibility to remove, edit, or | ||||
| reject comments, commits, code, wiki edits, issues, and other contributions | ||||
| that are not aligned to this Code of Conduct, or to ban temporarily or | ||||
| permanently any contributor for other behaviors that they deem inappropriate, | ||||
| threatening, offensive, or harmful. | ||||
|  | ||||
| ## Scope | ||||
|  | ||||
| This Code of Conduct applies both within project spaces and in public spaces | ||||
| when an individual is representing the project or its community. Examples of | ||||
| representing a project or community include using an official project e-mail | ||||
| address, posting via an official social media account, or acting as an appointed | ||||
| representative at an online or offline event. Representation of a project may be | ||||
| further defined and clarified by project maintainers. | ||||
|  | ||||
| ## Enforcement | ||||
|  | ||||
| Instances of abusive, harassing, or otherwise unacceptable behavior may be | ||||
| reported by contacting the project manager at jonah@jonah.pw. All | ||||
| complaints will be reviewed and investigated and will result in a response that | ||||
| is deemed necessary and appropriate to the circumstances. The project team is | ||||
| obligated to maintain confidentiality with regard to the reporter of an incident. | ||||
| Further details of specific enforcement policies may be posted separately. | ||||
|  | ||||
| Project maintainers who do not follow or enforce the Code of Conduct in good | ||||
| faith may face temporary or permanent repercussions as determined by other | ||||
| members of the project's leadership. | ||||
|  | ||||
| ## Attribution | ||||
|  | ||||
| This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, | ||||
| available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html | ||||
|  | ||||
| [homepage]: https://www.contributor-covenant.org | ||||
|  | ||||
| For answers to common questions about this code of conduct, see | ||||
| https://www.contributor-covenant.org/faq | ||||
							
								
								
									
										22
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| FROM node:15.9.0-alpine3.10 | ||||
|  | ||||
| WORKDIR /usr/src/app | ||||
|  | ||||
| ENV PORT=3000 | ||||
| EXPOSE 3000 | ||||
|  | ||||
| COPY package.json yarn.lock .yarnrc.yml tsconfig.json ./ | ||||
| COPY prisma ./prisma | ||||
| COPY .yarn ./.yarn | ||||
| COPY src ./src | ||||
|  | ||||
| RUN yarn install --immutable | ||||
| RUN yarn prisma generate | ||||
| RUN yarn build | ||||
|  | ||||
| # Remove devDependencies manually, Yarn 2 doesn't support skipping them (see https://yarnpkg.com/configuration/manifest#devDependencies) | ||||
| RUN yarn remove @semantic-release/exec @tsconfig/node14 @types/node @types/supports-color eslint-plugin-prettier prettier prettier-config-xo semantic-release ts-node type-fest typescript xo | ||||
| RUN yarn install --immutable | ||||
| RUN rm -rf .yarn/cache src tsconfig.json | ||||
|  | ||||
| CMD ["node", "."] | ||||
							
								
								
									
										1
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								LICENSE
									
									
									
									
									
								
							| @@ -1,4 +1,3 @@ | ||||
|  | ||||
|                                  Apache License | ||||
|                            Version 2.0, January 2004 | ||||
|                         http://www.apache.org/licenses/ | ||||
|   | ||||
							
								
								
									
										101
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,22 +1,93 @@ | ||||
| --- | ||||
| description: Shorten URLs with invisible spaces | ||||
| --- | ||||
| # [Zero Width Shortener (ZWS)](https://zws.im) | ||||
|  | ||||
| # Zero Width Shortener | ||||
| Shorten URLs with invisible spaces. | ||||
|  | ||||
| Zero Width Shortener \(abbreviated as ZWS\) is a URL shortener that shortens URLs using spaces that have zero width, making them invisible to humans. | ||||
| Or, [configure your own instance](#Self-hosting) to use any other kinds of characters. | ||||
|  | ||||
| ### Characters | ||||
| ## Contributors | ||||
|  | ||||
| We've done a bit of research on what characters work on different platforms | ||||
| ### Code Contributors | ||||
|  | ||||
| | Character | In use | [Twitter](https://twitter.com/) | [iMessage](https://support.apple.com/explore/messages)\* | [Discord](https://discordapp.com/) | [Slack](https://slack.com) | [Telegram](https://telegram.org/) | Notes | | ||||
| | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | ||||
| | `U+200B` | ✔️ | ❌ | ❌ | ✔️ | ❌ | ✔️ | Used in URLs since initial release, blacklisted space character on [Twitter](https://twitter.com/) | | ||||
| | `U+200D` | ✔️ | ❌ |  | ✔️ |  | ✔️ | [Discord](https://discordapp.com/) prompts you with a "spoopy URL" popup when clicked | | ||||
| | `U+200C` | ❌ | ❌ |  | ✔️ |  | ❌ | Blacklisted space on [Twitter](https://twitter.com/), discontinued \(previously used, replaced with `U+200D`\) | | ||||
| | `U+180E` | ❌ | ❌ | ❌ | ✔️ |  | ❌ | Visible on iOS, discontinued in b39897e \(previously used, replaced with `U+200C`\) | | ||||
| | `U+061C` | ❌ | ❌ |  | ✔️ |  | ✔️ |  | | ||||
| This project exists thanks to all the people who contribute. | ||||
| <a href="https://github.com/zws-im/zws/graphs/contributors"><img src="https://opencollective.com/zws/contributors.svg?width=890&button=false" /></a> | ||||
|  | ||||
| * [iMessage](https://support.apple.com/explore/messages) note: Tested on latest beta of iOS | ||||
| ### Financial Contributors | ||||
|  | ||||
| Become a financial contributor and help us sustain our community. [[Contribute][open-collective]] | ||||
|  | ||||
| #### Individuals | ||||
|  | ||||
| <a href="https://opencollective.com/zws"><img src="https://opencollective.com/zws/individuals.svg?width=890"></a> | ||||
|  | ||||
| #### Organizations | ||||
|  | ||||
| Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute][open-collective]] | ||||
|  | ||||
| <a href="https://opencollective.com/zws/organization/0/website"><img src="https://opencollective.com/zws/organization/0/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/1/website"><img src="https://opencollective.com/zws/organization/1/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/2/website"><img src="https://opencollective.com/zws/organization/2/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/3/website"><img src="https://opencollective.com/zws/organization/3/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/4/website"><img src="https://opencollective.com/zws/organization/4/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/5/website"><img src="https://opencollective.com/zws/organization/5/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/6/website"><img src="https://opencollective.com/zws/organization/6/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/7/website"><img src="https://opencollective.com/zws/organization/7/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/8/website"><img src="https://opencollective.com/zws/organization/8/avatar.svg"></a> | ||||
| <a href="https://opencollective.com/zws/organization/9/website"><img src="https://opencollective.com/zws/organization/9/avatar.svg"></a> | ||||
|  | ||||
| ## Self-hosting | ||||
|  | ||||
| ### Heroku | ||||
|  | ||||
| [![Deploy to Heroku][deploy-to-heroku-image]][deploy-to-heroku] | ||||
|  | ||||
| Running an instance of ZWS on Heroku is the easiest way to self-host. | ||||
| You can also stay totally within the free limits of both the [`web` process](https://devcenter.heroku.com/articles/procfile) and the [Heroku Postgres][heroku-postgres] database. | ||||
| Note that the Hobby Dev (free) plan of [Heroku Postgres][heroku-postgres] has a row limit of 10,000, which might not be enough for your use case. | ||||
|  | ||||
| ### [Docker Compose][docker-compose] | ||||
|  | ||||
| 1. [Clone the repository](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) | ||||
| 2. Copy `db.example.env` to `db.env` and fill in the values | ||||
| 3. Copy `example.env` to `.env` and update the `DATABASE_URL` environment variable to match the values in `db.env` | ||||
| 4. Run [`docker volume create --name=zws-postgres-storage`](https://docs.docker.com/engine/reference/commandline/volume_create/) | ||||
| 5. Run [`docker-compose up -d`](https://docs.docker.com/compose/reference/up/) (this will automatically apply database migrations) | ||||
|  | ||||
| ### Database migrations | ||||
|  | ||||
| After you create an app using the above button you'll need to run the database migrations before shortening any URLs. | ||||
| **These are done automatically, but manual usage may be required when upgrading versions**. | ||||
|  | ||||
| This can be done easily through [Docker Compose][docker-compose] by running the following commands: | ||||
|  | ||||
| ```sh | ||||
| docker volume create --name=zws-postgres-storage | ||||
| docker-compose up migration | ||||
| docker-compose down | ||||
| ``` | ||||
|  | ||||
| Even if your database isn't being run through [Docker Compose][docker-compose] you'll still need to create the volume and start the `db` service. | ||||
| You can delete the volume right after. | ||||
| If you know a better way to do this, please open a pull request! | ||||
|  | ||||
| #### [Heroku Postgres][heroku-postgres] | ||||
|  | ||||
| If you are using a Heroku database migrations are automatically applied, but to manually do so you'll need the credentials for your database: | ||||
|  | ||||
| 1. Get the [Heroku Postgres][heroku-postgres] connection URI from | ||||
|    - [the web interface](https://data.heroku.com/) (select your datastore, "Settings", "Database Credentials", "URI") | ||||
|    - [the Heroku CLI](https://devcenter.heroku.com/articles/heroku-postgresql#external-connections-ingress) | ||||
| 2. Create a `.env` file and enter in the connection URI | ||||
|  | ||||
| Example: | ||||
|  | ||||
| ```env | ||||
| DATABASE_URL=postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public | ||||
| ``` | ||||
|  | ||||
| Afterward you can run the migration commands shown above. | ||||
|  | ||||
| [deploy-to-heroku]: https://dashboard.heroku.com/new?template=https://github.com/zws-im/zws/tree/v2 | ||||
| [deploy-to-heroku-image]: https://www.herokucdn.com/deploy/button.svg | ||||
| [heroku-postgres]: https://www.heroku.com/postgres | ||||
| [docker-compose]: https://docs.docker.com/compose/ | ||||
| [open-collective]: https://opencollective.com/zws/contribute | ||||
|   | ||||
							
								
								
									
										10
									
								
								SUMMARY.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								SUMMARY.md
									
									
									
									
									
								
							| @@ -1,10 +0,0 @@ | ||||
| # Table of contents | ||||
|  | ||||
| * [Zero Width Shortener](README.md) | ||||
|  | ||||
| ## REST API <a id="api"></a> | ||||
|  | ||||
| * [Get URL](api/get-shortened-url.md) | ||||
| * [Shorten URL](api/shorten-url.md) | ||||
| * [Get URL stats](api/get-url-stats.md) | ||||
|  | ||||
| @@ -1,72 +0,0 @@ | ||||
| --- | ||||
| description: Redirect to the long URL from a shortened URL | ||||
| --- | ||||
|  | ||||
| # Get URL | ||||
|  | ||||
| {% api-method method="get" host="https://zws.im/api" path="/getURL/:short" %} | ||||
| {% api-method-summary %} | ||||
| Get URL | ||||
| {% endapi-method-summary %} | ||||
|  | ||||
| {% api-method-description %} | ||||
| This endpoint redirects you to the long URL corresponding to a short ID. | ||||
| {% endapi-method-description %} | ||||
|  | ||||
| {% api-method-spec %} | ||||
| {% api-method-request %} | ||||
| {% api-method-path-parameters %} | ||||
| {% api-method-parameter name="short" type="string" required=true %} | ||||
| Short ID of the URL to redirect to. | ||||
| {% endapi-method-parameter %} | ||||
| {% endapi-method-path-parameters %} | ||||
| {% endapi-method-request %} | ||||
|  | ||||
| {% api-method-response %} | ||||
| {% api-method-response-example httpCode=301 %} | ||||
| {% api-method-response-example-description %} | ||||
| Redirects you to the long URL corresponding to the provided short ID. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| ```text | ||||
|  | ||||
| ``` | ||||
| {% endapi-method-response-example %} | ||||
|  | ||||
| {% api-method-response-example httpCode=400 %} | ||||
| {% api-method-response-example-description %} | ||||
| The short ID wasn't specified or wasn't a string type. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| {% tabs %} | ||||
| {% tab title="no short ID" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "You must specify a short ID" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
|  | ||||
| {% tab title="invalid ID type" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "Short ID must be string type" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
| {% endtabs %} | ||||
| {% endapi-method-response-example %} | ||||
|  | ||||
| {% api-method-response-example httpCode=404 %} | ||||
| {% api-method-response-example-description %} | ||||
| The requested short ID couldn't be found. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| ```text | ||||
|  | ||||
| ``` | ||||
| {% endapi-method-response-example %} | ||||
| {% endapi-method-response %} | ||||
| {% endapi-method-spec %} | ||||
| {% endapi-method %} | ||||
|  | ||||
| @@ -1,117 +0,0 @@ | ||||
| --- | ||||
| description: Get usage stats for a URL | ||||
| --- | ||||
|  | ||||
| # Get URL stats | ||||
|  | ||||
| {% api-method method="get" host="https://zws.im/api" path="/:short" %} | ||||
| {% api-method-summary %} | ||||
| Get URL Stats | ||||
| {% endapi-method-summary %} | ||||
|  | ||||
| {% api-method-description %} | ||||
| This endpoint allows you to see stats for a URL that was shortened. | ||||
| {% endapi-method-description %} | ||||
|  | ||||
| {% api-method-spec %} | ||||
| {% api-method-request %} | ||||
| {% api-method-path-parameters %} | ||||
| {% api-method-parameter name="short" type="string" %} | ||||
| Short ID of the URL to get stats for. | ||||
| {% endapi-method-parameter %} | ||||
| {% endapi-method-path-parameters %} | ||||
|  | ||||
| {% api-method-query-parameters %} | ||||
| {% api-method-parameter type="string" name="short" %} | ||||
| Short ID of the URL to get stats for. | ||||
| {% endapi-method-parameter %} | ||||
|  | ||||
| {% api-method-parameter name="url" type="string" %} | ||||
| Long URL to get stats for. | ||||
| {% endapi-method-parameter %} | ||||
| {% endapi-method-query-parameters %} | ||||
| {% endapi-method-request %} | ||||
|  | ||||
| {% api-method-response %} | ||||
| {% api-method-response-example httpCode=200 %} | ||||
| {% api-method-response-example-description %} | ||||
| `get` is the number of times the shortened URL was visited and `shorten` is the number of times it was shortened. `usage` contains arrays of UNIX timestamps when the URL was visited or shortened. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| ```javascript | ||||
| { | ||||
|     "get": 123, | ||||
|     "shorten": 123, | ||||
|     "usage": { | ||||
|         "get": [1565561037] | ||||
|         "shorten": [1564561037] | ||||
|     } | ||||
| } | ||||
| ``` | ||||
| {% endapi-method-response-example %} | ||||
|  | ||||
| {% api-method-response-example httpCode=400 %} | ||||
| {% api-method-response-example-description %} | ||||
|  | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| {% tabs %} | ||||
| {% tab title="URL was not specified" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "You must specify a short ID or a URL" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
|  | ||||
| {% tab title="invalid URL type" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "URL must be string type" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
|  | ||||
| {% tab title="invalid short ID type" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "Short ID must be string type" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
|  | ||||
| {% tab title="URL is invalid" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "Not a valid URL" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
| {% endtabs %} | ||||
| {% endapi-method-response-example %} | ||||
|  | ||||
| {% api-method-response-example httpCode=404 %} | ||||
| {% api-method-response-example-description %} | ||||
| The URL or short ID couldn't be found. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| ```text | ||||
|  | ||||
| ``` | ||||
| {% endapi-method-response-example %} | ||||
|  | ||||
| {% api-method-response-example httpCode=413 %} | ||||
| {% api-method-response-example-description %} | ||||
| The URL exceed 500 characters. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| ```javascript | ||||
| { | ||||
|     "error": "URL can not exceed 500 characters" | ||||
| } | ||||
| ``` | ||||
| {% endapi-method-response-example %} | ||||
| {% endapi-method-response %} | ||||
| {% endapi-method-spec %} | ||||
| {% endapi-method %} | ||||
|  | ||||
| @@ -1,104 +0,0 @@ | ||||
| --- | ||||
| description: Shorten a long URL | ||||
| --- | ||||
|  | ||||
| # Shorten URL | ||||
|  | ||||
| {% api-method method="get" host="https://zws.im/api" path="/shortenURL" %} | ||||
| {% api-method-summary %} | ||||
| Shorten URL | ||||
| {% endapi-method-summary %} | ||||
|  | ||||
| {% api-method-description %} | ||||
| This endpoint returns the short ID corresponding to the specified long URL. | ||||
| {% endapi-method-description %} | ||||
|  | ||||
| {% api-method-spec %} | ||||
| {% api-method-request %} | ||||
| {% api-method-query-parameters %} | ||||
| {% api-method-parameter name="url" type="string" required=true %} | ||||
| The long URL to shorten. Must not contain the URL shortener's hostname or exceed 500 characters in length. | ||||
| {% endapi-method-parameter %} | ||||
| {% endapi-method-query-parameters %} | ||||
| {% endapi-method-request %} | ||||
|  | ||||
| {% api-method-response %} | ||||
| {% api-method-response-example httpCode=200 %} | ||||
| {% api-method-response-example-description %} | ||||
| The URL has already been shortened and was fetched from the database. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| ```javascript | ||||
| { | ||||
|     "short": "Short ID" | ||||
| } | ||||
| ``` | ||||
| {% endapi-method-response-example %} | ||||
|  | ||||
| {% api-method-response-example httpCode=201 %} | ||||
| {% api-method-response-example-description %} | ||||
| The URL was shortened and added to the database. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| ```javascript | ||||
| { | ||||
|     "short": "Short ID" | ||||
| } | ||||
| ``` | ||||
| {% endapi-method-response-example %} | ||||
|  | ||||
| {% api-method-response-example httpCode=400 %} | ||||
| {% api-method-response-example-description %} | ||||
| The URL wasn't specified, wasn't string type, was invalid, or contained the URL shortener's hostname. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| {% tabs %} | ||||
| {% tab title="URL was not specified" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "You must specify a URL" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
|  | ||||
| {% tab title="URL is invalid" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "Not a valid URL" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
|  | ||||
| {% tab title="URL contains hostname" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "Shortening a URL containing the URL shortener's hostname is disallowed" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
|  | ||||
| {% tab title="invalid URL type" %} | ||||
| ```javascript | ||||
| { | ||||
|     "error": "URL must be string type" | ||||
| } | ||||
| ``` | ||||
| {% endtab %} | ||||
| {% endtabs %} | ||||
| {% endapi-method-response-example %} | ||||
|  | ||||
| {% api-method-response-example httpCode=413 %} | ||||
| {% api-method-response-example-description %} | ||||
| The URL exceeded 500 characters. | ||||
| {% endapi-method-response-example-description %} | ||||
|  | ||||
| ```javascript | ||||
| { | ||||
|     "error": "URL can not exceed 500 characters" | ||||
| } | ||||
| ``` | ||||
| {% endapi-method-response-example %} | ||||
| {% endapi-method-response %} | ||||
| {% endapi-method-spec %} | ||||
| {% endapi-method %} | ||||
|  | ||||
							
								
								
									
										45
									
								
								app.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								app.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| { | ||||
| 	"addons": [ | ||||
| 		{ | ||||
| 			"plan": "heroku-postgresql", | ||||
| 			"options": { | ||||
| 				"version": "13" | ||||
| 			} | ||||
| 		} | ||||
| 	], | ||||
| 	"buildpacks": [ | ||||
| 		{ | ||||
| 			"url": "heroku/nodejs" | ||||
| 		} | ||||
| 	], | ||||
| 	"description": "A configurable URL shortener", | ||||
| 	"env": { | ||||
| 		"DATABASE_URL": { | ||||
| 			"description": "URL or path to database. Leave this blank if you are using a Heroku add-on for your database.", | ||||
| 			"required": false | ||||
| 		}, | ||||
| 		"SHORT_LENGTH": { | ||||
| 			"description": "Length of shortened IDs. Default is calculated based on length of SHORT_CHARS.", | ||||
| 			"required": false | ||||
| 		}, | ||||
| 		"SHORT_CHARS": { | ||||
| 			"description": "JSON array of characters to used in shortened IDs. Default is alphanumeric (a-z, A-Z, 0-9).", | ||||
| 			"required": false | ||||
| 		}, | ||||
| 		"SHORT_REWRITES": { | ||||
| 			"description": "JSON object of rewrites to normalize shortened IDs. Advanced use only.", | ||||
| 			"required": false | ||||
| 		} | ||||
| 	}, | ||||
| 	"formation": { | ||||
| 		"web": { | ||||
| 			"quantity": 1, | ||||
| 			"size": "free" | ||||
| 		} | ||||
| 	}, | ||||
| 	"keywords": ["URL Shortener", "Node.js"], | ||||
| 	"logo": "https://avatars.githubusercontent.com/u/53232036", | ||||
| 	"name": "Custom ZWS Instance", | ||||
| 	"repository": "https://github.com/zws-im/zws", | ||||
| 	"website": "https://docs.zws.im" | ||||
| } | ||||
							
								
								
									
										3
									
								
								db.example.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								db.example.env
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| POSTGRES_USER=johndoe | ||||
| POSTGRES_PASSWORD=randompassword | ||||
| POSTGRES_DB=mydb | ||||
							
								
								
									
										30
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| services: | ||||
|   web: | ||||
|     image: zwsim/zws | ||||
|     ports: | ||||
|       - '3000:3000' | ||||
|     links: | ||||
|       - db | ||||
|     depends_on: | ||||
|       - migration | ||||
|     env_file: ./.env | ||||
|   migration: | ||||
|     image: zwsim/zws | ||||
|     links: | ||||
|       - db | ||||
|     env_file: ./.env | ||||
|     command: ['yarn', 'run', 'migrations'] | ||||
|   db: | ||||
|     image: postgres:13 | ||||
|     env_file: ./db.env | ||||
|     volumes: | ||||
|       - zws-postgres-storage:/var/lib/postgresql/data | ||||
|     expose: | ||||
|       - '5432' | ||||
|   watchtower: | ||||
|     image: containrrr/watchtower | ||||
|     volumes: | ||||
|       - /var/run/docker.sock:/var/run/docker.sock | ||||
| volumes: | ||||
|   zws-postgres-storage: | ||||
|     external: true | ||||
							
								
								
									
										17
									
								
								example.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								example.env
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| # URL or path to database | ||||
| # Supports the native connection string format for PostgreSQL, MySQL and SQLite. | ||||
| # See the documentation for all the connection string options: https://pris.ly/d/connection-strings | ||||
| DATABASE_URL=postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public | ||||
|  | ||||
| # Length of shortened IDs | ||||
| # Default is calculated based on length of SHORT_CHARS | ||||
| SHORT_LENGTH=12 | ||||
| # JSON array of characters to used in shortened IDs | ||||
| # Default is alphanumeric (a-z, A-Z, 0-9) | ||||
| SHORT_CHARS=["a", "b", "c"] | ||||
| # JSON object of rewrites to normalize shortened IDs | ||||
| # Advanced use only | ||||
| SHORT_REWRITES={"d": "a", "e": "b", "f": "c"} | ||||
|  | ||||
| # HTTP port to listen on | ||||
| PORT=3000 | ||||
| @@ -1,11 +0,0 @@ | ||||
| { | ||||
|   "firestore": { | ||||
|     "indexes": "firestore.indexes.json", | ||||
|     "rules": "firestore.rules" | ||||
|   }, | ||||
|   "functions": { | ||||
|     "predeploy": [ | ||||
|       "npm --prefix \"$RESOURCE_DIR\" run lint --fix" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| @@ -1,26 +0,0 @@ | ||||
| { | ||||
|   // Example: | ||||
|   // | ||||
|   // "indexes": [ | ||||
|   //   { | ||||
|   //     "collectionGroup": "widgets", | ||||
|   //     "queryScope": "COLLECTION", | ||||
|   //     "fields": [ | ||||
|   //       { "fieldPath": "foo", "arrayConfig": "CONTAINS" }, | ||||
|   //       { "fieldPath": "bar", "mode": "DESCENDING" } | ||||
|   //     ] | ||||
|   //   }, | ||||
|   // | ||||
|   //  "fieldOverrides": [ | ||||
|   //    { | ||||
|   //      "collectionGroup": "widgets", | ||||
|   //      "fieldPath": "baz", | ||||
|   //      "indexes": [ | ||||
|   //        { "order": "ASCENDING", "queryScope": "COLLECTION" } | ||||
|   //      ] | ||||
|   //    }, | ||||
|   //   ] | ||||
|   // ] | ||||
|   "indexes": [], | ||||
|   "fieldOverrides": [] | ||||
| } | ||||
| @@ -1,7 +0,0 @@ | ||||
| service cloud.firestore { | ||||
|   match /databases/{database}/documents { | ||||
|     match /{document=**} { | ||||
|       allow read, write: if false; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,3 +0,0 @@ | ||||
| module.exports = { | ||||
|   "extends": "@dice-discord" | ||||
| } | ||||
| @@ -1,5 +0,0 @@ | ||||
| { | ||||
|   "$schema": "http://json.schemastore.org/prettierrc", | ||||
|   "endOfLine": "lf", | ||||
|   "printWidth": 120 | ||||
| } | ||||
| @@ -1,60 +0,0 @@ | ||||
| { | ||||
|   "author": "Jonah Snider <jonah@jonah.pw> (jonah.pw)", | ||||
|   "bugs": { | ||||
|     "url": "https://github.com/zws-im/zws/issues" | ||||
|   }, | ||||
|   "contributors": [ | ||||
|     "Yousef Sultan <yousef.su.2000@gmail.com>" | ||||
|   ], | ||||
|   "dependencies": { | ||||
|     "@google-cloud/debug-agent": "^4.0.1", | ||||
|     "@google-cloud/profiler": "^2.0.2", | ||||
|     "cors": "^2.8.5", | ||||
|     "firebase-admin": "~8.3.0", | ||||
|     "firebase-functions": "^3.2.0" | ||||
|   }, | ||||
|   "description": "Firebase Cloud Functions for Zero Width Shortener", | ||||
|   "devDependencies": { | ||||
|     "@dice-discord/eslint-config": "^3.0.0", | ||||
|     "eslint": "^6.1.0", | ||||
|     "eslint-config-prettier": "^6.0.0", | ||||
|     "eslint-plugin-prettier": "^3.1.0", | ||||
|     "eslint-plugin-promise": "^4.2.1", | ||||
|     "firebase-functions-test": "^0.1.6", | ||||
|     "firebase-tools": "^7.2.2", | ||||
|     "prettier": "^1.18.2" | ||||
|   }, | ||||
|   "engines": { | ||||
|     "node": "10" | ||||
|   }, | ||||
|   "homepage": "https://zws.im", | ||||
|   "keywords": [ | ||||
|     "zero", | ||||
|     "width", | ||||
|     "shortener", | ||||
|     "url", | ||||
|     "zero-width-shortener", | ||||
|     "zws" | ||||
|   ], | ||||
|   "license": "Apache-2.0", | ||||
|   "main": "./src/index.js", | ||||
|   "name": "zero-width-shortener-functions", | ||||
|   "peerDependencies": { | ||||
|     "sqreen": "^1.30.3" | ||||
|   }, | ||||
|   "private": true, | ||||
|   "repository": { | ||||
|     "type": "git", | ||||
|     "url": "git+https://github.com/zws-im/zws.git" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "deploy": "firebase deploy --only functions", | ||||
|     "lint": "eslint .", | ||||
|     "lint:fix": "eslint . --fix", | ||||
|     "logs": "firebase functions:log", | ||||
|     "serve": "firebase serve --only functions", | ||||
|     "shell": "firebase functions:shell", | ||||
|     "start": "npm run shell" | ||||
|   }, | ||||
|   "version": "1.11.14" | ||||
| } | ||||
| @@ -1,42 +0,0 @@ | ||||
| /** | ||||
|  * @typedef {Object} CharacterConfig Configuration object for a specific type of short character. | ||||
|  * @property {string} preferred The preferred character to use for this type of character | ||||
|  * @property {Array<string>} [compatible] Array of compatible other compatible characters, not including the preferred character | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * Space characters that are used in shortened URLs. | ||||
|  * @enum {CharacterConfig} Array of character configs, array index is their numerical ID | ||||
|  */ | ||||
| module.exports.characters = [ | ||||
|   { | ||||
|     preferred: "\u200c", | ||||
|     compatible: ["\u180e"] | ||||
|   }, | ||||
|   { | ||||
|     preferred: "\u200d", | ||||
|     compatible: ["\u200b"] | ||||
|   } | ||||
| ]; | ||||
|  | ||||
| /** | ||||
|  * Hostnames of ZWS instances. | ||||
|  * Used to prevent shortening a link that's already shortened. | ||||
|  */ | ||||
| module.exports.hostnames = [ | ||||
|   "zws.im", | ||||
|   "zero-width-shortener.firebaseapp.com", | ||||
|   "zero-width-shortener.web.app", | ||||
|   "zws.jonahsnider.ninja", | ||||
|   "zws.jonah.pw", | ||||
|   "zerowidthshortener.netlify.com" | ||||
| ]; | ||||
|  | ||||
| /** | ||||
|  * RegEx for space characters. | ||||
|  */ | ||||
| module.exports.spacesRegex = new RegExp( | ||||
|   `^(${module.exports.characters | ||||
|     .map(spaceConfig => `${spaceConfig.preferred}|${spaceConfig.compatible.join("|")}`) | ||||
|     .join("|")})+$` | ||||
| ); | ||||
| @@ -1,55 +0,0 @@ | ||||
| const spacesToBinary = require("../util/spacesToBinary"); | ||||
| const { spacesRegex } = require("../constants"); | ||||
| const functions = require("firebase-functions"); | ||||
| const admin = require("firebase-admin"); | ||||
|  | ||||
| const firestore = admin.firestore(); | ||||
| const urls = firestore.collection("urls"); | ||||
|  | ||||
| const cors = require("cors")({ origin: true }); | ||||
|  | ||||
| module.exports = functions.https.onRequest(async (req, res) => { | ||||
|   cors(req, res, () => {}); | ||||
|  | ||||
|   // Remove any trailing slashes | ||||
|   const short = req.params["0"].replace(/\//g, ""); | ||||
|  | ||||
|   if (short) { | ||||
|     if (typeof short === "string") { | ||||
|       if (spacesRegex.test(short)) { | ||||
|         const binary = spacesToBinary(short); | ||||
|  | ||||
|         const ref = urls.doc(binary); | ||||
|         const doc = await ref.get(); | ||||
|  | ||||
|         if (doc && doc.exists) { | ||||
|           // Increment the counter for this URL and record the timestamp in the background | ||||
|           ref.update({ | ||||
|             "stats.get": admin.firestore.FieldValue.increment(1), | ||||
|             "usage.get": admin.firestore.FieldValue.arrayUnion(new Date()) | ||||
|           }); | ||||
|  | ||||
|           const data = doc.data(); | ||||
|           return res.redirect(301, data.url); | ||||
|         } else { | ||||
|           return res.status(404).end(); | ||||
|         } | ||||
|       } else { | ||||
|         return res | ||||
|           .status(400) | ||||
|           .json({ error: "Short ID contained invalid characters" }) | ||||
|           .end(); | ||||
|       } | ||||
|     } else { | ||||
|       return res | ||||
|         .status(400) | ||||
|         .json({ error: "Short ID must be string type" }) | ||||
|         .end(); | ||||
|     } | ||||
|   } else { | ||||
|     return res | ||||
|       .status(400) | ||||
|       .json({ error: "You must specify a short ID" }) | ||||
|       .end(); | ||||
|   } | ||||
| }); | ||||
| @@ -1,93 +0,0 @@ | ||||
| const functions = require("firebase-functions"); | ||||
| const { characters, spacesRegex } = require("../constants"); | ||||
| const dataToResponse = require("../util/dataToResponse"); | ||||
| const admin = require("firebase-admin"); | ||||
|  | ||||
| const firestore = admin.firestore(); | ||||
| const urls = firestore.collection("urls"); | ||||
|  | ||||
| const cors = require("cors")({ origin: true }); | ||||
|  | ||||
| module.exports = functions.https.onRequest(async (req, res) => { | ||||
|   cors(req, res, () => {}); | ||||
|  | ||||
|   const short = req.params["0"].split("/")[0] || req.query.short; | ||||
|   const { url } = req.query; | ||||
|  | ||||
|   if (short) { | ||||
|     if (typeof short === "string") { | ||||
|       if (spacesRegex.test(short)) { | ||||
|         const binary = short | ||||
|           // Convert one type of space to zeroes | ||||
|           .replace(new RegExp(characters[0], "g"), "0") | ||||
|           // Convert the other type of space to ones | ||||
|           .replace(new RegExp(characters[1], "g"), "1"); | ||||
|  | ||||
|         const ref = urls.doc(binary); | ||||
|         const doc = await ref.get(); | ||||
|  | ||||
|         if (doc && doc.exists) { | ||||
|           return res | ||||
|             .status(200) | ||||
|             .json(dataToResponse(doc.data())) | ||||
|             .end(); | ||||
|         } else { | ||||
|           return res.status(404).end(); | ||||
|         } | ||||
|       } else { | ||||
|         return res | ||||
|           .status(400) | ||||
|           .json({ error: "Short ID contained invalid characters" }) | ||||
|           .end(); | ||||
|       } | ||||
|     } else { | ||||
|       return res | ||||
|         .status(400) | ||||
|         .json({ error: "Short ID must be string type" }) | ||||
|         .end(); | ||||
|     } | ||||
|   } else if (url) { | ||||
|     if (typeof url === "string") { | ||||
|       try { | ||||
|         new URL(url); | ||||
|       } catch (error) { | ||||
|         return res | ||||
|           .status(400) | ||||
|           .json({ error: "Not a valid URL" }) | ||||
|           .end(); | ||||
|       } | ||||
|  | ||||
|       if (url.length > 500) { | ||||
|         return res | ||||
|           .status(413) | ||||
|           .json({ error: "URL can not exceed 500 characters" }) | ||||
|           .end(); | ||||
|       } | ||||
|  | ||||
|       // Find documents that have the same long URL (duplicates) | ||||
|       const query = urls.where("url", "==", url); | ||||
|       const snapshot = await query.get(); | ||||
|       const { docs } = snapshot; | ||||
|       const [doc] = docs; | ||||
|  | ||||
|       if (doc && doc.exists) { | ||||
|         return res | ||||
|           .status(200) | ||||
|           .json(dataToResponse(doc.data())) | ||||
|           .end(); | ||||
|       } else { | ||||
|         return res.status(404).end(); | ||||
|       } | ||||
|     } else { | ||||
|       return res | ||||
|         .status(400) | ||||
|         .json({ error: "URL must be string type" }) | ||||
|         .end(); | ||||
|     } | ||||
|   } else { | ||||
|     return res | ||||
|       .status(400) | ||||
|       .json({ error: "You must specify a short ID or a URL" }) | ||||
|       .end(); | ||||
|   } | ||||
| }); | ||||
| @@ -1,74 +0,0 @@ | ||||
| const { hostnames } = require("../constants"); | ||||
| const binaryToSpaces = require("../util/binaryToSpaces"); | ||||
| const functions = require("firebase-functions"); | ||||
| const admin = require("firebase-admin"); | ||||
|  | ||||
| const firestore = admin.firestore(); | ||||
| const urls = firestore.collection("urls"); | ||||
|  | ||||
| const cors = require("cors")({ origin: true }); | ||||
|  | ||||
| module.exports = functions.https.onRequest(async (req, res) => { | ||||
|   cors(req, res, () => {}); | ||||
|  | ||||
|   const { url } = req.query; | ||||
|  | ||||
|   if (url) { | ||||
|     let urlInstance; | ||||
|     if (typeof url === "string") { | ||||
|       try { | ||||
|         urlInstance = new URL(url); | ||||
|       } catch (error) { | ||||
|         return res | ||||
|           .status(400) | ||||
|           .json({ error: "Not a valid URL" }) | ||||
|           .end(); | ||||
|       } | ||||
|  | ||||
|       if (hostnames.includes(urlInstance.hostname)) { | ||||
|         return res.status(400).json({ | ||||
|           error: "Shortening a URL containing the URL shortener's hostname is disallowed" | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (url.length > 500) { | ||||
|         return res | ||||
|           .status(413) | ||||
|           .json({ error: "URL can not exceed 500 characters" }) | ||||
|           .end(); | ||||
|       } | ||||
|  | ||||
|       // Count is a number used for generating the short ID | ||||
|       const countRef = firestore.collection("settings").doc("short"); | ||||
|       const countDoc = await countRef.get(); | ||||
|       const { count } = countDoc.data(); | ||||
|  | ||||
|       // The math here converts the number to binary (decimal => binary string => binary number) | ||||
|       const short = binaryToSpaces(count.toString(2)); | ||||
|  | ||||
|       await Promise.all([ | ||||
|         // Set the shortened URL document | ||||
|         urls | ||||
|           .doc(Number(count).toString(2)) | ||||
|           .set({ url, stats: { get: 0, shorten: 1 }, usage: { get: [], shorten: [new Date()] } }), | ||||
|         // Set the count to be one higher | ||||
|         countRef.update({ count: admin.firestore.FieldValue.increment(1) }) | ||||
|       ]); | ||||
|  | ||||
|       return res | ||||
|         .status(201) | ||||
|         .json({ short }) | ||||
|         .end(); | ||||
|     } else { | ||||
|       return res | ||||
|         .status(400) | ||||
|         .json({ error: "URL must be string type" }) | ||||
|         .end(); | ||||
|     } | ||||
|   } else { | ||||
|     return res | ||||
|       .status(400) | ||||
|       .json({ error: "You must specify a URL" }) | ||||
|       .end(); | ||||
|   } | ||||
| }); | ||||
| @@ -1,58 +0,0 @@ | ||||
| const functions = require("firebase-functions"); | ||||
| const debugAgent = require("@google-cloud/debug-agent"); | ||||
| const { version } = require("../package.json"); | ||||
|  | ||||
| const serviceContext = { | ||||
|   service: "functions", | ||||
|   version | ||||
| }; | ||||
|  | ||||
| if ( | ||||
|   functions.config().sqreen && | ||||
|   functions.config().sqreen.app && | ||||
|   functions.config().sqreen.app.name && | ||||
|   functions.config().sqreen.token | ||||
| ) { | ||||
|   process.env.SQREEN_APP_NAME = functions.config().sqreen.app.name; | ||||
|   process.env.SQREEN_TOKEN = functions.config().sqreen.token; | ||||
|  | ||||
|   require("sqreen"); | ||||
| } | ||||
|  | ||||
| const admin = require("firebase-admin"); | ||||
|  | ||||
| if (process.env.NODE_ENV === "development") { | ||||
|   const serviceAccount = require("../../serviceAccount.json"); | ||||
|   admin.initializeApp({ | ||||
|     credential: admin.credential.cert(serviceAccount), | ||||
|     databaseURL: "https://zero-width-shortener.firebaseio.com" | ||||
|   }); | ||||
|  | ||||
|   debugAgent.start({ | ||||
|     projectId: "zero-width-shortener", | ||||
|     keyFilename: "../../serviceAccount.json", | ||||
|     allowExpressions: true, | ||||
|     serviceContext | ||||
|   }); | ||||
| } else { | ||||
|   const profiler = require("@google-cloud/profiler"); | ||||
|  | ||||
|   admin.initializeApp(); | ||||
|  | ||||
|   profiler.start({ | ||||
|     serviceContext | ||||
|   }); | ||||
|  | ||||
|   debugAgent.start({ serviceContext }); | ||||
| } | ||||
|  | ||||
| // Lots of Firebase stuff must be required after the app is initialized, including endpoints | ||||
| const getURL = require("./endpoints/getURL"); | ||||
| const shortenURL = require("./endpoints/shortenURL"); | ||||
| const getURLStats = require("./endpoints/getURLStats"); | ||||
|  | ||||
| module.exports = { | ||||
|   getURL, | ||||
|   shortenURL, | ||||
|   getURLStats | ||||
| }; | ||||
| @@ -1,14 +0,0 @@ | ||||
| const { characters } = require("../constants"); | ||||
|  | ||||
| /** | ||||
|  * Convert binary numbers in string form to zero-width spaces | ||||
|  * @param {string} binaryString Binary values to convert | ||||
|  */ | ||||
| module.exports = binaryString => { | ||||
|   characters.forEach( | ||||
|     (spaceConfig, index) => | ||||
|       (binaryString = binaryString.replace(new RegExp(index.toString(), "g"), spaceConfig.preferred)) | ||||
|   ); | ||||
|  | ||||
|   return binaryString; | ||||
| }; | ||||
| @@ -1,30 +0,0 @@ | ||||
| const mergeDefault = require("./mergeDefault"); | ||||
|  | ||||
| /** | ||||
|  * Helper function for URL stats. | ||||
|  * @param {Object} data | ||||
|  */ | ||||
| module.exports = data => { | ||||
|   mergeDefault( | ||||
|     { | ||||
|       stats: { | ||||
|         shorten: 0, | ||||
|         get: 0 | ||||
|       }, | ||||
|       usage: { | ||||
|         shorten: [], | ||||
|         get: [] | ||||
|       } | ||||
|     }, | ||||
|     data | ||||
|   ); | ||||
|  | ||||
|   return { | ||||
|     shorten: data.stats.shorten, | ||||
|     get: data.stats.get, | ||||
|     usage: { | ||||
|       get: data.usage.get.map(firestoreTimestamp => firestoreTimestamp.toMillis()), | ||||
|       shorten: data.usage.shorten.map(firestoreTimestamp => firestoreTimestamp.toMillis()) | ||||
|     } | ||||
|   }; | ||||
| }; | ||||
| @@ -1,18 +0,0 @@ | ||||
| /** | ||||
|  * Sets default properties on an object that aren't already specified. | ||||
|  * @param {Object} def Default properties | ||||
|  * @param {Object} given Object to assign defaults to | ||||
|  * @returns {Object} | ||||
|  */ | ||||
| module.exports = (def, given) => { | ||||
|   if (!given) return def; | ||||
|   for (const key in def) { | ||||
|     if (!Object.prototype.hasOwnProperty.call(given, key) || given[key] === undefined) { | ||||
|       given[key] = def[key]; | ||||
|     } else if (given[key] === Object(given[key])) { | ||||
|       given[key] = module.exports(def[key], given[key]); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return given; | ||||
| }; | ||||
| @@ -1,17 +0,0 @@ | ||||
| const { characters } = require("../constants"); | ||||
|  | ||||
| /** | ||||
|  * Replace space characters with binary code IDs. | ||||
|  * @param {string} spaceCharacters String with space characters in it to be replaced with binary | ||||
|  */ | ||||
| module.exports = spaceCharacters => { | ||||
|   characters.forEach( | ||||
|     (spaceConfig, index) => | ||||
|       (spaceCharacters = spaceCharacters.replace( | ||||
|         new RegExp(`${spaceConfig.compatible.join("|")}|${spaceConfig.preferred}`, "g"), | ||||
|         index | ||||
|       )) | ||||
|   ); | ||||
|  | ||||
|   return spaceCharacters; | ||||
| }; | ||||
							
								
								
									
										219
									
								
								openapi.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								openapi.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | ||||
| openapi: '3.0.3' | ||||
| info: | ||||
|   title: Zero Width Shortener | ||||
|   version: 2.0.0 | ||||
|   contact: | ||||
|     name: Jonah Snider | ||||
|     email: jonah@jonah.pw | ||||
|     url: https://jonah.pw | ||||
|   license: | ||||
|     name: Apache 2.0 | ||||
|     url: https://www.apache.org/licenses/LICENSE-2.0.html | ||||
|   description: Shorten URLs with invisible spaces. | ||||
| servers: | ||||
|   - url: https://api.zws.im/ | ||||
|     description: Production API. | ||||
| paths: | ||||
|   /{short}: | ||||
|     get: | ||||
|       tags: | ||||
|         - urls | ||||
|       description: Retrieve a shortened URL. | ||||
|       parameters: | ||||
|         - $ref: '#/components/parameters/ShortenedUrl' | ||||
|         - in: query | ||||
|           name: visit | ||||
|           schema: | ||||
|             type: boolean | ||||
|             default: true | ||||
|           required: false | ||||
|           description: If you should be redirected to the URL instead of returning it as JSON. | ||||
|       responses: | ||||
|         '301': | ||||
|           description: Redirected to the long URL. | ||||
|         '404': | ||||
|           $ref: '#/components/responses/UrlNotFoundError' | ||||
|         '500': | ||||
|           $ref: '#/components/responses/Error' | ||||
|         '200': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: object | ||||
|                 properties: | ||||
|                   url: | ||||
|                     $ref: '#/components/schemas/Url' | ||||
|           description: Long URL returned. | ||||
|   /{short}/stats: | ||||
|     get: | ||||
|       tags: | ||||
|         - urls | ||||
|       description: Retrieve usage statistics for a shortened URL. | ||||
|       parameters: | ||||
|         - $ref: '#/components/parameters/ShortenedUrl' | ||||
|       responses: | ||||
|         '404': | ||||
|           $ref: '#/components/responses/UrlNotFoundError' | ||||
|         '500': | ||||
|           $ref: '#/components/responses/Error' | ||||
|         '200': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: object | ||||
|                 properties: | ||||
|                   url: | ||||
|                     $ref: '#/components/schemas/Url' | ||||
|                   visits: | ||||
|                     type: array | ||||
|                     items: | ||||
|                       type: string | ||||
|                       format: date-time | ||||
|                       example: 2021-02-21T03:58:25.794Z | ||||
|           description: Long URL returned. | ||||
|   /: | ||||
|     post: | ||||
|       tags: | ||||
|         - urls | ||||
|       description: Shorten a URL. | ||||
|       requestBody: | ||||
|         content: | ||||
|           application/json: | ||||
|             schema: | ||||
|               type: object | ||||
|               properties: | ||||
|                 url: | ||||
|                   $ref: '#/components/schemas/Url' | ||||
|               required: | ||||
|                 - url | ||||
|       responses: | ||||
|         '500': | ||||
|           $ref: '#/components/responses/Error' | ||||
|         '422': | ||||
|           $ref: '#/components/responses/AttemptedShortenHostnameError' | ||||
|         '503': | ||||
|           $ref: '#/components/responses/UniqueShortIdTimeoutError' | ||||
|         '201': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: object | ||||
|                 properties: | ||||
|                   short: | ||||
|                     type: string | ||||
|                     description: The shortened ID for the URL. | ||||
|           description: URL shortened. | ||||
| components: | ||||
|   schemas: | ||||
|     Url: | ||||
|       type: string | ||||
|       example: https://jonah.pw | ||||
|       maxLength: 500 | ||||
|   responses: | ||||
|     UrlNotFoundError: | ||||
|       description: The shortened URL couldn't be found. | ||||
|       content: | ||||
|         application/json: | ||||
|           schema: | ||||
|             type: object | ||||
|             properties: | ||||
|               statusCode: | ||||
|                 type: integer | ||||
|                 enum: | ||||
|                   - 404 | ||||
|               message: | ||||
|                 type: string | ||||
|               error: | ||||
|                 type: string | ||||
|                 enum: | ||||
|                   - Not Found | ||||
|               code: | ||||
|                 type: string | ||||
|                 enum: | ||||
|                   - E_URL_NOT_FOUND | ||||
|             required: | ||||
|               - statusCode | ||||
|               - message | ||||
|               - error | ||||
|               - code | ||||
|     AttemptedShortenHostnameError: | ||||
|       description: You attempted to shorten a URL with the same hostname as this instance. | ||||
|       content: | ||||
|         application/json: | ||||
|           schema: | ||||
|             type: object | ||||
|             properties: | ||||
|               statusCode: | ||||
|                 type: integer | ||||
|                 enum: | ||||
|                   - 422 | ||||
|               message: | ||||
|                 type: string | ||||
|               error: | ||||
|                 type: string | ||||
|                 enum: | ||||
|                   - Unprocessable Entity | ||||
|               code: | ||||
|                 type: string | ||||
|                 enum: | ||||
|                   - E_SHORTEN_HOSTNAME | ||||
|             required: | ||||
|               - statusCode | ||||
|               - message | ||||
|               - error | ||||
|               - code | ||||
|     UniqueShortIdTimeoutError: | ||||
|       description: A unique short ID couldn't be generated in enough time. | ||||
|       content: | ||||
|         application/json: | ||||
|           schema: | ||||
|             type: object | ||||
|             properties: | ||||
|               statusCode: | ||||
|                 type: integer | ||||
|                 enum: | ||||
|                   - 503 | ||||
|               message: | ||||
|                 type: string | ||||
|               error: | ||||
|                 type: string | ||||
|                 enum: | ||||
|                   - Service Unavailable | ||||
|               code: | ||||
|                 type: string | ||||
|                 enum: | ||||
|                   - E_UNIQUE_SHORT_ID_TIMEOUT | ||||
|             required: | ||||
|               - statusCode | ||||
|               - message | ||||
|               - error | ||||
|               - code | ||||
|     Error: | ||||
|       description: An error occurred while processing the request. | ||||
|       content: | ||||
|         application/json: | ||||
|           schema: | ||||
|             type: object | ||||
|             properties: | ||||
|               statusCode: | ||||
|                 type: integer | ||||
|                 enum: | ||||
|                   - 500 | ||||
|               message: | ||||
|                 type: string | ||||
|               error: | ||||
|                 type: string | ||||
|                 enum: | ||||
|                   - Internal Server Error | ||||
|             required: | ||||
|               - statusCode | ||||
|               - message | ||||
|               - error | ||||
|   parameters: | ||||
|     ShortenedUrl: | ||||
|       in: path | ||||
|       name: short | ||||
|       schema: | ||||
|         type: string | ||||
|       required: true | ||||
|       description: The shortened ID for the URL. | ||||
							
								
								
									
										78
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| { | ||||
| 	"author": { | ||||
| 		"email": "jonah@jonah.pw", | ||||
| 		"name": "Jonah Snider", | ||||
| 		"url": "https://jonah.pw" | ||||
| 	}, | ||||
| 	"bugs": { | ||||
| 		"url": "https://github.com/zws-im/zws/issues" | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@semantic-release/exec": "5.0.0", | ||||
| 		"@tsconfig/node14": "1.0.0", | ||||
| 		"@types/node": "14.14.31", | ||||
| 		"@types/supports-color": "7.2.0", | ||||
| 		"eslint-plugin-prettier": "3.3.1", | ||||
| 		"prettier": "2.2.1", | ||||
| 		"prettier-config-xo": "1.0.3", | ||||
| 		"prisma": "2.17.0", | ||||
| 		"semantic-release": "17.3.9", | ||||
| 		"semantic-release-docker": "2.2.0", | ||||
| 		"ts-node": "9.1.1", | ||||
| 		"type-fest": "0.21.1", | ||||
| 		"typescript": "4.1.5", | ||||
| 		"xo": "0.38.1" | ||||
| 	}, | ||||
| 	"license": "Apache-2.0", | ||||
| 	"main": "./tsc_output/index.js", | ||||
| 	"name": "@zws.im/zws", | ||||
| 	"engines": { | ||||
| 		"node": "15.x" | ||||
| 	}, | ||||
| 	"nyc": { | ||||
| 		"all": true, | ||||
| 		"extends": "@istanbuljs/nyc-config-typescript", | ||||
| 		"include": [ | ||||
| 			"src/**/*.ts" | ||||
| 		], | ||||
| 		"reporter": [ | ||||
| 			"lcov", | ||||
| 			"cobertura" | ||||
| 		] | ||||
| 	}, | ||||
| 	"private": true, | ||||
| 	"repository": { | ||||
| 		"type": "git", | ||||
| 		"url": "git+https://github.com/zws-im/zws.git" | ||||
| 	}, | ||||
| 	"scripts": { | ||||
| 		"build": "tsc", | ||||
| 		"deploy": "semantic-release", | ||||
| 		"lint": "xo", | ||||
| 		"prebuild": "rm -rf tsc_output", | ||||
| 		"style": "prettier --check .", | ||||
| 		"start": "node .", | ||||
| 		"migrations": "prisma migrate deploy --preview-feature" | ||||
| 	}, | ||||
| 	"version": "2.0.0", | ||||
| 	"xo": { | ||||
| 		"prettier": true, | ||||
| 		"rules": { | ||||
| 			"prettier/prettier": "off" | ||||
| 		} | ||||
| 	}, | ||||
| 	"dependencies": { | ||||
| 		"@pizzafox/util": "2.5.0", | ||||
| 		"@prisma/client": "2.17.0", | ||||
| 		"convert": "1.6.2", | ||||
| 		"dotenv": "8.2.0", | ||||
| 		"execa": "5.0.0", | ||||
| 		"fastify": "3.12.0", | ||||
| 		"fastify-cors": "5.2.0", | ||||
| 		"fastify-error": "0.3.0", | ||||
| 		"fluent-json-schema": "2.0.4", | ||||
| 		"ow": "0.23.0", | ||||
| 		"supports-color": "8.1.1", | ||||
| 		"tslog": "3.1.1" | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										4
									
								
								prettier.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								prettier.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| module.exports = { | ||||
| 	...require('prettier-config-xo'), | ||||
| 	printWidth: 160 | ||||
| }; | ||||
							
								
								
									
										22
									
								
								prisma/migrations/20210221131511_init/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								prisma/migrations/20210221131511_init/migration.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| -- CreateTable | ||||
| CREATE TABLE "shortened_urls" ( | ||||
|     "short_base64" TEXT NOT NULL, | ||||
|     "url" TEXT NOT NULL, | ||||
|     "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||
|  | ||||
|     PRIMARY KEY ("short_base64") | ||||
| ); | ||||
|  | ||||
| -- CreateTable | ||||
| CREATE TABLE "visits" ( | ||||
|     "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||
|     "shortened_url_base64" TEXT, | ||||
|  | ||||
|     PRIMARY KEY ("timestamp") | ||||
| ); | ||||
|  | ||||
| -- CreateIndex | ||||
| CREATE INDEX "visits.shortened_url_base64_index" ON "visits"("shortened_url_base64"); | ||||
|  | ||||
| -- AddForeignKey | ||||
| ALTER TABLE "visits" ADD FOREIGN KEY ("shortened_url_base64") REFERENCES "shortened_urls"("short_base64") ON DELETE SET NULL ON UPDATE CASCADE; | ||||
| @@ -0,0 +1,14 @@ | ||||
| /* | ||||
|   Warnings: | ||||
|  | ||||
|   - The migration will change the primary key for the `visits` table. If it partially fails, the table could be left without primary key constraint. | ||||
|   - Made the column `shortened_url_base64` on table `visits` required. The migration will fail if there are existing NULL values in that column. | ||||
|  | ||||
| */ | ||||
| -- DropIndex | ||||
| DROP INDEX "visits.shortened_url_base64_index"; | ||||
|  | ||||
| -- AlterTable | ||||
| ALTER TABLE "visits" DROP CONSTRAINT "visits_pkey", | ||||
| ALTER COLUMN "shortened_url_base64" SET NOT NULL, | ||||
| ADD PRIMARY KEY ("shortened_url_base64", "timestamp"); | ||||
							
								
								
									
										3
									
								
								prisma/migrations/migration_lock.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								prisma/migrations/migration_lock.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| # Please do not edit this file manually | ||||
| # It should be added in your version-control system (i.e. Git) | ||||
| provider = "postgresql" | ||||
							
								
								
									
										35
									
								
								prisma/schema.prisma
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								prisma/schema.prisma
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| // This is your Prisma schema file, | ||||
| // learn more about it in the docs: https://pris.ly/d/prisma-schema | ||||
|  | ||||
| datasource db { | ||||
|   provider = "postgresql" | ||||
|   url      = env("DATABASE_URL") | ||||
| } | ||||
|  | ||||
| generator client { | ||||
|   provider = "prisma-client-js" | ||||
| } | ||||
|  | ||||
| model ShortenedUrl { | ||||
|   /// The base64 encoded shortened ID | ||||
|   shortBase64 String   @id @map("short_base64") | ||||
|   /// The long URL | ||||
|   url         String | ||||
|   /// Statistics of when this URL was visited | ||||
|   visits      Visit[] | ||||
|   /// The datetime the URL was shortened | ||||
|   createdAt   DateTime @default(now()) @map("created_at") | ||||
|  | ||||
|   @@map("shortened_urls") | ||||
| } | ||||
|  | ||||
| model Visit { | ||||
|   /// When the visit occurred | ||||
|   timestamp      DateTime     @default(now()) | ||||
|   shortenedUrl   ShortenedUrl @relation(fields: [shortenedUrlId], references: [shortBase64]) | ||||
|   shortenedUrlId String       @map("shortened_url_base64") | ||||
|  | ||||
|  | ||||
|   @@id([shortenedUrlId, timestamp]) | ||||
|   @@map("visits") | ||||
| } | ||||
							
								
								
									
										12
									
								
								renovate.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								renovate.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| { | ||||
| 	"$schema": "https://docs.renovatebot.com/renovate-schema.json", | ||||
| 	"extends": ["config:base", ":semanticCommitTypeAll(build)"], | ||||
| 	"labels": ["dependencies"], | ||||
| 	"packageRules": [ | ||||
| 		{ | ||||
| 			"automerge": true, | ||||
| 			"updateTypes": ["patch"] | ||||
| 		} | ||||
| 	], | ||||
| 	"semanticCommits": true | ||||
| } | ||||
							
								
								
									
										102
									
								
								src/config/characters.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/config/characters.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | ||||
| import ow from 'ow'; | ||||
| import {JsonValue} from 'type-fest'; | ||||
|  | ||||
| /** | ||||
|  * Every character matching this regular expression: | ||||
|  * ```js | ||||
|  * /[a-z\d]/i; | ||||
|  * ``` | ||||
|  */ | ||||
| const alphaNumeric: string[] = [ | ||||
| 	// #region alphanumeric | ||||
| 	'a', | ||||
| 	'b', | ||||
| 	'c', | ||||
| 	'd', | ||||
| 	'e', | ||||
| 	'f', | ||||
| 	'g', | ||||
| 	'h', | ||||
| 	'i', | ||||
| 	'j', | ||||
| 	'k', | ||||
| 	'l', | ||||
| 	'm', | ||||
| 	'n', | ||||
| 	'o', | ||||
| 	'p', | ||||
| 	'q', | ||||
| 	'r', | ||||
| 	'z', | ||||
| 	't', | ||||
| 	'u', | ||||
| 	'v', | ||||
| 	'x', | ||||
| 	'w', | ||||
| 	'y', | ||||
| 	'z', | ||||
| 	'A', | ||||
| 	'B', | ||||
| 	'C', | ||||
| 	'D', | ||||
| 	'E', | ||||
| 	'F', | ||||
| 	'G', | ||||
| 	'H', | ||||
| 	'I', | ||||
| 	'J', | ||||
| 	'K', | ||||
| 	'L', | ||||
| 	'M', | ||||
| 	'N', | ||||
| 	'O', | ||||
| 	'P', | ||||
| 	'Q', | ||||
| 	'R', | ||||
| 	'Z', | ||||
| 	'T', | ||||
| 	'U', | ||||
| 	'V', | ||||
| 	'X', | ||||
| 	'W', | ||||
| 	'Y', | ||||
| 	'Z', | ||||
| 	'1', | ||||
| 	'2', | ||||
| 	'3', | ||||
| 	'4', | ||||
| 	'5', | ||||
| 	'6', | ||||
| 	'7', | ||||
| 	'8', | ||||
| 	'9', | ||||
| 	'0' | ||||
| 	// #endregion | ||||
| ]; | ||||
|  | ||||
| enum EnvVarNames { | ||||
| 	ShortChars = 'SHORT_CHARS', | ||||
| 	ShortLength = 'SHORT_LENGTH', | ||||
| 	ShortRewrites = 'SHORT_REWRITES' | ||||
| } | ||||
|  | ||||
| const parsedCharacters: JsonValue = JSON.parse(process.env[EnvVarNames.ShortChars] ?? 'null'); | ||||
|  | ||||
| ow(parsedCharacters, EnvVarNames.ShortChars, ow.any(ow.array.ofType(ow.string.nonEmpty).minLength(1))); | ||||
|  | ||||
| /** Characters to use in the shortened ID for a URL. */ | ||||
| export const characters = parsedCharacters === null ? alphaNumeric : [...new Set<string>(parsedCharacters)]; | ||||
|  | ||||
| const defaultShortLength = Math.round(8.5 / (2 * Math.log10(characters.length)) + 4); | ||||
| /** The length of the shortened ID for a URL. */ | ||||
| export const length = process.env[EnvVarNames.ShortLength] === undefined ? defaultShortLength : Number(process.env[EnvVarNames.ShortLength]); | ||||
|  | ||||
| ow(length, EnvVarNames.ShortLength, ow.number.integer.positive); | ||||
|  | ||||
| const parsedRewrites: JsonValue = JSON.parse(process.env[EnvVarNames.ShortRewrites] ?? '{}'); | ||||
|  | ||||
| ow(parsedRewrites, EnvVarNames.ShortRewrites, ow.object.valuesOfType(ow.string.nonEmpty)); | ||||
| ow(parsedRewrites, EnvVarNames.ShortRewrites, ow.object.not.instanceOf(Array)); | ||||
|  | ||||
| /** Rewrites applied to a URL before redirecting. */ | ||||
| export const rewrites = parsedRewrites as Record<string, string>; | ||||
							
								
								
									
										9
									
								
								src/config/env.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/config/env.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| export enum Env { | ||||
| 	Prod, | ||||
| 	Dev | ||||
| } | ||||
|  | ||||
| /** If the application is running on Heroku. */ | ||||
| export const heroku = 'DYNO' in process.env && process.env.NODE_HOME?.includes('heroku'); | ||||
|  | ||||
| export const env = process.env.NODE_ENV === 'development' ? Env.Dev : Env.Prod; | ||||
							
								
								
									
										10
									
								
								src/config/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/config/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import * as dotenv from 'dotenv'; | ||||
| import * as env from './env'; | ||||
|  | ||||
| if (env.env === env.Env.Dev) { | ||||
| 	dotenv.config(); | ||||
| } | ||||
|  | ||||
| export * as characters from './characters'; | ||||
| export * as env from './env'; | ||||
| export * as server from './server'; | ||||
							
								
								
									
										10
									
								
								src/config/server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/config/server.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import ow from 'ow'; | ||||
| import {JsonValue} from 'type-fest'; | ||||
|  | ||||
| export const port = process.env.PORT === undefined ? 3000 : Number(process.env.PORT); | ||||
|  | ||||
| const rawHostname: JsonValue = process.env.HOSTNAME ?? 'localhost'; | ||||
|  | ||||
| ow(rawHostname, 'HOSTNAME', ow.string); | ||||
|  | ||||
| export const hostname: string = rawHostname; | ||||
							
								
								
									
										23
									
								
								src/db.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/db.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import {PrismaClient} from '@prisma/client'; | ||||
| import execa from 'execa'; | ||||
| import baseLogger from './logger'; | ||||
|  | ||||
| const logger = baseLogger.getChildLogger({name: 'db'}); | ||||
|  | ||||
| export async function applyMigrations(): Promise<void> { | ||||
| 	await execa('yarn', ['run', 'migrations'], {stderr: 'inherit', stdout: 'inherit'}); | ||||
| } | ||||
|  | ||||
| const db = new PrismaClient({ | ||||
| 	log: [ | ||||
| 		{emit: 'event', level: 'error'}, | ||||
| 		{emit: 'event', level: 'info'}, | ||||
| 		{emit: 'event', level: 'warn'} | ||||
| 	] | ||||
| }); | ||||
|  | ||||
| db.$on('error', error => logger.error(error.message)); | ||||
| db.$on('info', info => logger.info(info.message)); | ||||
| db.$on('warn', warning => logger.warn(warning.message)); | ||||
|  | ||||
| export default db; | ||||
							
								
								
									
										20
									
								
								src/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import {server} from './config'; | ||||
| import fastify from './server'; | ||||
| import baseLogger from './logger'; | ||||
|  | ||||
| const logger = baseLogger.getChildLogger({name: 'http'}); | ||||
|  | ||||
| (async () => { | ||||
| 	try { | ||||
| 		const address = await fastify.listen({port: server.port, host: '0.0.0.0'}); | ||||
|  | ||||
| 		logger.info(`Listening at ${address}`); | ||||
| 	} catch (error: unknown) { | ||||
| 		logger.error(error); | ||||
| 		throw error; | ||||
| 	} | ||||
| })().catch(error => { | ||||
| 	process.nextTick(() => { | ||||
| 		throw error; | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										13
									
								
								src/logger.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/logger.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import supportsColor from 'supports-color'; | ||||
| import {Logger} from 'tslog'; | ||||
| import {heroku} from './config/env'; | ||||
|  | ||||
| const logger = new Logger({ | ||||
| 	displayFilePath: 'hidden', | ||||
| 	displayFunctionName: false, | ||||
| 	colorizePrettyLogs: supportsColor.stdout && supportsColor.stderr, | ||||
| 	// Heroku logs display timestamps next to each line | ||||
| 	displayDateTime: !heroku | ||||
| }); | ||||
|  | ||||
| export default logger; | ||||
							
								
								
									
										9
									
								
								src/server/errors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/server/errors.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import createError from 'fastify-error'; | ||||
|  | ||||
| /** When a user attempted to access a shortened URL that couldn't be found. */ | ||||
| export const UrlNotFound = createError('E_URL_NOT_FOUND', 'Shortened URL not found in database', 404); | ||||
|  | ||||
| /** When a user attempted to shorten a URL that had the same hostname as the app does. */ | ||||
| export const AttemptedShortenHostname = createError('E_SHORTEN_HOSTNAME', 'Shortening a URL with the same hostname as the server is disallowed', 422); | ||||
|  | ||||
| export const UniqueShortIdTimeout = createError('E_UNIQUE_SHORT_ID_TIMEOUT', `Couldn't generate a new shortened ID in %s attempts`, 503); | ||||
							
								
								
									
										48
									
								
								src/server/hooks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/server/hooks.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import {FastifyInstance} from 'fastify'; | ||||
| import {env} from '../config'; | ||||
| import db, {applyMigrations} from '../db'; | ||||
| import baseLogger from '../logger'; | ||||
|  | ||||
| let requestId: string | undefined; | ||||
|  | ||||
| const fastifyLogger = baseLogger.getChildLogger({ | ||||
| 	name: 'http', | ||||
| 	requestId: () => requestId! | ||||
| }); | ||||
|  | ||||
| export default function addHooks(fastify: FastifyInstance): void { | ||||
| 	fastify.addHook('onReady', async () => { | ||||
| 		if (env.heroku) { | ||||
| 			const dbLogger = baseLogger.getChildLogger({name: 'db'}); | ||||
|  | ||||
| 			dbLogger.info('Heroku environment detected, running migrations'); | ||||
|  | ||||
| 			try { | ||||
| 				await applyMigrations(); | ||||
| 			} catch (error: unknown) { | ||||
| 				dbLogger.error('Migrations failed', error); | ||||
| 				throw error; | ||||
| 			} | ||||
|  | ||||
| 			dbLogger.info('Migrations completed'); | ||||
| 		} | ||||
|  | ||||
| 		await db.$connect(); | ||||
| 	}); | ||||
|  | ||||
| 	fastify.addHook('onClose', async () => db.$disconnect()); | ||||
|  | ||||
| 	fastify.addHook('onRequest', async (request, reply) => { | ||||
| 		requestId = request.id; | ||||
|  | ||||
| 		fastifyLogger.info(`${request.routerMethod} ${request.routerPath}`); | ||||
| 	}); | ||||
|  | ||||
| 	fastify.addHook('onError', async (request, reply, error) => { | ||||
| 		if (reply.statusCode >= 500 && reply.statusCode < 600) { | ||||
| 			requestId = request.id; | ||||
|  | ||||
| 			fastifyLogger.error(error); | ||||
| 		} | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										24
									
								
								src/server/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/server/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import convert from 'convert'; | ||||
| import createServer, {RouteOptions} from 'fastify'; | ||||
| import cors from 'fastify-cors'; | ||||
| import {heroku} from '../config/env'; | ||||
| import logger from '../logger'; | ||||
| import registerHooks from './hooks'; | ||||
| import * as routes from './routes'; | ||||
|  | ||||
| const fastify = createServer({ | ||||
| 	maxParamLength: 1024, | ||||
| 	// Migrations are applied when running in Heroku which can take a while | ||||
| 	pluginTimeout: heroku ? convert(30).from('s').to('ms') : undefined | ||||
| }); | ||||
|  | ||||
| registerHooks(fastify); | ||||
|  | ||||
| for (const route of Object.values(routes)) { | ||||
| 	fastify.route(route as RouteOptions); | ||||
| } | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-empty-function | ||||
| fastify.register(cors).then(() => {}, logger.getChildLogger({name: 'http'}).error); | ||||
|  | ||||
| export default fastify; | ||||
							
								
								
									
										3
									
								
								src/server/routes/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/server/routes/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| export {default as visit} from './urls/visit'; | ||||
| export {default as stats} from './urls/stats'; | ||||
| export {default as shorten} from './urls/shorten'; | ||||
							
								
								
									
										32
									
								
								src/server/routes/urls/shorten.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/server/routes/urls/shorten.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import {RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, RouteOptions} from 'fastify'; | ||||
| import S from 'fluent-json-schema'; | ||||
| // eslint-disable-next-line node/prefer-global/url | ||||
| import {URL} from 'url'; | ||||
| import {server} from '../../../config'; | ||||
| import {urls} from '../../../services'; | ||||
| import {AttemptedShortenHostname} from '../../errors'; | ||||
|  | ||||
| const route: RouteOptions<RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, {Body: {url: string}; Reply: {short: string}}> = { | ||||
| 	method: 'POST', | ||||
| 	url: '/', | ||||
| 	schema: { | ||||
| 		body: S.object().prop('url', S.string().format(S.FORMATS.URL).maxLength(500)) | ||||
| 	}, | ||||
| 	handler: async (request, reply) => { | ||||
| 		const { | ||||
| 			body: {url} | ||||
| 		} = request; | ||||
|  | ||||
| 		if (new URL(url).hostname === server.hostname) { | ||||
| 			throw new AttemptedShortenHostname(); | ||||
| 		} | ||||
|  | ||||
| 		const id = await urls.shorten(url); | ||||
|  | ||||
| 		void reply.code(201); | ||||
|  | ||||
| 		return {short: id}; | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										32
									
								
								src/server/routes/urls/stats.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/server/routes/urls/stats.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import {RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, RouteOptions} from 'fastify'; | ||||
| import S from 'fluent-json-schema'; | ||||
| import {urls} from '../../../services'; | ||||
| import {UrlNotFound} from '../../errors'; | ||||
|  | ||||
| const route: RouteOptions< | ||||
| 	RawServerDefault, | ||||
| 	RawRequestDefaultExpression, | ||||
| 	RawReplyDefaultExpression, | ||||
| 	{Params: {short: string}; Reply: {url: string; visits: Date[]}} | ||||
| > = { | ||||
| 	method: 'GET', | ||||
| 	url: '/:short/stats', | ||||
| 	schema: { | ||||
| 		params: S.object().prop('short', S.string()) | ||||
| 	}, | ||||
| 	handler: async (request, reply) => { | ||||
| 		const { | ||||
| 			params: {short} | ||||
| 		} = request; | ||||
|  | ||||
| 		const stats = await urls.stats(urls.normalizeShortId(short)); | ||||
|  | ||||
| 		if (stats === null) { | ||||
| 			throw new UrlNotFound(); | ||||
| 		} | ||||
|  | ||||
| 		return stats; | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										38
									
								
								src/server/routes/urls/visit.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/server/routes/urls/visit.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import {RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, RouteOptions} from 'fastify'; | ||||
| import S from 'fluent-json-schema'; | ||||
| import {urls} from '../../../services'; | ||||
| import {UrlNotFound} from '../../errors'; | ||||
|  | ||||
| const route: RouteOptions< | ||||
| 	RawServerDefault, | ||||
| 	RawRequestDefaultExpression, | ||||
| 	RawReplyDefaultExpression, | ||||
| 	{Params: {short: string}; Querystring: {visit: boolean}; Reply: {url: string}} | ||||
| > = { | ||||
| 	method: 'GET', | ||||
| 	url: '/:short', | ||||
| 	schema: { | ||||
| 		params: S.object().prop('short', S.string()), | ||||
| 		querystring: S.object().prop('visit', S.boolean().default(true)) | ||||
| 	}, | ||||
| 	handler: async (request, reply) => { | ||||
| 		const { | ||||
| 			query: {visit}, | ||||
| 			params: {short} | ||||
| 		} = request; | ||||
|  | ||||
| 		const url = await urls.visit(urls.normalizeShortId(short), true); | ||||
|  | ||||
| 		if (url === null) { | ||||
| 			throw new UrlNotFound(); | ||||
| 		} | ||||
|  | ||||
| 		if (visit) { | ||||
| 			void reply.redirect(url); | ||||
| 		} else { | ||||
| 			return {url}; | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										1
									
								
								src/services/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/services/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export * as urls from './urls'; | ||||
							
								
								
									
										120
									
								
								src/services/urls.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/services/urls.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| import {sample} from '@pizzafox/util'; | ||||
| import {Opaque} from 'type-fest'; | ||||
| import {characters} from '../config'; | ||||
| import db from '../db'; | ||||
| import {UniqueShortIdTimeout} from '../server/errors'; | ||||
|  | ||||
| /** A base64 encoded string. */ | ||||
| type Base64 = Opaque<string, 'Base64'>; | ||||
|  | ||||
| /** Maximum number of attempts to generate a unique ID. */ | ||||
| const maxGenerationAttempts = 10; | ||||
|  | ||||
| function encode(value: string): Base64 { | ||||
| 	return Buffer.from(value).toString('base64') as Base64; | ||||
| } | ||||
|  | ||||
| export function debugInfo(id: string, encodedId: Base64) { | ||||
| 	return {short: id, encodedShort: encodedId, shortCodepoints: id.split('').map(char => char.charCodeAt(0).toString(16))}; | ||||
| } | ||||
|  | ||||
| // Reused to avoid expensive iteration | ||||
| const rewritesEntries = Object.entries(characters.rewrites); | ||||
|  | ||||
| export function normalizeShortId(id: string): string { | ||||
| 	for (const [original, rewrite] of rewritesEntries) { | ||||
| 		id = id.replaceAll(original, rewrite); | ||||
| 	} | ||||
|  | ||||
| 	return id; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Generate a short ID. | ||||
|  * Not guaranteed to be unique. | ||||
|  * | ||||
|  * @returns A short ID | ||||
|  */ | ||||
| function generateShortId(): string { | ||||
| 	let shortId = ''; | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/prefer-for-of | ||||
| 	for (let i = 0; i < characters.length; i++) { | ||||
| 		shortId += sample(characters.characters); | ||||
| 	} | ||||
|  | ||||
| 	return shortId; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Visit a shortened URL. | ||||
|  * | ||||
|  * @param id - The ID of the shortened URL to visit | ||||
|  * @param track - If the visit should be tracked | ||||
|  * | ||||
|  * @returns The long URL, or `null` if it couldn't be found | ||||
|  */ | ||||
| export async function visit(id: string, track: boolean): Promise<string | null> { | ||||
| 	const encodedId = encode(id); | ||||
|  | ||||
| 	const url = (await db.shortenedUrl.findUnique({where: {shortBase64: encodedId}, select: {url: true}}))?.url; | ||||
|  | ||||
| 	if (url === undefined) { | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
| 	if (track) { | ||||
| 		// TODO: Remove new Date()` when Prisma is patched (see https://github.com/prisma/prisma/issues/5762) | ||||
| 		db.visit.create({data: {shortenedUrl: {connect: {shortBase64: encodedId}}, timestamp: new Date()}}).catch(error => { | ||||
| 			throw error; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	return url; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Retrieve usage statistics for a shortened URL | ||||
|  * | ||||
|  * @param id - The ID of the shortened URL | ||||
|  * | ||||
|  * @returns Shortened URL information and statistics, or `null` if it couldn't be found | ||||
|  */ | ||||
| export async function stats(id: string): Promise<null | {url: string; visits: Date[]}> { | ||||
| 	const encodedId = encode(id); | ||||
|  | ||||
| 	const shortenedUrl = await db.shortenedUrl.findUnique({where: {shortBase64: encodedId}, select: {url: true, visits: {select: {timestamp: true}}}}); | ||||
|  | ||||
| 	if (!shortenedUrl) { | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
| 	return {visits: shortenedUrl.visits.map(visit => visit.timestamp), url: shortenedUrl.url}; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Shorten a long URL | ||||
|  * | ||||
|  * @param url - The long URL to shorten | ||||
|  * | ||||
|  * @returns The ID of the shortened URL | ||||
|  */ | ||||
| export async function shorten(url: string): Promise<string> { | ||||
| 	let id: string; | ||||
| 	let shortBase64: Base64; | ||||
| 	let attempts = 0; | ||||
|  | ||||
| 	do { | ||||
| 		if (attempts++ > maxGenerationAttempts) { | ||||
| 			throw new UniqueShortIdTimeout(maxGenerationAttempts); | ||||
| 		} | ||||
|  | ||||
| 		id = generateShortId(); | ||||
| 		shortBase64 = encode(id); | ||||
| 		// eslint-disable-next-line no-await-in-loop | ||||
| 	} while ((await db.shortenedUrl.count({where: {shortBase64}})) > 0); | ||||
|  | ||||
| 	await db.shortenedUrl.create({data: {url, shortBase64}}); | ||||
|  | ||||
| 	return id; | ||||
| } | ||||
							
								
								
									
										11
									
								
								tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								tsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| { | ||||
| 	"compilerOptions": { | ||||
| 		"moduleResolution": "node", | ||||
| 		"outDir": "tsc_output", | ||||
| 		"resolveJsonModule": true, | ||||
| 		"sourceMap": true | ||||
| 	}, | ||||
| 	"extends": "@tsconfig/node14/tsconfig.json", | ||||
| 	"exclude": ["node_modules", "nyc_output", "coverage"], | ||||
| 	"include": ["src"] | ||||
| } | ||||
		Reference in New Issue
	
	Block a user