diff --git a/.env.example b/.env.example index 7ca898c..cbf4ef0 100644 --- a/.env.example +++ b/.env.example @@ -23,18 +23,21 @@ ########## ## SERVER: ########## -## Set log verbosity [3]:integer -#? (0=none <- 1=error <- 2=warn <- 3=info <- 4=debug) -#LOGLEVEL=3 +## Set log verbosity [3]:integer<0-4> +#? (0=none; 1=error; 2=warn; 3=info; 4=debug) +#JSPB_LOGLEVEL=3 ## Port for the server [4000]:integer -#PORT=4000 +#JSPB_PORT=4000 -## Is website served over HTTPS? [true]:boolean -#TLS=true +## Is website served over HTTPS? [false]:boolean +#JSPB_TLS=false ############ ## DOCUMENT: ############ -## Maximum document size in kilobytes [1024]:integer -#DOCUMENT_MAXSIZE=1024 \ No newline at end of file +## Maximum uploaded size in kilobytes [1024]:integer +#JSPB_DOCUMENT_MAXSIZE=1024 + +## Set document compression level [1]:integer<0-22> +#JSPB_DOCUMENT_COMPRESSION_LEVEL=1 \ No newline at end of file diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 5a15180..8e76135 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -85,37 +85,29 @@ jobs: - name: Build artifact run: | - bun run build:server - - bun run build:standalone:darwin-arm64 - chmod 755 ./dist/server - tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ server | xz -z -6 >./dist/backend_${{ steps.tags.outputs.tag }}_darwin-arm64.tar.xz - tar -tJf ./dist/backend_${{ steps.tags.outputs.tag }}_darwin-arm64.tar.xz >/dev/null - - bun run build:standalone:linux-amd64-glibc - chmod 755 ./dist/server - tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ server | xz -z -6 >./dist/backend_${{ steps.tags.outputs.tag }}_linux-amd64-glibc.tar.xz - tar -tJf ./dist/backend_${{ steps.tags.outputs.tag }}_linux-amd64-glibc.tar.xz >/dev/null - - bun run build:standalone:linux-amd64-musl - chmod 755 ./dist/server - tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ server | xz -z -6 >./dist/backend_${{ steps.tags.outputs.tag }}_linux-amd64-musl.tar.xz - tar -tJf ./dist/backend_${{ steps.tags.outputs.tag }}_linux-amd64-musl.tar.xz >/dev/null - - bun run build:standalone:linux-arm64-glibc - chmod 755 ./dist/server - tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ server | xz -z -6 >./dist/backend_${{ steps.tags.outputs.tag }}_linux-arm64-glibc.tar.xz - tar -tJf ./dist/backend_${{ steps.tags.outputs.tag }}_linux-arm64-glibc.tar.xz >/dev/null - - bun run build:standalone:linux-arm64-musl - chmod 755 ./dist/server - tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ server | xz -z -6 >./dist/backend_${{ steps.tags.outputs.tag }}_linux-arm64-musl.tar.xz - tar -tJf ./dist/backend_${{ steps.tags.outputs.tag }}_linux-arm64-musl.tar.xz >/dev/null - - bun run build:standalone:windows-amd64 - chmod 755 ./dist/server.exe - zip -j -X -9 -l -o ./dist/backend_${{ steps.tags.outputs.tag }}_windows-amd64.zip .env.example LICENSE README.md ./dist/server.exe - zip -T ./dist/backend_${{ steps.tags.outputs.tag }}_windows-amd64.zip + bun run build:standalone:darwin-arm64 --outfile=./dist/backend + chmod 755 ./dist/backend + tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend | xz -z -6 >./dist/backend-${{ steps.tags.outputs.tag }}_darwin-arm64.tar.xz + tar -tJf ./dist/backend-${{ steps.tags.outputs.tag }}_darwin-arm64.tar.xz >/dev/null + + bun run build:standalone:linux-amd64-glibc --outfile=./dist/backend.glibc + chmod 755 ./dist/backend.glibc + bun run build:standalone:linux-amd64-musl --outfile=./dist/backend.musl + chmod 755 ./dist/backend.musl + tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend.glibc backend.musl | xz -z -6 >./dist/backend-${{ steps.tags.outputs.tag }}_linux-amd64.tar.xz + tar -tJf ./dist/backend-${{ steps.tags.outputs.tag }}_linux-amd64.tar.xz >/dev/null + + bun run build:standalone:linux-arm64-glibc --outfile=./dist/backend.glibc + chmod 755 ./dist/backend.glibc + bun run build:standalone:linux-arm64-musl --outfile=./dist/backend.musl + chmod 755 ./dist/backend.musl + tar -c --owner=0 --group=0 --mtime='now' --utc .env.example LICENSE README.md -C ./dist/ backend.glibc backend.musl | xz -z -6 >./dist/backend-${{ steps.tags.outputs.tag }}_linux-arm64.tar.xz + tar -tJf ./dist/backend-${{ steps.tags.outputs.tag }}_linux-arm64.tar.xz >/dev/null + + bun run build:standalone:windows-amd64 --outfile=./dist/backend.exe + chmod 755 ./dist/backend.exe + zip -j -X -9 -l -o ./dist/backend_${{ steps.tags.outputs.tag }}_windows-amd64.zip .env.example LICENSE README.md ./dist/backend.exe + zip -T ./dist/backend-${{ steps.tags.outputs.tag }}_windows-amd64.zip - if: inputs.artifact-action == 'build-release' name: Release artifact diff --git a/Dockerfile b/Dockerfile index aa22e5e..a869dcb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ -FROM --platform=$BUILDPLATFORM docker.io/oven/bun:1-alpine AS builder-standalone +FROM --platform=$BUILDPLATFORM docker.io/oven/bun:1-alpine AS builder WORKDIR /build/ + COPY . ./ RUN bun install --frozen-lockfile \ @@ -15,16 +16,17 @@ ARG TARGETARCH RUN bun run build:standalone -FROM --platform=$BUILDPLATFORM docker.io/library/alpine:3.21 +FROM --platform=$BUILDPLATFORM docker.io/library/alpine:3.22 AS dist RUN apk add --no-cache libstdc++ -COPY --from=builder-standalone /tmp/.backend.passwd /etc/passwd -COPY --from=builder-standalone /etc/group /etc/group +COPY --from=builder /tmp/.backend.passwd /etc/passwd +COPY --from=builder /etc/group /etc/group WORKDIR /backend/ -COPY --chown=jspaste:jspaste --from=builder-standalone /build/dist/server ./ -COPY --chown=jspaste:jspaste --from=builder-standalone /build/LICENSE ./ + +COPY --chown=jspaste:jspaste --from=builder /build/dist/server ./ +COPY --chown=jspaste:jspaste --from=builder /build/LICENSE ./ LABEL org.opencontainers.image.created="0001-01-01T00:00:00Z" \ org.opencontainers.image.description="JSPaste Backend" \ @@ -39,6 +41,4 @@ EXPOSE 4000 VOLUME /backend/storage/ -USER jspaste:jspaste - ENTRYPOINT ["/backend/server"] \ No newline at end of file diff --git a/biome.json b/biome.json index d6ca367..ae6a01c 100644 --- a/biome.json +++ b/biome.json @@ -1,9 +1,15 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.0.0-beta.6/schema.json", "files": { - "ignore": ["./dist/**", "./storage/**", "*.spec.ts"], + "includes": ["**", "!dist/**", "!node_modules/**", "!storage/**", "!**/*.spec.ts"], "ignoreUnknown": true }, + "assist": { + "enabled": true, + "actions": { + "recommended": true + } + }, "formatter": { "enabled": true, "formatWithErrors": false, @@ -16,41 +22,27 @@ "enabled": true, "rules": { "recommended": true, - "complexity": { - "noStaticOnlyClass": "off" - }, - "style": { - "noParameterAssign": "off" - }, "suspicious": { - "noConsoleLog": "error" + "noConsole": { + "fix": "none", + "level": "error", + "options": { + "allow": [] + } + } } } }, - "css": { - "formatter": { - "enabled": true - } - }, "javascript": { "formatter": { "arrowParentheses": "always", "bracketSameLine": false, "bracketSpacing": true, - "enabled": true, "jsxQuoteStyle": "single", "quoteProperties": "asNeeded", "quoteStyle": "single", "semicolons": "always", "trailingCommas": "none" } - }, - "json": { - "formatter": { - "enabled": true - } - }, - "organizeImports": { - "enabled": true } } diff --git a/bun.lock b/bun.lock index 2b18e6f..25e7886 100644 --- a/bun.lock +++ b/bun.lock @@ -2,14 +2,16 @@ "lockfileVersion": 1, "workspaces": { "": { + "name": "@jspaste/backend", "dependencies": { "@hono/zod-openapi": "~0.19.0", "env-var": "~7.5.0", "hono": "~4.7.0", + "node-cron": "^4.0.0", }, "devDependencies": { - "@biomejs/biome": "~1.9.0", - "@types/bun": "^1.2.0", + "@biomejs/biome": "2.0.0-beta.6", + "@types/bun": "^1.0.0", "lefthook": "~1.11.0", "sort-package-json": "^3.0.0", }, @@ -23,37 +25,35 @@ "lefthook", ], "packages": { - "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-7tE/r1gXwMIvGnXVUdIqUhCU1RevEFC4Jk6Bussa0fk1ecbnnINkZzj1EOAJyE/M3AI25DnHT/zKQL1/FPFi8Q=="], + "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.3", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-ioiw+R+gBGAUwmDp+/gJA16tedBivzDaji5wOvWej0ZYDE0CXTSSfJfXbrBIuWKh6JQhuXgNDniJdeDueKUZTA=="], - "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], + "@biomejs/biome": ["@biomejs/biome@2.0.0-beta.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.0.0-beta.6", "@biomejs/cli-darwin-x64": "2.0.0-beta.6", "@biomejs/cli-linux-arm64": "2.0.0-beta.6", "@biomejs/cli-linux-arm64-musl": "2.0.0-beta.6", "@biomejs/cli-linux-x64": "2.0.0-beta.6", "@biomejs/cli-linux-x64-musl": "2.0.0-beta.6", "@biomejs/cli-win32-arm64": "2.0.0-beta.6", "@biomejs/cli-win32-x64": "2.0.0-beta.6" }, "bin": { "biome": "bin/biome" } }, "sha512-14vw9b5QJxrcP7WLkCeRiB/fft9wNZwx6yEiikBDxFbN7IAp39Xtvt/gJPq4ifhZ5IS25CnQEAkLLwfBIDMjsA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.0.0-beta.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L7PBLJlGTz5anougOMJQvEbzgG9sT1wKIXvgjFhu0dIsDZ/px2caWFCnv7Q9L2K0+yF08EYRTTZVvoVO5D//sQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.0.0-beta.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-ekhOOyhcVJ1ZRqHjq+eUOv8/3XMRKQ9Qf0URuO/PvHgopejv+PEoix0RIyxholYELKc049M4J3IJgsX4q2pZzw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.0.0-beta.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-pu+rCLI36ziPtwnJY53HRr154711uVeCt1i2KNXehvwNZZMK141wwg4yPkXkBdBvw7H7sez0HE/rCQR2fByJnQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.0.0-beta.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-70WOWJI1/vZ97OUAt6r9HpiP5+vlL7yAdIoVQzVLjQy1TArfltN38KKqp9fnhgX173liUh0gry//MrWkKHYrIQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.0.0-beta.6", "", { "os": "linux", "cpu": "x64" }, "sha512-emqZAuAyRw4Ug4B+CTgozIxVg1QLol28oZyIWuIjWEDr7eOo6Ek9zSZGeusmbwIEPu6r6qon8JAV6OdukxEwIg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.0.0-beta.6", "", { "os": "linux", "cpu": "x64" }, "sha512-G9ZIoaNs6q9+mOoMURoXvNRfCOs28jrS4R8+3/y0h9ttOXpd4VALPOAfjzBGPpMd/4RoEMHXw/1Ts4dKvrv9zw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.0.0-beta.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-JijYVZC6R5qq94yLaElowLLzbZ4xR2qDiOVPQV8H1+ru3IqVOjQu5f/lIt4uuea1iRFbxS+mOaxOZM9tUl1pTQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.0.0-beta.6", "", { "os": "win32", "cpu": "x64" }, "sha512-zs29t/nxon11dKV+ckQB1yUOmhYx17e2+cHGK8PCVamqVGSMbjrd5evjtlfbnVJXP0ar7nNKhcg4ZWYGJ6aR1w=="], - "@hono/zod-openapi": ["@hono/zod-openapi@0.19.2", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.1.0", "@hono/zod-validator": "^0.4.1" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "3.*" } }, "sha512-lkFa6wdQVgY7d7/m++Ixr3hvKCF5Y+zjTIPM37fex5ylCfX53A/W28gZRDuFZx3aR+noKob7lHfwdk9dURLzxw=="], + "@hono/zod-openapi": ["@hono/zod-openapi@0.19.8", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@hono/zod-validator": "^0.7.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "3.*" } }, "sha512-CHUSW0K+bDGUYXovxQSbVjZffzoPeTsGu6wevPoGSmBdPuUw5yZqDeomnvyAAAAvEjhLQPlAsUyASc1Zi35exQ=="], - "@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="], + "@hono/zod-validator": ["@hono/zod-validator@0.7.0", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0" } }, "sha512-qe2ZE6sHFE98dcUrbYMtS3bAV8hqcCOflykvZga2S7XhmNSZzT+dIz4OuMILsjLHkJw9JMn912/dB7dQOmuPvg=="], - "@types/bun": ["@types/bun@1.2.5", "", { "dependencies": { "bun-types": "1.2.5" } }, "sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg=="], + "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], - "@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], + "@types/node": ["@types/node@22.15.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA=="], - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], - - "bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="], + "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], "detect-indent": ["detect-indent@7.0.1", "", {}, "sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g=="], @@ -61,56 +61,56 @@ "env-var": ["env-var@7.5.0", "", {}, "sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA=="], - "fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="], - - "get-stdin": ["get-stdin@9.0.0", "", {}, "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA=="], + "fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="], - "git-hooks-list": ["git-hooks-list@3.2.0", "", {}, "sha512-ZHG9a1gEhUMX1TvGrLdyWb9kDopCBbTnI8z4JgRMYxsijWipgjSEYoPWqBuIB0DnRnvqlQSEeVmzpeuPm7NdFQ=="], + "git-hooks-list": ["git-hooks-list@4.1.1", "", {}, "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA=="], - "hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="], + "hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "lefthook": ["lefthook@1.11.3", "", { "optionalDependencies": { "lefthook-darwin-arm64": "1.11.3", "lefthook-darwin-x64": "1.11.3", "lefthook-freebsd-arm64": "1.11.3", "lefthook-freebsd-x64": "1.11.3", "lefthook-linux-arm64": "1.11.3", "lefthook-linux-x64": "1.11.3", "lefthook-openbsd-arm64": "1.11.3", "lefthook-openbsd-x64": "1.11.3", "lefthook-windows-arm64": "1.11.3", "lefthook-windows-x64": "1.11.3" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-HJp37y62j3j8qzAOODWuUJl4ysLwsDvCTBV6odr3jIRHR/a5e+tI14VQGIBcpK9ysqC3pGWyW5Rp9Jv1YDubyw=="], + "lefthook": ["lefthook@1.11.13", "", { "optionalDependencies": { "lefthook-darwin-arm64": "1.11.13", "lefthook-darwin-x64": "1.11.13", "lefthook-freebsd-arm64": "1.11.13", "lefthook-freebsd-x64": "1.11.13", "lefthook-linux-arm64": "1.11.13", "lefthook-linux-x64": "1.11.13", "lefthook-openbsd-arm64": "1.11.13", "lefthook-openbsd-x64": "1.11.13", "lefthook-windows-arm64": "1.11.13", "lefthook-windows-x64": "1.11.13" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-SDTk3D4nW1XRpR9u9fdYQ/qj1xeZVIwZmIFdJUnyq+w9ZLdCCvIrOmtD8SFiJowSevISjQDC+f9nqyFXUxc0SQ=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@1.11.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gHwHofXupCtzNLN+8esdWfFTnAEkmBxE/WKA0EwxPPJXdZYa1GUsiG5ipq/CdG/0j8ekYyM9Hzyrrk5BqJ42xw=="], - "lefthook-darwin-arm64": ["lefthook-darwin-arm64@1.11.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IYzAOf8Qwqk7q+LoRyy7kSk9vzpUZ5wb/vLzEAH/F86Vay9AUaWe1f2pzeLwFg18qEc1QNklT69h9p/uLQMojA=="], + "lefthook-darwin-x64": ["lefthook-darwin-x64@1.11.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-zYxkWNUirmTidhskY9J9AwxvdMi3YKH+TqZ3AQ1EOqkOwPBWJQW5PbnzsXDrd3YnrtZScYm/tE/moXJpEPPIpQ=="], - "lefthook-darwin-x64": ["lefthook-darwin-x64@1.11.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-z/Wp7UMjE1Vyl+x9sjN3NvN6qKdwgHl+cDf98MKKDg/WyPE5XnzqLm9rLLJgImjyClfH7ptTfZxEyhTG3M3XvQ=="], + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@1.11.13", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gJzWnllcMcivusmPorEkXPpEciKotlBHn7QxWwYaSjss/U3YdZu+NTjDO30b3qeiVlyq4RAZ4BPKJODXxHHwUA=="], - "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@1.11.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QevwQ7lrv5wBCkk7LLTzT5KR3Bk/5nttSxT1UH2o0EsgirS/c2K5xSgQmV6m3CiZYuCe2Pja4BSIwN3zt17SMw=="], + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@1.11.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-689XdchgtDvZQWFFx1szUvm/mqrq/v6laki0odq5FAfcSgUeLu3w+z6UicBS5l55eFJuQTDNKARFqrKJ04e+Vw=="], - "lefthook-freebsd-x64": ["lefthook-freebsd-x64@1.11.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PYbcyNgdJJ4J2pEO9Ss4oYo5yq4vmQGTKm3RTYbRx4viSWR65hvKCP0C4LnIqspMvmR05SJi2bqe7UBP2t60EA=="], + "lefthook-linux-arm64": ["lefthook-linux-arm64@1.11.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-ujCLbaZg5S/Ao8KZAcNSb+Y3gl898ZEM0YKyiZmZo22dFFpm/5gcV46pF3xaqIw5IpH+3YYDTKDU+qTetmARyQ=="], - "lefthook-linux-arm64": ["lefthook-linux-arm64@1.11.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0pBMBAoafOAEg345eOPozsmRjWR0zCr6k+m5ZxwRBZbZx1bQFDqBakQ3TpFCphhcykmgFyaa1KeZJZUOrEsezA=="], + "lefthook-linux-x64": ["lefthook-linux-x64@1.11.13", "", { "os": "linux", "cpu": "x64" }, "sha512-O5WdodeBtFOXQlvPcckqp4W/yqVM9DbVQBkvOxwSJlmsxO4sGYK1TqdxH9ihLB85B2kPPssZj9ze36/oizzhVQ=="], - "lefthook-linux-x64": ["lefthook-linux-x64@1.11.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eiezheZ/bisBCMB2Ur0mctug/RDFyu39B5wzoE8y4z0W1yw6jHGrWMJ4Y8+5qKZ7fmdZg+7YPuMHZ2eFxOnhQA=="], + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@1.11.13", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-SyBpciUfvY/lUDbZu7L6MtL/SVG2+yMTckBgb4PdJQhJlisY0IsyOYdlTw2icPPrY7JnwdsFv8UW0EJOB76W4g=="], - "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@1.11.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DRLTzXdtCj/TizpLcGSqXcnrqvgxeXgn/6nqzclIGqNdKCsNXDzpI0D3sP13Vwwmyoqv2etoTak2IHqZiXZDqg=="], + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@1.11.13", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6+/0j6O2dzo9cjTWUKfL2J6hRR7Krna/ssqnW8cWh8QHZKO9WJn34epto9qgjeHwSysou8byI7Mwv5zOGthLCQ=="], - "lefthook-openbsd-x64": ["lefthook-openbsd-x64@1.11.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-l7om+ZjWpYrVZyDuElwnucZhEqa7YfwlRaKBenkBxEh2zMje8O6Zodeuma1KmyDbSFvnvEjARo/Ejiot4gLXEw=="], + "lefthook-windows-arm64": ["lefthook-windows-arm64@1.11.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-w5TwZ8bsZ17uOMtYGc5oEb4tCHjNTSeSXRy6H9Yic8E7IsPZtZLkaZGnIIwgXFuhhrcCdc6FuTvKt2tyV7EW2g=="], - "lefthook-windows-arm64": ["lefthook-windows-arm64@1.11.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-X0iTrql2gfPAkU2dzRwuHWgW5RcqCPbzJtKQ41X6Y/F7iQacRknmuYUGyC81funSvzGAsvlusMVLUvaFjIKnbA=="], + "lefthook-windows-x64": ["lefthook-windows-x64@1.11.13", "", { "os": "win32", "cpu": "x64" }, "sha512-7lvwnIs8CNOXKU4y3i1Pbqna+QegIORkSD2VCuHBNpIJ8H84NpjoG3tKU91IM/aI1a2eUvCk+dw+1rfMRz7Ytg=="], - "lefthook-windows-x64": ["lefthook-windows-x64@1.11.3", "", { "os": "win32", "cpu": "x64" }, "sha512-F+ORMn6YJXoS0EXU5LtN1FgV4QX9rC9LucZEkRmK6sKmS7hcb9IHpyb7siRGytArYzJvXVjPbxPBNSBdN4egZQ=="], + "node-cron": ["node-cron@4.1.0", "", {}, "sha512-OS+3ORu+h03/haS6Di8Qr7CrVs4YaKZZOynZwQpyPZDnR3tqRbwJmuP2gVR16JfhLgyNlloAV1VTrrWlRogCFA=="], "openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], - "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "sort-object-keys": ["sort-object-keys@1.1.3", "", {}, "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg=="], - "sort-package-json": ["sort-package-json@3.0.0", "", { "dependencies": { "detect-indent": "^7.0.1", "detect-newline": "^4.0.1", "get-stdin": "^9.0.0", "git-hooks-list": "^3.0.0", "is-plain-obj": "^4.1.0", "semver": "^7.7.1", "sort-object-keys": "^1.1.3", "tinyglobby": "^0.2.12" }, "bin": { "sort-package-json": "cli.js" } }, "sha512-vfZWx4DnFNB8R9Vg4Dnx21s20auNzWH15ZaCBfADAiyrCwemRmhWstTgvLjMek1DW3+MHcNaqkp86giCF24rMA=="], + "sort-package-json": ["sort-package-json@3.2.1", "", { "dependencies": { "detect-indent": "^7.0.1", "detect-newline": "^4.0.1", "git-hooks-list": "^4.0.0", "is-plain-obj": "^4.1.0", "semver": "^7.7.1", "sort-object-keys": "^1.1.3", "tinyglobby": "^0.2.12" }, "bin": { "sort-package-json": "cli.js" } }, "sha512-rTfRdb20vuoAn7LDlEtCqOkYfl2X+Qze6cLbNOzcDpbmKEhJI30tTN44d5shbKJnXsvz24QQhlCm81Bag7EOKg=="], - "tinyglobby": ["tinyglobby@0.2.12", "", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="], + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], - "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "yaml": ["yaml@2.7.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="], + "yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], - "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], + "zod": ["zod@3.25.56", "", {}, "sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ=="], } } diff --git a/bunfig.toml b/bunfig.toml index 4f06890..517a089 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -6,4 +6,4 @@ saveTextLockfile = true [run] bun = true -silent = true \ No newline at end of file +silent = true diff --git a/package.json b/package.json index 4e5d72d..4005c90 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,19 @@ { "$schema": "https://json.schemastore.org/package.json", + "name": "@jspaste/backend", "private": true, "license": "EUPL-1.2", "type": "module", "scripts": { "build": "bun run build:server", - "build:all": "bun run build:server && bun run build:standalone", - "build:server": "bun build ./src/server.ts --target=bun --minify --sourcemap=inline --outfile=./dist/server.js", - "build:standalone": "bun build ./dist/server.js --compile --minify --sourcemap=inline --outfile=./dist/server", - "build:standalone:darwin-arm64": "bun run build:standalone -- --target=bun-darwin-arm64", - "build:standalone:linux-amd64-glibc": "bun run build:standalone -- --target=bun-linux-x64-modern", - "build:standalone:linux-amd64-musl": "bun run build:standalone -- --target=bun-linux-x64-modern-musl", - "build:standalone:linux-arm64-glibc": "bun run build:standalone -- --target=bun-linux-arm64", - "build:standalone:linux-arm64-musl": "bun run build:standalone -- --target=bun-linux-arm64-musl", - "build:standalone:windows-amd64": "bun run build:standalone -- --target=bun-windows-x64-modern", + "build:server": "bun build ./src/index.ts --outfile=./dist/backend.js --target=bun --minify --sourcemap=inline", + "build:standalone": "bun build ./src/index.ts --compile --minify --sourcemap=inline --outfile=./dist/backend", + "build:standalone:darwin-arm64": "bun run build:standalone -- --outfile=./dist/backend.darwin-arm64 --target=bun-darwin-arm64", + "build:standalone:linux-amd64-glibc": "bun run build:standalone -- --outfile=./dist/backend.linux-amd64.glibc --target=bun-linux-x64-modern", + "build:standalone:linux-amd64-musl": "bun run build:standalone -- --outfile=./dist/backend.linux-amd64.musl --target=bun-linux-x64-modern-musl", + "build:standalone:linux-arm64-glibc": "bun run build:standalone -- --outfile=./dist/backend.linux-arm64.glibc --target=bun-linux-arm64", + "build:standalone:linux-arm64-musl": "bun run build:standalone -- --outfile=./dist/backend.linux-arm64.musl --target=bun-linux-arm64-musl", + "build:standalone:windows-amd64": "bun run build:standalone -- --outfile=./dist/backend.windows-amd64.exe --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", "clean:git:gc": "git gc --aggressive --prune", @@ -28,18 +28,19 @@ "lint:biome": "bun biome lint", "lint:tsc": "bun tsc --noEmit", "start": "bun run start:server", - "start:dev": "mkdir -p ./dist/ && LOGLEVEL=4 bun run --cwd=./dist/ ../src/server.ts", + "start:dev": "mkdir -p ./dist/ && LOGLEVEL=4 bun run --cwd=./dist/ ../src/index.ts", "start:rebuild": "bun run build:server && bun run start:server", - "start:server": "mkdir -p ./dist/ && bun run --cwd=./dist/ ./server.js" + "start:server": "mkdir -p ./dist/ && bun run --cwd=./dist/ ./backend.js" }, "dependencies": { "@hono/zod-openapi": "~0.19.0", "env-var": "~7.5.0", - "hono": "~4.7.0" + "hono": "~4.7.0", + "node-cron": "^4.0.0" }, "devDependencies": { - "@biomejs/biome": "~1.9.0", - "@types/bun": "^1.2.0", + "@biomejs/biome": "2.0.0-beta.6", + "@types/bun": "^1.0.0", "lefthook": "~1.11.0", "sort-package-json": "^3.0.0" }, diff --git a/src/config.ts b/src/config.ts index 9e76dc6..403be2f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,11 @@ -import { env } from '#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 0000000..6c9e3fc --- /dev/null +++ b/src/database/Database.ts @@ -0,0 +1,94 @@ +import { Database as SQLite } from 'bun:sqlite'; +import { existsSync, mkdirSync } from 'node:fs'; +import { query } from '#db/query.ts'; +import { logger } from '#util/logger.ts'; +import { shutdown } from '#util/shutdown.ts'; +import { config } from '../config.ts'; +import { env } from '../env.ts'; +import { map, migrations } from './migrations.ts'; + +export class Database { + public readonly instance: SQLite; + + public constructor() { + if (!existsSync(config.storagePath)) { + mkdirSync(config.storagePath); + } + + this.instance = new SQLite(env.debugDatabaseEphemeral ? undefined : config.storageDatabaseFile, { + strict: true + }); + + this.instance.run(` + PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 1024; + `); + } + + public migration(): void { + const currentVersion = this.instance + .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...'); + + throw shutdown(1); + } + + const migrationEntries = Object.entries(migrations).map( + ([key, value]) => [Number(key), value] as [number, string] + ); + + if (currentVersion === migrationEntries.length) { + logger.debug('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...' + ); + + throw shutdown(1); + } + + for (const [i, sql] of migrationEntries) { + if (i > currentVersion) { + try { + this.instance.transaction(() => { + this.instance.run(sql); + this.instance.run(`PRAGMA user_version = ${i}`); + })(); + } catch (error) { + logger.error(error); + logger.error(`Error running migration "${map[i]}", database reverted to a prior state.`); + + throw shutdown(1); + } + + logger.info(`Database migration "${map[i]}" ran successfully.`); + } + } + + if (currentVersion === 0) { + logger.info('Welcome to JSPaste!'); + + let token: string; + + try { + token = query.user.create(); + } catch (error) { + logger.error(error); + logger.error('Error creating Administrator user.'); + + throw shutdown(1); + } + + logger.warn('Created Administrator user.'); + logger.warn(`Save this token "${token}" and keep it safe. This will not be shown again.`); + } + } +} + +export const database = new Database(); diff --git a/src/database/migrations.ts b/src/database/migrations.ts new file mode 100644 index 0000000..87e9bd6 --- /dev/null +++ b/src/database/migrations.ts @@ -0,0 +1,51 @@ +export enum map { + /** + * @description + * Initial schema for the database + * + * ### user + * - **id:** The user UUIDv7 (used as a creation timestamp) + * - **token:** The user authentication token + * - **accessed_at:** The last time the user logged in + * + * ### document + * - **id:** The document UUIDv7 (used as a creation timestamp) + * - **user_id:** The user UUIDv7 + * - **version:** The document version number + * - **name:** The document unique name + * - **password:** The document password + * - **accessed_at:** The last time the document was accessed + * + * @date 2025-06-07 + */ + initialSchema_0001 = 1 +} + +/** + * @remarks New migrations should be added at the start + */ +export const migrations: Record = { + [map.initialSchema_0001]: ` + CREATE TABLE user + ( + id BLOB NOT NULL PRIMARY KEY, + token TEXT NOT NULL, + accessed_at INTEGER + ) STRICT; + + CREATE INDEX idx_user_token ON user (token); + + CREATE TABLE document + ( + id BLOB NOT NULL PRIMARY KEY, + user_id BLOB NOT NULL REFERENCES user (id) ON DELETE CASCADE, + version INTEGER NOT NULL, + name TEXT NOT NULL UNIQUE, + password TEXT, + accessed_at INTEGER + ) STRICT; + + CREATE INDEX idx_document_user_id ON document (user_id); + CREATE INDEX idx_document_name ON document (name); + ` +} as const; diff --git a/src/database/query.ts b/src/database/query.ts new file mode 100644 index 0000000..b7aba8a --- /dev/null +++ b/src/database/query.ts @@ -0,0 +1,166 @@ +import { database } from '#db/Database.ts'; +import { document } from '#util/document.ts'; + +export enum DocumentVersion { + V1 = 1 +} + +export type Document = { + id: Uint8Array; + user_id: Uint8Array; + version: DocumentVersion; + name: string; + password: string | null; + accessed_at: number | null; +}; + +type DocumentColumnsResponse = Pick<{ [K in keyof Document]: Document[K] | null }, T>; + +export type User = { + id: Uint8Array; + token: string; + accessed_at: number | null; +}; + +type UserColumnsResponse = Pick<{ [K in keyof User]: User[K] | null }, T>; + +export const query = { + document: { + create: (userId: Uint8Array | null, version: number, name: string, password: string | null): Uint8Array => { + if (!userId) userId = Buffer.from([0]); + + const id = document.uuidv7.generate(); + + database.instance + .prepare(` + INSERT INTO document (id, user_id, version, name, password) + VALUES (?, ?, ?, ?, ?) + `) + .run(id, userId, version, name, password); + + return id; + }, + + delete: (userId: Uint8Array | null, name: string): void => { + if (!userId) userId = Buffer.from([0]); + + database.instance + .prepare(` + DELETE + FROM document + WHERE user_id = ? + AND name = ? + `) + .run(userId, name); + }, + + update: { + select: ( + userId: Uint8Array, + name: string, + column: T, + value: Document[T] + ): void => { + database.instance + .prepare(` + UPDATE document + SET ${column} = ? + WHERE user_id = ? + AND name = ? + `) + .run(value, userId, name); + }, + + incrementVersion: (userId: Uint8Array, name: string): void => { + database.instance + .prepare(` + UPDATE document + SET version = version + 1 + WHERE user_id = ? + AND name = ? + `) + .run(userId, name); + } + }, + + get: { + all: (columns: T[]): DocumentColumnsResponse[] => { + return database.instance + .query, null>(` + SELECT ${columns.join(', ')} + FROM document + `) + .all(null); + }, + + select: (name: string, columns: T[]): DocumentColumnsResponse | null => { + return database.instance + .query, string>(` + SELECT ${columns.join(', ')} + FROM document + WHERE name = ? + `) + .get(name); + } + } + }, + + user: { + create: (): string => { + const generatedToken = document.generateToken(); + + database.instance + .prepare(` + INSERT INTO user (id, token) + VALUES (?, ?) + `) + .run(document.uuidv7.generate(), generatedToken); + + return generatedToken; + }, + + // TODO: Handle mass document deletion + delete: (token: string): void => { + database.instance + .prepare(` + DELETE + FROM user + WHERE token = ? + `) + .run(token); + }, + + update: { + select: (token: string, column: T, value: User[T]): void => { + database.instance + .prepare(` + UPDATE user + SET ${column} = ? + WHERE token = ? + `) + .run(value, token); + } + }, + + get: { + all: (columns: T[]): UserColumnsResponse[] => { + return database.instance + .query, null>(` + SELECT ${columns.join(', ')} + FROM user + `) + .all(null); + }, + + select: (token: string, columns: T[]): UserColumnsResponse | null => { + return database.instance + .query, string>(` + SELECT ${columns.join(', ')} + FROM user + WHERE token = ? + `) + .get(token); + } + } + } +} as const; diff --git a/src/document/assert.ts b/src/document/assert.ts new file mode 100644 index 0000000..112b542 --- /dev/null +++ b/src/document/assert.ts @@ -0,0 +1,44 @@ +import type { Document } from '#db/query.ts'; +import { validator } from '#util/validator.ts'; +import { config } from '../config.ts'; +import { errorHandler } from '../server/errorHandler.ts'; +import { ErrorCode } from '../types/ErrorHandler.ts'; + +export const assert = { + name: (name: string): void => { + if ( + !validator.isBase64URL(name) || + !validator.isLengthWithinRange( + Bun.stringWidth(name), + config.documentNameLengthMin, + config.documentNameLengthMax + ) + ) { + errorHandler.send(ErrorCode.documentInvalidName); + } + }, + + nameLength: (length?: number): void => { + if ( + length && + !validator.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax) + ) { + errorHandler.send(ErrorCode.documentInvalidNameLength); + } + }, + + password: (password: string, documentPassword: Document['password']): void => { + if (documentPassword && documentPassword !== password) { + errorHandler.send(ErrorCode.documentInvalidPassword); + } + }, + + passwordLength: (password?: string): void => { + if ( + password && + (validator.isEmptyString(password) || !validator.isLengthWithinRange(Bun.stringWidth(password), 1, 255)) + ) { + errorHandler.send(ErrorCode.documentInvalidPasswordLength); + } + } +} as const; diff --git a/src/document/compression.ts b/src/document/compression.ts index 92a7e35..b72c52d 100644 --- a/src/document/compression.ts +++ b/src/document/compression.ts @@ -1,11 +1,24 @@ -import { type InputType, brotliCompressSync, brotliDecompressSync } from 'node:zlib'; +import { Buffer } from 'node:buffer'; +import { zstdCompressSync, zstdDecompressSync } from 'bun'; +import { env } from '../env.ts'; + +// https://datatracker.ietf.org/doc/html/rfc8878#name-zstandard-frames +const zstdMagic = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd]); export const compression = { - encode: (data: InputType): Buffer => { - return brotliCompressSync(data); + encode: (data: ArrayBuffer): ArrayBuffer | Buffer => { + if (env.documentCompressionLevel <= 0) return data; + + return zstdCompressSync(data, { level: env.documentCompressionLevel }); }, - decode: (data: InputType): Buffer => { - return brotliDecompressSync(data); + decode: (data: ArrayBuffer): ArrayBuffer | Buffer => { + const buffer = Buffer.from(data); + + if (buffer.length >= 10 && buffer.subarray(0, 4).equals(zstdMagic)) { + return zstdDecompressSync(data); + } + + return data; } } as const; diff --git a/src/document/crypto.ts b/src/document/crypto.ts deleted file mode 100644 index da68052..0000000 --- a/src/document/crypto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -const hashAlgorithm = '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()]); - }, - - compare: (password: string, hash: Uint8Array): boolean => { - const salt = hash.subarray(0, saltLength); - const hasher = new Bun.CryptoHasher(hashAlgorithm, salt).update(password); - - const passwordHash = Buffer.concat([salt, hasher.digest()]); - - return hash.every((value, index) => value === passwordHash[index]); - } -} as const; diff --git a/src/document/storage.ts b/src/document/storage.ts index 4310dd5..0e37025 100644 --- a/src/document/storage.ts +++ b/src/document/storage.ts @@ -1,24 +1,23 @@ -import { deserialize, serialize } from 'bun:jsc'; -import { validator } from '#document/validator.ts'; -import { errorHandler } from '#server/errorHandler.ts'; -import type { Document } from '#type/Document.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; +import { type BunFile, file, write } from 'bun'; import { config } from '../config.ts'; export const storage = { - read: async (name: string): Promise => { - validator.validateName(name); - - const file = Bun.file(config.storagePath + name); - - if (!(await file.exists())) { - errorHandler.send(ErrorCode.documentNotFound); - } + // TODO: We don't need to wait for deletion, file sweeper will remove it if it didn't get deleted yet + delete: (id: Uint8Array): void => { + try { + // FIXME: Bun crashes if we try to delete a file that doesn't exist + file(config.storageDataPath + Buffer.from(id).toString('hex')).delete(); + } catch {} + }, - return deserialize(await file.arrayBuffer()); + read: (id: Uint8Array): BunFile => { + return file(config.storageDataPath + Buffer.from(id).toString('hex')); }, - write: async (name: string, document: Document): Promise => { - await Bun.write(config.storagePath + name, serialize(document)); + write: async ( + id: Uint8Array, + data: Blob | NodeJS.TypedArray | ArrayBufferLike | string | Bun.BlobPart[] + ): Promise => { + await write(config.storageDataPath + Buffer.from(id).toString('hex'), data); } } as const; diff --git a/src/document/validator.ts b/src/document/validator.ts deleted file mode 100644 index 188d613..0000000 --- a/src/document/validator.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { crypto } from '#document/crypto.ts'; -import { errorHandler } from '#server/errorHandler.ts'; -import type { Document } from '#type/Document.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { ValidatorUtils } from '#util/ValidatorUtils.ts'; -import { config } from '../config.ts'; - -export const validator = { - validateName: (name: string): void => { - if ( - !ValidatorUtils.isValidBase64URL(name) || - !ValidatorUtils.isLengthWithinRange( - Bun.stringWidth(name), - config.documentNameLengthMin, - config.documentNameLengthMax - ) - ) { - errorHandler.send(ErrorCode.documentInvalidName); - } - }, - - validateNameLength: (length: number | undefined): void => { - if ( - length && - !ValidatorUtils.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax) - ) { - errorHandler.send(ErrorCode.documentInvalidNameLength); - } - }, - - validatePassword: (password: string, dataHash: Document['header']['passwordHash']): void => { - if (dataHash && !crypto.compare(password, dataHash)) { - errorHandler.send(ErrorCode.documentInvalidPassword); - } - }, - - validatePasswordLength: (password: string | undefined): void => { - if ( - password && - (ValidatorUtils.isEmptyString(password) || - !ValidatorUtils.isLengthWithinRange(Bun.stringWidth(password), 1, 255)) - ) { - errorHandler.send(ErrorCode.documentInvalidPasswordLength); - } - }, - - validateSecret: (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)) { - errorHandler.send(ErrorCode.documentInvalidSecretLength); - } - } -} as const; diff --git a/src/endpoints/v1/access.route.ts b/src/endpoints/v1/access.route.ts deleted file mode 100644 index ef91dc3..0000000 --- a/src/endpoints/v1/access.route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { compression } from '#document/compression.ts'; -import { storage } from '#document/storage.ts'; -import { errorHandler, schema } from '#server/errorHandler.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { config } from '../../config.ts'; - -export const accessRoute = (endpoint: OpenAPIHono): void => { - const route = createRoute({ - method: 'get', - path: '/{name}', - tags: ['v1'], - summary: 'Get document', - deprecated: true, - request: { - params: z.object({ - name: z.string().min(config.documentNameLengthMin).max(config.documentNameLengthMax).openapi({ - description: 'The document name', - example: 'abc123' - }) - }) - }, - responses: { - 200: { - content: { - 'application/json': { - schema: z.object({ - key: z.string().openapi({ - description: 'The document name (formerly key)', - example: 'abc123' - }), - data: z.string().openapi({ - description: 'The document data', - example: 'Hello, World!' - }) - }) - } - }, - description: 'The document object' - }, - 400: schema, - 404: schema, - 500: schema - } - }); - - endpoint.openapi( - route, - async (ctx) => { - const params = ctx.req.valid('param'); - - const document = await storage.read(params.name); - - // V1 Endpoint does not support document protected password - if (document.header.passwordHash) { - errorHandler.send(ErrorCode.documentPasswordNeeded); - } - - const buffer = compression.decode(document.data); - - return ctx.json({ - key: params.name, - data: buffer.toString('binary') - }); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } - } - ); -}; diff --git a/src/endpoints/v1/accessRaw.route.ts b/src/endpoints/v1/accessRaw.route.ts deleted file mode 100644 index 0df2ac8..0000000 --- a/src/endpoints/v1/accessRaw.route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { compression } from '#document/compression.ts'; -import { storage } from '#document/storage.ts'; -import { errorHandler, schema } from '#server/errorHandler.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { config } from '../../config.ts'; - -export const accessRawRoute = (endpoint: OpenAPIHono): void => { - const route = createRoute({ - method: 'get', - path: '/{name}/raw', - tags: ['v1'], - summary: 'Get document data', - deprecated: true, - request: { - params: z.object({ - name: z.string().min(config.documentNameLengthMin).max(config.documentNameLengthMax).openapi({ - description: 'The document name', - example: 'abc123' - }) - }) - }, - responses: { - 200: { - content: { - 'text/plain': { - schema: z.any().openapi({ - description: 'The document data' - }), - example: 'Hello, World!' - } - }, - description: 'The document data' - }, - 400: schema, - 404: schema, - 500: schema - } - }); - - endpoint.openapi( - route, - async (ctx) => { - const params = ctx.req.valid('param'); - - const document = await storage.read(params.name); - - // V1 Endpoint does not support document protected password - if (document.header.passwordHash) { - errorHandler.send(ErrorCode.documentPasswordNeeded); - } - - // @ts-ignore: Return the buffer directly - return ctx.text(compression.decode(document.data)); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } - } - ); -}; diff --git a/src/endpoints/v1/publish.route.ts b/src/endpoints/v1/publish.route.ts deleted file mode 100644 index a9fa3d6..0000000 --- a/src/endpoints/v1/publish.route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -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 '#type/Document.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { StringUtils } from '#util/StringUtils.ts'; - -export const publishRoute = (endpoint: OpenAPIHono): void => { - const route = createRoute({ - method: 'post', - path: '/', - tags: ['v1'], - summary: 'Publish document', - deprecated: true, - middleware: [middleware.bodyLimit()], - request: { - body: { - content: { - 'text/plain': { - schema: z.string().openapi({ - description: 'Data to publish in the document', - example: 'Hello, World!' - }) - } - } - } - }, - responses: { - 200: { - content: { - 'application/json': { - schema: z.object({ - key: z.string().openapi({ - description: 'The document name (formerly key)', - example: 'abc123' - }), - secret: z.string().openapi({ - description: 'The document secret', - example: 'aaaaa-bbbbb-ccccc-ddddd' - }) - }) - } - }, - description: 'An object with a "name" and "secret" parameters of the created document' - }, - 400: schema, - 404: schema, - 500: schema - } - }); - - endpoint.openapi( - route, - async (ctx) => { - const body = await ctx.req.arrayBuffer(); - const name = await StringUtils.createName(); - const secret = StringUtils.createSecret(); - - await storage.write(name, { - data: compression.encode(body), - header: { - name: name, - secretHash: crypto.hash(secret), - passwordHash: null - }, - version: DocumentVersion.V1 - }); - - return ctx.json({ key: name, secret: secret }); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } - } - ); -}; diff --git a/src/endpoints/v1/remove.route.ts b/src/endpoints/v1/remove.route.ts deleted file mode 100644 index 153ab2e..0000000 --- a/src/endpoints/v1/remove.route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { unlink } from 'node:fs/promises'; -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { storage } from '#document/storage.ts'; -import { validator } from '#document/validator.ts'; -import { errorHandler, schema } from '#server/errorHandler.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { config } from '../../config.ts'; - -export const removeRoute = (endpoint: OpenAPIHono): void => { - const route = createRoute({ - method: 'delete', - path: '/{name}', - tags: ['v1'], - summary: 'Remove document', - deprecated: true, - request: { - params: z.object({ - name: z.string().min(config.documentNameLengthMin).max(config.documentNameLengthMax).openapi({ - description: 'The document name', - example: 'abc123' - }) - }), - headers: z.object({ - secret: z.string().min(1).openapi({ - description: 'The document secret', - example: 'aaaaa-bbbbb-ccccc-ddddd' - }) - }) - }, - responses: { - 200: { - content: { - 'application/json': { - schema: z.object({ - removed: z.boolean().openapi({ - description: 'Confirmation of deletion', - example: true - }) - }) - } - }, - description: 'An object with a "removed" parameter of the deleted document' - }, - 400: schema, - 404: schema, - 500: schema - } - }); - - endpoint.openapi( - route, - async (ctx) => { - const params = ctx.req.valid('param'); - const headers = ctx.req.valid('header'); - - const document = await storage.read(params.name); - - validator.validateSecret(headers.secret, document.header.secretHash); - - const result = await unlink(config.storagePath + params.name) - .then(() => true) - .catch(() => false); - - return ctx.json({ removed: result }); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } - } - ); -}; diff --git a/src/endpoints/v2/accessRaw.route.ts b/src/endpoints/v2/accessRaw.route.ts deleted file mode 100644 index 7517180..0000000 --- a/src/endpoints/v2/accessRaw.route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { compression } from '#document/compression.ts'; -import { storage } from '#document/storage.ts'; -import { validator } from '#document/validator.ts'; -import { errorHandler, schema } from '#server/errorHandler.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { config } from '../../config.ts'; - -export const accessRawRoute = (endpoint: OpenAPIHono): void => { - const route = createRoute({ - method: 'get', - path: '/{name}/raw', - tags: ['v2'], - summary: 'Get document data', - request: { - params: z.object({ - name: z.string().min(config.documentNameLengthMin).max(config.documentNameLengthMax).openapi({ - description: 'The document name', - example: 'abc123' - }) - }), - headers: z.object({ - password: z.string().optional().openapi({ - description: 'The password to access the document', - example: 'aabbccdd11223344' - }) - }), - query: z.object({ - p: z.string().optional().openapi({ - description: - 'The password to decrypt the document. It is preferred to pass the password through headers, only use this method for support of web browsers.', - example: 'aabbccdd11223344' - }) - }) - }, - responses: { - 200: { - content: { - 'text/plain': { - schema: z.any().openapi({ - description: 'The document data' - }), - example: 'Hello, World!' - } - }, - description: 'The document data' - }, - 400: schema, - 404: schema, - 500: schema - } - }); - - endpoint.openapi( - route, - async (ctx) => { - const params = ctx.req.valid('param'); - const headers = ctx.req.valid('header'); - const query = ctx.req.valid('query'); - - const options = { - password: headers.password || query.p - }; - - const document = await storage.read(params.name); - - if (document.header.passwordHash) { - if (!options.password) { - return errorHandler.send(ErrorCode.documentPasswordNeeded); - } - - validator.validatePassword(options.password, document.header.passwordHash); - } - - // @ts-ignore: Return the buffer directly - return ctx.text(compression.decode(document.data)); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } - } - ); -}; diff --git a/src/endpoints/v2/access.route.ts b/src/endpoints/v2/documents/access.route.ts similarity index 57% rename from src/endpoints/v2/access.route.ts rename to src/endpoints/v2/documents/access.route.ts index 7b94076..cebd834 100644 --- a/src/endpoints/v2/access.route.ts +++ b/src/endpoints/v2/documents/access.route.ts @@ -1,10 +1,11 @@ -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'; +import { query } from '#db/query.ts'; +import { assert } from '#document/assert.ts'; import { compression } from '#document/compression.ts'; import { storage } from '#document/storage.ts'; -import { validator } from '#document/validator.ts'; import { errorHandler, schema } from '#server/errorHandler.ts'; import { ErrorCode } from '#type/ErrorHandler.ts'; -import { config } from '../../config.ts'; +import { config } from '../../../config.ts'; export const accessRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -40,6 +41,7 @@ export const accessRoute = (endpoint: OpenAPIHono): void => { example: 'Hello, World!' }), url: z.string().openapi({ + deprecated: true, description: 'The document URL', example: 'https://jspaste.eu/abc123' }), @@ -59,35 +61,38 @@ export const accessRoute = (endpoint: OpenAPIHono): void => { } }); - endpoint.openapi( - route, - async (ctx) => { - const params = ctx.req.valid('param'); - const headers = ctx.req.valid('header'); + endpoint.openapi(route, async (ctx) => { + const params = ctx.req.valid('param'); + const headers = ctx.req.valid('header'); + + assert.name(params.name); - const document = await storage.read(params.name); + const document = query.document.get.select(params.name, ['id', 'user_id', 'password']); - if (document.header.passwordHash) { - if (!headers.password) { - return errorHandler.send(ErrorCode.documentPasswordNeeded); - } + if (!document || !document.id) { + return errorHandler.send(ErrorCode.documentNotFound); + } - validator.validatePassword(headers.password, document.header.passwordHash); + if (document.password) { + if (!headers.password) { + return errorHandler.send(ErrorCode.documentPasswordNeeded); } - const buffer = compression.decode(document.data); + assert.password(headers.password, document.password); + } - return ctx.json({ - key: params.name, - data: buffer.toString('binary'), - url: config.protocol.concat(new URL(ctx.req.url).host.concat('/', params.name)), - expirationTimestamp: 0 - }); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } + const data = await storage.read(document.id).arrayBuffer(); + const buffer = compression.decode(data); + + if (document.user_id) { + query.document.update.select(document.user_id, params.name, 'accessed_at', Date.now()); } - ); + + return ctx.json({ + key: params.name, + data: buffer.toString('binary'), + url: config.protocol.concat(new URL(ctx.req.url).host.concat('/', params.name)), + expirationTimestamp: 0 + }); + }); }; diff --git a/src/endpoints/v2/documents/accessRaw.route.ts b/src/endpoints/v2/documents/accessRaw.route.ts new file mode 100644 index 0000000..821fa0f --- /dev/null +++ b/src/endpoints/v2/documents/accessRaw.route.ts @@ -0,0 +1,99 @@ +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'; +import { query as queryDB } from '#db/query.ts'; +import { assert } from '#document/assert.ts'; +import { compression } from '#document/compression.ts'; +import { storage } from '#document/storage.ts'; +import { errorHandler, schema } from '#server/errorHandler.ts'; +import { ErrorCode } from '#type/ErrorHandler.ts'; +import { config } from '../../../config.ts'; + +export const accessRawRoute = (endpoint: OpenAPIHono): void => { + const route = createRoute({ + method: 'get', + path: '/{name}/raw', + tags: ['v2'], + summary: 'Get document data', + request: { + params: z.object({ + name: z.string().min(config.documentNameLengthMin).max(config.documentNameLengthMax).openapi({ + description: 'The document name', + example: 'abc123' + }) + }), + headers: z.object({ + password: z.string().optional().openapi({ + description: 'The password to access the document', + example: 'aabbccdd11223344' + }) + }), + query: z.object({ + p: z.string().optional().openapi({ + description: + 'The password to decrypt the document. It is preferred to pass the password through headers, only use this method for support of web browsers.', + example: 'aabbccdd11223344' + }), + download: z.string().optional().default('false').openapi({ + description: 'If true, the response will be treated as a file download', + example: 'false' + }) + }) + }, + responses: { + 200: { + content: { + 'text/plain': { + schema: z.any().openapi({ + description: 'The document data' + }), + example: 'Hello, World!' + } + }, + description: 'The document data' + }, + 400: schema, + 404: schema, + 500: schema + } + }); + + endpoint.openapi(route, async (ctx) => { + const params = ctx.req.valid('param'); + const headers = ctx.req.valid('header'); + const query = ctx.req.valid('query'); + + const options = { + password: headers.password || query.p + }; + + assert.name(params.name); + + const document = queryDB.document.get.select(params.name, ['id', 'user_id', 'password']); + + if (!document || !document.id) { + return errorHandler.send(ErrorCode.documentNotFound); + } + + if (document.password) { + if (!options.password) { + return errorHandler.send(ErrorCode.documentPasswordNeeded); + } + + assert.password(options.password, document.password); + } + + const data = await storage.read(document.id).arrayBuffer(); + const buffer = compression.decode(data); + + if (document.user_id) { + queryDB.document.update.select(document.user_id, params.name, 'accessed_at', Date.now()); + } + + if (query.download.toLowerCase() === 'true') { + ctx.header('Content-Disposition', `attachment; filename="${params.name}.txt"`); + } + + ctx.header('Content-Type', 'text/plain; charset=utf-8'); + + return ctx.body(buffer); + }); +}; diff --git a/src/endpoints/v2/edit.route.ts b/src/endpoints/v2/documents/edit.route.ts similarity index 50% rename from src/endpoints/v2/edit.route.ts rename to src/endpoints/v2/documents/edit.route.ts index bdcb7e8..52fe4d6 100644 --- a/src/endpoints/v2/edit.route.ts +++ b/src/endpoints/v2/documents/edit.route.ts @@ -1,11 +1,12 @@ -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'; +import { query } from '#db/query.ts'; +import { assert } from '#document/assert.ts'; import { compression } from '#document/compression.ts'; import { storage } from '#document/storage.ts'; -import { validator } from '#document/validator.ts'; import { errorHandler, schema } from '#server/errorHandler.ts'; import { middleware } from '#server/middleware.ts'; import { ErrorCode } from '#type/ErrorHandler.ts'; -import { config } from '../../config.ts'; +import { config } from '../../../config.ts'; export const editRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -37,9 +38,14 @@ export const editRoute = (endpoint: OpenAPIHono): void => { description: 'The password to access the document (not used anymore)', example: 'aabbccdd11223344' }), - secret: z.string().openapi({ - description: 'The document secret', - example: 'aaaaa-bbbbb-ccccc-ddddd' + secret: z.string().optional().openapi({ + deprecated: true, + description: 'The document secret (use "authentication" header)', + example: 'aabbccdd11223344' + }), + authentication: z.string().optional().openapi({ + description: 'The user token', + example: 'aabbccdd11223344' }) }) }, @@ -63,32 +69,45 @@ export const editRoute = (endpoint: OpenAPIHono): void => { } }); - endpoint.openapi( - route, - async (ctx) => { - const body = await ctx.req.arrayBuffer(); - const params = ctx.req.valid('param'); - const headers = ctx.req.valid('header'); + endpoint.openapi(route, async (ctx) => { + const params = ctx.req.valid('param'); + const headers = ctx.req.valid('header'); - const document = await storage.read(params.name); + const options = { + token: headers.authentication || headers.secret + }; - validator.validateSecret(headers.secret, document.header.secretHash); + if (!options.token) { + return errorHandler.send(ErrorCode.dummy); + } - document.data = compression.encode(body); + assert.name(params.name); - const result = await storage - .write(params.name, document) - .then(() => true) - .catch(() => false); + const document = query.document.get.select(params.name, ['id', 'user_id']); - return ctx.json({ - edited: result - }); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } + if (!document || !document.id) { + return errorHandler.send(ErrorCode.documentNotFound); } - ); + + const userId = query.user.get.select(options.token, ['id'])?.id; + + if (!userId || userId !== document.user_id) { + return errorHandler.send(ErrorCode.dummy); + } + + const body = await ctx.req.arrayBuffer(); + + const result = await storage + .write(document.id, compression.encode(body)) + .then(() => true) + .catch(() => false); + + if (result) { + query.document.update.select(userId, params.name, 'accessed_at', Date.now()); + } + + return ctx.json({ + edited: result + }); + }); }; diff --git a/src/endpoints/v2/exists.route.ts b/src/endpoints/v2/documents/exists.route.ts similarity index 79% rename from src/endpoints/v2/exists.route.ts rename to src/endpoints/v2/documents/exists.route.ts index 7d853af..dee2016 100644 --- a/src/endpoints/v2/exists.route.ts +++ b/src/endpoints/v2/documents/exists.route.ts @@ -1,8 +1,9 @@ -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { validator } from '#document/validator.ts'; +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'; +import { query } from '#db/query.ts'; +import { assert } from '#document/assert.ts'; import { errorHandler, schema } from '#server/errorHandler.ts'; import { ErrorCode } from '#type/ErrorHandler.ts'; -import { config } from '../../config.ts'; +import { config } from '../../../config.ts'; export const existsRoute = (endpoint: OpenAPIHono): void => { const route = createRoute({ @@ -50,9 +51,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(query.document.get.select(params.name, ['name']) ? 'true' : 'false'); }, (result) => { if (!result.success) { diff --git a/src/endpoints/v1/index.ts b/src/endpoints/v2/documents/index.ts similarity index 68% rename from src/endpoints/v1/index.ts rename to src/endpoints/v2/documents/index.ts index 6a5aa2a..76d2b08 100644 --- a/src/endpoints/v1/index.ts +++ b/src/endpoints/v2/documents/index.ts @@ -1,18 +1,18 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { accessRoute } from './access.route.ts'; import { accessRawRoute } from './accessRaw.route.ts'; +import { editRoute } from './edit.route.ts'; +import { existsRoute } from './exists.route.ts'; import { publishRoute } from './publish.route.ts'; import { removeRoute } from './remove.route.ts'; -export const v1 = (): typeof endpoint => { +export const documents = (): typeof endpoint => { const endpoint = new OpenAPIHono(); - endpoint.get('/', (ctx) => { - return ctx.text('Welcome to JSPaste API v1'); - }); - accessRoute(endpoint); accessRawRoute(endpoint); + editRoute(endpoint); + existsRoute(endpoint); publishRoute(endpoint); removeRoute(endpoint); diff --git a/src/endpoints/v2/documents/publish.route.ts b/src/endpoints/v2/documents/publish.route.ts new file mode 100644 index 0000000..e1f7c45 --- /dev/null +++ b/src/endpoints/v2/documents/publish.route.ts @@ -0,0 +1,151 @@ +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'; +import { DocumentVersion, query } from '#db/query.ts'; +import { assert } from '#document/assert.ts'; +import { compression } from '#document/compression.ts'; +import { storage } from '#document/storage.ts'; +import { errorHandler, schema } from '#server/errorHandler.ts'; +import { middleware } from '#server/middleware.ts'; +import { ErrorCode } from '#type/ErrorHandler.ts'; +import { document } from '#util/document.ts'; +import { config } from '../../../config.ts'; + +export const publishRoute = (endpoint: OpenAPIHono): void => { + const route = createRoute({ + method: 'post', + path: '/', + tags: ['v2'], + summary: 'Publish document', + middleware: [middleware.bodyLimit()], + request: { + body: { + content: { + 'text/plain': { + schema: z.string().openapi({ + description: 'Data to publish in the document', + example: 'Hello, World!' + }) + } + } + }, + headers: z.object({ + password: z.string().optional().openapi({ + description: 'The password to restrict the document', + example: 'aabbccdd11223344' + }), + key: z.string().optional().openapi({ + description: 'The document name (formerly key)', + example: 'abc123' + }), + keylength: z.string().optional().openapi({ + description: 'The document name length (formerly key length)', + example: config.documentNameLengthDefault.toString() + }), + secret: z.string().optional().openapi({ + deprecated: true, + description: 'The user token (use "authentication" header)', + example: 'aabbccdd11223344' + }), + authentication: z.string().optional().openapi({ + description: 'The user token', + example: 'aabbccdd11223344' + }) + }) + }, + responses: { + 200: { + content: { + 'application/json': { + schema: z.object({ + key: z.string().openapi({ + description: 'The document name (formerly key)', + example: 'abc123' + }), + secret: z.string().openapi({ + deprecated: true, + description: 'The document secret (your token or an empty string for anonymous users)', + example: 'aabbccdd11223344' + }), + url: z.string().openapi({ + deprecated: true, + description: 'The document URL', + example: 'https://jspaste.eu/abc123' + }), + expirationTimestamp: z.number().openapi({ + deprecated: true, + description: 'The document expiration timestamp (always will be 0)', + example: 0 + }) + }) + } + }, + description: 'An object with a "key", "secret" and "url" parameters of the created document' + }, + 400: schema, + 404: schema, + 500: schema + } + }); + + endpoint.openapi(route, async (ctx) => { + const headers = ctx.req.valid('header'); + + const options = { + token: headers.authentication || headers.secret + }; + + let userId: Uint8Array | null | undefined = null; + + if (options.token) { + userId = query.user.get.select(options.token, ['id'])?.id; + + if (!userId) { + return errorHandler.send(ErrorCode.dummy); + } + } + + let password: string | null = null; + + if (headers.password) { + assert.passwordLength(headers.password); + + password = headers.password; + } + + let name: string; + + if (headers.key) { + assert.name(headers.key); + + if (query.document.get.select(headers.key, ['name'])?.name) { + return errorHandler.send(ErrorCode.documentNameAlreadyExists); + } + + name = headers.key; + } else { + const nameLength = Number(headers.keylength || config.documentNameLengthDefault); + + assert.nameLength(nameLength); + + let randomName: string; + + do { + randomName = document.generateName(nameLength + 1); + } while (query.document.get.select(randomName, ['name'])?.name); + + name = randomName; + } + + const body = await ctx.req.arrayBuffer(); + + const id = query.document.create(userId, DocumentVersion.V1, name, password); + + await storage.write(id, compression.encode(body)); + + return ctx.json({ + key: name, + secret: options.token ?? '', + url: config.protocol.concat(new URL(ctx.req.url).host.concat('/', name)), + expirationTimestamp: 0 + }); + }); +}; diff --git a/src/endpoints/v2/documents/remove.route.ts b/src/endpoints/v2/documents/remove.route.ts new file mode 100644 index 0000000..130d381 --- /dev/null +++ b/src/endpoints/v2/documents/remove.route.ts @@ -0,0 +1,86 @@ +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'; +import { query } from '#db/query.ts'; +import { assert } from '#document/assert.ts'; +import { storage } from '#document/storage.ts'; +import { errorHandler, schema } from '#server/errorHandler.ts'; +import { ErrorCode } from '#type/ErrorHandler.ts'; +import { config } from '../../../config.ts'; + +export const removeRoute = (endpoint: OpenAPIHono): void => { + const route = createRoute({ + method: 'delete', + path: '/{name}', + tags: ['v2'], + summary: 'Remove document', + request: { + params: z.object({ + name: z.string().min(config.documentNameLengthMin).max(config.documentNameLengthMax).openapi({ + description: 'The document name', + example: 'abc123' + }) + }), + headers: z.object({ + secret: z.string().optional().openapi({ + deprecated: true, + description: 'The document secret (use "authentication" header)', + example: 'aabbccdd11223344' + }), + authentication: z.string().optional().openapi({ + description: 'The user token', + example: 'aabbccdd11223344' + }) + }) + }, + responses: { + 200: { + content: { + 'application/json': { + schema: z.object({ + removed: z.boolean().openapi({ + description: 'Confirmation of deletion', + example: true + }) + }) + } + }, + description: 'An object with a "removed" parameter of the deleted document' + }, + 400: schema, + 404: schema, + 500: schema + } + }); + + endpoint.openapi(route, async (ctx) => { + const params = ctx.req.valid('param'); + const headers = ctx.req.valid('header'); + + const options = { + token: headers.authentication || headers.secret + }; + + assert.name(params.name); + + const document = query.document.get.select(params.name, ['id']); + + if (!document || !document.id) { + return errorHandler.send(ErrorCode.documentNotFound); + } + + let userId: Uint8Array | null | undefined = null; + + if (options.token) { + userId = query.user.get.select(options.token, ['id'])?.id; + + if (!userId) { + return errorHandler.send(ErrorCode.dummy); + } + } + + query.document.delete(userId, params.name); + + storage.delete(document.id); + + return ctx.json({ removed: true }); + }); +}; diff --git a/src/endpoints/v2/index.ts b/src/endpoints/v2/index.ts index 4354ef4..632961d 100644 --- a/src/endpoints/v2/index.ts +++ b/src/endpoints/v2/index.ts @@ -1,24 +1,10 @@ import { OpenAPIHono } from '@hono/zod-openapi'; -import { accessRoute } from './access.route.ts'; -import { accessRawRoute } from './accessRaw.route.ts'; -import { editRoute } from './edit.route.ts'; -import { existsRoute } from './exists.route.ts'; -import { publishRoute } from './publish.route.ts'; -import { removeRoute } from './remove.route.ts'; +import { documents } from '#v2/documents'; export const v2 = (): typeof endpoint => { const endpoint = new OpenAPIHono(); - endpoint.get('/', (ctx) => { - return ctx.text('Welcome to JSPaste API v2'); - }); - - accessRoute(endpoint); - accessRawRoute(endpoint); - editRoute(endpoint); - existsRoute(endpoint); - publishRoute(endpoint); - removeRoute(endpoint); + endpoint.route('/documents', documents()); return endpoint; }; diff --git a/src/endpoints/v2/publish.route.ts b/src/endpoints/v2/publish.route.ts deleted file mode 100644 index f894f48..0000000 --- a/src/endpoints/v2/publish.route.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { compression } from '#document/compression.ts'; -import { crypto } from '#document/crypto.ts'; -import { storage } from '#document/storage.ts'; -import { validator } from '#document/validator.ts'; -import { errorHandler, schema } from '#server/errorHandler.ts'; -import { middleware } from '#server/middleware.ts'; -import { DocumentVersion } from '#type/Document.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { StringUtils } from '#util/StringUtils.ts'; -import { config } from '../../config.ts'; - -export const publishRoute = (endpoint: OpenAPIHono): void => { - const route = createRoute({ - method: 'post', - path: '/', - tags: ['v2'], - summary: 'Publish document', - middleware: [middleware.bodyLimit()], - request: { - body: { - content: { - 'text/plain': { - schema: z.string().openapi({ - description: 'Data to publish in the document', - example: 'Hello, World!' - }) - } - } - }, - headers: z.object({ - password: z.string().optional().openapi({ - description: 'The password to restrict the document', - example: 'aabbccdd11223344' - }), - key: z.string().optional().openapi({ - description: 'The document name (formerly key)', - example: 'abc123' - }), - keylength: z.string().optional().openapi({ - description: 'The document name length (formerly key length)', - example: config.documentNameLengthDefault.toString() - }), - secret: z.string().optional().openapi({ - description: 'The document secret', - example: 'aaaaa-bbbbb-ccccc-ddddd' - }) - }) - }, - responses: { - 200: { - content: { - 'application/json': { - schema: z.object({ - key: z.string().openapi({ - description: 'The document name (formerly key)', - example: 'abc123' - }), - secret: z.string().openapi({ - description: 'The document secret', - example: 'aaaaa-bbbbb-ccccc-ddddd' - }), - url: z.string().openapi({ - description: 'The document URL', - example: 'https://jspaste.eu/abc123' - }), - expirationTimestamp: z.number().openapi({ - deprecated: true, - description: 'The document expiration timestamp (always will be 0)', - example: 0 - }) - }) - } - }, - description: 'An object with a "key", "secret" and "url" parameters of the created document' - }, - 400: schema, - 404: schema, - 500: schema - } - }); - - endpoint.openapi( - route, - async (ctx) => { - const body = await ctx.req.arrayBuffer(); - const headers = ctx.req.valid('header'); - - if (headers.password) { - validator.validatePasswordLength(headers.password); - } - - let secret: string; - - if (headers.secret) { - validator.validateSecretLength(headers.secret); - - secret = headers.secret; - } else { - secret = StringUtils.createSecret(); - } - - let name: string; - - if (headers.key) { - validator.validateName(headers.key); - - if (await StringUtils.nameExists(headers.key)) { - errorHandler.send(ErrorCode.documentNameAlreadyExists); - } - - name = headers.key; - } else { - const nameLength = Number(headers.keylength || config.documentNameLengthDefault); - - name = await StringUtils.createName(nameLength); - } - - const data = compression.encode(body); - - await storage.write(name, { - data: data, - header: { - name: name, - secretHash: crypto.hash(secret), - passwordHash: headers.password ? crypto.hash(headers.password) : null - }, - version: DocumentVersion.V1 - }); - - return ctx.json({ - key: name, - secret: secret, - url: config.protocol.concat(new URL(ctx.req.url).host.concat('/', name)), - expirationTimestamp: 0 - }); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } - } - ); -}; diff --git a/src/endpoints/v2/remove.route.ts b/src/endpoints/v2/remove.route.ts deleted file mode 100644 index 4958729..0000000 --- a/src/endpoints/v2/remove.route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { unlink } from 'node:fs/promises'; -import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; -import { storage } from '#document/storage.ts'; -import { validator } from '#document/validator.ts'; -import { errorHandler, schema } from '#server/errorHandler.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { config } from '../../config.ts'; - -export const removeRoute = (endpoint: OpenAPIHono): void => { - const route = createRoute({ - method: 'delete', - path: '/{name}', - tags: ['v2'], - summary: 'Remove document', - request: { - params: z.object({ - name: z.string().min(config.documentNameLengthMin).max(config.documentNameLengthMax).openapi({ - description: 'The document name', - example: 'abc123' - }) - }), - headers: z.object({ - secret: z.string().min(1).openapi({ - description: 'The document secret', - example: 'aaaaa-bbbbb-ccccc-ddddd' - }) - }) - }, - responses: { - 200: { - content: { - 'application/json': { - schema: z.object({ - removed: z.boolean().openapi({ - description: 'Confirmation of deletion', - example: true - }) - }) - } - }, - description: 'An object with a "removed" parameter of the deleted document' - }, - 400: schema, - 404: schema, - 500: schema - } - }); - - endpoint.openapi( - route, - async (ctx) => { - const params = ctx.req.valid('param'); - const headers = ctx.req.valid('header'); - - const document = await storage.read(params.name); - - validator.validateSecret(headers.secret, document.header.secretHash); - - const result = await unlink(config.storagePath + params.name) - .then(() => true) - .catch(() => false); - - return ctx.json({ removed: result }); - }, - (result) => { - if (!result.success) { - return errorHandler.send(ErrorCode.validation); - } - } - ); -}; diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..8611307 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,11 @@ +import { get } from 'env-var'; +import { LogLevels } from '#util/logger.ts'; + +export const env = { + debugDatabaseEphemeral: get('JSPB_DEBUG_DATABASE_EPHEMERAL').asBoolStrict() ?? false, + documentCompressionLevel: get('JSPB_DOCUMENT_COMPRESSION_LEVEL').default(1).asIntPositive(), // FIXME: Check ranges + documentMaxSize: get('JSPB_DOCUMENT_MAXSIZE').default(1024).asIntPositive(), + logLevel: get('JSPB_LOGLEVEL').default(LogLevels.info).asIntPositive(), // FIXME: Check ranges + port: get('JSPB_PORT').default(4000).asPortNumber(), + tls: get('JSPB_TLS').asBoolStrict() ?? false +} as const; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ed637d5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +import { database } from '#db/Database.ts'; +import { logger } from '#util/logger.ts'; +import { shutdown } from '#util/shutdown.ts'; +import { env } from './env.ts'; + +process.on('SIGINT', async () => await shutdown()); +process.on('SIGTERM', async () => await shutdown()); + +logger.set(env.logLevel); + +database.migration(); diff --git a/src/server.ts b/src/server.ts index 2bd58c5..b804720 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,46 +2,39 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { serve } from 'bun'; import { cors } from 'hono/cors'; import { HTTPException } from 'hono/http-exception'; -import { oas } from '#server/oas.ts'; -import { env } from '#util/env.ts'; +import { endpoints } from '#server/endpoints.ts'; +import { errorHandler } from '#server/errorHandler.ts'; +import { ErrorCode } from '#type/ErrorHandler.ts'; import { logger } from '#util/logger.ts'; -import { config } from './config.ts'; -import { endpoints } from './server/endpoints.ts'; -import { errorHandler } from './server/errorHandler.ts'; -import { ErrorCode } from './types/ErrorHandler.ts'; +import { env } from './env.ts'; -process.on('SIGTERM', async () => await backend.stop()); +export const server = () => { + const instance = new OpenAPIHono().basePath('/api'); -logger.set(env.logLevel); - -const instance = new OpenAPIHono().basePath(config.apiPath); - -export const server = (): typeof instance => { instance.use('*', cors()); - instance.onError((err) => { - if (err instanceof HTTPException) { - return err.getResponse(); - } - - logger.error(err); - throw errorHandler.send(ErrorCode.unknown); - }); + endpoints(instance); instance.notFound((ctx) => { return ctx.body(null, 404); }); - oas(instance); - endpoints(instance); + instance.onError((error) => { + if (error instanceof HTTPException) { + return error.getResponse(); + } + + logger.error(error); + + return errorHandler.send(ErrorCode.unknown); + }); - logger.debug('Registered routes:', instance.routes.map((route) => route.path).join(', ')); logger.info(`Listening on: http://localhost:${env.port}`); return instance; }; -const backend = serve({ +export const instance = serve({ fetch: server().fetch, port: env.port }); diff --git a/src/server/endpoints.ts b/src/server/endpoints.ts index 76ece7d..87e6f18 100644 --- a/src/server/endpoints.ts +++ b/src/server/endpoints.ts @@ -1,13 +1,31 @@ import type { OpenAPIHono } from '@hono/zod-openapi'; -import { v1 } from '#v1/index.ts'; import { v2 } from '#v2/index.ts'; -import { config } from '../config.ts'; export const endpoints = (instance: OpenAPIHono): void => { + instance.doc31('/oas.json', () => ({ + openapi: '3.1.0', + info: { + title: 'JSPaste API', + version: 'rolling', + description: 'Welcome to JSPaste API documentation!', + license: { + name: 'EUPL-1.2', + url: 'https://eur-lex.europa.eu/eli/dec_impl/2017/863' + } + }, + servers: [ + { + url: 'https://jspaste.eu', + description: 'Official JSPaste instance' + } + ] + })); + + // TODO: Deprecated instance.get('/documents/*', (ctx) => { - return ctx.redirect(`${config.apiPath}/v2/documents`.concat(ctx.req.path.split('/documents').pop() ?? ''), 307); + return ctx.redirect(ctx.req.path.replace(/\/documents\//g, '/v2/documents/'), 307); }); - instance.route('/v2/documents', v2()); - instance.route('/v1/documents', v1()); + // TODO: Deprecated + instance.route('/v2', v2()); }; diff --git a/src/server/errorHandler.ts b/src/server/errorHandler.ts index d42ec92..e4adf17 100644 --- a/src/server/errorHandler.ts +++ b/src/server/errorHandler.ts @@ -102,10 +102,10 @@ export const errorHandler = { return { type, code, message }; }, - send: (code: ErrorCode) => { + send: (code: ErrorCode): never => { const { httpCode, type, message } = map[code]; - throw new HTTPException(httpCode, { + throw new HTTPException(undefined, { res: new Response(JSON.stringify({ type, code, message }), { status: httpCode, headers: { diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 5df085c..12abf93 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -1,14 +1,14 @@ import { bodyLimit as middlewareBodyLimit } from 'hono/body-limit'; -import { errorHandler } from '#server/errorHandler.ts'; -import { ErrorCode } from '#type/ErrorHandler.ts'; -import { env } from '#util/env.ts'; +import { env } from '../env.ts'; +import { ErrorCode } from '../types/ErrorHandler.ts'; +import { errorHandler } from './errorHandler.ts'; export const middleware = { bodyLimit: (maxSize: number = env.documentMaxSize) => { return middlewareBodyLimit({ maxSize: maxSize * 1024, onError: () => { - throw errorHandler.send(ErrorCode.documentInvalidSize); + return errorHandler.send(ErrorCode.documentInvalidSize); } }); } diff --git a/src/server/oas.ts b/src/server/oas.ts deleted file mode 100644 index 64db10a..0000000 --- a/src/server/oas.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { OpenAPIHono } from '@hono/zod-openapi'; -import { config } from '../config.ts'; - -export const oas = (instance: OpenAPIHono): void => { - instance.doc31('/oas.json', (ctx) => ({ - openapi: '3.1.0', - info: { - title: 'JSPaste API', - version: 'rolling', - description: `Note: The latest API version can be accessed with "${config.apiPath}/documents" alias route.`, - license: { - name: 'EUPL-1.2', - url: 'https://eur-lex.europa.eu/eli/dec_impl/2017/863' - } - }, - servers: [ - { - url: config.protocol.concat(new URL(ctx.req.url).host), - description: 'This instance' - }, - { - url: 'https://jspaste.eu', - description: 'Official JSPaste instance' - }, - { - url: 'https://paste.inetol.net', - description: 'Inetol Infrastructure instance' - } - ].filter((server, index, self) => self.findIndex((x) => x.url === server.url) === index) - })); -}; diff --git a/src/types/Document.ts b/src/types/Document.ts deleted file mode 100644 index db05eb4..0000000 --- a/src/types/Document.ts +++ /dev/null @@ -1,13 +0,0 @@ -export enum DocumentVersion { - V1 = 1 -} - -export type Document = { - data: Uint8Array; - header: { - name: string; - secretHash: Uint8Array; - passwordHash: Uint8Array | null; - }; - version: DocumentVersion; -}; diff --git a/src/types/ErrorHandler.ts b/src/types/ErrorHandler.ts index 3e16dc2..44678d0 100644 --- a/src/types/ErrorHandler.ts +++ b/src/types/ErrorHandler.ts @@ -1,7 +1,7 @@ -import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import type { StatusCode } from 'hono/utils/http-status'; export enum ErrorCode { - // * Generic + // Generic crash = 1000, unknown = 1001, validation = 1002, @@ -9,7 +9,7 @@ export enum ErrorCode { notFound = 1004, dummy = 1005, - // * Document + // Document documentNotFound = 1200, documentNameAlreadyExists = 1201, documentPasswordNeeded = 1202, @@ -26,7 +26,7 @@ export enum ErrorCode { type Type = 'generic' | 'document'; export type Schema = { - httpCode: ContentfulStatusCode; + httpCode: StatusCode; type: Type; message: string; }; diff --git a/src/utils/StringUtils.ts b/src/utils/StringUtils.ts deleted file mode 100644 index 7ceba70..0000000 --- a/src/utils/StringUtils.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { config } from '../config.ts'; -import type { Range } from '../types/Range.ts'; -import { ValidatorUtils } from './ValidatorUtils.ts'; - -export class StringUtils { - public static readonly BASE64URL = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'; - - public static random(length: number, base: Range<2, 64> = 62): string { - const baseSet = StringUtils.BASE64URL.slice(0, base); - let string = ''; - - while (length--) string += baseSet.charAt(Math.floor(Math.random() * baseSet.length)); - - return string; - } - - public static generateName(length: number = config.documentNameLengthDefault): string { - if (!ValidatorUtils.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax)) { - length = config.documentNameLengthDefault; - } - - return StringUtils.random(length, 64); - } - - public static async nameExists(name: string): Promise { - return Bun.file(config.storagePath + name).exists(); - } - - public static async createName(length: number = config.documentNameLengthDefault): Promise { - const key = StringUtils.generateName(length); - - return (await StringUtils.nameExists(key)) ? StringUtils.createName(length + 1) : key; - } - - public static createSecret(chunkLength = 5, chunks = 4): string { - return Array.from({ length: chunks }, () => StringUtils.random(chunkLength)).join('-'); - } -} diff --git a/src/utils/ValidatorUtils.ts b/src/utils/ValidatorUtils.ts deleted file mode 100644 index e7ef03b..0000000 --- a/src/utils/ValidatorUtils.ts +++ /dev/null @@ -1,31 +0,0 @@ -export class ValidatorUtils { - // biome-ignore lint/suspicious/noExplicitAny: We don't know the type of the value - 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/color.ts similarity index 70% rename from src/utils/colors.ts rename to src/utils/color.ts index c4cb40e..f754946 100644 --- a/src/utils/colors.ts +++ b/src/utils/color.ts @@ -1,12 +1,12 @@ -import { type ColorInput, color as bunColor } from 'bun'; +import { type ColorInput, color as colorConstructor } from 'bun'; const colorString = - (color: ColorInput) => + (code: ColorInput) => (...text: unknown[]): string => { - return bunColor(color, 'ansi') + text.join(' ') + colors.reset; + return colorConstructor(code, 'ansi') + text.join(' ') + color.reset; }; -export const colors = { +export const color = { red: colorString('#ef5454'), orange: colorString('#ef8354'), yellow: colorString('#efd554'), diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 0000000..7b67435 --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,21 @@ +import { Buffer } from 'node:buffer'; +import { randomBytes } from 'node:crypto'; +import { CryptoHasher } from 'bun'; + +const hasher = new CryptoHasher('blake2b256'); +const saltLength = 16; + +export const crypto = { + hash: (password: string): Uint8Array => { + const salt = randomBytes(saltLength); + + return Buffer.concat([salt, hasher.update(salt).update(password).digest()]); + }, + + compare: (password: string, hash: Uint8Array): boolean => { + const salt = hash.subarray(0, saltLength); + const computedHash = hasher.update(salt).update(password).digest(); + + return computedHash.compare(hash.subarray(saltLength)) === 0; + } +} as const; diff --git a/src/utils/document.ts b/src/utils/document.ts new file mode 100644 index 0000000..2630e26 --- /dev/null +++ b/src/utils/document.ts @@ -0,0 +1,35 @@ +import { randomUUIDv7 } from 'bun'; +import { config } from '../config.ts'; +import { string } from './string.ts'; +import { validator } from './validator.ts'; + +export const document = { + uuidv7: { + generate: (): Uint8Array => { + return randomUUIDv7('buffer'); + }, + + toUnixTimestamp: (uuid: Uint8Array): number => { + return ( + (uuid[0] ?? 0) * 2 ** 40 + + (uuid[1] ?? 0) * 2 ** 32 + + (uuid[2] ?? 0) * 2 ** 24 + + (uuid[3] ?? 0) * 2 ** 16 + + (uuid[4] ?? 0) * 2 ** 8 + + (uuid[5] ?? 0) + ); + } + }, + + generateToken: (): string => { + return string.random(32, 64); + }, + + generateName: (length: number = config.documentNameLengthDefault): string => { + if (!validator.isLengthWithinRange(length, config.documentNameLengthMin, config.documentNameLengthMax)) { + length = config.documentNameLengthDefault; + } + + return string.random(length, 64); + } +} as const; diff --git a/src/utils/env.ts b/src/utils/env.ts deleted file mode 100644 index 0e081cd..0000000 --- a/src/utils/env.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { get } from 'env-var'; -import { LogLevels } from '#util/logger.ts'; - -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/logger.ts b/src/utils/logger.ts index df201d6..d784b04 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,4 @@ -import { colors } from '#util/colors.ts'; +import { color } from './color.ts'; export enum LogLevels { none = 0, @@ -17,25 +17,29 @@ export const logger = { error: (...text: unknown[]): void => { if (logLevel >= LogLevels.error) { - console.error(colors.gray('[BACKEND]'), colors.red('[ERROR]'), text.join(' ')); + // biome-ignore lint/suspicious/noConsole: Abstract console logging + console.error(color.gray('[BACKEND]'), color.red('[ERROR]'), text.join(' ')); } }, warn: (...text: unknown[]): void => { if (logLevel >= LogLevels.warn) { - console.warn(colors.gray('[BACKEND]'), colors.yellow('[WARN]'), text.join(' ')); + // biome-ignore lint/suspicious/noConsole: Abstract console logging + console.warn(color.gray('[BACKEND]'), color.yellow('[WARN]'), text.join(' ')); } }, info: (...text: unknown[]): void => { if (logLevel >= LogLevels.info) { - console.info(colors.gray('[BACKEND]'), colors.blue('[INFO]'), text.join(' ')); + // biome-ignore lint/suspicious/noConsole: Abstract console logging + console.info(color.gray('[BACKEND]'), color.blue('[INFO]'), text.join(' ')); } }, debug: (...text: unknown[]): void => { if (logLevel >= LogLevels.debug) { - console.debug(colors.gray('[BACKEND]'), colors.gray('[DEBUG]'), text.join(' ')); + // biome-ignore lint/suspicious/noConsole: Abstract console logging + console.debug(color.gray('[BACKEND]'), color.gray('[DEBUG]'), text.join(' ')); } } } as const; diff --git a/src/utils/shutdown.ts b/src/utils/shutdown.ts new file mode 100644 index 0000000..a537eaa --- /dev/null +++ b/src/utils/shutdown.ts @@ -0,0 +1,21 @@ +import { database } from '#db/Database.ts'; +import { logger } from '#util/logger.ts'; +import { instance } from '../server.ts'; + +let shuttingDown = false; + +export const shutdown = async (code = 0): Promise => { + if (shuttingDown) return; + + shuttingDown = true; + process.exitCode = code; + + await instance.stop(); + instance.unref(); + logger.debug('Server stopped.'); + + database.instance.close(false); + logger.debug('Database closed.'); + + if (code === 0) logger.info('Bye.'); +}; diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 0000000..57d5f89 --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,14 @@ +import type { Range } from '../types/Range.ts'; + +const base64url = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'; + +export const string = { + random: (length: number, base: Range<2, 64> = 62): string => { + const baseSet = base64url.slice(0, base); + let string = ''; + + while (length--) string += baseSet.charAt(Math.floor(Math.random() * baseSet.length)); + + return string; + } +} as const; diff --git a/src/utils/validator.ts b/src/utils/validator.ts new file mode 100644 index 0000000..6799e0e --- /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; + }, + + // biome-ignore lint/suspicious/noExplicitAny: We don't know the type of value + 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 => { + 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 9ade037..b61c3fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,8 +33,9 @@ "baseUrl": ".", "paths": { - "#v1/*": ["./src/endpoints/v1/*"], "#v2/*": ["./src/endpoints/v2/*"], + "#v3/*": ["./src/endpoints/v3/*"], + "#db/*": ["./src/database/*"], "#document/*": ["./src/document/*"], "#server/*": ["./src/server/*"], "#type/*": ["./src/types/*"],