1
0
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:
Jonah Snider
2024-03-29 16:02:38 -07:00
committed by GitHub
parent 26a74a0cc7
commit 8414b8e370
212 changed files with 14612 additions and 2057 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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
View 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/*

View File

@@ -1,3 +0,0 @@
{
"extends": ["spectral:oas"]
}

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
View File

@@ -0,0 +1,3 @@
yarnPath: .yarn/releases/yarn-4.1.1.cjs
enableGlobalCache: true
nodeLinker: node-modules

View File

@@ -1,2 +1,3 @@
web: bun start
release: bun run migrations
web: bun --cwd ./apps/api start
release: bun run migrate

View File

@@ -1,2 +0,0 @@
export * as apiShort from './openapi';
export * as apiShortStats from './stats/openapi';

View File

@@ -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,
},
},
},
},
});
}

View File

@@ -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 });
},
);

View File

@@ -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,
},
},
},
},
});
}

View File

@@ -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);
},
);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -1,3 +0,0 @@
export enum Action {
ShortenUrl = 'urls:shorten',
}

View File

@@ -1,8 +0,0 @@
export enum Role {
/** An admin of this instance. */
Admin = 'admin',
/** An authenticated user. */
User = 'user',
/** An unauthenticated request. */
None = 'none',
}

View File

@@ -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);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 },
);
}
}

View File

@@ -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>;

View File

@@ -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',
}

View File

@@ -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>;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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>;

View File

@@ -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();

View File

@@ -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>;

View File

@@ -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);

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -1,6 +0,0 @@
import type { Short } from './urls.interface';
export type ShortenedUrlData = {
short: Short;
url: URL | undefined;
};

View File

@@ -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';

View File

@@ -1 +0,0 @@
export * as apiHealth from './openapi';

View File

@@ -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,
},
},
},
},
});
}

View File

@@ -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' });
});

View File

@@ -1 +0,0 @@
export * as apiOpenapijson from './route';

View File

@@ -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());
});

View File

@@ -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,
},
},
},
},
});
}

View File

@@ -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,
});
});

View File

@@ -1,2 +0,0 @@
export * as apiStats from './openapi';
export * from './shields/all-openapi';

View File

@@ -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,
},
},
},
},
});
}

View File

@@ -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);
});

View File

@@ -1,3 +0,0 @@
export * as apiStatsShieldsUrls from './urls/openapi';
export * as apiStatsShieldsVersion from './version/openapi';
export * as apiStatsShieldsVisits from './visits/openapi';

View File

@@ -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,
},
},
},
},
});
}

View File

@@ -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());
});

View File

@@ -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,
},
},
},
},
});
}

View File

@@ -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());
});

View File

@@ -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,
},
},
},
},
});
}

View File

@@ -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());
});

View File

@@ -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>
);
}

View File

@@ -1,3 +0,0 @@
export function DividerLine() {
return <hr className='mb-14 mt-5 w-32 border-2 border-zws-purple-400' />;
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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
View 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
View 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"
}
}

View 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 {}

View 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();
}
}

View 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 {}

View 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');
}
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { BlockedHostnamesService } from './blocked-hostnames.service';
@Module({
providers: [BlockedHostnamesService],
exports: [BlockedHostnamesService],
})
export class BlockedHostnamesModule {}

View File

@@ -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);

View 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 {}

View 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;
}
}

View 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
View File

@@ -0,0 +1 @@
export * as Schema from './schema';

View File

@@ -0,0 +1,4 @@
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { Schema } from '../index';
export type Db = NodePgDatabase<typeof Schema>;

View File

@@ -0,0 +1 @@
export const DB_PROVIDER = Symbol('DB_PROVIDER');

35
apps/api/src/db/schema.ts Normal file
View 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),
}),
);

View 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' };
}
}

View 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
View 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);

View 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;
}
}

View 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 {}

View 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;
}
}

View File

@@ -0,0 +1 @@
export const REDIS_PROVIDER = Symbol('QUEUE_PROVIDER');

View 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 {}

View File

@@ -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) {}

View 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();
}
}

View 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 {}

View File

@@ -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);

View 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) {}

View 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();
}
}

View 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 {}

View 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()),
});
}
}

View 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