diff --git a/.env.example b/.env.example index f04fd7d..c543642 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,27 @@ +# Enable HTTPS for document "url" parameter? [false]:boolean +#TLS=false + +# Domain for document "url" parameter [localhost]:string +#DOMAIN=localhost + # Port for the server [4000]:number # (Don't expose the server to the internet, use a reverse proxy) -PORT=4000 +#PORT=4000 ### Documentation: # Enable documentation? [true]:boolean # (Disabling this will also disable the playground) -DOCS_ENABLED=true +#DOCS_ENABLED=true # Path to documentation [/docs]:string -DOCS_PATH=/docs +#DOCS_PATH=/docs ### Documentation playground: # Enable HTTPS for domain? [true]:boolean -DOCS_PLAYGROUND_HTTPS=true +#DOCS_PLAYGROUND_TLS=true # Domain for documentation playground [jspaste.eu]:string -DOCS_PLAYGROUND_DOMAIN=jspaste.eu +#DOCS_PLAYGROUND_DOMAIN=jspaste.eu # Port for documentation playground [443]:number -DOCS_PLAYGROUND_PORT=443 +#DOCS_PLAYGROUND_PORT=443 diff --git a/.github/workflows/security-dependencies.yml b/.github/workflows/security-dependencies.yml deleted file mode 100644 index 045a91e..0000000 --- a/.github/workflows/security-dependencies.yml +++ /dev/null @@ -1,27 +0,0 @@ -# TODO: Experimental feature -name: 'CI -> Security-Dependencies' -on: - - pull_request - -permissions: - contents: read - -jobs: - dependency-review: - name: 'Dependency analysis' - runs-on: ubuntu-latest - steps: - - name: 'Harden Runner' - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 - with: - egress-policy: audit - - - name: 'Checkout' - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - persist-credentials: false - - - name: 'Dependency Review' - uses: actions/dependency-review-action@4cd9eb2d23752464a87e00499c30d256a59a01b4 # v4 - with: - fail-on-severity: moderate diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4f60884..973950c 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -21,7 +21,7 @@ permissions: read-all jobs: codeql: - name: 'CodeQL analysis' + name: 'CodeQL' runs-on: ubuntu-latest strategy: fail-fast: false @@ -53,7 +53,7 @@ jobs: category: '/language:${{ matrix.language }}' scoreboard: - name: 'Scorecard analysis' + name: 'Scorecard' runs-on: ubuntu-latest permissions: security-events: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b594593 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,80 @@ +# TODO: Experimental feature +name: 'CI -> Test' +on: + workflow_dispatch: + push: + branches: + - dev + paths-ignore: + - '*.md' + - '.*ignore' + + pull_request: + branches: + - dev + paths-ignore: + - '*.md' + - '.*ignore' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + name: 'Lint' + runs-on: ubuntu-latest + permissions: + id-token: write + + steps: + - name: 'Harden Runner' + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: 'Checkout' + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: 'Setup Bun' + uses: oven-sh/setup-bun@9b21598af8d4dfc302e5a30250007de0fda92acc # v1.1.1 + + - name: 'Setup dependencies' + run: bun install --production --frozen-lockfile --ignore-scripts + + # FIXME: Lint failing + - name: 'Run lint' + continue-on-error: true + run: bun run lint + + build: + name: 'Build' + runs-on: ubuntu-latest + permissions: + id-token: write + + steps: + - name: 'Harden Runner' + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: 'Checkout' + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: 'Setup Node.js' + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: current + + - name: 'Setup Bun' + uses: oven-sh/setup-bun@9b21598af8d4dfc302e5a30250007de0fda92acc # v1.1.1 + + - name: 'Setup dependencies' + run: bun install --production --frozen-lockfile --ignore-scripts + + - name: 'Run build' + run: bun run build:standalone diff --git a/.prettierignore b/.prettierignore index c65a96e..b1e5f4c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ -**/documents/ **/node_modules/ -**/src/structures/ +documents/ +src/structures/ .prettierrc.json LICENSE tsconfig.json \ No newline at end of file diff --git a/Containerfile b/Containerfile index 69591c0..56d36e1 100644 --- a/Containerfile +++ b/Containerfile @@ -1,16 +1,16 @@ # Builder -FROM docker.io/oven/bun:1.0-slim AS builder +FROM docker.io/imbios/bun-node:1.0-21-alpine AS builder WORKDIR /build/ COPY . ./ RUN bun install --production --frozen-lockfile --ignore-scripts +RUN bun run production:build # Runner -FROM docker.io/oven/bun:1.0-distroless AS runner -WORKDIR /home/nonroot/ +FROM gcr.io/distroless/base-nossl-debian12:nonroot AS runner -COPY --from=builder /build/. ./ +COPY --from=builder /build/dist/jspaste ./ ENV DOCS_ENABLED=false @@ -24,4 +24,4 @@ LABEL org.opencontainers.image.licenses="EUPL-1.2" VOLUME /home/nonroot/documents EXPOSE 4000/tcp -CMD ["./src/index.ts"] \ No newline at end of file +CMD ["./jspaste"] \ No newline at end of file diff --git a/README.md b/README.md index 749dd39..98f140e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # Backend -The backend for [JSPaste](https://jspaste.eu), built with [Bun](https://bun.sh) and [ElysiaJS](https://elysiajs.com) +This repository contains the backend code for [JSPaste](https://jspaste.eu). It is built using [Bun](https://bun.sh) and [ElysiaJS](https://elysiajs.com) + +## License + +This project is licensed under the EUPL License. See the [`LICENSE`](LICENSE) file for more details. diff --git a/bun.lockb b/bun.lockb index 4e44c87..17d889e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/compileStructures.cmd b/compileStructures.cmd deleted file mode 100644 index 2271509..0000000 --- a/compileStructures.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@echo off - -for /R %%i in (src\structures\*.proto) do ( - echo Compiling '%%~ni' - npx protoc --proto_path=".\src\structures" --ts_out=".\src\structures" "%%~ni.proto" -) diff --git a/compileStructures.sh b/compileStructures.sh deleted file mode 100755 index c9f528d..0000000 --- a/compileStructures.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env sh - -find src/structures -name '*.proto' -exec sh -c ' - echo "Compiling \"${1%.*}\"" - bunx protoc --proto_path="./src/structures" --ts_out="./src/structures" "$1" -' sh {} \; diff --git a/package.json b/package.json index b768c71..ad3d391 100644 --- a/package.json +++ b/package.json @@ -5,30 +5,35 @@ "license": "EUPL-1.2", "type": "module", "scripts": { - "dev": "bun run start:watch", - "fix": "bun run prettier", + "build": "bun run build:structures && bun run build:structures:dts && bun run build:bundle", + "build:bundle": "bun build --target bun --format esm --minify --outdir ./dist/ ./src/index.ts", + "build:standalone": "bun build --compile --target bun --format esm --minify --outfile ./dist/jspaste ./src/index.ts", + "build:structures": "bunx pbjs -t static-module -w es6 --no-create --no-typeurl --no-service -o ./src/structures/Structures.js ./src/structures/**/*.proto", + "build:structures:dts": "bunx pbts -o ./src/structures/Structures.d.ts ./src/structures/Structures.js", + "fix": "bun run fix:prettier", + "fix:prettier": "bunx prettier . --write", "lint": "bun run lint:tsc", "lint:tsc": "bunx tsc --noEmit", "prepare": "husky", - "prettier": "bunx prettier . --write", - "start": "bun ./src/index.ts", - "start:watch": "bun ./src/index.ts --watch" + "production:build": "bun run build:structures && bun run build:structures:dts && bun run build:standalone", + "start": "bun run build && bun ./dist/index.js", + "start:dev": "bun ./src/index.ts --watch" }, "dependencies": { "@elysiajs/cors": "^0.8.0", "@elysiajs/swagger": "^0.8.5", - "@protobuf-ts/plugin": "^2.9.3", - "@protobuf-ts/runtime": "^2.9.3", - "elysia": "^0.8.15", - "env-var": "^7.4.1" + "@types/bun": "^1.0.5", + "elysia": "^0.8.17", + "env-var": "^7.4.1", + "protobufjs": "~7.2.6", + "protobufjs-cli": "^1.1.2", + "typescript": "~5.3.3" }, "devDependencies": { - "@types/bun": "^1.0.4", - "husky": "^9.0.10", + "husky": "^9.0.11", "lint-staged": "^15.2.2", "prettier": "^3.2.5", "prettier-plugin-jsdoc": "^1.3.0", - "prettier-plugin-packagejson": "^2.4.10", - "typescript": "~5.3.3" + "prettier-plugin-packagejson": "^2.4.10" } } diff --git a/src/classes/AbstractPlugin.ts b/src/classes/AbstractPlugin.ts new file mode 100644 index 0000000..58f6536 --- /dev/null +++ b/src/classes/AbstractPlugin.ts @@ -0,0 +1,11 @@ +import type { Elysia } from 'elysia'; + +export abstract class AbstractPlugin { + protected readonly server: Elysia; + + protected constructor(server: Elysia) { + this.server = server; + } + + protected abstract load(): Elysia; +} diff --git a/src/classes/AbstractRoute.ts b/src/classes/AbstractRoute.ts new file mode 100644 index 0000000..d2335fa --- /dev/null +++ b/src/classes/AbstractRoute.ts @@ -0,0 +1,11 @@ +import type { Elysia } from 'elysia'; + +export abstract class AbstractRoute { + protected readonly server: Elysia; + + protected constructor(server: Elysia) { + this.server = server; + } + + protected abstract register(path: string): void; +} diff --git a/src/classes/DocumentHandler.ts b/src/classes/DocumentHandler.ts index 387f0d6..2f6dc0a 100644 --- a/src/classes/DocumentHandler.ts +++ b/src/classes/DocumentHandler.ts @@ -1,17 +1,19 @@ import { unlink } from 'node:fs/promises'; import { ValidatorUtils } from '../utils/ValidatorUtils.ts'; -import { DocumentManager } from './DocumentManager'; -import type { DocumentDataStruct } from '../structures/documentStruct.ts'; +import { DocumentManager } from './DocumentManager.ts'; import { basePath, defaultDocumentLifetime, + JSPErrorCode, JSPErrorMessage, maxDocLength, - ServerVersion, - viewDocumentPath + type Range, + serverConfig, + ServerVersion } from '../utils/constants.ts'; import { ErrorSender } from './ErrorSender.ts'; import { StringUtils } from '../utils/StringUtils.ts'; +import type { IDocumentDataStruct } from '../structures/Structures'; interface HandleAccess { errorSender: ErrorSender; @@ -38,6 +40,8 @@ interface HandlePublish { selectedSecret?: string; lifetime?: number; password?: string; + selectedKeyLength?: number; + selectedKey?: string; } interface HandleRemove { @@ -72,7 +76,7 @@ export class DocumentHandler { return { key, data, - url: viewDocumentPath + key, + url: (serverConfig.tls ? 'https://' : 'http://').concat(serverConfig.domain + '/') + key, expirationTimestamp: res.expirationTimestamp ? Number(res.expirationTimestamp) : undefined }; } @@ -80,22 +84,22 @@ export class DocumentHandler { public static async handleEdit({ errorSender, key, newBody, secret }: HandleEdit) { if (!ValidatorUtils.isStringLengthBetweenLimits(key, 1, 255) || !ValidatorUtils.isAlphanumeric(key)) - return errorSender.sendError(400, JSPErrorMessage['jsp.input.invalid']); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.inputInvalid]); const file = Bun.file(basePath + key); const fileExists = await file.exists(); - if (!fileExists) return errorSender.sendError(404, JSPErrorMessage['jsp.document.not_found']); + if (!fileExists) return errorSender.sendError(404, JSPErrorMessage[JSPErrorCode.documentNotFound]); const buffer = Buffer.from(newBody as ArrayBuffer); if (!ValidatorUtils.isLengthBetweenLimits(buffer, 1, maxDocLength)) - return errorSender.sendError(400, JSPErrorMessage['jsp.document.invalid_length']); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.documentInvalidLength]); const doc = await DocumentManager.read(file); if (doc.secret && doc.secret !== secret) - return errorSender.sendError(403, JSPErrorMessage['jsp.document.invalid_secret']); + return errorSender.sendError(403, JSPErrorMessage[JSPErrorCode.documentInvalidSecret]); doc.rawFileData = buffer; @@ -108,27 +112,37 @@ export class DocumentHandler { public static async handleExists({ errorSender, key }: HandleExists) { if (!ValidatorUtils.isStringLengthBetweenLimits(key, 1, 255) || !ValidatorUtils.isAlphanumeric(key)) - return errorSender.sendError(400, JSPErrorMessage['jsp.input.invalid']); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.inputInvalid]); return await Bun.file(basePath + key).exists(); } public static async handlePublish( - { errorSender, body, selectedSecret, lifetime, password }: HandlePublish, + { errorSender, body, selectedSecret, lifetime, password, selectedKeyLength, selectedKey }: HandlePublish, version: ServerVersion ) { const buffer = Buffer.from(body as ArrayBuffer); if (!ValidatorUtils.isLengthBetweenLimits(buffer, 1, maxDocLength)) - return errorSender.sendError(400, JSPErrorMessage['jsp.document.invalid_length']); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.documentInvalidLength]); const secret = selectedSecret || StringUtils.createSecret(); if (!ValidatorUtils.isStringLengthBetweenLimits(secret || '', 1, 255)) - return errorSender.sendError(400, JSPErrorMessage['jsp.document.invalid_secret_length']); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.documentInvalidSecretLength]); + + if ( + selectedKey && + (!ValidatorUtils.isStringLengthBetweenLimits(selectedKey, 2, 32) || + !ValidatorUtils.isAlphanumeric(selectedKey)) + ) + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.inputInvalid]); + + if (selectedKeyLength && (selectedKeyLength > 32 || selectedKeyLength < 2)) + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.documentInvalidKeyLength]); if (password && !ValidatorUtils.isStringLengthBetweenLimits(password, 0, 255)) - return errorSender.sendError(400, JSPErrorMessage['jsp.document.invalid_password_length']); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.documentInvalidPasswordLength]); lifetime = lifetime ?? defaultDocumentLifetime; @@ -138,14 +152,17 @@ export class DocumentHandler { const msLifetime = lifetime * 1000; const expirationTimestamp = msLifetime > 0 ? BigInt(Date.now() + msLifetime) : undefined; - const newDoc: DocumentDataStruct = { + const newDoc: IDocumentDataStruct = { rawFileData: buffer, secret, expirationTimestamp, password }; - const key = await StringUtils.createKey(); + const key = selectedKey || (await StringUtils.createKey((selectedKeyLength as Range<2, 32>) || 8)); + + if (selectedKey && (await StringUtils.keyExists(key))) + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.documentKeyAlreadyExists]); await DocumentManager.write(basePath + key, newDoc); @@ -157,7 +174,7 @@ export class DocumentHandler { return { key, secret, - url: viewDocumentPath + key, + url: (serverConfig.tls ? 'https://' : 'http://').concat(serverConfig.domain + '/') + key, expirationTimestamp: Number(expirationTimestamp ?? 0) }; } @@ -165,17 +182,17 @@ export class DocumentHandler { public static async handleRemove({ errorSender, key, secret }: HandleRemove) { if (!ValidatorUtils.isStringLengthBetweenLimits(key, 1, 255) || !ValidatorUtils.isAlphanumeric(key)) - return errorSender.sendError(400, JSPErrorMessage['jsp.input.invalid']); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.inputInvalid]); const file = Bun.file(basePath + key); const fileExists = await file.exists(); - if (!fileExists) return errorSender.sendError(404, JSPErrorMessage['jsp.document.not_found']); + if (!fileExists) return errorSender.sendError(404, JSPErrorMessage[JSPErrorCode.documentNotFound]); const doc = await DocumentManager.read(file); if (doc.secret && doc.secret !== secret) - return errorSender.sendError(403, JSPErrorMessage['jsp.document.invalid_secret']); + return errorSender.sendError(403, JSPErrorMessage[JSPErrorCode.documentInvalidSecret]); return { // TODO: Use optimized Bun.unlink when available -> https://bun.sh/docs/api/file-io#writing-files-bun-write @@ -187,7 +204,7 @@ export class DocumentHandler { private static async handleGetDocument({ errorSender, key, password }: HandleGetDocument) { if (!ValidatorUtils.isStringLengthBetweenLimits(key, 1, 255) || !ValidatorUtils.isAlphanumeric(key)) - return errorSender.sendError(400, JSPErrorMessage['jsp.input.invalid']); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.inputInvalid]); const file = Bun.file(basePath + key); const fileExists = await file.exists(); @@ -197,14 +214,14 @@ export class DocumentHandler { // TODO: Use optimized Bun.unlink when available -> https://bun.sh/docs/api/file-io#writing-files-bun-write if (fileExists) await unlink(basePath + key).catch(() => null); - return errorSender.sendError(404, JSPErrorMessage['jsp.document.not_found']); + return errorSender.sendError(404, JSPErrorMessage[JSPErrorCode.documentNotFound]); } if (doc.password && !password) - return errorSender.sendError(401, JSPErrorMessage['jsp.document.needs_password']); + return errorSender.sendError(401, JSPErrorMessage[JSPErrorCode.documentPasswordNeeded]); if (doc.password && doc.password !== password) - return errorSender.sendError(403, JSPErrorMessage['jsp.document.invalid_password']); + return errorSender.sendError(403, JSPErrorMessage[JSPErrorCode.documentInvalidPassword]); return doc; } diff --git a/src/classes/DocumentManager.ts b/src/classes/DocumentManager.ts index 309c4c2..30d2201 100644 --- a/src/classes/DocumentManager.ts +++ b/src/classes/DocumentManager.ts @@ -1,13 +1,13 @@ import type { BunFile } from 'bun'; -import { DocumentDataStruct } from '../structures/documentStruct'; import { zlibConfig } from '../utils/constants.ts'; +import { DocumentDataStruct, type IDocumentDataStruct } from '../structures/Structures'; export class DocumentManager { public static async read(file: BunFile): Promise { - return DocumentDataStruct.fromBinary(Bun.inflateSync(Buffer.from(await file.arrayBuffer()))); + return DocumentDataStruct.decode(Bun.inflateSync(Buffer.from(await file.arrayBuffer()))); } - public static async write(filePath: string, document: DocumentDataStruct): Promise { - await Bun.write(filePath, Bun.deflateSync(DocumentDataStruct.toBinary(document), zlibConfig)); + public static async write(filePath: string, document: IDocumentDataStruct): Promise { + await Bun.write(filePath, Bun.deflateSync(DocumentDataStruct.encode(document).finish(), zlibConfig)); } } diff --git a/src/classes/ErrorSender.ts b/src/classes/ErrorSender.ts index f2640ef..3d3a213 100644 --- a/src/classes/ErrorSender.ts +++ b/src/classes/ErrorSender.ts @@ -1,15 +1,14 @@ import { type Context, t } from 'elysia'; -import { JSPErrorCode } from '../utils/constants'; +import { JSPErrorCode } from '../utils/constants.ts'; export interface JSPError { type: 'error'; message: string; errorCode: JSPErrorCode; - hint?: any; } export class ErrorSender { - context: Context; + private readonly context: Context; public constructor(context: Context) { this.context = context; @@ -24,8 +23,7 @@ export class ErrorSender { { type: t.String({ description: 'The error type' }), message: t.String({ description: 'The error message' }), - errorCode: t.String({ description: 'The error code' }), - hint: t.Optional(t.Any({ description: 'The error hint' })) + errorCode: t.String({ description: 'The error code' }) }, { description: 'An object representing an error' } ); diff --git a/src/classes/Server.ts b/src/classes/Server.ts index 9b86fa7..33a1a4b 100644 --- a/src/classes/Server.ts +++ b/src/classes/Server.ts @@ -1,10 +1,21 @@ import { Elysia } from 'elysia'; import type { ServerOptions } from '../interfaces/ServerOptions.ts'; -import { JSPErrorMessage, serverConfig } from '../utils/constants.ts'; +import { serverConfig, ServerVersion } from '../utils/constants.ts'; import swagger from '@elysiajs/swagger'; -import { join } from 'path'; -import { errorSenderPlugin } from '../plugins/errorSender.ts'; import cors from '@elysiajs/cors'; +import { IndexV1 } from '../routes/IndexV1.ts'; +import { AccessV1 } from '../routes/AccessV1.ts'; +import { AccessRawV1 } from '../routes/AccessRawV1.ts'; +import { PublishV1 } from '../routes/PublishV1.ts'; +import { RemoveV1 } from '../routes/RemoveV1.ts'; +import { ErrorSenderPlugin } from '../plugins/ErrorSenderPlugin.ts'; +import { EditV2 } from '../routes/EditV2.ts'; +import { ExistsV2 } from '../routes/ExistsV2.ts'; +import { IndexV2 } from '../routes/IndexV2.ts'; +import { PublishV2 } from '../routes/PublishV2.ts'; +import { RemoveV2 } from '../routes/RemoveV2.ts'; +import { AccessV2 } from '../routes/AccessV2.ts'; +import { AccessRawV2 } from '../routes/AccessRawV2.ts'; export class Server { private readonly server: Elysia; @@ -12,20 +23,20 @@ export class Server { public constructor(options: Partial = {}) { this.serverConfig = { ...serverConfig, ...options }; - this.server = this.initServer(); + this.server = this.createServer(); } public get self(): Elysia { return this.server; } - private initServer(): Elysia { + private createServer(): Elysia { const server = new Elysia(); + this.initCORS(server); this.serverConfig.docs.enabled && this.initDocs(server); - this.initErrorHandler(server); + this.initPlugins(server); this.initRoutes(server); - this.initCORS(server); server.listen(this.serverConfig.port, (server) => console.info('Listening on port', server.port, `-> http://localhost:${server.port}`) @@ -49,7 +60,7 @@ export class Server { documentation: { servers: [ { - url: (this.serverConfig.docs.playground.https ? 'https://' : 'http://').concat( + url: (this.serverConfig.docs.playground.tls ? 'https://' : 'http://').concat( this.serverConfig.docs.playground.domain, ':', this.serverConfig.docs.playground.port.toString() @@ -76,68 +87,38 @@ export class Server { ); } - private initErrorHandler(server: Elysia): void { - server.use(errorSenderPlugin).onError(({ errorSender, path, set, code, error }) => { - switch (code) { - // Redirect to the frontend 404 page - case 'NOT_FOUND': - if (path === '/404') return 'Not found'; - set.redirect = '/404'; - return; - - case 'VALIDATION': - console.error(error); - return errorSender.sendError(400, JSPErrorMessage['jsp.validation_failed']); + private initPlugins(server: Elysia): void { + const plugins = [ErrorSenderPlugin]; - case 'INTERNAL_SERVER_ERROR': - console.error(error); - return errorSender.sendError(500, JSPErrorMessage['jsp.internal_server_error']); - - case 'PARSE': - console.error(error); - return errorSender.sendError(400, JSPErrorMessage['jsp.parse_failed']); - - default: - console.error(error); - return errorSender.sendError(400, JSPErrorMessage['jsp.unknown']); - } - }); + plugins.forEach((Plugin) => server.use(new Plugin(server).load())); } private initRoutes(server: Elysia): void { - const routes = './src/routes'; const apiVersions = this.serverConfig.versions.toReversed(); + const routes = { + [ServerVersion.v1]: { + endpoints: [AccessRawV1, AccessV1, IndexV1, PublishV1, RemoveV1], + prefixes: ['/api/v1/documents'] + }, + [ServerVersion.v2]: { + endpoints: [AccessRawV2, AccessV2, EditV2, ExistsV2, IndexV2, PublishV2, RemoveV2], + prefixes: ['/api/v2/documents', '/documents'] + } + }; - console.info('Registering routes for', apiVersions.length, 'versions...'); + for (const [i, version] of apiVersions.entries()) { + routes[version].endpoints.forEach((Endpoint) => { + const endpoint = new Endpoint(server); - for (const [i, apiVersion] of apiVersions.entries()) { - const isLatestVersion = i === 0; - const routesGlob = new Bun.Glob(`v${apiVersion}/**/*.route.ts`); - const routesArray = Array.from(routesGlob.scanSync({ cwd: routes })).map((route) => { - try { - return require(join('../routes', route)).default; - } catch (err) { - console.error('Unable to import route', err); - return null; - } + routes[version].prefixes.forEach(endpoint.register.bind(endpoint)); }); - for (const resolvedRoute of routesArray) { - if (!resolvedRoute) continue; - - server.group(`/api/v${apiVersion as number}/documents`, (prefix) => prefix.use(resolvedRoute)); - - if (isLatestVersion) { - server.group('/documents', (prefix) => prefix.use(resolvedRoute)); - } - } - console.info( 'Registered', - routesArray.length, - 'routes for API version', - apiVersion, - isLatestVersion ? '(latest)' : '' + routes[version].endpoints.length, + 'routes for version', + version, + i === 0 ? '(latest)' : '' ); } } diff --git a/src/index.ts b/src/index.ts index 479166d..5e25a21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,12 @@ import { Server } from './classes/Server.ts'; const server = new Server(); -// FIXME(inetol): Handle exit properly (Docker) -process.on('exit', () => { - console.log('Bye'); - server.self.stop(); -}); +process + .on('SIGTERM', () => { + server.self.stop(); + process.exit(0); + }) + .on('SIGINT', () => { + server.self.stop(); + process.exit(0); + }); diff --git a/src/interfaces/ServerOptions.ts b/src/interfaces/ServerOptions.ts index 46332e4..4ae4009 100644 --- a/src/interfaces/ServerOptions.ts +++ b/src/interfaces/ServerOptions.ts @@ -1,13 +1,16 @@ -import type { ServerVersion } from '../utils/constants'; +import type { ServerVersion } from '../utils/constants.ts'; export interface ServerOptions { + tls: boolean; + domain: string; port: number; versions: ServerVersion[]; + files: {}; docs: { enabled: boolean; path: string; playground: { - https: boolean; + tls: boolean; domain: string; port: number; }; diff --git a/src/plugins/ErrorSenderPlugin.ts b/src/plugins/ErrorSenderPlugin.ts new file mode 100644 index 0000000..49a16d2 --- /dev/null +++ b/src/plugins/ErrorSenderPlugin.ts @@ -0,0 +1,44 @@ +import Elysia from 'elysia'; +import { ErrorSender } from '../classes/ErrorSender.ts'; +import { AbstractPlugin } from '../classes/AbstractPlugin.ts'; +import { JSPErrorCode, JSPErrorMessage } from '../utils/constants.ts'; + +export class ErrorSenderPlugin extends AbstractPlugin { + public constructor(server: Elysia) { + super(server); + } + + public override load(): Elysia { + return this.server + .derive((context) => { + return { + errorSender: new ErrorSender(context) + }; + }) + .onError(({ errorSender, path, set, code, error }) => { + switch (code) { + // Redirect to the frontend 404 page + case 'NOT_FOUND': + if (path === '/404') return 'Not found'; + set.redirect = '/404'; + return; + + case 'VALIDATION': + console.error(error); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.validation]); + + case 'INTERNAL_SERVER_ERROR': + console.error(error); + return errorSender.sendError(500, JSPErrorMessage[JSPErrorCode.internalServerError]); + + case 'PARSE': + console.error(error); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.parseFailed]); + + default: + console.error(error); + return errorSender.sendError(400, JSPErrorMessage[JSPErrorCode.unknown]); + } + }); + } +} diff --git a/src/plugins/errorSender.ts b/src/plugins/errorSender.ts deleted file mode 100644 index 6f1cd3d..0000000 --- a/src/plugins/errorSender.ts +++ /dev/null @@ -1,10 +0,0 @@ -import Elysia from 'elysia'; -import { ErrorSender } from '../classes/ErrorSender'; - -export const errorSenderPlugin = new Elysia({ - name: 'plugins:errorHandler' -}).derive((context) => { - return { - errorSender: new ErrorSender(context) - }; -}); diff --git a/src/routes/AccessRawV1.ts b/src/routes/AccessRawV1.ts new file mode 100644 index 0000000..70fde82 --- /dev/null +++ b/src/routes/AccessRawV1.ts @@ -0,0 +1,50 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; +import { ServerVersion } from '../utils/constants.ts'; +import { ErrorSender } from '../classes/ErrorSender.ts'; + +export class AccessRawV1 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + params: t.Object( + { + key: t.String({ + description: 'The document key', + examples: ['abc123'] + }) + }, + { + description: 'The request parameters', + examples: [{ key: 'abc123' }] + } + ), + response: { + 200: t.Any({ + description: 'The raw document', + examples: ['Hello world'] + }), + 400: ErrorSender.errorType(), + 404: ErrorSender.errorType() + }, + detail: { + summary: 'Get raw document', + tags: ['v1'] + } + }; + + this.server.get( + path.concat('/:key/raw'), + async ({ errorSender, set, params: { key } }) => { + set.headers['Content-Type'] = 'text/plain'; + + return DocumentHandler.handleAccess({ errorSender, key: key, raw: true }, ServerVersion.v1); + }, + hook + ); + } +} diff --git a/src/routes/AccessRawV2.ts b/src/routes/AccessRawV2.ts new file mode 100644 index 0000000..eeb1e6b --- /dev/null +++ b/src/routes/AccessRawV2.ts @@ -0,0 +1,79 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; +import { ServerVersion } from '../utils/constants.ts'; +import { ErrorSender } from '../classes/ErrorSender.ts'; + +export class AccessRawV2 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + params: t.Object( + { + key: t.String({ + description: 'The document key', + examples: ['abc123'] + }) + }, + { + description: 'The request parameters', + examples: [{ key: 'abc123' }] + } + ), + headers: t.Optional( + t.Object({ + password: t.Optional( + t.String({ + description: 'The document password if aplicable', + examples: ['aaaaa-bbbbb-ccccc-ddddd'] + }) + ) + }) + ), + query: t.Optional( + t.Object({ + p: t.Optional( + t.String({ + description: + 'The document password if aplicable, It is preferred to pass the password through headers, only use this method for support of web browsers.', + examples: ['aaaaa-bbbbb-ccccc-ddddd'] + }) + ) + }) + ), + response: { + 200: t.Any({ + description: 'The raw document', + examples: ['Hello world'] + }), + 400: ErrorSender.errorType(), + 404: ErrorSender.errorType() + }, + detail: { + summary: 'Get raw document', + tags: ['v2'] + } + }; + + this.server.get( + path.concat('/:key/raw'), + async ({ errorSender, set, request, query: { p }, params: { key } }) => { + set.headers['Content-Type'] = 'text/plain'; + + return DocumentHandler.handleAccess( + { + errorSender, + key, + password: request.headers.get('password') || p || '', + raw: true + }, + ServerVersion.v2 + ); + }, + hook + ); + } +} diff --git a/src/routes/AccessV1.ts b/src/routes/AccessV1.ts new file mode 100644 index 0000000..e7fe9b2 --- /dev/null +++ b/src/routes/AccessV1.ts @@ -0,0 +1,47 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; +import { ServerVersion } from '../utils/constants.ts'; +import { ErrorSender } from '../classes/ErrorSender.ts'; + +export class AccessV1 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + params: t.Object({ + key: t.String({ + description: 'The document key', + examples: ['abc123'] + }) + }), + response: { + 200: t.Object( + { + key: t.String({ + description: 'The key of the document', + examples: ['abc123'] + }), + data: t.String({ + description: 'The document', + examples: ['Hello world'] + }) + }, + { description: 'The document object' } + ), + 400: ErrorSender.errorType(), + 404: ErrorSender.errorType() + }, + detail: { summary: 'Get document', tags: ['v1'] } + }; + + this.server.get( + path.concat('/:key'), + async ({ errorSender, params: { key } }) => + DocumentHandler.handleAccess({ errorSender, key: key }, ServerVersion.v1), + hook + ); + } +} diff --git a/src/routes/AccessV2.ts b/src/routes/AccessV2.ts new file mode 100644 index 0000000..9bd0fff --- /dev/null +++ b/src/routes/AccessV2.ts @@ -0,0 +1,91 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; +import { ServerVersion } from '../utils/constants.ts'; +import { ErrorSender } from '../classes/ErrorSender.ts'; + +export class AccessV2 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + params: t.Object({ + key: t.String({ + description: 'The document key', + examples: ['abc123'] + }) + }), + headers: t.Optional( + t.Object({ + password: t.Optional( + t.String({ + description: 'The document password if aplicable', + examples: ['abc123'] + }) + ) + }) + ), + query: t.Optional( + t.Object({ + p: t.Optional( + t.String({ + description: + 'The document password if aplicable, It is preferred to pass the password through headers, only use this method for support of web browsers.', + examples: ['aaaaa-bbbbb-ccccc-ddddd'] + }) + ) + }) + ), + response: { + 200: t.Object( + { + key: t.String({ + description: 'The key of the document', + examples: ['abc123'] + }), + data: t.String({ + description: 'The document', + examples: ['Hello world'] + }), + url: t.Optional( + t.String({ + description: 'The URL for viewing the document on the web', + examples: ['https://jspaste.eu/abc123'] + }) + ), + expirationTimestamp: t.Optional( + t.Number({ + description: + 'UNIX timestamp with the expiration date in milliseconds. Undefined if the document is permanent.', + examples: [60, 0] + }) + ) + }, + { + description: + 'The document object, including the key, the data, the display URL and an expiration timestamp for the document' + } + ), + 400: ErrorSender.errorType(), + 404: ErrorSender.errorType() + }, + detail: { summary: 'Get document', tags: ['v2'] } + }; + + this.server.get( + path.concat('/:key'), + async ({ errorSender, request, query: { p }, params: { key } }) => + DocumentHandler.handleAccess( + { + errorSender, + key, + password: request.headers.get('password') || p || '' + }, + ServerVersion.v2 + ), + hook + ); + } +} diff --git a/src/routes/v2/edit.route.ts b/src/routes/EditV2.ts similarity index 51% rename from src/routes/v2/edit.route.ts rename to src/routes/EditV2.ts index 4314f70..ff22bad 100644 --- a/src/routes/v2/edit.route.ts +++ b/src/routes/EditV2.ts @@ -1,22 +1,15 @@ -import { Elysia, t } from 'elysia'; -import { ErrorSender } from '../../classes/ErrorSender'; -import { DocumentHandler } from '../../classes/DocumentHandler.ts'; -import { errorSenderPlugin } from '../../plugins/errorSender.ts'; +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; +import { ErrorSender } from '../classes/ErrorSender.ts'; -export default new Elysia({ - name: 'routes:v2:documents:edit' -}) - .use(errorSenderPlugin) - .patch( - ':key', - async ({ errorSender, request, body, params: { key } }) => - DocumentHandler.handleEdit({ - errorSender, - key, - newBody: body, - secret: request.headers.get('secret') || '' - }), - { +export class EditV2 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { type: 'arrayBuffer', body: t.Any({ description: 'The new file' }), params: t.Object({ @@ -45,5 +38,18 @@ export default new Elysia({ 404: ErrorSender.errorType() }, detail: { summary: 'Edit document', tags: ['v2'] } - } - ); + }; + + this.server.patch( + path.concat('/:key'), + async ({ errorSender, request, body, params: { key } }) => + DocumentHandler.handleEdit({ + errorSender, + key, + newBody: body, + secret: request.headers.get('secret') || '' + }), + hook + ); + } +} diff --git a/src/routes/ExistsV2.ts b/src/routes/ExistsV2.ts new file mode 100644 index 0000000..e4d47ec --- /dev/null +++ b/src/routes/ExistsV2.ts @@ -0,0 +1,32 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { ErrorSender } from '../classes/ErrorSender.ts'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; + +export class ExistsV2 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + params: t.Object({ + key: t.String({ + description: 'The document key', + examples: ['abc123'] + }) + }), + response: { + 200: t.Boolean({ description: 'A boolean indicating if the document exists' }), + 400: ErrorSender.errorType() + }, + detail: { summary: 'Check document', tags: ['v2'] } + }; + + this.server.get( + path.concat('/:key/exists'), + async ({ errorSender, params: { key } }) => DocumentHandler.handleExists({ errorSender, key: key }), + hook + ); + } +} diff --git a/src/routes/IndexV1.ts b/src/routes/IndexV1.ts new file mode 100644 index 0000000..34a0c2d --- /dev/null +++ b/src/routes/IndexV1.ts @@ -0,0 +1,20 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; + +export class IndexV1 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + response: t.String({ + description: 'A small welcome message with the current API version', + examples: ['Welcome to JSPaste API v1'] + }), + detail: { summary: 'Index', tags: ['v1'] } + }; + + this.server.get(path, () => 'Welcome to JSPaste API v1', hook); + } +} diff --git a/src/routes/IndexV2.ts b/src/routes/IndexV2.ts new file mode 100644 index 0000000..7b1796f --- /dev/null +++ b/src/routes/IndexV2.ts @@ -0,0 +1,20 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; + +export class IndexV2 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + response: t.String({ + description: 'A small welcome message with the current API version', + examples: ['Welcome to JSPaste API v2'] + }), + detail: { summary: 'Index', tags: ['v2'] } + }; + + this.server.get(path, () => 'Welcome to JSPaste API v2', hook); + } +} diff --git a/src/routes/PublishV1.ts b/src/routes/PublishV1.ts new file mode 100644 index 0000000..675607a --- /dev/null +++ b/src/routes/PublishV1.ts @@ -0,0 +1,39 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; +import { ErrorSender } from '../classes/ErrorSender.ts'; +import { ServerVersion } from '../utils/constants.ts'; + +export class PublishV1 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + type: 'arrayBuffer', + body: t.Any({ description: 'The file to be uploaded' }), + response: { + 200: t.Object( + { + key: t.String({ + description: 'The generated key to access the document' + }), + secret: t.String({ + description: 'The generated secret to delete the document' + }) + }, + { description: 'An object with a key and a secret for the document' } + ), + 400: ErrorSender.errorType() + }, + detail: { summary: 'Publish document', tags: ['v1'] } + }; + + this.server.post( + path, + async ({ errorSender, body }) => DocumentHandler.handlePublish({ errorSender, body }, ServerVersion.v1), + hook + ); + } +} diff --git a/src/routes/PublishV2.ts b/src/routes/PublishV2.ts new file mode 100644 index 0000000..6795af2 --- /dev/null +++ b/src/routes/PublishV2.ts @@ -0,0 +1,107 @@ +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; +import { ErrorSender } from '../classes/ErrorSender.ts'; +import { defaultDocumentLifetime, ServerVersion } from '../utils/constants.ts'; + +export class PublishV2 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { + type: 'arrayBuffer', + body: t.Any({ + description: 'The file to be uploaded' + }), + headers: t.Optional( + t.Object({ + key: t.Optional( + t.String({ + description: 'A custom key, if null, a new key will be generated', + examples: ['abc123'] + }) + ), + ['key-length']: t.Optional( + t.Numeric({ + description: + 'If a custom key is not set, this will determine the key length of the automatically generated key', + examples: ['20', '4'] + }) + ), + secret: t.Optional( + t.String({ + description: 'A custom secret, if null, a new secret will be generated', + examples: ['aaaaa-bbbbb-ccccc-ddddd'] + }) + ), + password: t.Optional( + t.String({ + description: + 'A custom password for the document, if null, anyone who has the key will be able to see the content of the document', + examples: ['abc123'] + }) + ), + lifetime: t.Optional( + t.Numeric({ + description: `Number in seconds that the document will exist before it is automatically removed. Set to 0 to make the document permanent. If nothing is set, the default period is: ${defaultDocumentLifetime}`, + examples: ['60', '0'] + }) + ) + }) + ), + response: { + 200: t.Object( + { + key: t.String({ + description: 'The generated key to access the document', + examples: ['abc123'] + }), + secret: t.String({ + description: 'The generated secret to delete the document', + examples: ['aaaaa-bbbbb-ccccc-ddddd'] + }), + url: t.Optional( + t.String({ + description: 'The URL for viewing the document on the web', + examples: ['https://jspaste.eu/abc123'] + }) + ), + expirationTimestamp: t.Optional( + t.Number({ + description: + 'UNIX timestamp with the expiration date in milliseconds. Undefined if the document is permanent.', + examples: [60, 0] + }) + ) + }, + { + description: + 'An object with a key, a secret, the display URL and an expiration timestamp for the document' + } + ), + 400: ErrorSender.errorType() + }, + detail: { summary: 'Publish document', tags: ['v2'] } + }; + + this.server.post( + path, + async ({ errorSender, request, query, body }) => + DocumentHandler.handlePublish( + { + errorSender, + body, + selectedKey: request.headers.get('key') || '', + selectedKeyLength: parseInt(request.headers.get('key-length') ?? '') || undefined, + selectedSecret: request.headers.get('secret') || '', + lifetime: parseInt(request.headers.get('lifetime') || defaultDocumentLifetime.toString()), + password: request.headers.get('password') || query['password'] || '' + }, + ServerVersion.v2 + ), + hook + ); + } +} diff --git a/src/routes/v1/remove.route.ts b/src/routes/RemoveV1.ts similarity index 50% rename from src/routes/v1/remove.route.ts rename to src/routes/RemoveV1.ts index 5f75de5..e39255a 100644 --- a/src/routes/v1/remove.route.ts +++ b/src/routes/RemoveV1.ts @@ -1,21 +1,15 @@ -import { Elysia, t } from 'elysia'; -import { ErrorSender } from '../../classes/ErrorSender'; -import { DocumentHandler } from '../../classes/DocumentHandler.ts'; -import { errorSenderPlugin } from '../../plugins/errorSender.ts'; +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { ErrorSender } from '../classes/ErrorSender.ts'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; -export default new Elysia({ - name: 'routes:v1:documents:remove' -}) - .use(errorSenderPlugin) - .delete( - ':key', - async ({ errorSender, request, params: { key } }) => - DocumentHandler.handleRemove({ - errorSender, - key, - secret: request.headers.get('secret') || '' - }), - { +export class RemoveV1 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { params: t.Object({ key: t.String({ description: 'The document key', @@ -42,5 +36,17 @@ export default new Elysia({ 404: ErrorSender.errorType() }, detail: { summary: 'Remove document', tags: ['v1'] } - } - ); + }; + + this.server.delete( + path.concat('/:key'), + async ({ errorSender, request, params: { key } }) => + DocumentHandler.handleRemove({ + errorSender, + key, + secret: request.headers.get('secret') || '' + }), + hook + ); + } +} diff --git a/src/routes/v2/remove.route.ts b/src/routes/RemoveV2.ts similarity index 50% rename from src/routes/v2/remove.route.ts rename to src/routes/RemoveV2.ts index f8b2fd6..0c736ca 100644 --- a/src/routes/v2/remove.route.ts +++ b/src/routes/RemoveV2.ts @@ -1,21 +1,15 @@ -import { Elysia, t } from 'elysia'; -import { ErrorSender } from '../../classes/ErrorSender'; -import { DocumentHandler } from '../../classes/DocumentHandler.ts'; -import { errorSenderPlugin } from '../../plugins/errorSender.ts'; +import { AbstractRoute } from '../classes/AbstractRoute.ts'; +import { type Elysia, t } from 'elysia'; +import { ErrorSender } from '../classes/ErrorSender.ts'; +import { DocumentHandler } from '../classes/DocumentHandler.ts'; -export default new Elysia({ - name: 'routes:v2:documents:remove' -}) - .use(errorSenderPlugin) - .delete( - ':key', - async ({ errorSender, request, params: { key } }) => - DocumentHandler.handleRemove({ - errorSender, - key, - secret: request.headers.get('secret') || '' - }), - { +export class RemoveV2 extends AbstractRoute { + public constructor(server: Elysia) { + super(server); + } + + public override register(path: string): void { + const hook = { params: t.Object({ key: t.String({ description: 'The document key', @@ -42,5 +36,17 @@ export default new Elysia({ 404: ErrorSender.errorType() }, detail: { summary: 'Remove document', tags: ['v2'] } - } - ); + }; + + this.server.delete( + path.concat('/:key'), + async ({ errorSender, request, params: { key } }) => + DocumentHandler.handleRemove({ + errorSender, + key, + secret: request.headers.get('secret') || '' + }), + hook + ); + } +} diff --git a/src/routes/v1/access.route.ts b/src/routes/v1/access.route.ts deleted file mode 100644 index 0ec94ef..0000000 --- a/src/routes/v1/access.route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Elysia, t } from 'elysia'; -import { ErrorSender } from '../../classes/ErrorSender'; -import { DocumentHandler } from '../../classes/DocumentHandler.ts'; -import { errorSenderPlugin } from '../../plugins/errorSender.ts'; -import { ServerVersion } from '../../utils/constants.ts'; - -export default new Elysia({ - name: 'routes:v1:documents:access' -}) - .use(errorSenderPlugin) - .get( - ':key', - async ({ errorSender, params: { key } }) => - DocumentHandler.handleAccess({ errorSender, key: key }, ServerVersion.v1), - { - params: t.Object({ - key: t.String({ - description: 'The document key', - examples: ['abc123'] - }) - }), - response: { - 200: t.Object( - { - key: t.String({ - description: 'The key of the document', - examples: ['abc123'] - }), - data: t.String({ - description: 'The document', - examples: ['Hello world'] - }) - }, - { description: 'The document object' } - ), - 400: ErrorSender.errorType(), - 404: ErrorSender.errorType() - }, - detail: { summary: 'Get document', tags: ['v1'] } - } - ) - .get( - ':key/raw', - async ({ errorSender, set, params: { key } }) => { - set.headers['Content-Type'] = 'text/plain'; - - return DocumentHandler.handleAccess({ errorSender, key: key, raw: true }, ServerVersion.v1); - }, - { - params: t.Object( - { - key: t.String({ - description: 'The document key', - examples: ['abc123'] - }) - }, - { - description: 'The request parameters', - examples: [{ key: 'abc123' }] - } - ), - response: { - 200: t.Any({ - description: 'The raw document', - examples: ['Hello world'] - }), - 400: ErrorSender.errorType(), - 404: ErrorSender.errorType() - }, - detail: { - summary: 'Get raw document', - tags: ['v1'] - } - } - ); diff --git a/src/routes/v1/index.route.ts b/src/routes/v1/index.route.ts deleted file mode 100644 index dc9e617..0000000 --- a/src/routes/v1/index.route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Elysia, t } from 'elysia'; - -export default new Elysia({ - name: 'routes:v1:documents' -}).get( - '', - () => { - return 'Welcome to JSPaste API v1'; - }, - { - response: t.String({ - description: 'A small welcome message with the current API version', - examples: ['Welcome to JSPaste API v1'] - }), - detail: { summary: 'Index', tags: ['v1'] } - } -); diff --git a/src/routes/v1/publish.route.ts b/src/routes/v1/publish.route.ts deleted file mode 100644 index 67589bb..0000000 --- a/src/routes/v1/publish.route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Elysia, t } from 'elysia'; -import { ErrorSender } from '../../classes/ErrorSender'; -import { DocumentHandler } from '../../classes/DocumentHandler.ts'; -import { errorSenderPlugin } from '../../plugins/errorSender.ts'; -import { ServerVersion } from '../../utils/constants.ts'; - -export default new Elysia({ - name: 'routes:v1:documents:publish' -}) - .use(errorSenderPlugin) - .post('', async ({ errorSender, body }) => DocumentHandler.handlePublish({ errorSender, body }, ServerVersion.v1), { - type: 'arrayBuffer', - body: t.Any({ description: 'The file to be uploaded' }), - response: { - 200: t.Object( - { - key: t.String({ - description: 'The generated key to access the document' - }), - secret: t.String({ - description: 'The generated secret to delete the document' - }) - }, - { description: 'An object with a key and a secret for the document' } - ), - 400: ErrorSender.errorType() - }, - detail: { summary: 'Publish document', tags: ['v1'] } - }); diff --git a/src/routes/v2/access.route.ts b/src/routes/v2/access.route.ts deleted file mode 100644 index e887d07..0000000 --- a/src/routes/v2/access.route.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Elysia, t } from 'elysia'; -import { ErrorSender } from '../../classes/ErrorSender'; -import { errorSenderPlugin } from '../../plugins/errorSender.ts'; -import { DocumentHandler } from '../../classes/DocumentHandler.ts'; -import { ServerVersion } from '../../utils/constants.ts'; - -export default new Elysia({ - name: 'routes:v2:documents:access' -}) - .use(errorSenderPlugin) - .get( - ':key', - async ({ errorSender, request, query: { p }, params: { key } }) => - DocumentHandler.handleAccess( - { - errorSender, - key, - password: request.headers.get('password') || p || '' - }, - ServerVersion.v2 - ), - { - params: t.Object({ - key: t.String({ - description: 'The document key', - examples: ['abc123'] - }) - }), - headers: t.Optional( - t.Object({ - password: t.Optional( - t.String({ - description: 'The document password if aplicable', - examples: ['abc123'] - }) - ) - }) - ), - query: t.Optional( - t.Object({ - p: t.Optional( - t.String({ - description: - 'The document password if aplicable, It is preferred to pass the password through headers, only use this method for support of web browsers.', - examples: ['aaaaa-bbbbb-ccccc-ddddd'] - }) - ) - }) - ), - response: { - 200: t.Object( - { - key: t.String({ - description: 'The key of the document', - examples: ['abc123'] - }), - data: t.String({ - description: 'The document', - examples: ['Hello world'] - }), - url: t.Optional( - t.String({ - description: 'The URL for viewing the document on the web', - examples: ['https://jspaste.eu/abc123'] - }) - ), - expirationTimestamp: t.Optional( - t.Number({ - description: - 'UNIX timestamp with the expiration date in milliseconds. Undefined if the document is permanent.', - examples: [60, 0] - }) - ) - }, - { - description: - 'The document object, including the key, the data, the display URL and an expiration timestamp for the document' - } - ), - 400: ErrorSender.errorType(), - 404: ErrorSender.errorType() - }, - detail: { summary: 'Get document', tags: ['v2'] } - } - ) - .get( - ':key/raw', - async ({ errorSender, set, request, query: { p }, params: { key } }) => { - set.headers['Content-Type'] = 'text/plain'; - - return DocumentHandler.handleAccess( - { - errorSender, - key, - password: request.headers.get('password') || p || '', - raw: true - }, - ServerVersion.v2 - ); - }, - { - params: t.Object( - { - key: t.String({ - description: 'The document key', - examples: ['abc123'] - }) - }, - { - description: 'The request parameters', - examples: [{ key: 'abc123' }] - } - ), - headers: t.Optional( - t.Object({ - password: t.Optional( - t.String({ - description: 'The document password if aplicable', - examples: ['aaaaa-bbbbb-ccccc-ddddd'] - }) - ) - }) - ), - query: t.Optional( - t.Object({ - p: t.Optional( - t.String({ - description: - 'The document password if aplicable, It is preferred to pass the password through headers, only use this method for support of web browsers.', - examples: ['aaaaa-bbbbb-ccccc-ddddd'] - }) - ) - }) - ), - response: { - 200: t.Any({ - description: 'The raw document', - examples: ['Hello world'] - }), - 400: ErrorSender.errorType(), - 404: ErrorSender.errorType() - }, - detail: { - summary: 'Get raw document', - tags: ['v2'] - } - } - ); diff --git a/src/routes/v2/exists.route.ts b/src/routes/v2/exists.route.ts deleted file mode 100644 index 52c6049..0000000 --- a/src/routes/v2/exists.route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Elysia, t } from 'elysia'; -import { ErrorSender } from '../../classes/ErrorSender.ts'; -import { DocumentHandler } from '../../classes/DocumentHandler.ts'; -import { errorSenderPlugin } from '../../plugins/errorSender.ts'; - -export default new Elysia({ - name: 'routes:v2:documents:exists' -}) - .use(errorSenderPlugin) - .get( - ':key/exists', - async ({ errorSender, params: { key } }) => DocumentHandler.handleExists({ errorSender, key: key }), - { - params: t.Object({ - key: t.String({ - description: 'The document key', - examples: ['abc123'] - }) - }), - response: { - 200: t.Boolean({ description: 'A boolean indicating if the document exists' }), - 400: ErrorSender.errorType() - }, - - detail: { summary: 'Check document', tags: ['v2'] } - } - ); diff --git a/src/routes/v2/index.route.ts b/src/routes/v2/index.route.ts deleted file mode 100644 index 5fc6f49..0000000 --- a/src/routes/v2/index.route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Elysia, t } from 'elysia'; - -export default new Elysia({ - name: 'routes:v2:documents' -}).get( - '', - () => { - return 'Welcome to JSPaste API v2'; - }, - { - response: t.String({ - description: 'A small welcome message with the current API version', - examples: ['Welcome to JSPaste API v2'] - }), - detail: { summary: 'Index', tags: ['v2'] } - } -); diff --git a/src/routes/v2/publish.route.ts b/src/routes/v2/publish.route.ts deleted file mode 100644 index da8fd5d..0000000 --- a/src/routes/v2/publish.route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Elysia, t } from 'elysia'; -import { ErrorSender } from '../../classes/ErrorSender'; -import { DocumentHandler } from '../../classes/DocumentHandler.ts'; -import { defaultDocumentLifetime, ServerVersion } from '../../utils/constants.ts'; -import { errorSenderPlugin } from '../../plugins/errorSender.ts'; - -export default new Elysia({ - name: 'routes:v2:documents:publish' -}) - .use(errorSenderPlugin) - .post( - '', - async ({ errorSender, request, query, body }) => - DocumentHandler.handlePublish( - { - errorSender, - body, - selectedSecret: request.headers.get('secret') || '', - lifetime: parseInt(request.headers.get('lifetime') || defaultDocumentLifetime.toString()), - password: request.headers.get('password') || query['password'] || '' - }, - ServerVersion.v2 - ), - { - type: 'arrayBuffer', - body: t.Any({ - description: 'The file to be uploaded' - }), - headers: t.Optional( - t.Object({ - secret: t.Optional( - t.String({ - description: 'The selected secret, if null a new secret will be generated', - examples: ['aaaaa-bbbbb-ccccc-ddddd'] - }) - ), - password: t.Optional( - t.String({ - description: 'The document password, can be null', - examples: ['abc123'] - }) - ), - lifetime: t.Optional( - t.Numeric({ - description: `Number in seconds that the document will exist before it is automatically removed. Set to 0 to make the document permanent. If nothing is set, the default period is: ${defaultDocumentLifetime}`, - examples: [60, 0] - }) - ) - }) - ), - response: { - 200: t.Object( - { - key: t.String({ - description: 'The generated key to access the document', - examples: ['abc123'] - }), - secret: t.String({ - description: 'The generated secret to delete the document', - examples: ['aaaaa-bbbbb-ccccc-ddddd'] - }), - url: t.Optional( - t.String({ - description: 'The URL for viewing the document on the web', - examples: ['https://jspaste.eu/abc123'] - }) - ), - expirationTimestamp: t.Optional( - t.Number({ - description: - 'UNIX timestamp with the expiration date in milliseconds. Undefined if the document is permanent.', - examples: [60, 0] - }) - ) - }, - { - description: - 'An object with a key, a secret, the display URL and an expiration timestamp for the document' - } - ), - 400: ErrorSender.errorType() - }, - detail: { summary: 'Publish document', tags: ['v2'] } - } - ); diff --git a/src/structures/Structures.d.ts b/src/structures/Structures.d.ts new file mode 100644 index 0000000..b42c27c --- /dev/null +++ b/src/structures/Structures.d.ts @@ -0,0 +1,108 @@ +import * as $protobuf from "protobufjs"; +import Long = require("long"); +/** Properties of a DocumentDataStruct. */ +export interface IDocumentDataStruct { + + /** DocumentDataStruct rawFileData */ + rawFileData?: (Uint8Array|null); + + /** DocumentDataStruct secret */ + secret?: (string|null); + + /** DocumentDataStruct expirationTimestamp */ + expirationTimestamp?: (number|Long|null); + + /** DocumentDataStruct password */ + password?: (string|null); +} + +/** Represents a DocumentDataStruct. */ +export class DocumentDataStruct implements IDocumentDataStruct { + + /** + * Constructs a new DocumentDataStruct. + * @param [properties] Properties to set + */ + constructor(properties?: IDocumentDataStruct); + + /** DocumentDataStruct rawFileData. */ + public rawFileData: Uint8Array; + + /** DocumentDataStruct secret. */ + public secret: string; + + /** DocumentDataStruct expirationTimestamp. */ + public expirationTimestamp?: (number|Long|null); + + /** DocumentDataStruct password. */ + public password?: (string|null); + + /** DocumentDataStruct _expirationTimestamp. */ + public _expirationTimestamp?: "expirationTimestamp"; + + /** DocumentDataStruct _password. */ + public _password?: "password"; + + /** + * Encodes the specified DocumentDataStruct message. Does not implicitly {@link DocumentDataStruct.verify|verify} messages. + * @param message DocumentDataStruct message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: IDocumentDataStruct, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified DocumentDataStruct message, length delimited. Does not implicitly {@link DocumentDataStruct.verify|verify} messages. + * @param message DocumentDataStruct message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: IDocumentDataStruct, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a DocumentDataStruct message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns DocumentDataStruct + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): DocumentDataStruct; + + /** + * Decodes a DocumentDataStruct message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns DocumentDataStruct + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): DocumentDataStruct; + + /** + * Verifies a DocumentDataStruct message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a DocumentDataStruct message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns DocumentDataStruct + */ + public static fromObject(object: { [k: string]: any }): DocumentDataStruct; + + /** + * Creates a plain object from a DocumentDataStruct message. Also converts values to other types if specified. + * @param message DocumentDataStruct + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: DocumentDataStruct, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this DocumentDataStruct to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; +} diff --git a/src/structures/Structures.js b/src/structures/Structures.js new file mode 100644 index 0000000..918d60a --- /dev/null +++ b/src/structures/Structures.js @@ -0,0 +1,309 @@ +/*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars*/ +import * as $protobuf from "protobufjs/minimal"; + +// Common aliases +const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util; + +// Exported root namespace +const $root = $protobuf.roots["default"] || ($protobuf.roots["default"] = {}); + +export const DocumentDataStruct = $root.DocumentDataStruct = (() => { + + /** + * Properties of a DocumentDataStruct. + * @exports IDocumentDataStruct + * @interface IDocumentDataStruct + * @property {Uint8Array|null} [rawFileData] DocumentDataStruct rawFileData + * @property {string|null} [secret] DocumentDataStruct secret + * @property {number|Long|null} [expirationTimestamp] DocumentDataStruct expirationTimestamp + * @property {string|null} [password] DocumentDataStruct password + */ + + /** + * Constructs a new DocumentDataStruct. + * @exports DocumentDataStruct + * @classdesc Represents a DocumentDataStruct. + * @implements IDocumentDataStruct + * @constructor + * @param {IDocumentDataStruct=} [properties] Properties to set + */ + function DocumentDataStruct(properties) { + if (properties) + for (let keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * DocumentDataStruct rawFileData. + * @member {Uint8Array} rawFileData + * @memberof DocumentDataStruct + * @instance + */ + DocumentDataStruct.prototype.rawFileData = $util.newBuffer([]); + + /** + * DocumentDataStruct secret. + * @member {string} secret + * @memberof DocumentDataStruct + * @instance + */ + DocumentDataStruct.prototype.secret = ""; + + /** + * DocumentDataStruct expirationTimestamp. + * @member {number|Long|null|undefined} expirationTimestamp + * @memberof DocumentDataStruct + * @instance + */ + DocumentDataStruct.prototype.expirationTimestamp = null; + + /** + * DocumentDataStruct password. + * @member {string|null|undefined} password + * @memberof DocumentDataStruct + * @instance + */ + DocumentDataStruct.prototype.password = null; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + /** + * DocumentDataStruct _expirationTimestamp. + * @member {"expirationTimestamp"|undefined} _expirationTimestamp + * @memberof DocumentDataStruct + * @instance + */ + Object.defineProperty(DocumentDataStruct.prototype, "_expirationTimestamp", { + get: $util.oneOfGetter($oneOfFields = ["expirationTimestamp"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * DocumentDataStruct _password. + * @member {"password"|undefined} _password + * @memberof DocumentDataStruct + * @instance + */ + Object.defineProperty(DocumentDataStruct.prototype, "_password", { + get: $util.oneOfGetter($oneOfFields = ["password"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Encodes the specified DocumentDataStruct message. Does not implicitly {@link DocumentDataStruct.verify|verify} messages. + * @function encode + * @memberof DocumentDataStruct + * @static + * @param {IDocumentDataStruct} message DocumentDataStruct message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + DocumentDataStruct.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.rawFileData != null && Object.hasOwnProperty.call(message, "rawFileData")) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.rawFileData); + if (message.secret != null && Object.hasOwnProperty.call(message, "secret")) + writer.uint32(/* id 2, wireType 2 =*/18).string(message.secret); + if (message.expirationTimestamp != null && Object.hasOwnProperty.call(message, "expirationTimestamp")) + writer.uint32(/* id 3, wireType 0 =*/24).uint64(message.expirationTimestamp); + if (message.password != null && Object.hasOwnProperty.call(message, "password")) + writer.uint32(/* id 4, wireType 2 =*/34).string(message.password); + return writer; + }; + + /** + * Encodes the specified DocumentDataStruct message, length delimited. Does not implicitly {@link DocumentDataStruct.verify|verify} messages. + * @function encodeDelimited + * @memberof DocumentDataStruct + * @static + * @param {IDocumentDataStruct} message DocumentDataStruct message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + DocumentDataStruct.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a DocumentDataStruct message from the specified reader or buffer. + * @function decode + * @memberof DocumentDataStruct + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {DocumentDataStruct} DocumentDataStruct + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + DocumentDataStruct.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + let end = length === undefined ? reader.len : reader.pos + length, message = new $root.DocumentDataStruct(); + while (reader.pos < end) { + let tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + message.rawFileData = reader.bytes(); + break; + } + case 2: { + message.secret = reader.string(); + break; + } + case 3: { + message.expirationTimestamp = reader.uint64(); + break; + } + case 4: { + message.password = reader.string(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a DocumentDataStruct message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof DocumentDataStruct + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {DocumentDataStruct} DocumentDataStruct + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + DocumentDataStruct.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a DocumentDataStruct message. + * @function verify + * @memberof DocumentDataStruct + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + DocumentDataStruct.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + let properties = {}; + if (message.rawFileData != null && message.hasOwnProperty("rawFileData")) + if (!(message.rawFileData && typeof message.rawFileData.length === "number" || $util.isString(message.rawFileData))) + return "rawFileData: buffer expected"; + if (message.secret != null && message.hasOwnProperty("secret")) + if (!$util.isString(message.secret)) + return "secret: string expected"; + if (message.expirationTimestamp != null && message.hasOwnProperty("expirationTimestamp")) { + properties._expirationTimestamp = 1; + if (!$util.isInteger(message.expirationTimestamp) && !(message.expirationTimestamp && $util.isInteger(message.expirationTimestamp.low) && $util.isInteger(message.expirationTimestamp.high))) + return "expirationTimestamp: integer|Long expected"; + } + if (message.password != null && message.hasOwnProperty("password")) { + properties._password = 1; + if (!$util.isString(message.password)) + return "password: string expected"; + } + return null; + }; + + /** + * Creates a DocumentDataStruct message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof DocumentDataStruct + * @static + * @param {Object.} object Plain object + * @returns {DocumentDataStruct} DocumentDataStruct + */ + DocumentDataStruct.fromObject = function fromObject(object) { + if (object instanceof $root.DocumentDataStruct) + return object; + let message = new $root.DocumentDataStruct(); + if (object.rawFileData != null) + if (typeof object.rawFileData === "string") + $util.base64.decode(object.rawFileData, message.rawFileData = $util.newBuffer($util.base64.length(object.rawFileData)), 0); + else if (object.rawFileData.length >= 0) + message.rawFileData = object.rawFileData; + if (object.secret != null) + message.secret = String(object.secret); + if (object.expirationTimestamp != null) + if ($util.Long) + (message.expirationTimestamp = $util.Long.fromValue(object.expirationTimestamp)).unsigned = true; + else if (typeof object.expirationTimestamp === "string") + message.expirationTimestamp = parseInt(object.expirationTimestamp, 10); + else if (typeof object.expirationTimestamp === "number") + message.expirationTimestamp = object.expirationTimestamp; + else if (typeof object.expirationTimestamp === "object") + message.expirationTimestamp = new $util.LongBits(object.expirationTimestamp.low >>> 0, object.expirationTimestamp.high >>> 0).toNumber(true); + if (object.password != null) + message.password = String(object.password); + return message; + }; + + /** + * Creates a plain object from a DocumentDataStruct message. Also converts values to other types if specified. + * @function toObject + * @memberof DocumentDataStruct + * @static + * @param {DocumentDataStruct} message DocumentDataStruct + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + DocumentDataStruct.toObject = function toObject(message, options) { + if (!options) + options = {}; + let object = {}; + if (options.defaults) { + if (options.bytes === String) + object.rawFileData = ""; + else { + object.rawFileData = []; + if (options.bytes !== Array) + object.rawFileData = $util.newBuffer(object.rawFileData); + } + object.secret = ""; + } + if (message.rawFileData != null && message.hasOwnProperty("rawFileData")) + object.rawFileData = options.bytes === String ? $util.base64.encode(message.rawFileData, 0, message.rawFileData.length) : options.bytes === Array ? Array.prototype.slice.call(message.rawFileData) : message.rawFileData; + if (message.secret != null && message.hasOwnProperty("secret")) + object.secret = message.secret; + if (message.expirationTimestamp != null && message.hasOwnProperty("expirationTimestamp")) { + if (typeof message.expirationTimestamp === "number") + object.expirationTimestamp = options.longs === String ? String(message.expirationTimestamp) : message.expirationTimestamp; + else + object.expirationTimestamp = options.longs === String ? $util.Long.prototype.toString.call(message.expirationTimestamp) : options.longs === Number ? new $util.LongBits(message.expirationTimestamp.low >>> 0, message.expirationTimestamp.high >>> 0).toNumber(true) : message.expirationTimestamp; + if (options.oneofs) + object._expirationTimestamp = "expirationTimestamp"; + } + if (message.password != null && message.hasOwnProperty("password")) { + object.password = message.password; + if (options.oneofs) + object._password = "password"; + } + return object; + }; + + /** + * Converts this DocumentDataStruct to JSON. + * @function toJSON + * @memberof DocumentDataStruct + * @instance + * @returns {Object.} JSON object + */ + DocumentDataStruct.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return DocumentDataStruct; +})(); + +export { $root as default }; diff --git a/src/structures/documentStruct.proto b/src/structures/documentStruct.proto index a8a3ab2..f6793ea 100644 --- a/src/structures/documentStruct.proto +++ b/src/structures/documentStruct.proto @@ -1,5 +1,3 @@ -//npx protoc --proto_path=".\src\structures" --ts_out=".\src\structures" "documentStruct.proto" - syntax = "proto3"; option optimize_for = SPEED; diff --git a/src/structures/documentStruct.ts b/src/structures/documentStruct.ts deleted file mode 100644 index e771534..0000000 --- a/src/structures/documentStruct.ts +++ /dev/null @@ -1,105 +0,0 @@ -// @generated by protobuf-ts 2.9.3 -// @generated from protobuf file "documentStruct.proto" (syntax proto3) -// tslint:disable -// -//npx protoc --proto_path=".\src\structures" --ts_out=".\src\structures" "documentStruct.proto" -// -import type { BinaryWriteOptions } from "@protobuf-ts/runtime"; -import type { IBinaryWriter } from "@protobuf-ts/runtime"; -import { WireType } from "@protobuf-ts/runtime"; -import type { BinaryReadOptions } from "@protobuf-ts/runtime"; -import type { IBinaryReader } from "@protobuf-ts/runtime"; -import { UnknownFieldHandler } from "@protobuf-ts/runtime"; -import type { PartialMessage } from "@protobuf-ts/runtime"; -import { reflectionMergePartial } from "@protobuf-ts/runtime"; -import { MessageType } from "@protobuf-ts/runtime"; -/** - * @generated from protobuf message DocumentDataStruct - */ -export interface DocumentDataStruct { - /** - * @generated from protobuf field: bytes raw_file_data = 1; - */ - rawFileData: Uint8Array; - /** - * @generated from protobuf field: string secret = 2; - */ - secret: string; - /** - * @generated from protobuf field: optional uint64 expiration_timestamp = 3; - */ - expirationTimestamp?: bigint; - /** - * @generated from protobuf field: optional string password = 4; - */ - password?: string; -} -// @generated message type with reflection information, may provide speed optimized methods -class DocumentDataStruct$Type extends MessageType { - constructor() { - super("DocumentDataStruct", [ - { no: 1, name: "raw_file_data", kind: "scalar", T: 12 /*ScalarType.BYTES*/ }, - { no: 2, name: "secret", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 3, name: "expiration_timestamp", kind: "scalar", opt: true, T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }, - { no: 4, name: "password", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } - ]); - } - create(value?: PartialMessage): DocumentDataStruct { - const message = globalThis.Object.create((this.messagePrototype!)); - message.rawFileData = new Uint8Array(0); - message.secret = ""; - if (value !== undefined) - reflectionMergePartial(this, message, value); - return message; - } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DocumentDataStruct): DocumentDataStruct { - let message = target ?? this.create(), end = reader.pos + length; - while (reader.pos < end) { - let [fieldNo, wireType] = reader.tag(); - switch (fieldNo) { - case /* bytes raw_file_data */ 1: - message.rawFileData = reader.bytes(); - break; - case /* string secret */ 2: - message.secret = reader.string(); - break; - case /* optional uint64 expiration_timestamp */ 3: - message.expirationTimestamp = reader.uint64().toBigInt(); - break; - case /* optional string password */ 4: - message.password = reader.string(); - break; - default: - let u = options.readUnknownField; - if (u === "throw") - throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); - let d = reader.skip(wireType); - if (u !== false) - (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); - } - } - return message; - } - internalBinaryWrite(message: DocumentDataStruct, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { - /* bytes raw_file_data = 1; */ - if (message.rawFileData.length) - writer.tag(1, WireType.LengthDelimited).bytes(message.rawFileData); - /* string secret = 2; */ - if (message.secret !== "") - writer.tag(2, WireType.LengthDelimited).string(message.secret); - /* optional uint64 expiration_timestamp = 3; */ - if (message.expirationTimestamp !== undefined) - writer.tag(3, WireType.Varint).uint64(message.expirationTimestamp); - /* optional string password = 4; */ - if (message.password !== undefined) - writer.tag(4, WireType.LengthDelimited).string(message.password); - let u = options.writeUnknownFields; - if (u !== false) - (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); - return writer; - } -} -/** - * @generated MessageType for protobuf message DocumentDataStruct - */ -export const DocumentDataStruct = new DocumentDataStruct$Type(); diff --git a/src/utils/StringUtils.ts b/src/utils/StringUtils.ts index 036bb6b..7a65d01 100644 --- a/src/utils/StringUtils.ts +++ b/src/utils/StringUtils.ts @@ -1,19 +1,28 @@ -import { basePath, characters, type Range } from './constants.ts'; +import { base64URL, basePath, type Range } from './constants.ts'; export class StringUtils { public static random(length: number, base: Range<2, 64> = 62): string { - const baseSet = characters.slice(0, base); + const baseSet = base64URL.slice(0, base); + let string = ''; while (length--) string += baseSet.charAt(Math.floor(Math.random() * baseSet.length)); + return string; } - public static async createKey(length: Range<6, 16> = 8): Promise { - const key = StringUtils.random(length, 64); - const exists = await Bun.file(basePath + key).exists(); + public static generateKey(length: Range<2, 32> = 8): string { + return StringUtils.random(length, 64); + } + + public static async keyExists(key: string): Promise { + return Bun.file(basePath + key).exists(); + } + + public static async createKey(length: Range<2, 32> = 8): Promise { + const key = StringUtils.generateKey(length); - return exists ? StringUtils.createKey() : key; + return (await StringUtils.keyExists(key)) ? StringUtils.createKey((length + 1) as Range<2, 32>) : key; } public static createSecret(chunkLength: number = 5, chunks: number = 4): string { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 5b4e8c8..89a6ef1 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -3,18 +3,6 @@ import type { ZlibCompressionOptions } from 'bun'; import type { JSPError } from '../classes/ErrorSender.ts'; import * as env from 'env-var'; -// interface Bun.env -declare module 'bun' { - interface Env { - PORT: number; - DOCS_ENABLED: boolean; - DOCS_PATH: string; - DOCS_PLAYGROUND_HTTPS: boolean; - DOCS_PLAYGROUND_DOMAIN: string; - DOCS_PLAYGROUND_PORT: number; - } -} - export enum ServerVersion { v1 = 1, v2 = 2 @@ -33,17 +21,22 @@ export enum JSPErrorCode { documentInvalidPassword = 'jsp.document.invalid_password', documentInvalidLength = 'jsp.document.invalid_length', documentInvalidSecret = 'jsp.document.invalid_secret', - documentInvalidSecretLength = 'jsp.document.invalid_secret_length' + documentInvalidSecretLength = 'jsp.document.invalid_secret_length', + documentInvalidKeyLength = 'jsp.document.invalid_key_length', + documentKeyAlreadyExists = 'jsp.document.key_already_exists' } export const serverConfig: Required = { + tls: env.get('TLS').asBoolStrict() ?? false, + domain: env.get('DOMAIN').default('localhost').asString(), port: env.get('PORT').default(4000).asPortNumber(), versions: [ServerVersion.v1, ServerVersion.v2], + files: {}, docs: { enabled: env.get('DOCS_ENABLED').asBoolStrict() ?? true, path: env.get('DOCS_PATH').default('/docs').asString(), playground: { - https: env.get('DOCS_PLAYGROUND_HTTPS').asBoolStrict() ?? true, + tls: env.get('DOCS_PLAYGROUND_TLS').asBoolStrict() ?? true, domain: env.get('DOCS_PLAYGROUND_DOMAIN').default('jspaste.eu').asString(), port: env.get('DOCS_PLAYGROUND_PORT').default(443).asPortNumber() } @@ -58,8 +51,7 @@ export const zlibConfig: ZlibCompressionOptions = { export const basePath = process.env['DOCUMENTS_PATH'] || 'documents/'; export const maxDocLength = parseInt(process.env['MAX_FILE_LENGTH'] || '2000000'); export const defaultDocumentLifetime = parseInt(process.env['DEFAULT_DOCUMENT_LIFETIME'] || '86400'); -export const viewDocumentPath = process.env['VIEW_DOCUMENTS_PATH'] || 'https://jspaste.eu/'; -export const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'; +export const base64URL = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_' as const; export const JSPErrorMessage: Record = { [JSPErrorCode.unknown]: { @@ -126,8 +118,18 @@ export const JSPErrorMessage: Record = { type: 'error', errorCode: JSPErrorCode.documentInvalidSecretLength, message: 'The provided secret length is invalid' + }, + [JSPErrorCode.documentInvalidKeyLength]: { + type: 'error', + errorCode: JSPErrorCode.documentInvalidKeyLength, + message: 'The provided key length is invalid' + }, + [JSPErrorCode.documentKeyAlreadyExists]: { + type: 'error', + errorCode: JSPErrorCode.documentKeyAlreadyExists, + message: 'The provided key already exists' } -}; +} as const; // https://github.com/microsoft/TypeScript/issues/43505 export type Range< diff --git a/tsconfig.json b/tsconfig.json index 11214e7..ab75125 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,8 +24,10 @@ "noImplicitReturns": true, "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "verbatimModuleSyntax": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*"], "exclude": ["dist/", "documents/", "node_modules/"] } \ No newline at end of file