mirror of
				https://github.com/zws-im/zws.git
				synced 2025-10-30 23:27:52 +02:00 
			
		
		
		
	feat: split API into separate service (#698)
* feat: split API into separate service * fix: fix compilation errors * docs: remove commented code * ci: make format script work in CI * build(yarn): set Node linker to node-modules
This commit is contained in:
		
							
								
								
									
										94
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										94
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -7,8 +7,8 @@ env: | ||||
|   VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     name: Build | ||||
|   build-and-test: | ||||
|     name: Build and test | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 5 | ||||
| @@ -16,80 +16,26 @@ jobs: | ||||
|     steps: | ||||
|       - name: Checkout Git repository | ||||
|         uses: actions/checkout@v4 | ||||
|       - name: Setup Bun | ||||
|         uses: oven-sh/setup-bun@v1 | ||||
|       - name: Install dependencies with Bun | ||||
|         run: bun install --frozen-lockfile | ||||
|       - name: Pull environment variables | ||||
|         run: bun vercel env pull --environment development .env --token ${{ secrets.VERCEL_TOKEN }} | ||||
|       - name: Cache Next.js | ||||
|         uses: actions/cache@v3 | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 'lts/*' | ||||
|           cache: 'yarn' | ||||
|       - name: Install dependencies with Yarn | ||||
|         run: yarn install --immutable | ||||
|       - name: Pull environment variables | ||||
|         run: yarn vercel env pull --environment development .env --token ${{ secrets.VERCEL_TOKEN }} | ||||
|       - name: Cache Next.js | ||||
|         uses: actions/cache@v4 | ||||
|         with: | ||||
|           # See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node | ||||
|           path: | | ||||
|             ${{ github.workspace }}/.next/cache | ||||
|             ~/.npm | ||||
|             ${{ github.workspace }}/apps/web/.next/cache | ||||
|           # Generate a new cache whenever packages or source files change. | ||||
|           key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} | ||||
|           key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} | ||||
|           # If source files changed but packages didn't, rebuild from a prior cache. | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}- | ||||
|       - name: Build | ||||
|         run: bun run build | ||||
|   lint: | ||||
|     name: Lint | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 5 | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout Git repository | ||||
|         uses: actions/checkout@v4 | ||||
|       - name: Setup Bun | ||||
|         uses: oven-sh/setup-bun@v1 | ||||
|       - name: Install dependencies with Bun | ||||
|         run: bun install --frozen-lockfile | ||||
|       - name: Lint | ||||
|         run: bun run lint | ||||
|   lint-openapi: | ||||
|     name: Lint OpenAPI | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 5 | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout Git repository | ||||
|         uses: actions/checkout@v4 | ||||
|       - name: Setup Bun | ||||
|         uses: oven-sh/setup-bun@v1 | ||||
|       - name: Install dependencies with Bun | ||||
|         run: bun install --frozen-lockfile | ||||
|       - name: Pull environment variables | ||||
|         run: bun vercel env pull --environment development .env --token ${{ secrets.VERCEL_TOKEN }} | ||||
|       - name: Cache Next.js | ||||
|         uses: actions/cache@v3 | ||||
|         with: | ||||
|           path: | | ||||
|             ${{ github.workspace }}/.next/cache | ||||
|           # Generate a new cache whenever packages or source files change. | ||||
|           key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} | ||||
|           # If source files changed but packages didn't, rebuild from a prior cache. | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}- | ||||
|       - name: Start local server and download OpenAPI schema | ||||
|         run: bun run openapi:download | ||||
|       - name: Lint | ||||
|         run: bun run openapi:lint | ||||
|   lint-exports: | ||||
|     name: Lint exports and dependencies | ||||
|  | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 5 | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout Git repository | ||||
|         uses: actions/checkout@v4 | ||||
|       - name: Setup Bun | ||||
|         uses: oven-sh/setup-bun@v1 | ||||
|       - name: Install dependencies with Bun | ||||
|         run: bun install --frozen-lockfile | ||||
|       - name: Lint | ||||
|         run: bun run lint:exports | ||||
|             ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}- | ||||
|       - name: Build and test | ||||
|         run: yarn run test | ||||
|   | ||||
							
								
								
									
										67
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										67
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,67 +0,0 @@ | ||||
| # 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@v4 | ||||
|  | ||||
|       # Initializes the CodeQL tools for scanning. | ||||
|       - name: Initialize CodeQL | ||||
|         uses: github/codeql-action/init@v2 | ||||
|         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@v2 | ||||
|  | ||||
|       # ℹ️ 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@v2 | ||||
							
								
								
									
										23
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -45,6 +45,10 @@ Network Trash Folder | ||||
| Temporary Items | ||||
| .apdisk | ||||
|  | ||||
| ### macOS Patch ### | ||||
| # iCloud generated files | ||||
| *.icloud | ||||
|  | ||||
| ### Node ### | ||||
| # Logs | ||||
| logs | ||||
| @@ -204,8 +208,6 @@ dist | ||||
| .history | ||||
| .ionide | ||||
|  | ||||
| # Support for Project snippet scope | ||||
|  | ||||
| ### Windows ### | ||||
| # Windows thumbnail cache files | ||||
| Thumbs.db | ||||
| @@ -234,12 +236,17 @@ $RECYCLE.BIN/ | ||||
|  | ||||
| # End of https://www.toptal.com/developers/gitignore/api/node,linux,macos,windows,visualstudiocode | ||||
|  | ||||
| # Dotenv files | ||||
| *.env | ||||
| !*example.env | ||||
|  | ||||
| # Vercel | ||||
| .vercel | ||||
|  | ||||
| # Downloaded OpenAPI spec for linting | ||||
| ./openapi.json | ||||
| # Turbo | ||||
| .turbo | ||||
|  | ||||
| # Yarn | ||||
| .pnp.* | ||||
| .yarn/* | ||||
| !.yarn/patches | ||||
| !.yarn/plugins | ||||
| !.yarn/releases | ||||
| !.yarn/sdks | ||||
| !.yarn/versions | ||||
|   | ||||
							
								
								
									
										247
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										247
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,247 @@ | ||||
| # Created by https://www.toptal.com/developers/gitignore/api/node,linux,macos,windows,visualstudiocode | ||||
| # Edit at https://www.toptal.com/developers/gitignore?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 | ||||
|  | ||||
| ### macOS Patch ### | ||||
| # iCloud generated files | ||||
| *.icloud | ||||
|  | ||||
| ### Node ### | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| lerna-debug.log* | ||||
| .pnpm-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/ | ||||
|  | ||||
| # Snowpack dependency directory (https://snowpack.dev/) | ||||
| web_modules/ | ||||
|  | ||||
| # TypeScript cache | ||||
| *.tsbuildinfo | ||||
|  | ||||
| # Optional npm cache directory | ||||
| .npm | ||||
|  | ||||
| # Optional eslint cache | ||||
| .eslintcache | ||||
|  | ||||
| # Optional stylelint cache | ||||
| .stylelintcache | ||||
|  | ||||
| # Microbundle cache | ||||
| .rpt2_cache/ | ||||
| .rts2_cache_cjs/ | ||||
| .rts2_cache_es/ | ||||
| .rts2_cache_umd/ | ||||
|  | ||||
| # Optional REPL history | ||||
| .node_repl_history | ||||
|  | ||||
| # Output of 'npm pack' | ||||
| *.tgz | ||||
|  | ||||
| # Yarn Integrity file | ||||
| .yarn-integrity | ||||
|  | ||||
| # dotenv environment variable files | ||||
| .env | ||||
| .env.development.local | ||||
| .env.test.local | ||||
| .env.production.local | ||||
| .env.local | ||||
|  | ||||
| # parcel-bundler cache (https://parceljs.org/) | ||||
| .cache | ||||
| .parcel-cache | ||||
|  | ||||
| # Next.js build output | ||||
| .next | ||||
| out | ||||
|  | ||||
| # Nuxt.js build / generate output | ||||
| .nuxt | ||||
| dist | ||||
|  | ||||
| # Gatsby files | ||||
| .cache/ | ||||
| # Comment in the public line in if your project uses Gatsby and not Next.js | ||||
| # https://nextjs.org/blog/next-9-1#public-directory-support | ||||
| # public | ||||
|  | ||||
| # vuepress build output | ||||
| .vuepress/dist | ||||
|  | ||||
| # vuepress v2.x temp and cache directory | ||||
| .temp | ||||
|  | ||||
| # Docusaurus cache and generated files | ||||
| .docusaurus | ||||
|  | ||||
| # Serverless directories | ||||
| .serverless/ | ||||
|  | ||||
| # FuseBox cache | ||||
| .fusebox/ | ||||
|  | ||||
| # DynamoDB Local files | ||||
| .dynamodb/ | ||||
|  | ||||
| # TernJS port file | ||||
| .tern-port | ||||
|  | ||||
| # Stores VSCode versions used for testing VSCode extensions | ||||
| .vscode-test | ||||
|  | ||||
| # yarn v2 | ||||
| .yarn/cache | ||||
| .yarn/unplugged | ||||
| .yarn/build-state.yml | ||||
| .yarn/install-state.gz | ||||
| .pnp.* | ||||
|  | ||||
| ### Node Patch ### | ||||
| # Serverless Webpack directories | ||||
| .webpack/ | ||||
|  | ||||
| # Optional stylelint cache | ||||
|  | ||||
| # SvelteKit build / generate output | ||||
| .svelte-kit | ||||
|  | ||||
| ### VisualStudioCode ### | ||||
| .vscode/* | ||||
| !.vscode/settings.json | ||||
| !.vscode/tasks.json | ||||
| !.vscode/launch.json | ||||
| !.vscode/extensions.json | ||||
| !.vscode/*.code-snippets | ||||
|  | ||||
| # Local History for Visual Studio Code | ||||
| .history/ | ||||
|  | ||||
| # Built Visual Studio Code Extensions | ||||
| *.vsix | ||||
|  | ||||
| ### VisualStudioCode Patch ### | ||||
| # Ignore all local history of files | ||||
| .history | ||||
| .ionide | ||||
|  | ||||
| ### 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.toptal.com/developers/gitignore/api/node,linux,macos,windows,visualstudiocode | ||||
|  | ||||
| # Vercel | ||||
| .vercel | ||||
|  | ||||
| # Turbo | ||||
| .turbo | ||||
|  | ||||
| # Yarn | ||||
| .pnp.* | ||||
| .yarn/* | ||||
| @@ -1,3 +0,0 @@ | ||||
| { | ||||
| 	"extends": ["spectral:oas"] | ||||
| } | ||||
							
								
								
									
										893
									
								
								.yarn/releases/yarn-4.1.1.cjs
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
							
						
						
									
										893
									
								
								.yarn/releases/yarn-4.1.1.cjs
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										3
									
								
								.yarnrc.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.yarnrc.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| yarnPath: .yarn/releases/yarn-4.1.1.cjs | ||||
| enableGlobalCache: true | ||||
| nodeLinker: node-modules | ||||
							
								
								
									
										5
									
								
								Procfile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Procfile
									
									
									
									
									
								
							| @@ -1,2 +1,3 @@ | ||||
| web: bun start | ||||
| release: bun run migrations | ||||
| web: bun --cwd ./apps/api start | ||||
|  | ||||
| release: bun run migrate | ||||
|   | ||||
| @@ -1,2 +0,0 @@ | ||||
| export * as apiShort from './openapi'; | ||||
| export * as apiShortStats from './stats/openapi'; | ||||
| @@ -1,60 +0,0 @@ | ||||
| import { QueryBooleanSchema } from 'next-api-utils'; | ||||
| import type { SchemaObject } from 'openapi3-ts/oas31'; | ||||
| import { zodToJsonSchema } from 'zod-to-json-schema'; | ||||
| import { ExceptionSchema } from '../_lib/exceptions/dtos/exception.dto'; | ||||
| import { OpenapiTag } from '../_lib/openapi/enums/openapi-tag.enum'; | ||||
| import type { OpenapiService } from '../_lib/openapi/openapi.service'; | ||||
| import { LongUrlSchema } from '../_lib/urls/dtos/long-url.dto'; | ||||
| import { ShortSchema } from '../_lib/urls/dtos/short.dto'; | ||||
|  | ||||
| export function openapi(oas: OpenapiService): void { | ||||
| 	oas.addPath({ | ||||
| 		method: 'get', | ||||
| 		path: '/{short}', | ||||
| 		tags: [OpenapiTag.ShortenedUrls], | ||||
| 		summary: 'Visit or retrieve a shortened URL', | ||||
| 		parameters: [ | ||||
| 			{ | ||||
| 				in: 'path', | ||||
| 				name: 'short', | ||||
| 				required: true, | ||||
| 				schema: zodToJsonSchema(ShortSchema) as SchemaObject, | ||||
| 			}, | ||||
| 			{ | ||||
| 				in: 'query', | ||||
| 				name: 'visit', | ||||
| 				required: false, | ||||
| 				schema: zodToJsonSchema(QueryBooleanSchema) as SchemaObject, | ||||
| 			}, | ||||
| 		], | ||||
| 		responses: { | ||||
| 			200: { | ||||
| 				description: 'Get the long URL for a short URL', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: LongUrlSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			302: { | ||||
| 				description: 'Redirect to the long URL', | ||||
| 			}, | ||||
| 			404: { | ||||
| 				description: 'The short URL was not found', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ExceptionSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			410: { | ||||
| 				description: 'The short URL was blocked', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ExceptionSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
| @@ -1,37 +0,0 @@ | ||||
| import { type NextRouteHandlerContext, QueryBooleanSchema, validateParams, validateQuery } from 'next-api-utils'; | ||||
| import { redirect } from 'next/navigation'; | ||||
| import { NextResponse } from 'next/server'; | ||||
| import { z } from 'zod'; | ||||
| import { exceptionRouteWrapper } from '../_lib/exception-route-wrapper'; | ||||
| import { urlStatsService } from '../_lib/url-stats/url-stats.service'; | ||||
| import type { LongUrlSchema } from '../_lib/urls/dtos/long-url.dto'; | ||||
| import { ShortSchema } from '../_lib/urls/dtos/short.dto'; | ||||
| import { UrlBlockedException } from '../_lib/urls/exceptions/url-blocked.exception'; | ||||
| import { UrlNotFoundException } from '../_lib/urls/exceptions/url-not-found.exception'; | ||||
| import { urlsService } from '../_lib/urls/urls.service'; | ||||
|  | ||||
| export const GET = exceptionRouteWrapper.wrapRoute<LongUrlSchema, NextRouteHandlerContext<{ short: string }>>( | ||||
| 	async (request, context) => { | ||||
| 		const params = validateParams(context, z.object({ short: ShortSchema })); | ||||
| 		const short = params.short; | ||||
| 		const query = validateQuery(request, z.object({ visit: QueryBooleanSchema.optional() })); | ||||
|  | ||||
| 		const url = await urlsService.retrieveUrl(short); | ||||
|  | ||||
| 		if (!url) { | ||||
| 			throw new UrlNotFoundException(); | ||||
| 		} | ||||
|  | ||||
| 		if (url.blocked) { | ||||
| 			throw new UrlBlockedException(); | ||||
| 		} | ||||
|  | ||||
| 		if (query.visit !== false) { | ||||
| 			await urlStatsService.trackUrlVisit(short); | ||||
|  | ||||
| 			redirect(encodeURI(url.longUrl)); | ||||
| 		} | ||||
|  | ||||
| 		return NextResponse.json({ url: url.longUrl }); | ||||
| 	}, | ||||
| ); | ||||
| @@ -1,42 +0,0 @@ | ||||
| import type { SchemaObject } from 'openapi3-ts/oas31'; | ||||
| import zodToJsonSchema from 'zod-to-json-schema'; | ||||
| import { ExceptionSchema } from '../../_lib/exceptions/dtos/exception.dto'; | ||||
| import { OpenapiTag } from '../../_lib/openapi/enums/openapi-tag.enum'; | ||||
| import type { OpenapiService } from '../../_lib/openapi/openapi.service'; | ||||
| import { UrlStatsSchema } from '../../_lib/url-stats/dtos/url-stats.dto'; | ||||
| import { ShortSchema } from '../../_lib/urls/dtos/short.dto'; | ||||
|  | ||||
| export function openapi(oas: OpenapiService): void { | ||||
| 	oas.addPath({ | ||||
| 		method: 'get', | ||||
| 		path: '/{short}/stats', | ||||
| 		tags: [OpenapiTag.ShortenedUrls], | ||||
| 		summary: 'Get statistics for a shortened URL', | ||||
| 		parameters: [ | ||||
| 			{ | ||||
| 				in: 'path', | ||||
| 				name: 'short', | ||||
| 				required: true, | ||||
| 				schema: zodToJsonSchema(ShortSchema) as SchemaObject, | ||||
| 			}, | ||||
| 		], | ||||
| 		responses: { | ||||
| 			200: { | ||||
| 				description: 'The stats for the URL', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: UrlStatsSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			404: { | ||||
| 				description: 'The URL was not found', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ExceptionSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
| @@ -1,25 +0,0 @@ | ||||
| import { NextResponse } from 'next/server'; | ||||
|  | ||||
| import { type NextRouteHandlerContext, validateParams } from 'next-api-utils'; | ||||
| import { z } from 'zod'; | ||||
| import { exceptionRouteWrapper } from '../../_lib/exception-route-wrapper'; | ||||
| import type { UrlStatsSchema } from '../../_lib/url-stats/dtos/url-stats.dto'; | ||||
| import { urlStatsService } from '../../_lib/url-stats/url-stats.service'; | ||||
| import { ShortSchema } from '../../_lib/urls/dtos/short.dto'; | ||||
| import { UrlNotFoundException } from '../../_lib/urls/exceptions/url-not-found.exception'; | ||||
|  | ||||
| export const GET = exceptionRouteWrapper.wrapRoute<UrlStatsSchema, NextRouteHandlerContext<{ short: string }>>( | ||||
| 	async (_request, context) => { | ||||
| 		const params = validateParams(context, z.object({ short: ShortSchema })); | ||||
|  | ||||
| 		const short = params.short; | ||||
|  | ||||
| 		const stats = await urlStatsService.statsForUrl(short); | ||||
|  | ||||
| 		if (!stats) { | ||||
| 			throw new UrlNotFoundException(); | ||||
| 		} | ||||
|  | ||||
| 		return NextResponse.json(stats); | ||||
| 	}, | ||||
| ); | ||||
| @@ -1,55 +0,0 @@ | ||||
| import type { Buffer } from 'node:buffer'; | ||||
| import type { Hash } from 'node:crypto'; | ||||
| import { createHash, timingSafeEqual } from 'node:crypto'; | ||||
| import type { NextRequest } from 'next/server'; | ||||
| import { Role } from '../authorization/enums/role.enum'; | ||||
| import { type ConfigService, configService } from '../config/config.service'; | ||||
| import { IncorrectApiKeyException } from './exceptions/incorrect-api-key.exception'; | ||||
|  | ||||
| export class AuthenticationService { | ||||
| 	private static bearerToApiKey(header: string): Hash { | ||||
| 		return AuthenticationService.hashApiKey(header.replace(/^bearer /i, '')); | ||||
| 	} | ||||
|  | ||||
| 	private static hashApiKey(apiKey: string): Hash { | ||||
| 		const hash = createHash('sha512'); | ||||
|  | ||||
| 		hash.update(apiKey); | ||||
|  | ||||
| 		return hash; | ||||
| 	} | ||||
|  | ||||
| 	/** The hash for the user API key, or `undefined` if the server is not configured to use a user API key. */ | ||||
| 	private readonly userApiKeyHash: Buffer | undefined; | ||||
|  | ||||
| 	constructor(config: ConfigService) { | ||||
| 		if (config.userApiKey) { | ||||
| 			this.userApiKeyHash = AuthenticationService.hashApiKey(config.userApiKey).digest(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	getRole(request: NextRequest): Role { | ||||
| 		if (!this.userApiKeyHash) { | ||||
| 			// If there is no API key configured, provide a default role | ||||
| 			return Role.User; | ||||
| 		} | ||||
|  | ||||
| 		const authorizationHeader = request.headers.get('authorization'); | ||||
|  | ||||
| 		if (!authorizationHeader) { | ||||
| 			// No authorization header means no role | ||||
| 			return Role.None; | ||||
| 		} | ||||
|  | ||||
| 		const hash = AuthenticationService.bearerToApiKey(authorizationHeader); | ||||
|  | ||||
| 		if (timingSafeEqual(hash.digest(), this.userApiKeyHash)) { | ||||
| 			return Role.User; | ||||
| 		} | ||||
|  | ||||
| 		// If the hashes don't match, throw an error | ||||
| 		throw new IncorrectApiKeyException(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export const authenticationService = new AuthenticationService(configService); | ||||
| @@ -1,10 +0,0 @@ | ||||
| import { Http } from '@jonahsnider/util'; | ||||
| import { BaseHttpException } from '../../exceptions/base.exception'; | ||||
| import { ExceptionCode } from '../../exceptions/enums/exceptions.enum'; | ||||
|  | ||||
| /** An incorrect API key was provided. */ | ||||
| export class IncorrectApiKeyException extends BaseHttpException { | ||||
| 	constructor() { | ||||
| 		super('The provided API key is incorrect', Http.Status.Unauthorized, ExceptionCode.IncorrectApiKey); | ||||
| 	} | ||||
| } | ||||
| @@ -1,41 +0,0 @@ | ||||
| import type { NextRequest } from 'next/server'; | ||||
| import { type AuthenticationService, authenticationService } from '../authentication/authentication.service'; | ||||
| import { Action } from './enums/action.enum'; | ||||
| import { Role } from './enums/role.enum'; | ||||
| import { MissingApiKeyException } from './exceptions/missing-api-key.exception'; | ||||
| import { MissingPermissionsException } from './exceptions/missing-permissions.exception'; | ||||
|  | ||||
| class AuthorizationService { | ||||
| 	private static readonly policies: Readonly<Record<Role, ReadonlySet<Action>>> = { | ||||
| 		[Role.Admin]: new Set([Action.ShortenUrl]), | ||||
| 		[Role.User]: new Set([Action.ShortenUrl]), | ||||
| 		[Role.None]: new Set(), | ||||
| 	}; | ||||
|  | ||||
| 	private static assertPermissions(role: Role, actions: readonly Action[]): void { | ||||
| 		for (const action of actions) { | ||||
| 			if (!AuthorizationService.hasPermission(role, action)) { | ||||
| 				if (role === Role.None) { | ||||
| 					throw new MissingApiKeyException(); | ||||
| 				} | ||||
|  | ||||
| 				throw new MissingPermissionsException(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private static hasPermission(role: Role, action: Action): boolean { | ||||
| 		return AuthorizationService.policies[role].has(action); | ||||
| 	} | ||||
|  | ||||
| 	// biome-ignore lint/suspicious/noEmptyBlockStatements: This is a class field | ||||
| 	constructor(private readonly authenticationService: AuthenticationService) {} | ||||
|  | ||||
| 	assertPermissions(request: NextRequest, ...actions: readonly Action[]): void { | ||||
| 		const role = this.authenticationService.getRole(request); | ||||
|  | ||||
| 		AuthorizationService.assertPermissions(role, actions); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export const authorizationService = new AuthorizationService(authenticationService); | ||||
| @@ -1,3 +0,0 @@ | ||||
| export enum Action { | ||||
| 	ShortenUrl = 'urls:shorten', | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| export enum Role { | ||||
| 	/** An admin of this instance. */ | ||||
| 	Admin = 'admin', | ||||
| 	/** An authenticated user. */ | ||||
| 	User = 'user', | ||||
| 	/** An unauthenticated request. */ | ||||
| 	None = 'none', | ||||
| } | ||||
| @@ -1,10 +0,0 @@ | ||||
| import { Http } from '@jonahsnider/util'; | ||||
| import { BaseHttpException } from '../../exceptions/base.exception'; | ||||
| import { ExceptionCode } from '../../exceptions/enums/exceptions.enum'; | ||||
|  | ||||
| /** An API key was required, but not provided. */ | ||||
| export class MissingApiKeyException extends BaseHttpException { | ||||
| 	constructor() { | ||||
| 		super('You must provide an API key to access this route', Http.Status.Unauthorized, ExceptionCode.MissingApiKey); | ||||
| 	} | ||||
| } | ||||
| @@ -1,14 +0,0 @@ | ||||
| import { Http } from '@jonahsnider/util'; | ||||
| import { BaseHttpException } from '../../exceptions/base.exception'; | ||||
| import { ExceptionCode } from '../../exceptions/enums/exceptions.enum'; | ||||
|  | ||||
| /** The provided API key doesn't have the correct permissions. */ | ||||
| export class MissingPermissionsException extends BaseHttpException { | ||||
| 	constructor() { | ||||
| 		super( | ||||
| 			'Your API key is recognized, but does not have the permissions required to access this route', | ||||
| 			Http.Status.Forbidden, | ||||
| 			ExceptionCode.MissingPermissions, | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
| @@ -1,126 +0,0 @@ | ||||
| import type { JsonValue } from 'type-fest'; | ||||
| import { z } from 'zod'; | ||||
| import pkg from '../../../../package.json'; | ||||
|  | ||||
| const DEFAULT_SHORT_CHARS: readonly string[] = [ | ||||
| 	'\u200C', | ||||
| 	'\u200D', | ||||
| 	'\uDB40\uDC61', | ||||
| 	'\uDB40\uDC62', | ||||
| 	'\uDB40\uDC63', | ||||
| 	'\uDB40\uDC64', | ||||
| 	'\uDB40\uDC65', | ||||
| 	'\uDB40\uDC66', | ||||
| 	'\uDB40\uDC67', | ||||
| 	'\uDB40\uDC68', | ||||
| 	'\uDB40\uDC69', | ||||
| 	'\uDB40\uDC6A', | ||||
| 	'\uDB40\uDC6B', | ||||
| 	'\uDB40\uDC6C', | ||||
| 	'\uDB40\uDC6D', | ||||
| 	'\uDB40\uDC6E', | ||||
| 	'\uDB40\uDC6F', | ||||
| 	'\uDB40\uDC70', | ||||
| 	'\uDB40\uDC71', | ||||
| 	'\uDB40\uDC72', | ||||
| 	'\uDB40\uDC73', | ||||
| 	'\uDB40\uDC74', | ||||
| 	'\uDB40\uDC75', | ||||
| 	'\uDB40\uDC76', | ||||
| 	'\uDB40\uDC77', | ||||
| 	'\uDB40\uDC78', | ||||
| 	'\uDB40\uDC79', | ||||
| 	'\uDB40\uDC7A', | ||||
| 	'\uDB40\uDC7F', | ||||
| ]; | ||||
|  | ||||
| /** The maximum number of short URLs that can be generated. */ | ||||
| const MAX_SHORT_URLS = 1e9; | ||||
|  | ||||
| export class ConfigService { | ||||
| 	public readonly characters: readonly string[]; | ||||
| 	public readonly shortenedLength: number; | ||||
| 	public readonly shortCharRewrites: Readonly<Record<string, string>>; | ||||
| 	public readonly shortenedBaseUrl: string | undefined; | ||||
| 	public readonly blockedHostnames: ReadonlySet<string>; | ||||
| 	/** | ||||
| 	 * The API key for regular users. | ||||
| 	 * In the future an admin API key may also be configured, which is why there is a distinction. | ||||
| 	 */ | ||||
| 	public readonly userApiKey: string | undefined; | ||||
| 	public readonly version: string = pkg.version; | ||||
| 	public readonly nodeEnv; | ||||
| 	public readonly mongodb: Readonly<{ | ||||
| 		uri: string; | ||||
| 		database: string; | ||||
| 	}>; | ||||
|  | ||||
| 	constructor(source: Readonly<NodeJS.ProcessEnv>) { | ||||
| 		this.characters = z | ||||
| 			.array(z.string().min(1)) | ||||
| 			.min(1) | ||||
| 			.default([...DEFAULT_SHORT_CHARS]) | ||||
| 			.parse( | ||||
| 				z | ||||
| 					.string() | ||||
| 					.optional() | ||||
| 					.transform((characters) => (characters === undefined ? undefined : (JSON.parse(characters) as JsonValue))) | ||||
| 					.parse(source.SHORT_CHARS), | ||||
| 			); | ||||
|  | ||||
| 		this.shortenedLength = z | ||||
| 			.number() | ||||
| 			.int() | ||||
| 			.positive() | ||||
| 			.default(() => { | ||||
| 				let shortenedLength = 1; | ||||
| 				while (this.characters.length ** shortenedLength < MAX_SHORT_URLS) { | ||||
| 					shortenedLength++; | ||||
| 				} | ||||
| 				return shortenedLength; | ||||
| 			}) | ||||
| 			.parse( | ||||
| 				z | ||||
| 					.string() | ||||
| 					.transform((raw) => (raw === undefined ? undefined : Number(raw))) | ||||
| 					.parse(source.SHORT_LENGTH), | ||||
| 			); | ||||
|  | ||||
| 		this.shortCharRewrites = z | ||||
| 			.object({}) | ||||
| 			.catchall(z.string().min(1)) | ||||
| 			.parse( | ||||
| 				z | ||||
| 					.string() | ||||
| 					.optional() | ||||
| 					.transform((rewrites) => (rewrites === undefined ? {} : (JSON.parse(rewrites) as JsonValue))) | ||||
| 					.parse(source.SHORT_REWRITES), | ||||
| 			); | ||||
|  | ||||
| 		this.shortenedBaseUrl = z.string().optional().parse(source.SHORTENED_BASE_URL); | ||||
|  | ||||
| 		this.blockedHostnames = new Set( | ||||
| 			z | ||||
| 				.array(z.string().min(1)) | ||||
| 				.default([]) | ||||
| 				.parse( | ||||
| 					z | ||||
| 						.string() | ||||
| 						.optional() | ||||
| 						.transform((hostnames) => (hostnames === undefined ? [] : (JSON.parse(hostnames) as JsonValue))) | ||||
| 						.parse(source.BLOCKED_HOSTNAMES), | ||||
| 				), | ||||
| 		); | ||||
|  | ||||
| 		this.userApiKey = z.string().min(1).optional().parse(source.API_KEY); | ||||
|  | ||||
| 		this.nodeEnv = source.NODE_ENV; | ||||
|  | ||||
| 		this.mongodb = { | ||||
| 			uri: z.string().min(1).parse(source.MONGODB_URI), | ||||
| 			database: z.string().min(1).parse(source.MONGODB_DATABASE), | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export const configService = new ConfigService(process.env); | ||||
| @@ -1,8 +0,0 @@ | ||||
| import { ExceptionWrapper } from 'next-api-utils'; | ||||
| import { BaseHttpException } from './exceptions/base.exception'; | ||||
|  | ||||
| function isException(maybeException: unknown): maybeException is BaseHttpException { | ||||
| 	return maybeException instanceof BaseHttpException; | ||||
| } | ||||
|  | ||||
| export const exceptionRouteWrapper = new ExceptionWrapper(isException); | ||||
| @@ -1,31 +0,0 @@ | ||||
| import { STATUS_CODES } from 'node:http'; | ||||
| import { TO_RESPONSE } from 'next-api-utils'; | ||||
| import { NextResponse } from 'next/server'; | ||||
| import type { ExceptionSchema } from './dtos/exception.dto'; | ||||
| import type { ExceptionCode } from './enums/exceptions.enum'; | ||||
|  | ||||
| export class BaseHttpException extends Error { | ||||
| 	readonly error: string; | ||||
| 	readonly code: ExceptionCode | undefined; | ||||
| 	readonly statusCode: number; | ||||
|  | ||||
| 	constructor(message: string, statusCode: number, code: ExceptionCode | undefined) { | ||||
| 		super(message); | ||||
|  | ||||
| 		this.code = code; | ||||
| 		this.statusCode = statusCode; | ||||
| 		this.error = STATUS_CODES[statusCode] ?? BaseHttpException.name; | ||||
| 	} | ||||
|  | ||||
| 	[TO_RESPONSE](): NextResponse<ExceptionSchema> { | ||||
| 		return NextResponse.json( | ||||
| 			{ | ||||
| 				statusCode: this.statusCode, | ||||
| 				error: this.error, | ||||
| 				code: this.code, | ||||
| 				message: this.message, | ||||
| 			}, | ||||
| 			{ status: this.statusCode }, | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
| @@ -1,17 +0,0 @@ | ||||
| import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; | ||||
| import { ValidationExceptionSchema } from 'next-api-utils/client'; | ||||
| import { z } from 'zod'; | ||||
| import { ExceptionCode } from '../enums/exceptions.enum'; | ||||
|  | ||||
| extendZodWithOpenApi(z); | ||||
|  | ||||
| export const ExceptionSchema = z | ||||
| 	.object({ | ||||
| 		message: z.string(), | ||||
| 		code: z.nativeEnum(ExceptionCode).optional(), | ||||
| 		statusCode: z.number(), | ||||
| 		error: z.string(), | ||||
| 	}) | ||||
| 	.or(ValidationExceptionSchema) | ||||
| 	.openapi('Exception'); | ||||
| export type ExceptionSchema = z.infer<typeof ExceptionSchema>; | ||||
| @@ -1,11 +0,0 @@ | ||||
| export enum ExceptionCode { | ||||
| 	UrlNotFound = 'E_URL_NOT_FOUND', | ||||
| 	UrlBlocked = 'E_URL_BLOCKED', | ||||
|  | ||||
| 	UniqueShortIdTimeout = 'E_UNIQUE_SHORT_ID_TIMEOUT', | ||||
| 	ShortenBlockedHostname = 'E_SHORTEN_BLOCKED_HOSTNAME', | ||||
|  | ||||
| 	MissingPermissions = 'E_MISSING_PERMISSIONS', | ||||
| 	MissingApiKey = 'E_MISSING_API_KEY', | ||||
| 	IncorrectApiKey = 'E_INCORRECT_API_KEY', | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; | ||||
| import { z } from 'zod'; | ||||
|  | ||||
| extendZodWithOpenApi(z); | ||||
|  | ||||
| export const HealthCheckResultSchema = z | ||||
| 	.object({ | ||||
| 		status: z.literal('ok'), | ||||
| 	}) | ||||
| 	.describe('A health check result') | ||||
| 	.openapi('HealthCheckResult'); | ||||
|  | ||||
| export type HealthCheckResultSchema = z.infer<typeof HealthCheckResultSchema>; | ||||
| @@ -1,9 +0,0 @@ | ||||
| import { schema, types } from 'papr'; | ||||
| import { papr } from '../papr'; | ||||
|  | ||||
| const blockedHostnameSchema = schema({ | ||||
| 	hostname: types.string({ required: true }), | ||||
| 	createdAt: types.date({ required: true }), | ||||
| }); | ||||
|  | ||||
| export const BlockedHostnameModel = papr.model('blockedHostnames', blockedHostnameSchema); | ||||
| @@ -1,12 +0,0 @@ | ||||
| import { schema, types } from 'papr'; | ||||
| import { papr } from '../papr'; | ||||
|  | ||||
| const shortenedUrlSchema = schema({ | ||||
| 	shortBase64: types.string({ required: true }), | ||||
| 	url: types.string({ required: true }), | ||||
| 	blocked: types.boolean({ required: true }), | ||||
| 	createdAt: types.date({ required: true }), | ||||
| }); | ||||
|  | ||||
| export type ShortenedUrl = (typeof shortenedUrlSchema)[0]; | ||||
| export const ShortenedUrlModel = papr.model('shortenedUrls', shortenedUrlSchema); | ||||
| @@ -1,9 +0,0 @@ | ||||
| import { schema, types } from 'papr'; | ||||
| import { papr } from '../papr'; | ||||
|  | ||||
| const visitSchema = schema({ | ||||
| 	timestamp: types.date({ required: true }), | ||||
| 	shortenedUrl: types.objectId({ required: true }), | ||||
| }); | ||||
|  | ||||
| export const VisitModel = papr.model('visits', visitSchema); | ||||
| @@ -1,22 +0,0 @@ | ||||
| import { MongoClient } from 'mongodb'; | ||||
| import Papr from 'papr'; | ||||
| import { configService } from '../config/config.service'; | ||||
|  | ||||
| const globalForMongo = globalThis as unknown as { mongo: MongoClient }; | ||||
|  | ||||
| const client = globalForMongo.mongo || new MongoClient(configService.mongodb.uri); | ||||
|  | ||||
| if (!globalForMongo.mongo) { | ||||
| 	client.connect(); | ||||
| } | ||||
|  | ||||
| if (process.env.NODE_ENV !== 'production') { | ||||
| 	globalForMongo.mongo = client; | ||||
| } | ||||
|  | ||||
| const db = client.db(configService.mongodb.database); | ||||
|  | ||||
| export const papr = new Papr(); | ||||
| papr.initialize(db); | ||||
|  | ||||
| papr.updateSchemas(); | ||||
| @@ -1,62 +0,0 @@ | ||||
| import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi'; | ||||
| import type { oas31 } from 'openapi3-ts'; | ||||
| import * as allRoutes from '../../all-openapi'; | ||||
| import { openapi } from '../../stats/shields/version/openapi'; | ||||
| import { type ConfigService, configService } from '../config/config.service'; | ||||
| import { OpenapiTag } from './enums/openapi-tag.enum'; | ||||
|  | ||||
| type RegisterOpenapiFn = (oas: OpenapiService) => void; | ||||
|  | ||||
| export class OpenapiService { | ||||
| 	/** | ||||
| 	 * OpenAPI information is lazily loaded like this to minimize cold start time. | ||||
| 	 */ | ||||
| 	private static loadOpenapi(): RegisterOpenapiFn[] { | ||||
| 		return Object.values(allRoutes) | ||||
| 			.filter((route): route is Extract<typeof route, { openapi: RegisterOpenapiFn }> => 'openapi' in route) | ||||
| 			.map((route) => route.openapi); | ||||
| 	} | ||||
|  | ||||
| 	private readonly registry = new OpenAPIRegistry(); | ||||
| 	private readonly generator: OpenApiGeneratorV31; | ||||
|  | ||||
| 	constructor(private readonly configService: ConfigService) { | ||||
| 		openapi(this); | ||||
|  | ||||
| 		for (const route of OpenapiService.loadOpenapi()) { | ||||
| 			route(this); | ||||
| 		} | ||||
|  | ||||
| 		this.generator = new OpenApiGeneratorV31(this.registry.definitions); | ||||
| 	} | ||||
|  | ||||
| 	getOpenapi(): oas31.OpenAPIObject { | ||||
| 		return this.generator.generateDocument({ | ||||
| 			openapi: '3.1.0', | ||||
| 			info: { | ||||
| 				title: 'Zero Width Shortener', | ||||
| 				description: 'A URL shortener that uses zero width characters to shorten URLs.', | ||||
| 				version: '2.0.0', | ||||
| 				contact: { | ||||
| 					email: 'jonah@jonahsnider.com', | ||||
| 				}, | ||||
| 				license: { | ||||
| 					name: 'Apache 2.0', | ||||
| 					url: 'https://www.apache.org/licenses/LICENSE-2.0.html', | ||||
| 				}, | ||||
| 			}, | ||||
| 			servers: [ | ||||
| 				{ | ||||
| 					url: new URL('api', this.configService.shortenedBaseUrl ?? 'https://zws.im').toString(), | ||||
| 				}, | ||||
| 			], | ||||
| 			tags: Object.values(OpenapiTag).map((tag) => ({ name: tag })), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	addPath(...parameters: Parameters<OpenAPIRegistry['registerPath']>): void { | ||||
| 		this.registry.registerPath(...parameters); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export const openapiService = new OpenapiService(configService); | ||||
| @@ -1,12 +0,0 @@ | ||||
| import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; | ||||
| import { z } from 'zod'; | ||||
|  | ||||
| extendZodWithOpenApi(z); | ||||
|  | ||||
| export const StatsSchema = z | ||||
| 	.object({ | ||||
| 		urls: z.number().int().nonnegative(), | ||||
| 		visits: z.number().int().nonnegative(), | ||||
| 	}) | ||||
| 	.openapi('Stats'); | ||||
| export type StatsSchema = z.infer<typeof StatsSchema>; | ||||
| @@ -1,17 +0,0 @@ | ||||
| import { ShortenedUrlModel } from '../mongodb/models/shortened-url.model'; | ||||
| import { VisitModel } from '../mongodb/models/visit.model'; | ||||
|  | ||||
| import type { StatsSchema } from './dtos/stats.dto'; | ||||
|  | ||||
| export class StatsService { | ||||
| 	async getInstanceStats(): Promise<StatsSchema> { | ||||
| 		const [urls, visits] = await Promise.all([ | ||||
| 			ShortenedUrlModel.collection.estimatedDocumentCount(), | ||||
| 			VisitModel.collection.estimatedDocumentCount(), | ||||
| 		]); | ||||
|  | ||||
| 		return { urls, visits }; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export const statsService = new StatsService(); | ||||
| @@ -1,13 +0,0 @@ | ||||
| import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; | ||||
| import { z } from 'zod'; | ||||
| import { LongUrlSchema } from '../../urls/dtos/long-url.dto'; | ||||
|  | ||||
| extendZodWithOpenApi(z); | ||||
|  | ||||
| export const UrlStatsSchema = z | ||||
| 	.object({ | ||||
| 		url: LongUrlSchema.shape.url, | ||||
| 		visits: z.array(z.string().datetime()), | ||||
| 	}) | ||||
| 	.openapi('UrlStats'); | ||||
| export type UrlStatsSchema = z.infer<typeof UrlStatsSchema>; | ||||
| @@ -1,63 +0,0 @@ | ||||
| import assert from 'node:assert/strict'; | ||||
| import { type BlockedHostnamesService, blockedHostnamesService } from '../blocked-hostnames/blocked-hostnames.service'; | ||||
| import { ShortenedUrlModel } from '../mongodb/models/shortened-url.model'; | ||||
| import { VisitModel } from '../mongodb/models/visit.model'; | ||||
| import { UrlBlockedException } from '../urls/exceptions/url-blocked.exception'; | ||||
| import type { Short } from '../urls/interfaces/urls.interface'; | ||||
| import { UrlsService } from '../urls/urls.service'; | ||||
| import type { UrlStatsSchema } from './dtos/url-stats.dto'; | ||||
|  | ||||
| class UrlStatsService { | ||||
| 	// biome-ignore lint/suspicious/noEmptyBlockStatements: This is a class field | ||||
| 	constructor(private readonly blockedHostnamesService: BlockedHostnamesService) {} | ||||
|  | ||||
| 	/** | ||||
| 	 * Retrieve usage statistics for a shortened URL. | ||||
| 	 * | ||||
| 	 * @param id - The ID of the shortened URL | ||||
| 	 * | ||||
| 	 * @returns Shortened URL information and statistics, or `undefined` if it couldn't be found | ||||
| 	 */ | ||||
| 	async statsForUrl(id: Short): Promise<UrlStatsSchema | undefined> { | ||||
| 		const encodedId = UrlsService.encode(id); | ||||
|  | ||||
| 		const shortenedUrl = await ShortenedUrlModel.findOne( | ||||
| 			{ shortBase64: encodedId }, | ||||
| 			{ projection: { url: 1, blocked: 1, _id: 1 } }, | ||||
| 		); | ||||
|  | ||||
| 		if (!shortenedUrl) { | ||||
| 			return undefined; | ||||
| 		} | ||||
|  | ||||
| 		if (await this.blockedHostnamesService.isUrlBlocked(shortenedUrl)) { | ||||
| 			throw new UrlBlockedException(); | ||||
| 		} | ||||
|  | ||||
| 		const visits = await VisitModel.find({ shortenedUrl: shortenedUrl._id }, { projection: { timestamp: 1 } }); | ||||
|  | ||||
| 		return { | ||||
| 			visits: visits.map((visit) => visit.timestamp.toISOString()), | ||||
| 			url: shortenedUrl.url, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Tracks a URL visit. | ||||
| 	 * @param id - The ID of the shortened URL | ||||
| 	 */ | ||||
| 	async trackUrlVisit(id: Short): Promise<void> { | ||||
| 		const encodedId = UrlsService.encode(id); | ||||
|  | ||||
| 		const shortenedUrl = await ShortenedUrlModel.findOne({ shortBase64: encodedId }, { projection: { _id: 1 } }); | ||||
|  | ||||
| 		assert(shortenedUrl); | ||||
|  | ||||
| 		await VisitModel.insertOne({ | ||||
| 			timestamp: new Date(), | ||||
| 			shortenedUrl: shortenedUrl._id, | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export const urlStatsService = new UrlStatsService(blockedHostnamesService); | ||||
| @@ -1,7 +0,0 @@ | ||||
| import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; | ||||
| import { z } from 'zod'; | ||||
|  | ||||
| extendZodWithOpenApi(z); | ||||
|  | ||||
| export const LongUrlSchema = z.object({ url: z.string().url().max(500) }).openapi('LongUrl'); | ||||
| export type LongUrlSchema = z.infer<typeof LongUrlSchema>; | ||||
| @@ -1,9 +0,0 @@ | ||||
| import { multiReplace } from '@jonahsnider/util'; | ||||
| import { z } from 'zod'; | ||||
| import { configService } from '../../config/config.service'; | ||||
| import type { Short } from '../interfaces/urls.interface'; | ||||
|  | ||||
| export const ShortSchema = z.string().transform((raw) => { | ||||
| 	return multiReplace(raw, configService.shortCharRewrites) as Short; | ||||
| }); | ||||
| export type ShortSchema = z.infer<typeof ShortSchema>; | ||||
| @@ -1,13 +0,0 @@ | ||||
| import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; | ||||
| import { z } from 'zod'; | ||||
| import { ShortSchema } from './short.dto'; | ||||
|  | ||||
| extendZodWithOpenApi(z); | ||||
|  | ||||
| export const ShortenedUrlSchema = z | ||||
| 	.object({ | ||||
| 		short: ShortSchema, | ||||
| 		url: z.string().url().optional(), | ||||
| 	}) | ||||
| 	.openapi('ShortenedUrl'); | ||||
| export type ShortenedUrlSchema = z.infer<typeof ShortenedUrlSchema>; | ||||
| @@ -1,14 +0,0 @@ | ||||
| import { Http } from '@jonahsnider/util'; | ||||
| import { BaseHttpException } from '../../exceptions/base.exception'; | ||||
| import { ExceptionCode } from '../../exceptions/enums/exceptions.enum'; | ||||
|  | ||||
| /** You tried to shorten a blocked hostname. */ | ||||
| export class AttemptedShortenBlockedHostnameException extends BaseHttpException { | ||||
| 	constructor() { | ||||
| 		super( | ||||
| 			'Shortening that hostname is forbidden', | ||||
| 			Http.Status.UnprocessableEntity, | ||||
| 			ExceptionCode.ShortenBlockedHostname, | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
| @@ -1,14 +0,0 @@ | ||||
| import { Http } from '@jonahsnider/util'; | ||||
| import { BaseHttpException } from '../../exceptions/base.exception'; | ||||
| import { ExceptionCode } from '../../exceptions/enums/exceptions.enum'; | ||||
|  | ||||
| /** The maximum number of attempts to generate a unique short ID were exceeded. */ | ||||
| export class UniqueShortIdTimeoutException extends BaseHttpException { | ||||
| 	constructor(attempts: number) { | ||||
| 		super( | ||||
| 			`Couldn't generate a unique shortened ID in ${attempts} attempts`, | ||||
| 			Http.Status.ServiceUnavailable, | ||||
| 			ExceptionCode.UniqueShortIdTimeout, | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
| @@ -1,10 +0,0 @@ | ||||
| import { Http } from '@jonahsnider/util'; | ||||
| import { BaseHttpException } from '../../exceptions/base.exception'; | ||||
| import { ExceptionCode } from '../../exceptions/enums/exceptions.enum'; | ||||
|  | ||||
| /** That URL has been blocked and can't be visited. */ | ||||
| export class UrlBlockedException extends BaseHttpException { | ||||
| 	constructor() { | ||||
| 		super("That URL has been blocked and can't be visited", Http.Status.Gone, ExceptionCode.UrlBlocked); | ||||
| 	} | ||||
| } | ||||
| @@ -1,10 +0,0 @@ | ||||
| import { Http } from '@jonahsnider/util'; | ||||
| import { BaseHttpException } from '../../exceptions/base.exception'; | ||||
| import { ExceptionCode } from '../../exceptions/enums/exceptions.enum'; | ||||
|  | ||||
| /** Shortened URL not found in database. */ | ||||
| export class UrlNotFoundException extends BaseHttpException { | ||||
| 	constructor() { | ||||
| 		super('Shortened URL not found in database', Http.Status.NotFound, ExceptionCode.UrlNotFound); | ||||
| 	} | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| import type { Short } from './urls.interface'; | ||||
|  | ||||
| export type ShortenedUrlData = { | ||||
| 	short: Short; | ||||
| 	url: URL | undefined; | ||||
| }; | ||||
| @@ -1,5 +0,0 @@ | ||||
| export * from './[short]/all-openapi'; | ||||
| export * as api from './openapi'; | ||||
| export * from './stats/all-openapi'; | ||||
| export * from './openapi.json/all-openapi'; | ||||
| export * from './health/all-openapi'; | ||||
| @@ -1 +0,0 @@ | ||||
| export * as apiHealth from './openapi'; | ||||
| @@ -1,22 +0,0 @@ | ||||
| import { HealthCheckResultSchema } from '../_lib/health/dtos/health.dto'; | ||||
| import { OpenapiTag } from '../_lib/openapi/enums/openapi-tag.enum'; | ||||
| import type { OpenapiService } from '../_lib/openapi/openapi.service'; | ||||
|  | ||||
| export function openapi(oas: OpenapiService): void { | ||||
| 	oas.addPath({ | ||||
| 		method: 'get', | ||||
| 		path: '/health', | ||||
| 		tags: [OpenapiTag.Health], | ||||
| 		summary: 'Get the health of this ZWS instance', | ||||
| 		responses: { | ||||
| 			200: { | ||||
| 				description: 'Get the health of this ZWS instance', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: HealthCheckResultSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
| @@ -1,7 +0,0 @@ | ||||
| import { NextResponse } from 'next/server'; | ||||
| import { exceptionRouteWrapper } from '../_lib/exception-route-wrapper'; | ||||
| import type { HealthCheckResultSchema } from '../_lib/health/dtos/health.dto'; | ||||
|  | ||||
| export const GET = exceptionRouteWrapper.wrapRoute<HealthCheckResultSchema>(() => { | ||||
| 	return NextResponse.json({ status: 'ok' }); | ||||
| }); | ||||
| @@ -1 +0,0 @@ | ||||
| export * as apiOpenapijson from './route'; | ||||
| @@ -1,8 +0,0 @@ | ||||
| import { NextResponse } from 'next/server'; | ||||
| import type { oas31 } from 'openapi3-ts'; | ||||
| import { exceptionRouteWrapper } from '../_lib/exception-route-wrapper'; | ||||
| import { openapiService } from '../_lib/openapi/openapi.service'; | ||||
|  | ||||
| export const GET = exceptionRouteWrapper.wrapRoute<oas31.OpenAPIObject>(() => { | ||||
| 	return NextResponse.json(openapiService.getOpenapi()); | ||||
| }); | ||||
| @@ -1,50 +0,0 @@ | ||||
| import type { SchemaObject } from 'openapi3-ts/oas31'; | ||||
| import zodToJsonSchema from 'zod-to-json-schema'; | ||||
| import { ExceptionSchema } from './_lib/exceptions/dtos/exception.dto'; | ||||
| import { OpenapiTag } from './_lib/openapi/enums/openapi-tag.enum'; | ||||
| import type { OpenapiService } from './_lib/openapi/openapi.service'; | ||||
| import { LongUrlSchema } from './_lib/urls/dtos/long-url.dto'; | ||||
| import { ShortenedUrlSchema } from './_lib/urls/dtos/shortened-url.dto'; | ||||
|  | ||||
| export function openapi(oas: OpenapiService): void { | ||||
| 	oas.addPath({ | ||||
| 		method: 'post', | ||||
| 		path: '/', | ||||
| 		tags: [OpenapiTag.ShortenedUrls], | ||||
| 		summary: 'Shorten a URL', | ||||
| 		requestBody: { | ||||
| 			required: true, | ||||
| 			content: { | ||||
| 				'application/json': { | ||||
| 					schema: zodToJsonSchema(LongUrlSchema) as SchemaObject, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		responses: { | ||||
| 			201: { | ||||
| 				description: 'The URL was shortened', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ShortenedUrlSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			422: { | ||||
| 				description: "That URL can't be shortened because it's blocked", | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ExceptionSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			503: { | ||||
| 				description: 'The maximum number of attempts to generate a unique short ID were exceeded', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ExceptionSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
| @@ -1,29 +0,0 @@ | ||||
| import { Http } from '@jonahsnider/util'; | ||||
| import { validateBody } from 'next-api-utils'; | ||||
| import { NextResponse } from 'next/server'; | ||||
| import { authorizationService } from './_lib/authorization/authorization.service'; | ||||
| import { Action } from './_lib/authorization/enums/action.enum'; | ||||
| import { exceptionRouteWrapper } from './_lib/exception-route-wrapper'; | ||||
| import { LongUrlSchema } from './_lib/urls/dtos/long-url.dto'; | ||||
| import type { ShortenedUrlSchema } from './_lib/urls/dtos/shortened-url.dto'; | ||||
| import type { ShortenedUrlData } from './_lib/urls/interfaces/shortened-url.interface'; | ||||
| import { urlsService } from './_lib/urls/urls.service'; | ||||
|  | ||||
| function shortIdToShortenedUrlDto(url: ShortenedUrlData): ShortenedUrlSchema { | ||||
| 	return { | ||||
| 		short: url.short, | ||||
| 		url: url.url ? decodeURI(url.url.toString()) : undefined, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| export const POST = exceptionRouteWrapper.wrapRoute<ShortenedUrlSchema>(async (request) => { | ||||
| 	authorizationService.assertPermissions(request, Action.ShortenUrl); | ||||
|  | ||||
| 	const longUrl = await validateBody(request, LongUrlSchema); | ||||
|  | ||||
| 	const url = await urlsService.shortenUrl(longUrl.url); | ||||
|  | ||||
| 	return NextResponse.json(shortIdToShortenedUrlDto(url), { | ||||
| 		status: Http.Status.Created, | ||||
| 	}); | ||||
| }); | ||||
| @@ -1,2 +0,0 @@ | ||||
| export * as apiStats from './openapi'; | ||||
| export * from './shields/all-openapi'; | ||||
| @@ -1,22 +0,0 @@ | ||||
| import { StatsSchema } from '@/app/api/_lib/stats/dtos/stats.dto'; | ||||
| import { OpenapiTag } from '../_lib/openapi/enums/openapi-tag.enum'; | ||||
| import type { OpenapiService } from '../_lib/openapi/openapi.service'; | ||||
|  | ||||
| export function openapi(oas: OpenapiService): void { | ||||
| 	oas.addPath({ | ||||
| 		method: 'get', | ||||
| 		path: '/stats', | ||||
| 		tags: [OpenapiTag.InstanceStats], | ||||
| 		summary: 'Get stats about the API', | ||||
| 		responses: { | ||||
| 			200: { | ||||
| 				description: 'Get stats about the API', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: StatsSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
| @@ -1,10 +0,0 @@ | ||||
| import type { StatsSchema } from '@/app/api/_lib/stats/dtos/stats.dto'; | ||||
| import { statsService } from '@/app/api/_lib/stats/stats.service'; | ||||
| import { NextResponse } from 'next/server'; | ||||
| import { exceptionRouteWrapper } from '../_lib/exception-route-wrapper'; | ||||
|  | ||||
| export const GET = exceptionRouteWrapper.wrapRoute<StatsSchema>(async () => { | ||||
| 	const stats = await statsService.getInstanceStats(); | ||||
|  | ||||
| 	return NextResponse.json(stats); | ||||
| }); | ||||
| @@ -1,3 +0,0 @@ | ||||
| export * as apiStatsShieldsUrls from './urls/openapi'; | ||||
| export * as apiStatsShieldsVersion from './version/openapi'; | ||||
| export * as apiStatsShieldsVisits from './visits/openapi'; | ||||
| @@ -1,22 +0,0 @@ | ||||
| import { OpenapiTag } from '@/app/api/_lib/openapi/enums/openapi-tag.enum'; | ||||
| import type { OpenapiService } from '@/app/api/_lib/openapi/openapi.service'; | ||||
| import { ShieldsResponseSchema } from '@/app/api/_lib/shields-badges/dtos/shields-response.dto'; | ||||
|  | ||||
| export function openapi(oas: OpenapiService): void { | ||||
| 	oas.addPath({ | ||||
| 		method: 'get', | ||||
| 		path: '/stats/shields/urls', | ||||
| 		tags: [OpenapiTag.Badges], | ||||
| 		summary: 'Shields.io badge for the number of shortened URLs', | ||||
| 		responses: { | ||||
| 			200: { | ||||
| 				description: 'Get a JSON body with the number of shortened URLs for use with Shields.io custom badges', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ShieldsResponseSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| import { exceptionRouteWrapper } from '@/app/api/_lib/exception-route-wrapper'; | ||||
| import type { ShieldsResponseSchema } from '@/app/api/_lib/shields-badges/dtos/shields-response.dto'; | ||||
| import { shieldsBadgesService } from '@/app/api/_lib/shields-badges/shields-badges.service'; | ||||
| import { NextResponse } from 'next/server'; | ||||
|  | ||||
| export const GET = exceptionRouteWrapper.wrapRoute<ShieldsResponseSchema>(async () => { | ||||
| 	return NextResponse.json(await shieldsBadgesService.getUrlStatsBadge()); | ||||
| }); | ||||
| @@ -1,22 +0,0 @@ | ||||
| import { OpenapiTag } from '@/app/api/_lib/openapi/enums/openapi-tag.enum'; | ||||
| import type { OpenapiService } from '@/app/api/_lib/openapi/openapi.service'; | ||||
| import { ShieldsResponseSchema } from '@/app/api/_lib/shields-badges/dtos/shields-response.dto'; | ||||
|  | ||||
| export function openapi(oas: OpenapiService): void { | ||||
| 	oas.addPath({ | ||||
| 		method: 'get', | ||||
| 		path: '/stats/shields/version', | ||||
| 		tags: [OpenapiTag.Badges], | ||||
| 		summary: 'Shields.io badge for the version of this ZWS instance', | ||||
| 		responses: { | ||||
| 			200: { | ||||
| 				description: 'Get a JSON body with the version of this ZWS instance for use with Shields.io custom badges', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ShieldsResponseSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| import { exceptionRouteWrapper } from '@/app/api/_lib/exception-route-wrapper'; | ||||
| import type { ShieldsResponseSchema } from '@/app/api/_lib/shields-badges/dtos/shields-response.dto'; | ||||
| import { shieldsBadgesService } from '@/app/api/_lib/shields-badges/shields-badges.service'; | ||||
| import { NextResponse } from 'next/server'; | ||||
|  | ||||
| export const GET = exceptionRouteWrapper.wrapRoute<ShieldsResponseSchema>(() => { | ||||
| 	return NextResponse.json(shieldsBadgesService.getVersionBadge()); | ||||
| }); | ||||
| @@ -1,22 +0,0 @@ | ||||
| import { OpenapiTag } from '@/app/api/_lib/openapi/enums/openapi-tag.enum'; | ||||
| import type { OpenapiService } from '@/app/api/_lib/openapi/openapi.service'; | ||||
| import { ShieldsResponseSchema } from '@/app/api/_lib/shields-badges/dtos/shields-response.dto'; | ||||
|  | ||||
| export function openapi(oas: OpenapiService): void { | ||||
| 	oas.addPath({ | ||||
| 		method: 'get', | ||||
| 		path: '/stats/shields/visits', | ||||
| 		tags: [OpenapiTag.Badges], | ||||
| 		summary: 'Shields.io badge for the number of shortened URL visits', | ||||
| 		responses: { | ||||
| 			200: { | ||||
| 				description: 'Get a JSON body with the number of shortened URL visits for use with Shields.io custom badges', | ||||
| 				content: { | ||||
| 					'application/json': { | ||||
| 						schema: ShieldsResponseSchema, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| import { exceptionRouteWrapper } from '@/app/api/_lib/exception-route-wrapper'; | ||||
| import type { ShieldsResponseSchema } from '@/app/api/_lib/shields-badges/dtos/shields-response.dto'; | ||||
| import { shieldsBadgesService } from '@/app/api/_lib/shields-badges/shields-badges.service'; | ||||
| import { NextResponse } from 'next/server'; | ||||
|  | ||||
| export const GET = exceptionRouteWrapper.wrapRoute<ShieldsResponseSchema>(async () => { | ||||
| 	return NextResponse.json(await shieldsBadgesService.getVisitsStatsBadge()); | ||||
| }); | ||||
| @@ -1,12 +0,0 @@ | ||||
| import { Suspense } from 'react'; | ||||
| import { StatsTilesActual, StatsTilesFallback } from './stats-tiles'; | ||||
|  | ||||
| export function Stats() { | ||||
| 	return ( | ||||
| 		<div className='grid min-w-max grid-cols-2 gap-6 max-md:w-full'> | ||||
| 			<Suspense fallback={<StatsTilesFallback />}> | ||||
| 				<StatsTilesActual /> | ||||
| 			</Suspense> | ||||
| 		</div> | ||||
| 	); | ||||
| } | ||||
| @@ -1,3 +0,0 @@ | ||||
| export function DividerLine() { | ||||
| 	return <hr className='mb-14 mt-5 w-32 border-2 border-zws-purple-400' />; | ||||
| } | ||||
| @@ -1,47 +0,0 @@ | ||||
| 'use server'; | ||||
|  | ||||
| import { ExceptionSchema } from '@/app/api/_lib/exceptions/dtos/exception.dto'; | ||||
| import { ShortenedUrlSchema } from '@/app/api/_lib/urls/dtos/shortened-url.dto'; | ||||
| import type { Short } from '@/app/api/_lib/urls/interfaces/urls.interface'; | ||||
| import * as route from '@/app/api/route'; | ||||
| import { NextRequest } from 'next/server'; | ||||
|  | ||||
| export async function shortenUrlAction(longUrl: string): Promise< | ||||
| 	| { | ||||
| 			shortened: { url: string } | { short: Short }; | ||||
| 			error: undefined; | ||||
| 	  } | ||||
| 	| { shortened: undefined; error: string } | ||||
| > { | ||||
| 	const request = new NextRequest('http://localhost:3000/api', { | ||||
| 		body: JSON.stringify({ url: longUrl }), | ||||
| 		method: 'POST', | ||||
| 		headers: { | ||||
| 			'Content-Type': 'application/json', | ||||
| 		}, | ||||
| 	}); | ||||
| 	const response = await route.POST(request); | ||||
|  | ||||
| 	if (!response.ok) { | ||||
| 		const parsed = ExceptionSchema.safeParse(await response.json()); | ||||
|  | ||||
| 		if (parsed.success) { | ||||
| 			return { | ||||
| 				shortened: undefined, | ||||
| 				error: parsed.data.message, | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
| 			shortened: undefined, | ||||
| 			error: 'An unknown error occurred', | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	const shortened = ShortenedUrlSchema.parse(await response.json()); | ||||
|  | ||||
| 	return { | ||||
| 		shortened: shortened.url ? { url: shortened.url.toString() } : { short: shortened.short }, | ||||
| 		error: undefined, | ||||
| 	}; | ||||
| } | ||||
| @@ -1,63 +0,0 @@ | ||||
| 'use client'; | ||||
|  | ||||
| import { ExceptionCode } from '@/app/api/_lib/exceptions/enums/exceptions.enum'; | ||||
| import type { UrlStatsSchema } from '@/app/api/_lib/url-stats/dtos/url-stats.dto'; | ||||
| import { usePlausible } from '@/app/hooks/plausible'; | ||||
| import { type HttpError, fetcher } from '@/app/swr'; | ||||
| import va from '@vercel/analytics'; | ||||
| import { _ExceptionCode as ValidationExceptionCode } from 'next-api-utils/client'; | ||||
| import { Suspense, useState } from 'react'; | ||||
| import useSwr from 'swr'; | ||||
| import { UrlStatsChart } from './url-stats-chart'; | ||||
| import { UrlStatsInput } from './url-stats-input'; | ||||
|  | ||||
| function extractShort(url: string): string | undefined { | ||||
| 	try { | ||||
| 		return new URL(url).pathname.slice(1); | ||||
| 	} catch { | ||||
| 		return undefined; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export function UrlStats() { | ||||
| 	const [url, setUrl] = useState(''); | ||||
| 	const short = extractShort(url); | ||||
| 	const plausible = usePlausible(); | ||||
|  | ||||
| 	const { | ||||
| 		data: stats, | ||||
| 		error, | ||||
| 		isLoading, | ||||
| 	} = useSwr<UrlStatsSchema, HttpError>(short ? `/api/${encodeURIComponent(short)}/stats` : undefined, { | ||||
| 		fetcher, | ||||
| 		onSuccess: () => { | ||||
| 			va.track('Check URL stats'); | ||||
| 			plausible('Check URL stats'); | ||||
| 		}, | ||||
| 	}); | ||||
|  | ||||
| 	let errorText = error?.exception?.message ?? error?.message; | ||||
|  | ||||
| 	if (error?.exception?.code === ExceptionCode.UrlNotFound) { | ||||
| 		errorText = 'URL not found'; | ||||
| 	} else if (error?.exception?.code === ValidationExceptionCode.InvalidPathParameters) { | ||||
| 		errorText = 'Invalid URL'; | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div className='w-full space-y-8'> | ||||
| 			<UrlStatsInput error={errorText} isLoading={isLoading && !stats && !errorText} setShortUrl={setUrl} /> | ||||
|  | ||||
| 			<div className='h-48 min-h-max max-lg:h-72 max-md:w-full md:w-[36rem] lg:h-96'> | ||||
| 				<Suspense | ||||
| 					fallback={ | ||||
| 						// Prevent layout shift | ||||
| 						<div className='h-full w-full' /> | ||||
| 					} | ||||
| 				> | ||||
| 					<UrlStatsChart stats={errorText ? undefined : stats} /> | ||||
| 				</Suspense> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	); | ||||
| } | ||||
| @@ -1,7 +0,0 @@ | ||||
| import { configService } from './api/_lib/config/config.service'; | ||||
|  | ||||
| export const siteDescription = 'ZWS is a URL shortener which uses zero width characters to shorten URLs.'; | ||||
|  | ||||
| export const siteName = 'Zero Width Shortener'; | ||||
|  | ||||
| export const metadataBase = configService.shortenedBaseUrl ? new URL(configService.shortenedBaseUrl) : undefined; | ||||
							
								
								
									
										41
									
								
								app/swr.ts
									
									
									
									
									
								
							
							
						
						
									
										41
									
								
								app/swr.ts
									
									
									
									
									
								
							| @@ -1,41 +0,0 @@ | ||||
| import { ExceptionSchema } from './api/_lib/exceptions/dtos/exception.dto'; | ||||
|  | ||||
| export class HttpError extends Error { | ||||
| 	static async create(response: Response): Promise<HttpError> { | ||||
| 		const text = await response.text(); | ||||
| 		let json: Record<string, unknown> | undefined; | ||||
| 		try { | ||||
| 			json = JSON.parse(text); | ||||
| 		} catch { | ||||
| 			// Ignore errors from parsing JSON | ||||
| 		} | ||||
|  | ||||
| 		return new HttpError(json, text, response.status, response.statusText); | ||||
| 	} | ||||
|  | ||||
| 	public readonly exception?: ExceptionSchema; | ||||
|  | ||||
| 	private constructor( | ||||
| 		public readonly json: Record<string, unknown> | undefined, | ||||
| 		public readonly bodyText: string, | ||||
| 		public readonly statusCode: number, | ||||
| 		public readonly statusText: string, | ||||
| 	) { | ||||
| 		super(`An error occurred while fetching the data: ${statusCode} ${statusText} ${JSON.stringify(json)}`); | ||||
| 		const parsed = ExceptionSchema.safeParse(json); | ||||
|  | ||||
| 		if (parsed.success) { | ||||
| 			this.exception = parsed.data; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export const fetcher = async (url: string) => { | ||||
| 	const response = await fetch(url); | ||||
|  | ||||
| 	if (response.ok) { | ||||
| 		return response.json(); | ||||
| 	} | ||||
|  | ||||
| 	throw await HttpError.create(response); | ||||
| }; | ||||
							
								
								
									
										11
									
								
								apps/api/nest-cli.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								apps/api/nest-cli.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| { | ||||
| 	"$schema": "https://json.schemastore.org/nest-cli", | ||||
| 	"collection": "@nestjs/schematics", | ||||
| 	"sourceRoot": "src", | ||||
| 	"generateOptions": { | ||||
| 		"spec": false | ||||
| 	}, | ||||
| 	"compilerOptions": { | ||||
| 		"deleteOutDir": true | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										51
									
								
								apps/api/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								apps/api/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| { | ||||
| 	"name": "@zws.im/api", | ||||
| 	"version": "0.0.1", | ||||
| 	"private": true, | ||||
| 	"description": "", | ||||
| 	"license": "UNLICENSED", | ||||
| 	"author": "", | ||||
| 	"scripts": { | ||||
| 		"dev": "PORT=3001 bun run --env-file ../../.env --watch src/main.ts", | ||||
| 		"start": "bun run src/main.ts", | ||||
| 		"type-check": "tsc" | ||||
| 	}, | ||||
| 	"dependencies": { | ||||
| 		"@anatine/zod-nestjs": "2.0.7", | ||||
| 		"@anatine/zod-openapi": "2.2.5", | ||||
| 		"@bull-board/api": "5.15.1", | ||||
| 		"@bull-board/express": "5.15.1", | ||||
| 		"@jonahsnider/util": "10.3.0", | ||||
| 		"@nestjs/bullmq": "10.1.0", | ||||
| 		"@nestjs/common": "10.3.5", | ||||
| 		"@nestjs/core": "10.3.5", | ||||
| 		"@nestjs/platform-express": "10.3.5", | ||||
| 		"@nestjs/swagger": "7.3.0", | ||||
| 		"@ntegral/nestjs-sentry": "4.0.1", | ||||
| 		"@sentry/bun": "7.107.0", | ||||
| 		"@trpc/server": "next", | ||||
| 		"bullmq": "5.4.2", | ||||
| 		"cors": "2.8.5", | ||||
| 		"devalue": "4.3.2", | ||||
| 		"drizzle-orm": "0.30.2", | ||||
| 		"ioredis": "5.3.2", | ||||
| 		"ky": "1.2.2", | ||||
| 		"next-api-utils": "1.1.0", | ||||
| 		"openapi3-ts": "4.2.2", | ||||
| 		"pg": "8.11.3", | ||||
| 		"reflect-metadata": "0.2.1", | ||||
| 		"rxjs": "7.8.1", | ||||
| 		"superjson": "2.2.1", | ||||
| 		"zod": "3.22.4" | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@nestjs/cli": "^10.0.0", | ||||
| 		"@nestjs/schematics": "^10.0.0", | ||||
| 		"@tsconfig/bun": "1.0.4", | ||||
| 		"@tsconfig/strictest": "2.0.3", | ||||
| 		"@types/cors": "2.8.17", | ||||
| 		"@types/express": "^4.17.17", | ||||
| 		"prettier": "^3.0.0", | ||||
| 		"typescript": "5.4.2" | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										47
									
								
								apps/api/src/app.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								apps/api/src/app.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| import { ZodValidationPipe } from '@anatine/zod-nestjs'; | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { APP_PIPE } from '@nestjs/core'; | ||||
| import { SentryModule } from '@ntegral/nestjs-sentry'; | ||||
| import { BlockedHostnamesModule } from './blocked-hostnames/blocked-hostnames.module'; | ||||
| import { ConfigModule } from './config/config.module'; | ||||
| import { ConfigService } from './config/config.service'; | ||||
| import { DbModule } from './db/db.module'; | ||||
| import { HealthModule } from './health/health.module'; | ||||
| import { OpenapiModule } from './openapi/openapi.module'; | ||||
| import { RedisModule } from './redis/redis.module'; | ||||
| import { ShieldsBadgesModule } from './shields-badges/shields-badges.module'; | ||||
| import { StatsModule } from './stats/stats.module'; | ||||
| import { TrpcModule } from './trpc/trpc.module'; | ||||
| import { UrlStatsModule } from './url-stats/url-stats.module'; | ||||
| import { UrlsModule } from './urls/urls.module'; | ||||
|  | ||||
| @Module({ | ||||
| 	imports: [ | ||||
| 		OpenapiModule, | ||||
| 		ConfigModule, | ||||
| 		SentryModule.forRootAsync({ | ||||
| 			imports: [ConfigModule], | ||||
| 			inject: [ConfigService], | ||||
| 			useFactory: (config: ConfigService) => ({ | ||||
| 				dsn: config.sentryDsn, | ||||
| 				environment: config.nodeEnv, | ||||
| 			}), | ||||
| 		}), | ||||
| 		DbModule, | ||||
| 		RedisModule, | ||||
| 		TrpcModule, | ||||
| 		HealthModule, | ||||
| 		BlockedHostnamesModule, | ||||
| 		StatsModule, | ||||
| 		UrlStatsModule, | ||||
| 		UrlsModule, | ||||
| 		ShieldsBadgesModule, | ||||
| 	], | ||||
| 	providers: [ | ||||
| 		{ | ||||
| 			provide: APP_PIPE, | ||||
| 			useValue: new ZodValidationPipe({ errorHttpStatusCode: 422 }), | ||||
| 		}, | ||||
| 	], | ||||
| }) | ||||
| export class AppModule {} | ||||
							
								
								
									
										42
									
								
								apps/api/src/auth/auth.middleware.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								apps/api/src/auth/auth.middleware.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| import { Inject, Injectable, type NestMiddleware, UnauthorizedException } from '@nestjs/common'; | ||||
| import type { NextFunction, Request, Response } from 'express'; | ||||
| import { AuthService } from './auth.service'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AuthMiddleware implements NestMiddleware { | ||||
| 	constructor(@Inject(AuthService) private readonly authService: AuthService) {} | ||||
|  | ||||
| 	use(request: Request, response: Response, next: NextFunction) { | ||||
| 		response.setHeader('WWW-Authenticate', 'Basic realm="realm", charset="UTF-8"'); | ||||
|  | ||||
| 		const authHeader = request.headers.authorization; | ||||
|  | ||||
| 		if (!authHeader) { | ||||
| 			throw new UnauthorizedException('Missing Authorization header'); | ||||
| 		} | ||||
|  | ||||
| 		const [type, credentials] = authHeader.split(' '); | ||||
|  | ||||
| 		if (type !== 'Basic') { | ||||
| 			throw new UnauthorizedException('Invalid Authorization type'); | ||||
| 		} | ||||
|  | ||||
| 		if (!credentials) { | ||||
| 			throw new UnauthorizedException('Missing credentials'); | ||||
| 		} | ||||
|  | ||||
| 		const [username, password] = Buffer.from(credentials, 'base64').toString().split(':'); | ||||
|  | ||||
| 		if (!username) { | ||||
| 			throw new UnauthorizedException('Missing username'); | ||||
| 		} | ||||
|  | ||||
| 		if (!password) { | ||||
| 			throw new UnauthorizedException('Missing password'); | ||||
| 		} | ||||
|  | ||||
| 		this.authService.assertBasicAuth(username, password); | ||||
|  | ||||
| 		next(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										9
									
								
								apps/api/src/auth/auth.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								apps/api/src/auth/auth.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { AuthMiddleware } from './auth.middleware'; | ||||
| import { AuthService } from './auth.service'; | ||||
|  | ||||
| @Module({ | ||||
| 	providers: [AuthService, AuthMiddleware], | ||||
| 	exports: [AuthService, AuthMiddleware], | ||||
| }) | ||||
| export class AuthModule {} | ||||
							
								
								
									
										13
									
								
								apps/api/src/auth/auth.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								apps/api/src/auth/auth.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; | ||||
| import { ConfigService } from '../config/config.service'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AuthService { | ||||
| 	constructor(@Inject(ConfigService) private readonly configService: ConfigService) {} | ||||
|  | ||||
| 	assertBasicAuth(username: string, password: string): void { | ||||
| 		if (username !== this.configService.adminUsername || password !== this.configService.adminApiToken) { | ||||
| 			throw new ForbiddenException('Invalid username or password'); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { BlockedHostnamesService } from './blocked-hostnames.service'; | ||||
|  | ||||
| @Module({ | ||||
| 	providers: [BlockedHostnamesService], | ||||
| 	exports: [BlockedHostnamesService], | ||||
| }) | ||||
| export class BlockedHostnamesModule {} | ||||
| @@ -1,11 +1,15 @@ | ||||
| import { type VercelKV, kv } from '@vercel/kv'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import convert from 'convert'; | ||||
| import { type ConfigService, configService } from '../config/config.service'; | ||||
| import { BlockedHostnameModel } from '../mongodb/models/blocked-hostname.model'; | ||||
| import type { ShortenedUrl } from '../mongodb/models/shortened-url.model'; | ||||
| import { inArray } from 'drizzle-orm'; | ||||
| import type Ioredis from 'ioredis'; | ||||
| import { Schema } from '../db/index'; | ||||
| import type { Db } from '../db/interfaces/db.interface'; | ||||
| import { DB_PROVIDER } from '../db/providers'; | ||||
| import { REDIS_PROVIDER } from '../redis/providers'; | ||||
| 
 | ||||
| type HostnameDomainNamePair = { hostname: string; domainName: string }; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class BlockedHostnamesService { | ||||
| 	/** The number of seconds to cache private blocked hostnames from the database for. */ | ||||
| 	private static readonly BLOCKED_HOSTNAMES_CACHE_DURATION = convert(30, 'min').to('s'); | ||||
| @@ -15,15 +19,12 @@ export class BlockedHostnamesService { | ||||
| 	/** A regular expression for a domain name. */ | ||||
| 	private static readonly DOMAIN_NAME_REG_EXP = /(?:.+\.)?(.+\..+)$/i; | ||||
| 
 | ||||
| 	private readonly blockedHostnames = new Set(this.configService.blockedHostnames); | ||||
| 
 | ||||
| 	constructor( | ||||
| 		private readonly kv: VercelKV, | ||||
| 		private readonly configService: ConfigService, | ||||
| 		// biome-ignore lint/suspicious/noEmptyBlockStatements: This is a class field
 | ||||
| 		@Inject(REDIS_PROVIDER) private readonly redis: Ioredis, | ||||
| 		@Inject(DB_PROVIDER) private readonly db: Db, | ||||
| 	) {} | ||||
| 
 | ||||
| 	async isUrlBlocked(url: Pick<ShortenedUrl, 'blocked' | 'url'>): Promise<boolean> { | ||||
| 	async isUrlBlocked(url: Pick<(typeof Schema)['urls']['$inferSelect'], 'blocked' | 'url'>): Promise<boolean> { | ||||
| 		return url.blocked || (await this.isHostnameBlocked(new URL(url.url))); | ||||
| 	} | ||||
| 
 | ||||
| @@ -32,10 +33,6 @@ export class BlockedHostnamesService { | ||||
| 		const domainName = hostname.replace(BlockedHostnamesService.DOMAIN_NAME_REG_EXP, '$1'); | ||||
| 		const hostnames = { hostname, domainName }; | ||||
| 
 | ||||
| 		if (this.cacheContainsHostname(hostnames)) { | ||||
| 			return true; | ||||
| 		} | ||||
| 
 | ||||
| 		const redisResult = await this.redisContainsHostnames(hostnames); | ||||
| 
 | ||||
| 		// Check Redis as a fallback
 | ||||
| @@ -58,23 +55,39 @@ export class BlockedHostnamesService { | ||||
| 	} | ||||
| 
 | ||||
| 	private async populateRedisCache(): Promise<void> { | ||||
| 		const hostnamesDocuments = await BlockedHostnameModel.find({}, { projection: { hostname: 1 } }); | ||||
| 		const hostnames = hostnamesDocuments.map((document) => document.hostname); | ||||
| 		const hostnames = ( | ||||
| 			await this.db | ||||
| 				.select({ | ||||
| 					hostname: Schema.blockedHostnames.hostname, | ||||
| 				}) | ||||
| 				.from(Schema.blockedHostnames) | ||||
| 		).map((row) => row.hostname); | ||||
| 
 | ||||
| 		await this.kv.sadd(BlockedHostnamesService.BLOCKED_HOSTNAMES_REDIS_KEY, hostnames); | ||||
| 		await this.kv.expire( | ||||
| 		if (hostnames.length === 0) { | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		await this.redis.sadd(BlockedHostnamesService.BLOCKED_HOSTNAMES_REDIS_KEY, hostnames); | ||||
| 		await this.redis.expire( | ||||
| 			BlockedHostnamesService.BLOCKED_HOSTNAMES_REDIS_KEY, | ||||
| 			BlockedHostnamesService.BLOCKED_HOSTNAMES_CACHE_DURATION, | ||||
| 		); | ||||
| 	} | ||||
| 
 | ||||
| 	private databaseContainsHostnames(hostnames: HostnameDomainNamePair): Promise<boolean> { | ||||
| 		return BlockedHostnameModel.exists({ hostname: { $in: [hostnames.hostname, hostnames.domainName] } }); | ||||
| 	private async databaseContainsHostnames(hostnames: HostnameDomainNamePair): Promise<boolean> { | ||||
| 		const rows = await this.db | ||||
| 			.select({ | ||||
| 				hostname: Schema.blockedHostnames.hostname, | ||||
| 			}) | ||||
| 			.from(Schema.blockedHostnames) | ||||
| 			.where(inArray(Schema.blockedHostnames.hostname, [hostnames.hostname, hostnames.domainName])); | ||||
| 
 | ||||
| 		return rows.length > 0; | ||||
| 	} | ||||
| 
 | ||||
| 	/** @returns Whether the hostnames were blocked in Redis, or `undefined` if they were missing. */ | ||||
| 	private async redisContainsHostnames(hostnames: HostnameDomainNamePair): Promise<boolean | undefined> { | ||||
| 		const result = await this.kv.smismember(BlockedHostnamesService.BLOCKED_HOSTNAMES_REDIS_KEY, [ | ||||
| 		const result = await this.redis.smismember(BlockedHostnamesService.BLOCKED_HOSTNAMES_REDIS_KEY, [ | ||||
| 			hostnames.hostname, | ||||
| 			hostnames.domainName, | ||||
| 		]); | ||||
| @@ -85,7 +98,7 @@ export class BlockedHostnamesService { | ||||
| 			return true; | ||||
| 		} | ||||
| 
 | ||||
| 		const redisCacheExists = await this.kv.exists(BlockedHostnamesService.BLOCKED_HOSTNAMES_REDIS_KEY); | ||||
| 		const redisCacheExists = await this.redis.exists(BlockedHostnamesService.BLOCKED_HOSTNAMES_REDIS_KEY); | ||||
| 
 | ||||
| 		if (redisCacheExists) { | ||||
| 			return false; | ||||
| @@ -93,10 +106,4 @@ export class BlockedHostnamesService { | ||||
| 
 | ||||
| 		return undefined; | ||||
| 	} | ||||
| 
 | ||||
| 	private cacheContainsHostname(hostnames: HostnameDomainNamePair): boolean { | ||||
| 		return this.blockedHostnames.has(hostnames.hostname) || this.blockedHostnames.has(hostnames.domainName); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| export const blockedHostnamesService = new BlockedHostnamesService(kv, configService); | ||||
							
								
								
									
										9
									
								
								apps/api/src/config/config.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								apps/api/src/config/config.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { Global, Module } from '@nestjs/common'; | ||||
| import { ConfigService } from './config.service'; | ||||
|  | ||||
| @Global() | ||||
| @Module({ | ||||
| 	providers: [ConfigService], | ||||
| 	exports: [ConfigService], | ||||
| }) | ||||
| export class ConfigModule {} | ||||
							
								
								
									
										95
									
								
								apps/api/src/config/config.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								apps/api/src/config/config.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { url, cleanEnv, json, num, port, str } from 'envalid'; | ||||
|  | ||||
| type NodeEnv = 'production' | 'development' | 'staging'; | ||||
|  | ||||
| const DEFAULT_SHORT_CHARS: readonly string[] = [ | ||||
| 	'\u200C', | ||||
| 	'\u200D', | ||||
| 	'\uDB40\uDC61', | ||||
| 	'\uDB40\uDC62', | ||||
| 	'\uDB40\uDC63', | ||||
| 	'\uDB40\uDC64', | ||||
| 	'\uDB40\uDC65', | ||||
| 	'\uDB40\uDC66', | ||||
| 	'\uDB40\uDC67', | ||||
| 	'\uDB40\uDC68', | ||||
| 	'\uDB40\uDC69', | ||||
| 	'\uDB40\uDC6A', | ||||
| 	'\uDB40\uDC6B', | ||||
| 	'\uDB40\uDC6C', | ||||
| 	'\uDB40\uDC6D', | ||||
| 	'\uDB40\uDC6E', | ||||
| 	'\uDB40\uDC6F', | ||||
| 	'\uDB40\uDC70', | ||||
| 	'\uDB40\uDC71', | ||||
| 	'\uDB40\uDC72', | ||||
| 	'\uDB40\uDC73', | ||||
| 	'\uDB40\uDC74', | ||||
| 	'\uDB40\uDC75', | ||||
| 	'\uDB40\uDC76', | ||||
| 	'\uDB40\uDC77', | ||||
| 	'\uDB40\uDC78', | ||||
| 	'\uDB40\uDC79', | ||||
| 	'\uDB40\uDC7A', | ||||
| 	'\uDB40\uDC7F', | ||||
| ]; | ||||
|  | ||||
| export const env = cleanEnv(process.env, { | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	NODE_ENV: str({ default: 'production', choices: ['production', 'development', 'staging'] }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	PORT: port({ default: 3000 }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	DATABASE_URL: url({ desc: 'PostgreSQL URL' }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	SENTRY_DSN: url({ desc: 'Sentry DSN' }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	REDIS_URL: url({ desc: 'Redis URL' }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	ADMIN_USERNAME: str({ desc: 'Admin username' }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	ADMIN_API_TOKEN: str({ desc: 'Admin API token' }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	WEBSITE_URL: url({ desc: 'Website URL' }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	SHORT_LENGTH: num({ desc: 'Number of characters to generate in a shortened URL', default: 7 }), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	SHORT_CHARS: json<readonly string[]>({ | ||||
| 		desc: 'Characters to use in shortened URLs', | ||||
| 		default: DEFAULT_SHORT_CHARS, | ||||
| 	}), | ||||
| 	// biome-ignore lint/style/useNamingConvention: This is an environment variable | ||||
| 	SHORT_REWRITES: json<Record<string, string>>({ | ||||
| 		desc: 'A mapping of characters to apply to short IDs before they are used', | ||||
| 		default: {}, | ||||
| 	}), | ||||
| }); | ||||
|  | ||||
| @Injectable() | ||||
| export class ConfigService { | ||||
| 	public readonly nodeEnv: NodeEnv; | ||||
| 	public readonly port: number; | ||||
| 	public readonly databaseUrl: string; | ||||
| 	public readonly sentryDsn: string; | ||||
| 	public readonly redisUrl: string; | ||||
| 	public readonly adminUsername: string; | ||||
| 	public readonly adminApiToken: string; | ||||
| 	public readonly websiteUrl: string; | ||||
| 	public readonly shortenedLength: number; | ||||
| 	public readonly characters: readonly string[]; | ||||
| 	public readonly version = '3.0.0'; | ||||
|  | ||||
| 	constructor() { | ||||
| 		this.nodeEnv = env.NODE_ENV; | ||||
| 		this.port = env.PORT; | ||||
| 		this.databaseUrl = env.DATABASE_URL; | ||||
| 		this.sentryDsn = env.SENTRY_DSN; | ||||
| 		this.adminUsername = env.ADMIN_USERNAME; | ||||
| 		this.adminApiToken = env.ADMIN_API_TOKEN; | ||||
| 		this.websiteUrl = env.WEBSITE_URL; | ||||
| 		this.redisUrl = env.REDIS_URL; | ||||
| 		this.shortenedLength = env.SHORT_LENGTH; | ||||
| 		this.characters = env.SHORT_CHARS; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										27
									
								
								apps/api/src/db/db.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								apps/api/src/db/db.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import { Global, Module } from '@nestjs/common'; | ||||
| import { drizzle } from 'drizzle-orm/node-postgres'; | ||||
| import { Client } from 'pg'; | ||||
| import { ConfigService } from '../config/config.service'; | ||||
| import { Schema } from './index'; | ||||
| import { DB_PROVIDER } from './providers'; | ||||
|  | ||||
| @Global() | ||||
| @Module({ | ||||
| 	providers: [ | ||||
| 		{ | ||||
| 			provide: DB_PROVIDER, | ||||
| 			inject: [ConfigService], | ||||
| 			useFactory: async (configService: ConfigService) => { | ||||
| 				const options = { schema: Schema }; | ||||
|  | ||||
| 				const client = new Client({ connectionString: configService.databaseUrl }); | ||||
| 				await client.connect(); | ||||
| 				const db = drizzle(client, options); | ||||
|  | ||||
| 				return db; | ||||
| 			}, | ||||
| 		}, | ||||
| 	], | ||||
| 	exports: [DB_PROVIDER], | ||||
| }) | ||||
| export class DbModule {} | ||||
							
								
								
									
										1
									
								
								apps/api/src/db/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/api/src/db/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export * as Schema from './schema'; | ||||
							
								
								
									
										4
									
								
								apps/api/src/db/interfaces/db.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								apps/api/src/db/interfaces/db.interface.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; | ||||
| import type { Schema } from '../index'; | ||||
|  | ||||
| export type Db = NodePgDatabase<typeof Schema>; | ||||
							
								
								
									
										1
									
								
								apps/api/src/db/providers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/api/src/db/providers.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export const DB_PROVIDER = Symbol('DB_PROVIDER'); | ||||
							
								
								
									
										35
									
								
								apps/api/src/db/schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								apps/api/src/db/schema.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import { boolean, index, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; | ||||
|  | ||||
| export const blockedHostnames = pgTable('blocked_hostnames', { | ||||
| 	hostname: text('hostname').primaryKey().notNull(), | ||||
| 	createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), | ||||
| }); | ||||
|  | ||||
| export const urls = pgTable( | ||||
| 	'urls', | ||||
| 	{ | ||||
| 		blocked: boolean('blocked').default(false).notNull(), | ||||
| 		createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), | ||||
| 		shortBase64: text('short_base64').notNull().primaryKey(), | ||||
| 		url: text('url').notNull(), | ||||
| 	}, | ||||
| 	(urls) => ({ | ||||
| 		blockedIdx: index().on(urls.blocked), | ||||
| 		urlIdx: index().on(urls.url), | ||||
| 	}), | ||||
| ); | ||||
|  | ||||
| export const visits = pgTable( | ||||
| 	'visits', | ||||
| 	{ | ||||
| 		id: serial('id').primaryKey().notNull(), | ||||
| 		timestamp: timestamp('timestamp', { withTimezone: true }).defaultNow().notNull(), | ||||
| 		urlShortBase64: text('url_short_base64') | ||||
| 			.references(() => urls.shortBase64, { onDelete: 'cascade', onUpdate: 'cascade' }) | ||||
| 			.notNull(), | ||||
| 	}, | ||||
| 	(visits) => ({ | ||||
| 		urlShortBase64Idx: index().on(visits.urlShortBase64), | ||||
| 		timestampIdx: index().on(visits.timestamp), | ||||
| 	}), | ||||
| ); | ||||
							
								
								
									
										12
									
								
								apps/api/src/health/health.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								apps/api/src/health/health.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { Controller, Get } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| import { OpenapiTag } from '../openapi/openapi-tag.enum'; | ||||
|  | ||||
| @Controller('health') | ||||
| @ApiTags(OpenapiTag.Health) | ||||
| export class HealthController { | ||||
| 	@Get('/') | ||||
| 	getHealth(): { status: 'ok' } { | ||||
| 		return { status: 'ok' }; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										7
									
								
								apps/api/src/health/health.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								apps/api/src/health/health.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { HealthController } from './health.controller'; | ||||
|  | ||||
| @Module({ | ||||
| 	controllers: [HealthController], | ||||
| }) | ||||
| export class HealthModule {} | ||||
							
								
								
									
										22
									
								
								apps/api/src/main.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								apps/api/src/main.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| import { NestFactory } from '@nestjs/core'; | ||||
| import { AppModule } from './app.module'; | ||||
| import { ConfigService } from './config/config.service'; | ||||
| import { OpenapiService } from './openapi/openapi.service'; | ||||
| import { TrpcService } from './trpc/trpc.service'; | ||||
|  | ||||
| const app = await NestFactory.create(AppModule, { | ||||
| 	abortOnError: process.env['NODE_ENV'] !== 'development', | ||||
| 	cors: true, | ||||
| }); | ||||
|  | ||||
| const trpcService = app.get(TrpcService); | ||||
|  | ||||
| trpcService.register(app); | ||||
|  | ||||
| const openapiService = app.get(OpenapiService); | ||||
|  | ||||
| openapiService.createSpec(app); | ||||
|  | ||||
| const configService = app.get(ConfigService); | ||||
|  | ||||
| await app.listen(configService.port); | ||||
							
								
								
									
										19
									
								
								apps/api/src/openapi/openapi.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								apps/api/src/openapi/openapi.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { Controller, Get, Inject, NotFoundException } from '@nestjs/common'; | ||||
| import type { OpenAPIObject } from '@nestjs/swagger'; | ||||
| import { OpenapiService } from './openapi.service'; | ||||
|  | ||||
| @Controller('/openapi.json') | ||||
| export class OpenapiController { | ||||
| 	constructor(@Inject(OpenapiService) private readonly openapiService: OpenapiService) {} | ||||
|  | ||||
| 	@Get('/') | ||||
| 	getSpec(): OpenAPIObject { | ||||
| 		const spec = this.openapiService.getSpec(); | ||||
|  | ||||
| 		if (!spec) { | ||||
| 			throw new NotFoundException('OpenAPI spec not found'); | ||||
| 		} | ||||
|  | ||||
| 		return spec; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										9
									
								
								apps/api/src/openapi/openapi.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								apps/api/src/openapi/openapi.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { OpenapiController } from './openapi.controller'; | ||||
| import { OpenapiService } from './openapi.service'; | ||||
|  | ||||
| @Module({ | ||||
| 	providers: [OpenapiService], | ||||
| 	controllers: [OpenapiController], | ||||
| }) | ||||
| export class OpenapiModule {} | ||||
							
								
								
									
										36
									
								
								apps/api/src/openapi/openapi.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								apps/api/src/openapi/openapi.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| import { type INestApplication, Injectable } from '@nestjs/common'; | ||||
| import { DocumentBuilder, type OpenAPIObject, SwaggerModule } from '@nestjs/swagger'; | ||||
| import { OpenapiTag } from './openapi-tag.enum'; | ||||
|  | ||||
| @Injectable() | ||||
| export class OpenapiService { | ||||
| 	private spec: OpenAPIObject | undefined; | ||||
|  | ||||
| 	private createDocument(): DocumentBuilder { | ||||
| 		const config = new DocumentBuilder() | ||||
| 			.setTitle('Zero Width Shortener') | ||||
| 			.setDescription('A URL shortener that uses zero width characters to shorten URLs.') | ||||
| 			.setVersion('2.0.0') | ||||
| 			.addServer('https://zws.im/api') | ||||
| 			.setContact('Jonah Snider', 'https://jonahsnider.com', 'jonah@jonahsnider.com') | ||||
| 			.setLicense('Apache 2.0', 'https://www.apache.org/licenses/LICENSE-2.0.html'); | ||||
|  | ||||
| 		for (const tag of Object.values(OpenapiTag)) { | ||||
| 			config.addTag(tag); | ||||
| 		} | ||||
|  | ||||
| 		return config; | ||||
| 	} | ||||
|  | ||||
| 	createSpec(app: INestApplication): OpenAPIObject { | ||||
| 		const spec = SwaggerModule.createDocument(app, this.createDocument().build()); | ||||
|  | ||||
| 		this.spec = spec; | ||||
|  | ||||
| 		return spec; | ||||
| 	} | ||||
|  | ||||
| 	public getSpec(): OpenAPIObject | undefined { | ||||
| 		return this.spec; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										1
									
								
								apps/api/src/redis/providers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/api/src/redis/providers.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export const REDIS_PROVIDER = Symbol('QUEUE_PROVIDER'); | ||||
							
								
								
									
										17
									
								
								apps/api/src/redis/redis.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								apps/api/src/redis/redis.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import { Global, Module } from '@nestjs/common'; | ||||
| import Ioredis from 'ioredis'; | ||||
| import { ConfigService } from '../config/config.service'; | ||||
| import { REDIS_PROVIDER } from './providers'; | ||||
|  | ||||
| @Global() | ||||
| @Module({ | ||||
| 	providers: [ | ||||
| 		{ | ||||
| 			provide: REDIS_PROVIDER, | ||||
| 			inject: [ConfigService], | ||||
| 			useFactory: (configService: ConfigService) => new Ioredis(configService.redisUrl), | ||||
| 		}, | ||||
| 	], | ||||
| 	exports: [REDIS_PROVIDER], | ||||
| }) | ||||
| export class RedisModule {} | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; | ||||
| import { createZodDto } from '@anatine/zod-nestjs'; | ||||
| import { extendZodWithOpenApi } from '@anatine/zod-openapi'; | ||||
| import { z } from 'zod'; | ||||
| 
 | ||||
| extendZodWithOpenApi(z); | ||||
| @@ -23,5 +24,10 @@ export const ShieldsResponseSchema = z | ||||
| 		logoPosition: z.string().optional(), | ||||
| 		style: z.string().optional(), | ||||
| 	}) | ||||
| 	.openapi('ShieldsResponse'); | ||||
| 	.openapi({ | ||||
| 		title: 'ShieldsResponse', | ||||
| 	}); | ||||
| 
 | ||||
| export type ShieldsResponseSchema = z.infer<typeof ShieldsResponseSchema>; | ||||
| 
 | ||||
| export class ShieldsResponseDto extends createZodDto(ShieldsResponseSchema) {} | ||||
							
								
								
									
										29
									
								
								apps/api/src/shields-badges/shields-badges.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								apps/api/src/shields-badges/shields-badges.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| import { Controller, Get, Inject } from '@nestjs/common'; | ||||
| import { ApiResponse, ApiTags } from '@nestjs/swagger'; | ||||
| import { OpenapiTag } from '../openapi/openapi-tag.enum'; | ||||
| import { ShieldsResponseDto } from './dtos/shields-response.dto'; | ||||
| import { ShieldsBadgesService } from './shields-badges.service'; | ||||
|  | ||||
| @Controller('/stats/shields') | ||||
| @ApiTags(OpenapiTag.Badges) | ||||
| export class ShieldsBadgesController { | ||||
| 	constructor(@Inject(ShieldsBadgesService) private readonly shieldsBadgesService: ShieldsBadgesService) {} | ||||
|  | ||||
| 	@Get('/version') | ||||
| 	@ApiResponse({ type: ShieldsResponseDto }) | ||||
| 	getVersionBadge(): ShieldsResponseDto { | ||||
| 		return this.shieldsBadgesService.getVersionBadge(); | ||||
| 	} | ||||
|  | ||||
| 	@Get('/urls') | ||||
| 	@ApiResponse({ type: ShieldsResponseDto }) | ||||
| 	getUrlsBadge(): Promise<ShieldsResponseDto> { | ||||
| 		return this.shieldsBadgesService.getUrlStatsBadge(); | ||||
| 	} | ||||
|  | ||||
| 	@Get('/visits') | ||||
| 	@ApiResponse({ type: ShieldsResponseDto }) | ||||
| 	getVisitsBadge(): Promise<ShieldsResponseDto> { | ||||
| 		return this.shieldsBadgesService.getVisitsStatsBadge(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										11
									
								
								apps/api/src/shields-badges/shields-badges.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								apps/api/src/shields-badges/shields-badges.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { StatsModule } from '../stats/stats.module'; | ||||
| import { ShieldsBadgesController } from './shields-badges.controller'; | ||||
| import { ShieldsBadgesService } from './shields-badges.service'; | ||||
|  | ||||
| @Module({ | ||||
| 	imports: [StatsModule], | ||||
| 	controllers: [ShieldsBadgesController], | ||||
| 	providers: [ShieldsBadgesService], | ||||
| }) | ||||
| export class ShieldsBadgesModule {} | ||||
| @@ -1,10 +1,12 @@ | ||||
| import { millify } from 'millify'; | ||||
| import { type StatsService, statsService } from '../stats/stats.service'; | ||||
| import { StatsService } from '../stats/stats.service'; | ||||
| 
 | ||||
| import { type ConfigService, configService } from '../config/config.service'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { ConfigService } from '../config/config.service'; | ||||
| import type { ShieldsResponseSchema } from './dtos/shields-response.dto'; | ||||
| 
 | ||||
| class ShieldsBadgesService { | ||||
| @Injectable() | ||||
| export class ShieldsBadgesService { | ||||
| 	/** | ||||
| 	 * Abbreviate a number for displaying in badges. | ||||
| 	 * | ||||
| @@ -19,8 +21,8 @@ class ShieldsBadgesService { | ||||
| 	private readonly version: string; | ||||
| 
 | ||||
| 	constructor( | ||||
| 		private readonly statsService: StatsService, | ||||
| 		config: ConfigService, | ||||
| 		@Inject(StatsService) private readonly statsService: StatsService, | ||||
| 		@Inject(ConfigService) config: ConfigService, | ||||
| 	) { | ||||
| 		this.version = config.version; | ||||
| 	} | ||||
| @@ -56,5 +58,3 @@ class ShieldsBadgesService { | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| export const shieldsBadgesService = new ShieldsBadgesService(statsService, configService); | ||||
							
								
								
									
										17
									
								
								apps/api/src/stats/dtos/stats.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								apps/api/src/stats/dtos/stats.dto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import { createZodDto } from '@anatine/zod-nestjs'; | ||||
| import { extendApi } from '@anatine/zod-openapi'; | ||||
| import { z } from 'zod'; | ||||
|  | ||||
| export const InstanceStats = extendApi( | ||||
| 	z.object({ | ||||
| 		urls: z.number().int().nonnegative(), | ||||
| 		visits: z.number().int().nonnegative(), | ||||
| 	}), | ||||
| 	{ | ||||
| 		title: 'Stats', | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| export type InstanceStats = z.infer<typeof InstanceStats>; | ||||
|  | ||||
| export class InstanceStatsDto extends createZodDto(InstanceStats) {} | ||||
							
								
								
									
										17
									
								
								apps/api/src/stats/stats.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								apps/api/src/stats/stats.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import { Controller, Get, Inject } from '@nestjs/common'; | ||||
| import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; | ||||
| import { OpenapiTag } from '../openapi/openapi-tag.enum'; | ||||
| import { InstanceStatsDto } from './dtos/stats.dto'; | ||||
| import { StatsService } from './stats.service'; | ||||
|  | ||||
| @Controller('stats') | ||||
| @ApiTags(OpenapiTag.InstanceStats) | ||||
| export class StatsController { | ||||
| 	constructor(@Inject(StatsService) private readonly statsService: StatsService) {} | ||||
|  | ||||
| 	@Get('/') | ||||
| 	@ApiOkResponse({ type: InstanceStatsDto }) | ||||
| 	getInstanceStats(): Promise<InstanceStatsDto> { | ||||
| 		return this.statsService.getInstanceStats(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										11
									
								
								apps/api/src/stats/stats.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								apps/api/src/stats/stats.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { StatsController } from './stats.controller'; | ||||
| import { StatsRouter } from './stats.router'; | ||||
| import { StatsService } from './stats.service'; | ||||
|  | ||||
| @Module({ | ||||
| 	controllers: [StatsController], | ||||
| 	providers: [StatsService, StatsRouter], | ||||
| 	exports: [StatsService, StatsRouter], | ||||
| }) | ||||
| export class StatsModule {} | ||||
							
								
								
									
										14
									
								
								apps/api/src/stats/stats.router.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								apps/api/src/stats/stats.router.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { publicProcedure, router } from '../trpc/trpc'; | ||||
| import { InstanceStats } from './dtos/stats.dto'; | ||||
| import { StatsService } from './stats.service'; | ||||
|  | ||||
| @Injectable() | ||||
| export class StatsRouter { | ||||
| 	constructor(@Inject(StatsService) private readonly statsService: StatsService) {} | ||||
| 	createRouter() { | ||||
| 		return router({ | ||||
| 			getInstanceStats: publicProcedure.output(InstanceStats).query(() => this.statsService.getInstanceStats()), | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										29
									
								
								apps/api/src/stats/stats.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								apps/api/src/stats/stats.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { eq, sql } from 'drizzle-orm'; | ||||
| import type { Db } from '../db/interfaces/db.interface'; | ||||
| import { DB_PROVIDER } from '../db/providers'; | ||||
| import type { InstanceStats } from './dtos/stats.dto'; | ||||
|  | ||||
| @Injectable() | ||||
| export class StatsService { | ||||
| 	constructor(@Inject(DB_PROVIDER) private readonly db: Db) {} | ||||
|  | ||||
| 	async getInstanceStats(): Promise<InstanceStats> { | ||||
| 		const [[urls], [visits]] = await Promise.all([ | ||||
| 			this.db | ||||
| 				.select({ | ||||
| 					estimate: sql`reltuples`, | ||||
| 				}) | ||||
| 				.from(sql`pg_class`) | ||||
| 				.where(eq(sql`relname`, 'urls')), | ||||
| 			this.db | ||||
| 				.select({ | ||||
| 					estimate: sql`reltuples`, | ||||
| 				}) | ||||
| 				.from(sql`pg_class`) | ||||
| 				.where(eq(sql`relname`, 'visits')), | ||||
| 		]); | ||||
|  | ||||
| 		return { urls: (urls?.estimate as number | undefined) ?? 0, visits: (visits?.estimate as number | undefined) ?? 0 }; | ||||
| 	} | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user