diff --git a/.env.example b/.env.example index a4c22751..e6d1be78 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,55 @@ +#? Rename or copy this file to ".env" and set the variables there. +#? +#?#################### +#? VARIABLE STRUCTURE: +#?#################### +#? [ default ] : type < min - max > +#? ^ ^ ^ +#? | | | +#? | | +---- RANGE between two values (a & b) +#? | +-------------- TYPE of the variable +#? +------------------------ DEFAULT value applied if not set +#? +#?################### +#? COMMENT STRUCTURE: +#?################### +#? "#?#..." or "###..." are used to comment a section line. +#? "#?" is used to comment a help line. +#? "##" is used to comment a description line. +#? "#" is used to comment a variable line. +#? +#? You should remove the comment on variable lines only if you want to set the variable. + +########## ## SERVER: -# Set log verbosity [3]:integer -# (0=none <- 1=error <- 2=warn <- 3=info <- 4=debug) +########## +## Set log verbosity [3]:integer<0-4> +#? (0=none; 1=error; 2=warn; 3=info; 4=debug) #LOGLEVEL=3 -# Port for the server [4000]:integer +## Port for the server [4000]:integer #PORT=4000 -# Root path for the server (NOT IMPLEMENTED) [/api]:string -# (Everything will be served under this path) +## Root path for the server (NOT IMPLEMENTED) [/api]:string +#? (Everything will be served under this path) #PATH=/api -# Is website served over HTTPS? [true]:boolean +## Is website served over HTTPS? [true]:boolean #TLS=true +############ ## DOCUMENT: -# Maximum document size in kilobytes [1024]:integer -#DOCUMENT_MAXSIZE=1024 \ No newline at end of file +############ +## Maximum uploaded size in kilobytes [1024]:integer +#DOCUMENT_MAXSIZE=1024 + +## Compression type to use on saved documents [brotli]:string +#? (This doesn't apply retroactively to already saved documents) +#? (none; deflate; brotli) +#DOCUMENT_COMPRESSION=brotli + +## Set DEFLATE compression level [1]:integer<0-9> +#DOCUMENT_COMPRESSION_DEFLATE_LEVEL=1 + +## Set Brotli compression level [1]:integer<0-11> +#DOCUMENT_COMPRESSION_BROTLI_LEVEL=1 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1aff77b8..83f76464 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,18 +74,18 @@ jobs: run: | bun run build:standalone:darwin-arm64 chmod 755 ./dist/backend - tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | gzip --best >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_darwin-arm64.tar.gz - tar -tzf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_darwin-arm64.tar.gz >/dev/null + tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | xz -z -6 >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_darwin-arm64.tar.xz + tar -tJf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_darwin-arm64.tar.xz >/dev/null - bun run build:standalone:linux-amd64 + bun run build:standalone:linux-glibc-amd64 chmod 755 ./dist/backend - tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | gzip --best >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-amd64.tar.gz - tar -tzf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-amd64.tar.gz >/dev/null + tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | xz -z -6 >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-glibc-amd64.tar.xz + tar -tJf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-glibc-amd64.tar.xz >/dev/null - bun run build:standalone:linux-arm64 + bun run build:standalone:linux-glibc-arm64 chmod 755 ./dist/backend - tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | gzip --best >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-arm64.tar.gz - tar -tzf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-arm64.tar.gz >/dev/null + tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | xz -z -6 >./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-glibc-arm64.tar.xz + tar -tJf ./dist/backend_${{ steps.tags-artifact.outputs.tag }}_linux-glibc-arm64.tar.xz >/dev/null bun run build:standalone:windows-amd64 chmod 755 ./dist/backend.exe @@ -98,7 +98,7 @@ jobs: with: name: ${{ steps.tags-artifact.outputs.extended }} tag: ${{ steps.tags-artifact.outputs.extended }} - artifacts: dist/*.tar.gz,dist/*.zip + artifacts: dist/*.tar.xz,dist/*.zip makeLatest: true prerelease: ${{ github.ref != 'refs/heads/stable' }} generateReleaseNotes: ${{ github.ref == 'refs/heads/stable' }} @@ -108,7 +108,7 @@ jobs: uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4 with: subject-path: | - dist/*.tar.gz + dist/*.tar.xz dist/*.zip container: diff --git a/bun.lockb b/bun.lockb index 46ea48ce..571f4caa 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c34cf488..21938963 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ "build:server": "bun build ./src/server.ts --outfile=./dist/server.js --target=bun --minify --sourcemap=inline", "build:standalone": "bun build ./src/server.ts --outfile=./dist/backend --compile --minify --sourcemap=inline", "build:standalone:darwin-arm64": "bun run build:standalone -- --target=bun-darwin-arm64", - "build:standalone:linux-amd64": "bun run build:standalone -- --target=bun-linux-x64-modern", - "build:standalone:linux-arm64": "bun run build:standalone -- --target=bun-linux-arm64", + "build:standalone:linux-glibc-amd64": "bun run build:standalone -- --target=bun-linux-x64-modern", + "build:standalone:linux-glibc-arm64": "bun run build:standalone -- --target=bun-linux-arm64", + "build:standalone:linux-musl-amd64": "bun run build:standalone -- --target=bun-linux-x64-modern-musl", + "build:standalone:linux-musl-arm64": "bun run build:standalone -- --target=bun-linux-arm64-musl", "build:standalone:windows-amd64": "bun run build:standalone -- --target=bun-windows-x64-modern", "clean:git:all": "bun run clean:git:untracked && bun run clean:git:gc && bun run clean:git:hooks", "clean:git:all:force": "bun run clean:git:untracked:force && bun run clean:git:gc && bun run clean:git:hooks", @@ -31,15 +33,15 @@ "start:server": "bun run ./dist/server.js" }, "dependencies": { - "@hono/zod-openapi": "~0.18.0", + "@hono/zod-openapi": "~0.18.3", "env-var": "~7.5.0", - "hono": "~4.6.10" + "hono": "~4.6.12" }, "devDependencies": { "@biomejs/biome": "~1.9.4", - "@types/bun": "^1.1.13", + "@types/bun": "^1.1.14", "lefthook": "~1.8.4", - "sort-package-json": "~2.11.0" + "sort-package-json": "~2.12.0" }, "peerDependencies": { "typescript": "5.5.4" diff --git a/src/config.ts b/src/config.ts index fb78f15b..b952df26 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,12 @@ -import { env } from '@x-util/env.ts'; +import { env } from './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://', + storageDataPath: './storage/data/', + storageDatabaseFile: './storage/database.db', + storagePath: './storage/' } as const; diff --git a/src/database/Database.ts b/src/database/Database.ts new file mode 100644 index 00000000..208b884e --- /dev/null +++ b/src/database/Database.ts @@ -0,0 +1,85 @@ +import { Database as SQLite } from 'bun:sqlite'; +import { existsSync, mkdirSync } from 'node:fs'; +import { logger } from '@x-util/logger.ts'; +import { config } from '../config.ts'; +import { env } from '../env.ts'; +import { shutdown } from '../server.ts'; +import { map, migrations } from './migrations.ts'; + +export class Database { + private readonly database: SQLite; + + public constructor() { + // TODO: Move this out of the constructor + if (!existsSync(config.storagePath)) { + mkdirSync(config.storagePath); + } + + this.database = new SQLite(env.debugDatabaseEphemeral ? undefined : config.storageDatabaseFile, { + strict: true + }); + + this.migration(); + } + + public get instance(): SQLite { + return this.database; + } + + public close(graceful = true): void { + this.database.close(!graceful); + + logger.debug('Database closed', graceful ? 'gracefully.' : 'forcefully.'); + } + + private migration(): void { + const currentVersion = this.database + .prepare<{ user_version: number }, null>('PRAGMA user_version') + .get(null)?.user_version; + + if (currentVersion === undefined) { + logger.error('Failed to get the current database version. Aborting...'); + + shutdown(1); + return; + } + + const migrationEntries = Object.entries(migrations).map( + ([key, value]) => [Number(key), value] as [number, string] + ); + + if (currentVersion === migrationEntries.length) { + logger.info('Database already up to date.'); + return; + } + + if (currentVersion > migrationEntries.length) { + logger.error( + 'Database version is higher than the available migrations. This might indicate that you are running an older version of the backend. Aborting...' + ); + + shutdown(1); + return; + } + + // TODO: Check for WAL files existence + for (const [i, sql] of migrationEntries) { + if (i > currentVersion) { + try { + this.database.transaction(() => { + this.database.run(sql); + this.database.run(`PRAGMA user_version = ${i}`); + })(); + } catch (error) { + logger.error(error); + logger.error(`Error running migration "${map[i]}", database reverted to a prior state.`); + + shutdown(1); + return; + } + + logger.info(`Database migration "${map[i]}" ran successfully.`); + } + } + } +} diff --git a/src/database/migrations.ts b/src/database/migrations.ts new file mode 100644 index 00000000..b29de3c6 --- /dev/null +++ b/src/database/migrations.ts @@ -0,0 +1,42 @@ +export enum map { + initialConfig_0001 = 1, + initial_0002 = 2 +} + +/** + * @remarks Keep the migrations in reverse order by their version number. + */ +export const migrations: { [migration: number]: string } = { + /** + * @experimental May be subject to change + */ + [map.initial_0002]: ` + CREATE TABLE user + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT, + created_at TEXT, + accessed_at TEXT + ) STRICT; + + CREATE TABLE document + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + version INTEGER, + name TEXT, + password TEXT, + created_at TEXT, + accessed_at TEXT, + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ) STRICT; + `, + + /** + * @experimental May be subject to change + */ + [map.initialConfig_0001]: ` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + ` +} as const; diff --git a/src/database/query.ts b/src/database/query.ts new file mode 100644 index 00000000..4b8b2ef7 --- /dev/null +++ b/src/database/query.ts @@ -0,0 +1,12 @@ +import { database } from '../server.ts'; + +// TODO: Remove this when other queries are implemented +export const databaseVersion = { + get: (): number | undefined => { + return database.instance.prepare<{ user_version: number }, null>('PRAGMA user_version').get(null)?.user_version; + }, + + set: (version: number): void => { + database.instance.run(`PRAGMA user_version = ${version}`); + } +}; 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 17a9da7c..266dde94 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 92a7e359..962c5612 100644 --- a/src/document/compression.ts +++ b/src/document/compression.ts @@ -1,11 +1,69 @@ -import { type InputType, brotliCompressSync, brotliDecompressSync } from 'node:zlib'; +import { Buffer } from 'node:buffer'; +import { + type InputType, + brotliCompressSync, + brotliDecompressSync, + deflateSync, + inflateSync, + constants as zlibConstants +} from 'node:zlib'; +import { errorHandler } from '@x-server/errorHandler.ts'; +import { env } from '../env.ts'; +import { ErrorCode } from '../types/ErrorHandler.ts'; + +enum CompressionByte { + none = 0x00, + deflate = 0x11, + gzip = 0x12, + brotli = 0x13 +} export const compression = { - encode: (data: InputType): Buffer => { - return brotliCompressSync(data); + encode: (data: InputType): Buffer | null => { + if (env.documentCompression === 'none') return null; + + // head byte, compressor byte, reserved byte, tail byte + const identifier = Buffer.from([0xba, CompressionByte[env.documentCompression], 0xff, 0xca]); + + switch (env.documentCompression) { + case 'deflate': { + return Buffer.concat([ + identifier, + deflateSync(data, { + level: env.documentCompressionDeflateLevel + }) + ]); + } + case 'brotli': { + return Buffer.concat([ + identifier, + brotliCompressSync(data, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: env.documentCompressionBrotliLevel, + [zlibConstants.BROTLI_PARAM_LGWIN]: 24 + } + }) + ]); + } + } }, - decode: (data: InputType): Buffer => { - return brotliDecompressSync(data); + decode: (data: string | Uint8Array): Buffer | null => { + if (data[0] === 0xba && data[3] === 0xca) { + switch (data[1]) { + case CompressionByte.deflate: { + return inflateSync(data.slice(4)); + } + case CompressionByte.brotli: { + return brotliDecompressSync(data.slice(4)); + } + // Unknown compression type + default: { + return errorHandler.send(ErrorCode.documentCorrupted); + } + } + } + + return null; } } as const; diff --git a/src/document/crypto.ts b/src/document/crypto.ts index da680523..7b67435b 100644 --- a/src/document/crypto.ts +++ b/src/document/crypto.ts @@ -1,22 +1,21 @@ +import { Buffer } from 'node:buffer'; import { randomBytes } from 'node:crypto'; +import { CryptoHasher } from 'bun'; -const hashAlgorithm = 'blake2b256'; +const hasher = new CryptoHasher('blake2b256'); const saltLength = 16; export const crypto = { hash: (password: string): Uint8Array => { const salt = randomBytes(saltLength); - const hasher = new Bun.CryptoHasher(hashAlgorithm, salt).update(password); - return Buffer.concat([salt, hasher.digest()]); + return Buffer.concat([salt, hasher.update(salt).update(password).digest()]); }, compare: (password: string, hash: Uint8Array): boolean => { const salt = hash.subarray(0, saltLength); - const hasher = new Bun.CryptoHasher(hashAlgorithm, salt).update(password); + const computedHash = hasher.update(salt).update(password).digest(); - const passwordHash = Buffer.concat([salt, hasher.digest()]); - - return hash.every((value, index) => value === passwordHash[index]); + return computedHash.compare(hash.subarray(saltLength)) === 0; } } as const; diff --git a/src/document/storage.ts b/src/document/storage.ts index fee43c4c..e216c747 100644 --- a/src/document/storage.ts +++ b/src/document/storage.ts @@ -1,24 +1,28 @@ import { deserialize, serialize } from 'bun:jsc'; +import { logger } from '@x-util/logger.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 { validator } from './validator.ts'; export const storage = { read: async (name: string): Promise => { - validator.validateName(name); + const document = await Bun.file(config.storageDataPath + name) + .arrayBuffer() + .catch(() => errorHandler.send(ErrorCode.documentNotFound)); - const file = Bun.file(config.storagePath + name); + try { + if (document.byteLength <= 0) throw null; - if (!(await file.exists())) { - errorHandler.send(ErrorCode.documentNotFound); - } + return deserialize(document); + } catch { + logger.error(`Document "${name}" is corrupted! Aborting...`); - return deserialize(await file.arrayBuffer()); + return errorHandler.send(ErrorCode.documentCorrupted); + } }, write: async (name: string, document: Document): Promise => { - await Bun.write(config.storagePath + name, serialize(document)); + await Bun.write(config.storageDataPath + name, serialize(document)); } } as const; diff --git a/src/endpoints/v1/access.route.ts b/src/endpoints/v1/access.route.ts index 628755bc..7f066c55 100644 --- a/src/endpoints/v1/access.route.ts +++ b/src/endpoints/v1/access.route.ts @@ -1,9 +1,11 @@ +import { Buffer } from 'node:buffer'; import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; +import { ErrorCode } from '@x-type/ErrorHandler.ts'; import { config } from '../../config.ts'; import { compression } from '../../document/compression.ts'; import { storage } from '../../document/storage.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; -import { ErrorCode } from '../../types/ErrorHandler.ts'; export const accessRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -49,14 +51,16 @@ 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 if (document.header.passwordHash) { - errorHandler.send(ErrorCode.documentPasswordNeeded); + return errorHandler.send(ErrorCode.documentPasswordNeeded); } - const buffer = compression.decode(document.data); + const buffer = compression.decode(document.data) ?? Buffer.from(document.data); return ctx.json({ key: params.name, diff --git a/src/endpoints/v1/accessRaw.route.ts b/src/endpoints/v1/accessRaw.route.ts index 01794a95..0aa9d0bc 100644 --- a/src/endpoints/v1/accessRaw.route.ts +++ b/src/endpoints/v1/accessRaw.route.ts @@ -1,9 +1,10 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; +import { ErrorCode } from '@x-type/ErrorHandler.ts'; import { config } from '../../config.ts'; import { compression } from '../../document/compression.ts'; import { storage } from '../../document/storage.ts'; import { errorHandler, schema } from '../../server/errorHandler.ts'; -import { ErrorCode } from '../../types/ErrorHandler.ts'; export const accessRawRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -43,15 +44,17 @@ 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 if (document.header.passwordHash) { - errorHandler.send(ErrorCode.documentPasswordNeeded); + return errorHandler.send(ErrorCode.documentPasswordNeeded); } - // @ts-ignore: Return the buffer directly - return ctx.text(compression.decode(document.data)); + // @ts-ignore: Return the document data directly + return ctx.text(compression.decode(document.data) ?? document.data); }, (result) => { if (!result.success) { diff --git a/src/endpoints/v1/publish.route.ts b/src/endpoints/v1/publish.route.ts index ab45f2bd..4c16e789 100644 --- a/src/endpoints/v1/publish.route.ts +++ b/src/endpoints/v1/publish.route.ts @@ -1,12 +1,13 @@ +import { Buffer } from 'node:buffer'; import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { StringUtils } from '@x-util/StringUtils.ts'; +import { DocumentVersion } from '@x-type/Document.ts'; +import { ErrorCode } from '@x-type/ErrorHandler.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'; import { errorHandler, schema } from '../../server/errorHandler.ts'; import { middleware } from '../../server/middleware.ts'; -import { DocumentVersion } from '../../types/Document.ts'; -import { ErrorCode } from '../../types/ErrorHandler.ts'; export const publishRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -56,11 +57,11 @@ 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), + data: compression.encode(body) ?? Buffer.from(body), header: { name: name, secretHash: crypto.hash(secret), diff --git a/src/endpoints/v1/remove.route.ts b/src/endpoints/v1/remove.route.ts index ad406798..eb4cd54a 100644 --- a/src/endpoints/v1/remove.route.ts +++ b/src/endpoints/v1/remove.route.ts @@ -1,10 +1,10 @@ import { unlink } from 'node:fs/promises'; import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; +import { ErrorCode } from '@x-type/ErrorHandler.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'; export const removeRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -53,11 +53,13 @@ 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) + const result = await unlink(config.storageDataPath + params.name) .then(() => true) .catch(() => false); diff --git a/src/endpoints/v2/access.route.ts b/src/endpoints/v2/access.route.ts index 131f2418..8b3dd5a6 100644 --- a/src/endpoints/v2/access.route.ts +++ b/src/endpoints/v2/access.route.ts @@ -1,10 +1,11 @@ +import { Buffer } from 'node:buffer'; import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { storage } from '@x-document/storage.ts'; +import { ErrorCode } from '@x-type/ErrorHandler.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'; export const accessRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -65,6 +66,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,10 +75,10 @@ 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); + const buffer = compression.decode(document.data) ?? Buffer.from(document.data); return ctx.json({ key: params.name, diff --git a/src/endpoints/v2/accessRaw.route.ts b/src/endpoints/v2/accessRaw.route.ts index 2ec184e5..95e3b160 100644 --- a/src/endpoints/v2/accessRaw.route.ts +++ b/src/endpoints/v2/accessRaw.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 { ErrorCode } from '@x-type/ErrorHandler.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'; export const accessRawRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -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,11 +71,11 @@ 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 - return ctx.text(compression.decode(document.data)); + // @ts-ignore: Return the document data directly + return ctx.text(compression.decode(document.data) ?? document.data); }, (result) => { if (!result.success) { diff --git a/src/endpoints/v2/edit.route.ts b/src/endpoints/v2/edit.route.ts index 88a92e12..81f4bcd3 100644 --- a/src/endpoints/v2/edit.route.ts +++ b/src/endpoints/v2/edit.route.ts @@ -1,11 +1,12 @@ +import { Buffer } from 'node:buffer'; import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; import { storage } from '@x-document/storage.ts'; +import { ErrorCode } from '@x-type/ErrorHandler.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'; export const editRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -70,11 +71,13 @@ 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); + document.data = compression.encode(body) ?? Buffer.from(body); const result = await storage .write(params.name, document) diff --git a/src/endpoints/v2/exists.route.ts b/src/endpoints/v2/exists.route.ts index 56498464..ec0e4660 100644 --- a/src/endpoints/v2/exists.route.ts +++ b/src/endpoints/v2/exists.route.ts @@ -1,8 +1,8 @@ import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { assert } from '@x-document/assert.ts'; +import { ErrorCode } from '@x-type/ErrorHandler.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'; export const existsRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -50,9 +50,9 @@ 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())); + return ctx.text(String(await Bun.file(config.storageDataPath + params.name).exists())); }, (result) => { if (!result.success) { diff --git a/src/endpoints/v2/publish.route.ts b/src/endpoints/v2/publish.route.ts index a9495078..b41aabbf 100644 --- a/src/endpoints/v2/publish.route.ts +++ b/src/endpoints/v2/publish.route.ts @@ -1,14 +1,15 @@ +import { Buffer } from 'node:buffer'; 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 { DocumentVersion } from '@x-type/Document.ts'; +import { ErrorCode } from '@x-type/ErrorHandler.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'; -import { ErrorCode } from '../../types/ErrorHandler.ts'; export const publishRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -87,39 +88,37 @@ 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)) { - errorHandler.send(ErrorCode.documentNameAlreadyExists); + if (await string.nameExists(headers.key)) { + return errorHandler.send(ErrorCode.documentNameAlreadyExists); } name = headers.key; } else { const nameLength = Number(headers.keylength || config.documentNameLengthDefault); - name = await StringUtils.createName(nameLength); + name = await string.createName(nameLength); } - const data = compression.encode(body); - await storage.write(name, { - data: data, + data: compression.encode(body) ?? Buffer.from(body), header: { name: name, secretHash: crypto.hash(secret), diff --git a/src/endpoints/v2/remove.route.ts b/src/endpoints/v2/remove.route.ts index 8d5178be..b374511d 100644 --- a/src/endpoints/v2/remove.route.ts +++ b/src/endpoints/v2/remove.route.ts @@ -1,10 +1,10 @@ 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 { ErrorCode } from '@x-type/ErrorHandler.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'; export const removeRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -52,11 +52,13 @@ 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) + const result = await unlink(config.storageDataPath + params.name) .then(() => true) .catch(() => false); diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..a70eae7b --- /dev/null +++ b/src/env.ts @@ -0,0 +1,13 @@ +import { LogLevels } from '@x-util/logger.ts'; +import { get } from 'env-var'; + +export const env = { + debugDatabaseEphemeral: get('DEBUG_DATABASE_EPHEMERAL').asBoolStrict() ?? false, + documentCompression: get('DOCUMENT_COMPRESSION').default('brotli').asEnum(['none', 'deflate', 'brotli']), + documentCompressionBrotliLevel: get('DOCUMENT_COMPRESSION_BROTLI_LEVEL').default(1).asIntPositive(), // FIXME: Check ranges + documentCompressionDeflateLevel: get('DOCUMENT_COMPRESSION_DEFLATE_LEVEL').default(1).asIntPositive(), // FIXME: Check ranges + documentMaxSize: get('DOCUMENT_MAXSIZE').default(1024).asIntPositive(), + logLevel: get('LOGLEVEL').default(LogLevels.info).asIntPositive(), // FIXME: Check ranges + port: get('PORT').default(4000).asPortNumber(), + tls: get('TLS').asBoolStrict() ?? true +} as const; diff --git a/src/server.ts b/src/server.ts index 26fd6878..68c7f60f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,31 +1,33 @@ import { OpenAPIHono } from '@hono/zod-openapi'; +import { Database } from '@x-db/Database.ts'; import { oas } from '@x-server/oas.ts'; -import { env } from '@x-util/env.ts'; import { logger } from '@x-util/logger.ts'; import { serve } from 'bun'; import { cors } from 'hono/cors'; import { HTTPException } from 'hono/http-exception'; import { config } from './config.ts'; +import { env } from './env.ts'; import { endpoints } from './server/endpoints.ts'; import { errorHandler } from './server/errorHandler.ts'; import { ErrorCode } from './types/ErrorHandler.ts'; -process.on('SIGTERM', async () => await backend.stop()); - logger.set(env.logLevel); -const instance = new OpenAPIHono().basePath(config.apiPath); +export const database = new Database(); export const server = (): typeof instance => { + const instance = new OpenAPIHono().basePath(config.apiPath); + instance.use('*', cors()); - instance.onError((err) => { - if (err instanceof HTTPException) { - return err.getResponse(); + instance.onError((error) => { + if (error instanceof HTTPException) { + return error.getResponse(); } - logger.error(err); - throw errorHandler.send(ErrorCode.unknown); + logger.error(error); + + return errorHandler.send(ErrorCode.unknown); }); instance.notFound((ctx) => { @@ -35,7 +37,6 @@ export const server = (): typeof instance => { oas(instance); endpoints(instance); - logger.debug('Registered routes:', instance.routes); logger.info(`Listening on: http://localhost:${env.port}`); return instance; @@ -45,3 +46,31 @@ const backend = serve({ fetch: server().fetch, port: env.port }); + +/** + * @remarks This MUST be synchronous + */ +export const shutdown = (code = 0, fastStop = false): never => { + /* FIXME: Something loops and makes the process hang forever + backend.stop(fastStop).finally(() => { + logger.debug('Backend closed.'); + + database.close(); + + logger.info('Bye.'); + process.exit(code); + }); + */ + + backend.stop(fastStop); + logger.debug('Backend closed.'); + + database.close(); + + logger.info('Bye.'); + process.exit(code); +}; + +// TODO: Test with container +process.on('SIGINT', () => shutdown()); +process.on('SIGTERM', () => shutdown()); diff --git a/src/server/errorHandler.ts b/src/server/errorHandler.ts index 265da5e1..99ee57b3 100644 --- a/src/server/errorHandler.ts +++ b/src/server/errorHandler.ts @@ -102,7 +102,7 @@ export const errorHandler = { return { type, code, message }; }, - send: (code: ErrorCode) => { + send: (code: ErrorCode): never => { const { httpCode, type, message } = map[code]; throw new HTTPException(httpCode, { diff --git a/src/server/middleware.ts b/src/server/middleware.ts index f7469461..12abf935 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -1,5 +1,5 @@ -import { env } from '@x-util/env.ts'; import { bodyLimit as middlewareBodyLimit } from 'hono/body-limit'; +import { env } from '../env.ts'; import { ErrorCode } from '../types/ErrorHandler.ts'; import { errorHandler } from './errorHandler.ts'; @@ -8,7 +8,7 @@ export const middleware = { return middlewareBodyLimit({ maxSize: maxSize * 1024, onError: () => { - throw errorHandler.send(ErrorCode.documentInvalidSize); + return errorHandler.send(ErrorCode.documentInvalidSize); } }); } diff --git a/src/types/Document.ts b/src/types/Document.ts index a5a1dbe5..75afa20b 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 894ad334..0680919a 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 9eff5bf9..612a2bb7 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 7ceba70f..00000000 --- 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 7617dd25..00000000 --- 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 c4cb40ec..ee51840d 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/env.ts b/src/utils/env.ts deleted file mode 100644 index 6e98b714..00000000 --- a/src/utils/env.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { LogLevels } from '@x-util/logger.ts'; -import { get } from 'env-var'; - -export const env = { - documentMaxSize: get('DOCUMENT_MAXSIZE').default(1024).asIntPositive(), - logLevel: get('LOGLEVEL').default(LogLevels.info).asIntPositive(), - port: get('PORT').default(4000).asPortNumber(), - tls: get('TLS').asBoolStrict() ?? true -} as const; diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 00000000..507fa5c3 --- /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.storageDataPath + 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 00000000..b5223dfb --- /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; diff --git a/tsconfig.json b/tsconfig.json index f1ee2928..eda83a53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,8 +34,10 @@ "paths": { "@x-v1/*": ["./src/endpoints/v1/*"], "@x-v2/*": ["./src/endpoints/v2/*"], + "@x-db/*": ["./src/database/*"], "@x-document/*": ["./src/document/*"], "@x-server/*": ["./src/server/*"], + "@x-type/*": ["./src/types/*"], "@x-util/*": ["./src/utils/*"] } },