diff --git a/bun.lockb b/bun.lockb index f0752bd..0424b96 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index f283063..2b512e5 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "dependencies": { "@hono/zod-openapi": "~0.18.0", "env-var": "~7.5.0", - "hono": "~4.6.10" + "hono": "~4.6.12" }, "devDependencies": { "@biomejs/biome": "~1.9.4", diff --git a/src/config.ts b/src/config.ts index fb78f15..dc5de2e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,10 @@ import { env } from '@x-util/env.ts'; export const config = { - protocol: env.tls ? 'https://' : 'http://', apiPath: '/api', - storagePath: 'storage/', - documentNameLengthMin: 2, + documentNameLengthDefault: 8, documentNameLengthMax: 32, - documentNameLengthDefault: 8 + documentNameLengthMin: 2, + protocol: env.tls ? 'https://' : 'http://', + storagePath: 'storage/' } as const; diff --git a/src/document/validator.ts b/src/document/assert.ts similarity index 51% rename from src/document/validator.ts rename to src/document/assert.ts index 17a9da7..266dde9 100644 --- a/src/document/validator.ts +++ b/src/document/assert.ts @@ -1,15 +1,15 @@ +import { validator } from '@x-util/validator.ts'; import { config } from '../config.ts'; import { errorHandler } from '../server/errorHandler.ts'; import type { Document } from '../types/Document.ts'; import { ErrorCode } from '../types/ErrorHandler.ts'; -import { ValidatorUtils } from '../utils/ValidatorUtils.ts'; import { crypto } from './crypto.ts'; -export const validator = { - validateName: (name: string): void => { +export const assert = { + name: (name: string): void => { if ( - !ValidatorUtils.isValidBase64URL(name) || - !ValidatorUtils.isLengthWithinRange( + !validator.isBase64URL(name) || + !validator.isLengthWithinRange( Bun.stringWidth(name), config.documentNameLengthMin, config.documentNameLengthMax @@ -19,39 +19,38 @@ export const validator = { } }, - validateNameLength: (length: number | undefined): void => { + nameLength: (length?: number): void => { if ( length && - !ValidatorUtils.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax) + !validator.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax) ) { errorHandler.send(ErrorCode.documentInvalidNameLength); } }, - validatePassword: (password: string, dataHash: Document['header']['passwordHash']): void => { + password: (password: string, dataHash: Document['header']['passwordHash']): void => { if (dataHash && !crypto.compare(password, dataHash)) { errorHandler.send(ErrorCode.documentInvalidPassword); } }, - validatePasswordLength: (password: string | undefined): void => { + passwordLength: (password?: string): void => { if ( password && - (ValidatorUtils.isEmptyString(password) || - !ValidatorUtils.isLengthWithinRange(Bun.stringWidth(password), 1, 255)) + (validator.isEmptyString(password) || !validator.isLengthWithinRange(Bun.stringWidth(password), 1, 255)) ) { errorHandler.send(ErrorCode.documentInvalidPasswordLength); } }, - validateSecret: (secret: string, secretHash: Document['header']['secretHash']): void => { + secret: (secret: string, secretHash: Document['header']['secretHash']): void => { if (!crypto.compare(secret, secretHash)) { errorHandler.send(ErrorCode.documentInvalidSecret); } }, - validateSecretLength: (secret: string): void => { - if (!ValidatorUtils.isLengthWithinRange(Bun.stringWidth(secret), 1, 255)) { + secretLength: (secret: string): void => { + if (!validator.isLengthWithinRange(Bun.stringWidth(secret), 1, 255)) { errorHandler.send(ErrorCode.documentInvalidSecretLength); } } diff --git a/src/document/compression.ts b/src/document/compression.ts index b4e212d..96dd05b 100644 --- a/src/document/compression.ts +++ b/src/document/compression.ts @@ -1,5 +1,4 @@ -import { type InputType, brotliCompressSync, brotliDecompressSync } from 'node:zlib'; -import { constants as zlibConstants } from 'zlib'; +import { type InputType, brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'node:zlib'; export const compression = { encode: (data: InputType): Buffer => { diff --git a/src/document/crypto.ts b/src/document/crypto.ts index ae07e98..7b67435 100644 --- a/src/document/crypto.ts +++ b/src/document/crypto.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer'; import { randomBytes } from 'node:crypto'; import { CryptoHasher } from 'bun'; diff --git a/src/document/storage.ts b/src/document/storage.ts index fee43c4..a5a2007 100644 --- a/src/document/storage.ts +++ b/src/document/storage.ts @@ -3,19 +3,14 @@ import { config } from '../config.ts'; import { errorHandler } from '../server/errorHandler.ts'; import type { Document } from '../types/Document.ts'; import { ErrorCode } from '../types/ErrorHandler.ts'; -import { validator } from './validator.ts'; export const storage = { read: async (name: string): Promise => { - validator.validateName(name); - - const file = Bun.file(config.storagePath + name); - - if (!(await file.exists())) { - errorHandler.send(ErrorCode.documentNotFound); + try { + return deserialize(await Bun.file(config.storagePath + name).arrayBuffer()); + } catch { + return errorHandler.send(ErrorCode.documentNotFound); } - - return deserialize(await file.arrayBuffer()); }, write: async (name: string, document: Document): Promise => { diff --git a/src/endpoints/v1/access.route.ts b/src/endpoints/v1/access.route.ts index 628755b..d283ca3 100644 --- a/src/endpoints/v1/access.route.ts +++ b/src/endpoints/v1/access.route.ts @@ -1,4 +1,5 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { config } from '../../config.ts'; import { compression } from '../../document/compression.ts'; import { storage } from '../../document/storage.ts'; @@ -49,6 +50,8 @@ export const accessRoute = (endpoint: OpenAPIHono): void => { async (ctx) => { const params = ctx.req.valid('param'); + assert.name(params.name); + const document = await storage.read(params.name); // V1 Endpoint does not support document protected password diff --git a/src/endpoints/v1/accessRaw.route.ts b/src/endpoints/v1/accessRaw.route.ts index 01794a9..233f445 100644 --- a/src/endpoints/v1/accessRaw.route.ts +++ b/src/endpoints/v1/accessRaw.route.ts @@ -1,4 +1,5 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { config } from '../../config.ts'; import { compression } from '../../document/compression.ts'; import { storage } from '../../document/storage.ts'; @@ -43,6 +44,8 @@ export const accessRawRoute = (endpoint: OpenAPIHono): void => { async (ctx) => { const params = ctx.req.valid('param'); + assert.name(params.name); + const document = await storage.read(params.name); // V1 Endpoint does not support document protected password diff --git a/src/endpoints/v1/publish.route.ts b/src/endpoints/v1/publish.route.ts index ab45f2b..01a811b 100644 --- a/src/endpoints/v1/publish.route.ts +++ b/src/endpoints/v1/publish.route.ts @@ -1,5 +1,5 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { StringUtils } from '@x-util/StringUtils.ts'; +import { string } from '@x-util/string.ts'; import { compression } from '../../document/compression.ts'; import { crypto } from '../../document/crypto.ts'; import { storage } from '../../document/storage.ts'; @@ -56,8 +56,8 @@ export const publishRoute = (endpoint: OpenAPIHono): void => { route, async (ctx) => { const body = await ctx.req.arrayBuffer(); - const name = await StringUtils.createName(); - const secret = StringUtils.createSecret(); + const name = await string.createName(); + const secret = string.createSecret(); await storage.write(name, { data: compression.encode(body), diff --git a/src/endpoints/v1/remove.route.ts b/src/endpoints/v1/remove.route.ts index ad40679..8cfa250 100644 --- a/src/endpoints/v1/remove.route.ts +++ b/src/endpoints/v1/remove.route.ts @@ -1,8 +1,8 @@ import { unlink } from 'node:fs/promises'; import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { config } from '../../config.ts'; import { storage } from '../../document/storage.ts'; -import { validator } from '../../document/validator.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; import { ErrorCode } from '../../types/ErrorHandler.ts'; @@ -53,9 +53,11 @@ export const removeRoute = (endpoint: OpenAPIHono): void => { const params = ctx.req.valid('param'); const headers = ctx.req.valid('header'); + assert.name(params.name); + const document = await storage.read(params.name); - validator.validateSecret(headers.secret, document.header.secretHash); + assert.secret(headers.secret, document.header.secretHash); const result = await unlink(config.storagePath + params.name) .then(() => true) diff --git a/src/endpoints/v2/access.route.ts b/src/endpoints/v2/access.route.ts index 131f241..b3fed6f 100644 --- a/src/endpoints/v2/access.route.ts +++ b/src/endpoints/v2/access.route.ts @@ -1,8 +1,8 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { storage } from '@x-document/storage.ts'; import { config } from '../../config.ts'; import { compression } from '../../document/compression.ts'; -import { validator } from '../../document/validator.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; import { ErrorCode } from '../../types/ErrorHandler.ts'; @@ -65,6 +65,8 @@ export const accessRoute = (endpoint: OpenAPIHono): void => { const params = ctx.req.valid('param'); const headers = ctx.req.valid('header'); + assert.name(params.name); + const document = await storage.read(params.name); if (document.header.passwordHash) { @@ -72,7 +74,7 @@ export const accessRoute = (endpoint: OpenAPIHono): void => { return errorHandler.send(ErrorCode.documentPasswordNeeded); } - validator.validatePassword(headers.password, document.header.passwordHash); + assert.password(headers.password, document.header.passwordHash); } const buffer = compression.decode(document.data); diff --git a/src/endpoints/v2/accessRaw.route.ts b/src/endpoints/v2/accessRaw.route.ts index 2ec184e..081c711 100644 --- a/src/endpoints/v2/accessRaw.route.ts +++ b/src/endpoints/v2/accessRaw.route.ts @@ -1,8 +1,8 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { storage } from '@x-document/storage.ts'; import { config } from '../../config.ts'; import { compression } from '../../document/compression.ts'; -import { validator } from '../../document/validator.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; import { ErrorCode } from '../../types/ErrorHandler.ts'; @@ -62,6 +62,8 @@ export const accessRawRoute = (endpoint: OpenAPIHono): void => { password: headers.password || query.p }; + assert.name(params.name); + const document = await storage.read(params.name); if (document.header.passwordHash) { @@ -69,7 +71,7 @@ export const accessRawRoute = (endpoint: OpenAPIHono): void => { return errorHandler.send(ErrorCode.documentPasswordNeeded); } - validator.validatePassword(options.password, document.header.passwordHash); + assert.password(options.password, document.header.passwordHash); } // @ts-ignore: Return the buffer directly diff --git a/src/endpoints/v2/edit.route.ts b/src/endpoints/v2/edit.route.ts index 88a92e1..844cff4 100644 --- a/src/endpoints/v2/edit.route.ts +++ b/src/endpoints/v2/edit.route.ts @@ -1,8 +1,8 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { storage } from '@x-document/storage.ts'; import { config } from '../../config.ts'; import { compression } from '../../document/compression.ts'; -import { validator } from '../../document/validator.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; import { middleware } from '../../server/middleware.ts'; import { ErrorCode } from '../../types/ErrorHandler.ts'; @@ -70,9 +70,11 @@ export const editRoute = (endpoint: OpenAPIHono): void => { const params = ctx.req.valid('param'); const headers = ctx.req.valid('header'); + assert.name(params.name); + const document = await storage.read(params.name); - validator.validateSecret(headers.secret, document.header.secretHash); + assert.secret(headers.secret, document.header.secretHash); document.data = compression.encode(body); diff --git a/src/endpoints/v2/exists.route.ts b/src/endpoints/v2/exists.route.ts index 5649846..bc24a17 100644 --- a/src/endpoints/v2/exists.route.ts +++ b/src/endpoints/v2/exists.route.ts @@ -1,6 +1,6 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { config } from '../../config.ts'; -import { validator } from '../../document/validator.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; import { ErrorCode } from '../../types/ErrorHandler.ts'; @@ -50,7 +50,7 @@ export const existsRoute = (endpoint: OpenAPIHono): void => { async (ctx) => { const params = ctx.req.valid('param'); - validator.validateName(params.name); + assert.name(params.name); return ctx.text(String(await Bun.file(config.storagePath + params.name).exists())); }, diff --git a/src/endpoints/v2/publish.route.ts b/src/endpoints/v2/publish.route.ts index a949507..454cdda 100644 --- a/src/endpoints/v2/publish.route.ts +++ b/src/endpoints/v2/publish.route.ts @@ -1,10 +1,10 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { storage } from '@x-document/storage.ts'; -import { StringUtils } from '@x-util/StringUtils.ts'; +import { string } from '@x-util/string.ts'; import { config } from '../../config.ts'; import { compression } from '../../document/compression.ts'; import { crypto } from '../../document/crypto.ts'; -import { validator } from '../../document/validator.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; import { middleware } from '../../server/middleware.ts'; import { DocumentVersion } from '../../types/Document.ts'; @@ -87,25 +87,25 @@ export const publishRoute = (endpoint: OpenAPIHono): void => { const headers = ctx.req.valid('header'); if (headers.password) { - validator.validatePasswordLength(headers.password); + assert.passwordLength(headers.password); } let secret: string; if (headers.secret) { - validator.validateSecretLength(headers.secret); + assert.secretLength(headers.secret); secret = headers.secret; } else { - secret = StringUtils.createSecret(); + secret = string.createSecret(); } let name: string; if (headers.key) { - validator.validateName(headers.key); + assert.name(headers.key); - if (await StringUtils.nameExists(headers.key)) { + if (await string.nameExists(headers.key)) { errorHandler.send(ErrorCode.documentNameAlreadyExists); } @@ -113,7 +113,7 @@ export const publishRoute = (endpoint: OpenAPIHono): void => { } else { const nameLength = Number(headers.keylength || config.documentNameLengthDefault); - name = await StringUtils.createName(nameLength); + name = await string.createName(nameLength); } const data = compression.encode(body); diff --git a/src/endpoints/v2/remove.route.ts b/src/endpoints/v2/remove.route.ts index 8d5178b..ae81081 100644 --- a/src/endpoints/v2/remove.route.ts +++ b/src/endpoints/v2/remove.route.ts @@ -1,8 +1,8 @@ import { unlink } from 'node:fs/promises'; import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { storage } from '@x-document/storage.ts'; import { config } from '../../config.ts'; -import { validator } from '../../document/validator.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; import { ErrorCode } from '../../types/ErrorHandler.ts'; @@ -52,9 +52,11 @@ export const removeRoute = (endpoint: OpenAPIHono): void => { const params = ctx.req.valid('param'); const headers = ctx.req.valid('header'); + assert.name(params.name); + const document = await storage.read(params.name); - validator.validateSecret(headers.secret, document.header.secretHash); + assert.secret(headers.secret, document.header.secretHash); const result = await unlink(config.storagePath + params.name) .then(() => true) diff --git a/src/types/Document.ts b/src/types/Document.ts index a5a1dbe..75afa20 100644 --- a/src/types/Document.ts +++ b/src/types/Document.ts @@ -1,8 +1,8 @@ -enum DocumentVersion { +export enum DocumentVersion { V1 = 1 } -interface Document { +export interface Document { data: Uint8Array; header: { name: string; @@ -11,6 +11,3 @@ interface Document { }; version: DocumentVersion; } - -export type { Document }; -export { DocumentVersion }; diff --git a/src/types/ErrorHandler.ts b/src/types/ErrorHandler.ts index 894ad33..0680919 100644 --- a/src/types/ErrorHandler.ts +++ b/src/types/ErrorHandler.ts @@ -1,6 +1,6 @@ import type { StatusCode } from 'hono/utils/http-status'; -enum ErrorCode { +export enum ErrorCode { // * Generic crash = 1000, unknown = 1001, @@ -25,11 +25,8 @@ enum ErrorCode { type Type = 'generic' | 'document'; -type Schema = { +export type Schema = { httpCode: StatusCode; type: Type; message: string; }; - -export type { Schema }; -export { ErrorCode }; diff --git a/src/types/Range.ts b/src/types/Range.ts index 9eff5bf..612a2bb 100644 --- a/src/types/Range.ts +++ b/src/types/Range.ts @@ -1,5 +1,5 @@ // https://github.com/microsoft/TypeScript/issues/43505 -type Range< +export type Range< START extends number, END extends number, ARR extends unknown[] = [], @@ -7,5 +7,3 @@ type Range< > = ARR['length'] extends END ? ACC | START | END : Range; - -export type { Range }; diff --git a/src/utils/StringUtils.ts b/src/utils/StringUtils.ts deleted file mode 100644 index 7ceba70..0000000 --- a/src/utils/StringUtils.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { config } from '../config.ts'; -import type { Range } from '../types/Range.ts'; -import { ValidatorUtils } from './ValidatorUtils.ts'; - -export class StringUtils { - public static readonly BASE64URL = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'; - - public static random(length: number, base: Range<2, 64> = 62): string { - const baseSet = StringUtils.BASE64URL.slice(0, base); - let string = ''; - - while (length--) string += baseSet.charAt(Math.floor(Math.random() * baseSet.length)); - - return string; - } - - public static generateName(length: number = config.documentNameLengthDefault): string { - if (!ValidatorUtils.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax)) { - length = config.documentNameLengthDefault; - } - - return StringUtils.random(length, 64); - } - - public static async nameExists(name: string): Promise { - return Bun.file(config.storagePath + name).exists(); - } - - public static async createName(length: number = config.documentNameLengthDefault): Promise { - const key = StringUtils.generateName(length); - - return (await StringUtils.nameExists(key)) ? StringUtils.createName(length + 1) : key; - } - - public static createSecret(chunkLength = 5, chunks = 4): string { - return Array.from({ length: chunks }, () => StringUtils.random(chunkLength)).join('-'); - } -} diff --git a/src/utils/ValidatorUtils.ts b/src/utils/ValidatorUtils.ts deleted file mode 100644 index 7617dd2..0000000 --- a/src/utils/ValidatorUtils.ts +++ /dev/null @@ -1,30 +0,0 @@ -export class ValidatorUtils { - public static isInstanceOf(value: unknown, type: new (...args: any[]) => T): value is T { - return value instanceof type; - } - - public static isTypeOf(value: unknown, type: string): value is T { - // biome-ignore lint/suspicious/useValidTypeof: We are checking the type of the value - return typeof value === type; - } - - public static isEmptyString(value: string): boolean { - return value.trim().length === 0; - } - - public static isValidArray(value: T[], validator: (value: T) => boolean): boolean { - return Array.isArray(value) && value.every(validator); - } - - public static isValidDomain(value: string): boolean { - return /\b((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}\b/.test(value); - } - - public static isValidBase64URL(value: string): boolean { - return /^[\w-]+$/.test(value); - } - - public static isLengthWithinRange(value: number, min: number, max: number): boolean { - return value >= min && value <= max; - } -} diff --git a/src/utils/colors.ts b/src/utils/colors.ts index c4cb40e..ee51840 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -1,9 +1,9 @@ -import { type ColorInput, color as bunColor } from 'bun'; +import { type ColorInput, color } from 'bun'; const colorString = - (color: ColorInput) => + (code: ColorInput) => (...text: unknown[]): string => { - return bunColor(color, 'ansi') + text.join(' ') + colors.reset; + return color(code, 'ansi') + text.join(' ') + colors.reset; }; export const colors = { diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 0000000..5eb6a7b --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,38 @@ +import { validator } from '@x-util/validator.ts'; +import { config } from '../config.ts'; +import type { Range } from '../types/Range.ts'; + +const base64url = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'; + +export const string = { + createName: async (length: number = config.documentNameLengthDefault): Promise => { + const key = string.generateName(length); + + return (await string.nameExists(key)) ? string.createName(length + 1) : key; + }, + + createSecret: (chunkLength = 5, chunks = 4): string => { + return Array.from({ length: chunks }, () => string.random(chunkLength)).join('-'); + }, + + generateName: (length: number = config.documentNameLengthDefault): string => { + if (!validator.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax)) { + length = config.documentNameLengthDefault; + } + + return string.random(length, 64); + }, + + nameExists: (name: string): Promise => { + return Bun.file(config.storagePath + name).exists(); + }, + + random: (length: number, base: Range<2, 64> = 62): string => { + const baseSet = base64url.slice(0, base); + let string = ''; + + while (length--) string += baseSet.charAt(Math.floor(Math.random() * baseSet.length)); + + return string; + } +} as const; diff --git a/src/utils/validator.ts b/src/utils/validator.ts new file mode 100644 index 0000000..b5223df --- /dev/null +++ b/src/utils/validator.ts @@ -0,0 +1,30 @@ +export const validator = { + isBase64URL: (value: string): boolean => { + return /^[\w-]+$/.test(value); + }, + + isDomain: (value: string): boolean => { + return /\b((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}\b/.test(value); + }, + + isEmptyString: (value: string): boolean => { + return value.trim().length === 0; + }, + + isInstanceOf: (value: unknown, type: new (...args: any[]) => T): value is T => { + return value instanceof type; + }, + + isLengthWithinRange: (value: number, min: number, max: number): boolean => { + return value >= min && value <= max; + }, + + isTypeOf: (value: unknown, type: string): value is T => { + // biome-ignore lint/suspicious/useValidTypeof: We are checking the type of the value + return typeof value === type; + }, + + isValidArray: (value: T[], validator: (value: T) => boolean): boolean => { + return Array.isArray(value) && value.every(validator); + } +} as const;