From b04ccd837fd5b48c9ca66d1bee76372aa087cfa7 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 5 Jul 2026 22:54:39 +0200 Subject: [PATCH] feat: Add files upload / storage support --- apps/api/.env.example | 17 + apps/api/.gitignore | 5 +- apps/api/src/index.ts | 30 + apps/api/src/locales/@vitnode/core/en.json | 26 +- apps/api/src/vitnode.api.config.ts | 24 + apps/docs/.gitignore | 3 + apps/docs/content/docs/dev/meta.json | 1 + .../docs/dev/storage/custom-adapter.mdx | 78 + apps/docs/content/docs/dev/storage/index.mdx | 212 ++ apps/docs/content/docs/dev/storage/local.mdx | 98 + apps/docs/content/docs/dev/storage/meta.json | 11 + apps/docs/content/docs/dev/storage/s3-r2.mdx | 120 ++ .../content/docs/dev/storage/supabase.mdx | 95 + .../migrations/0008_silky_forgotten_one.sql | 17 + apps/docs/migrations/meta/0008_snapshot.json | 1880 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + apps/docs/src/locales/@vitnode/core/en.json | 26 +- apps/docs/src/vitnode.api.config.ts | 11 + packages/s3/.npmignore | 6 + packages/s3/.swcrc | 25 + packages/s3/README.md | 22 + packages/s3/eslint.config.mjs | 17 + packages/s3/package.json | 46 + packages/s3/src/index.ts | 81 + packages/s3/tsconfig.json | 20 + packages/supabase-storage/.npmignore | 6 + packages/supabase-storage/.swcrc | 25 + packages/supabase-storage/README.md | 20 + packages/supabase-storage/eslint.config.mjs | 17 + packages/supabase-storage/package.json | 45 + packages/supabase-storage/src/index.ts | 55 + packages/supabase-storage/tsconfig.json | 20 + packages/vitnode/package.json | 1 + .../vitnode/src/api/adapters/storage/local.ts | 60 + packages/vitnode/src/api/config.ts | 1 + .../src/api/middlewares/global.middleware.ts | 7 + .../src/api/models/storage-image.test.ts | 93 + .../vitnode/src/api/models/storage.test.ts | 159 ++ packages/vitnode/src/api/models/storage.ts | 181 ++ .../modules/admin/debug/debug.admin.module.ts | 2 + .../admin/debug/routes/integrations.route.ts | 48 +- .../debug/routes/test-storage-upload.route.ts | 71 + packages/vitnode/src/api/plugin.ts | 1 + .../vitnode/src/components/table/filters.tsx | 3 - .../vitnode/src/components/ui/attachment.tsx | 208 ++ packages/vitnode/src/database/files.ts | 30 + .../vitnode/src/lib/api/is-cron-stale.test.ts | 51 + packages/vitnode/src/lib/api/is-cron-stale.ts | 13 + packages/vitnode/src/lib/api/upload.test.ts | 78 + packages/vitnode/src/lib/api/upload.ts | 55 + packages/vitnode/src/lib/config.ts | 32 +- .../vitnode/src/lib/fetcher-client.test.ts | 14 + packages/vitnode/src/lib/fetcher-client.ts | 17 + packages/vitnode/src/lib/fetcher/core.ts | 16 +- packages/vitnode/src/locales/en.json | 26 +- .../system/integrations/integration-card.tsx | 93 +- .../system/integrations/integrations-view.tsx | 66 +- .../integrations/test-storage/content.tsx | 138 ++ .../test-storage/test-storage.tsx | 49 + packages/vitnode/src/vitnode.config.ts | 18 + pnpm-lock.yaml | 737 ++++++- 61 files changed, 5228 insertions(+), 106 deletions(-) create mode 100644 apps/docs/content/docs/dev/storage/custom-adapter.mdx create mode 100644 apps/docs/content/docs/dev/storage/index.mdx create mode 100644 apps/docs/content/docs/dev/storage/local.mdx create mode 100644 apps/docs/content/docs/dev/storage/meta.json create mode 100644 apps/docs/content/docs/dev/storage/s3-r2.mdx create mode 100644 apps/docs/content/docs/dev/storage/supabase.mdx create mode 100644 apps/docs/migrations/0008_silky_forgotten_one.sql create mode 100644 apps/docs/migrations/meta/0008_snapshot.json create mode 100644 packages/s3/.npmignore create mode 100644 packages/s3/.swcrc create mode 100644 packages/s3/README.md create mode 100644 packages/s3/eslint.config.mjs create mode 100644 packages/s3/package.json create mode 100644 packages/s3/src/index.ts create mode 100644 packages/s3/tsconfig.json create mode 100644 packages/supabase-storage/.npmignore create mode 100644 packages/supabase-storage/.swcrc create mode 100644 packages/supabase-storage/README.md create mode 100644 packages/supabase-storage/eslint.config.mjs create mode 100644 packages/supabase-storage/package.json create mode 100644 packages/supabase-storage/src/index.ts create mode 100644 packages/supabase-storage/tsconfig.json create mode 100644 packages/vitnode/src/api/adapters/storage/local.ts create mode 100644 packages/vitnode/src/api/models/storage-image.test.ts create mode 100644 packages/vitnode/src/api/models/storage.test.ts create mode 100644 packages/vitnode/src/api/models/storage.ts create mode 100644 packages/vitnode/src/api/modules/admin/debug/routes/test-storage-upload.route.ts create mode 100644 packages/vitnode/src/components/ui/attachment.tsx create mode 100644 packages/vitnode/src/database/files.ts create mode 100644 packages/vitnode/src/lib/api/is-cron-stale.test.ts create mode 100644 packages/vitnode/src/lib/api/is-cron-stale.ts create mode 100644 packages/vitnode/src/lib/api/upload.test.ts create mode 100644 packages/vitnode/src/lib/api/upload.ts create mode 100644 packages/vitnode/src/lib/fetcher-client.test.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/test-storage.tsx diff --git a/apps/api/.env.example b/apps/api/.env.example index 2c9d40a9c..946f602f5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -2,10 +2,27 @@ POSTGRES_URL=postgresql://root:root@localhost:5432/vitnode REDIS_URL=redis://localhost:6379 NEXT_PUBLIC_WEB_URL=http://localhost:3000 +# Public origin of this API. Used to build absolute upload URLs for the Local +# storage adapter, so it must match where the server is reachable. +NEXT_PUBLIC_API_URL=http://localhost:8080 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key +# === Storage (file uploads) === +# The Local adapter needs no env vars. To use a cloud adapter, uncomment one set. +# --- AWS S3 / Cloudflare R2 (@vitnode/s3) --- +# S3_BUCKET=your-bucket +# S3_REGION=us-east-1 +# S3_ACCESS_KEY_ID=your_access_key_id +# S3_SECRET_ACCESS_KEY=your_secret_access_key +# S3_ENDPOINT=https://.r2.cloudflarestorage.com # Cloudflare R2 only +# S3_PUBLIC_URL=https://cdn.example.com # optional CDN base +# --- Supabase Storage (@vitnode/supabase-storage) --- +# SUPABASE_URL=https://your-project.supabase.co +# SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +# SUPABASE_STORAGE_BUCKET=your-bucket + # === Docker Database Postgres === POSTGRES_USER=root POSTGRES_PASSWORD=root diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 5c27e35ce..a6055938d 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -26,4 +26,7 @@ yarn-error.log* .env.production.local # Others -.vercel \ No newline at end of file +.vercel + +# Local storage adapter uploads +/public/uploads \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 363476bdc..ba1029901 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,16 +1,46 @@ +import type { StorageStaticConfig } from "@vitnode/core/api/models/storage"; + import { serve, upgradeWebSocket } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; import { OpenAPIHono } from "@hono/zod-openapi"; import { VitNodeAPI } from "@vitnode/core/api/config"; import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle"; +import { mkdirSync } from "node:fs"; import { WebSocketServer } from "ws"; import { vitNodeApiConfig } from "./vitnode.api.config.js"; const app = new OpenAPIHono().basePath("/api"); +// Serve locally stored uploads (Local storage adapter) before VitNodeAPI so the +// request skips cors/csrf/rate-limiter/global middleware. No-op for cloud adapters. +// Annotated explicitly: the nested type inferred through the config chain widens +// to `any` under NodeNext, so we re-attach the directly-imported type here. +const staticStorage: StorageStaticConfig | undefined = + vitNodeApiConfig.storage?.adapter?.static; +if (staticStorage) { + // Create the uploads directory up front so serveStatic doesn't warn about a + // missing root before the first file is uploaded. + mkdirSync(staticStorage.root, { recursive: true }); + app.get( + staticStorage.mountPath, + serveStatic({ + root: staticStorage.root, + rewriteRequestPath: path => path.replace(staticStorage.stripPrefix, ""), + }), + ); +} + +// Allow the web frontend to make credentialed browser requests (e.g. client-side +// file uploads) when it runs on a different origin than this API. Credentialed +// requests can't use a wildcard origin, and CSRF must trust it too. +const webOrigin = process.env.NEXT_PUBLIC_WEB_URL ?? "http://localhost:3000"; + VitNodeAPI({ app, vitNodeApiConfig, + cors: { credentials: true, origin: webOrigin }, + csrf: { origin: webOrigin }, }); const wss = new WebSocketServer({ noServer: true }); diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index b591e6eb4..dd8a0d98d 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -15,6 +15,7 @@ "@vitnode/core:system": "System", "@vitnode/core:system:can_view": "View integrations", "@vitnode/core:system:can_send_test_email": "Send test email", + "@vitnode/core:system:can_test_storage": "Test storage", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", "@vitnode/core:staff_moderators": "Staff: Moderators", @@ -553,6 +554,27 @@ "success": "Test email sent. Check the recipient's inbox to confirm your configuration." } }, + "storage": { + "title": "Storage", + "desc": "File uploads such as avatars and attachments, stored via your storage adapter.", + "test": { + "label": "Test storage", + "title": "Test file storage", + "desc": "Upload a file and run a round-trip check to confirm your storage adapter is working.", + "info": { + "title": "How to verify your configuration", + "desc": "Upload a test image. If it succeeds, your storage adapter is configured correctly. If it fails, double-check your adapter settings and the system logs." + }, + "upload": { + "label": "Upload a test image", + "desc": "Click to choose an image", + "pending": "Uploading…", + "done": "Uploaded", + "error": "Upload failed — try again", + "success": "Image uploaded — it's stored in your adapter." + } + } + }, "captcha": { "title": "Captcha", "desc": "Bot protection on public forms like sign-up and password reset.", @@ -566,13 +588,15 @@ "desc": "Scheduled background tasks such as clearing expired sessions and tokens.", "insecure": "Using the default CRON_SECRET — set a secure one in production.", "not_configured": "No cron adapter configured — jobs won't run automatically.", + "stale": "No job has run in over 6 hours — cron may be misconfigured or stopped. Check your cron setup.", "jobs": "{count, plural, =0 {No jobs scheduled} one {# job scheduled} other {# jobs scheduled}}" }, "queue": { "title": "Queue Tasks", "desc": "Background jobs enqueued in the database and drained by the cron worker.", "tasks": "{count, plural, =0 {No tasks registered} one {# task registered} other {# tasks registered}}", - "queued": "{pending} pending · {processing} running" + "queued": "{pending} pending · {processing} running", + "cron_stale": "Offline — the cron worker that drains the queue isn't running. Fix cron jobs to resume processing." } } }, diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 99f41d51f..095e71621 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -1,7 +1,10 @@ import { blogApiPlugin } from "@vitnode/blog/config.api"; +import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local"; import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { NodeCronAdapter } from "@vitnode/node-cron"; import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; +// import { S3StorageAdapter } from "@vitnode/s3"; +// import { SupabaseStorageAdapter } from "@vitnode/supabase-storage"; import { config } from "dotenv"; import { drizzle } from "drizzle-orm/postgres-js"; @@ -35,6 +38,27 @@ export const vitNodeApiConfig = buildApiConfig({ src: "http://localhost:3000/logo_vitnode_dark.png", }, }, + storage: { + // Zero-config: writes to `public/uploads` and serves via Hono static files. + adapter: LocalStorageAdapter(), + // Re-encode uploaded images with sharp to shrink them before storing. + image: { + quality: 85, + }, + // adapter: S3StorageAdapter({ + // bucket: process.env.S3_BUCKET, + // region: process.env.S3_REGION, + // accessKeyId: process.env.S3_ACCESS_KEY_ID, + // secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + // endpoint: process.env.S3_ENDPOINT, // Cloudflare R2 endpoint + // publicUrl: process.env.S3_PUBLIC_URL, + // }), + // adapter: SupabaseStorageAdapter({ + // url: process.env.SUPABASE_URL, + // serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY, + // bucket: process.env.SUPABASE_STORAGE_BUCKET, + // }), + }, metadata: { title: "VitNode API", shortTitle: "VitNode", diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 78b8c7949..a22ec72c6 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -36,3 +36,6 @@ yarn-error.log* .vercel next-env.d.ts .env*.local + +# Local storage adapter uploads +/public/uploads diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index 1186208ec..25474a2c6 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -19,6 +19,7 @@ "---Adapters---", "captcha", "email", + "storage", "sso", "cron", "websocket", diff --git a/apps/docs/content/docs/dev/storage/custom-adapter.mdx b/apps/docs/content/docs/dev/storage/custom-adapter.mdx new file mode 100644 index 000000000..65d39588a --- /dev/null +++ b/apps/docs/content/docs/dev/storage/custom-adapter.mdx @@ -0,0 +1,78 @@ +--- +title: Custom Adapter +description: Create your own custom storage adapter for VitNode. +--- + +Want to store files somewhere else? Implement the `StorageApiPlugin` interface. +An adapter is a plain factory returning three methods — `upload`, `delete` and +`getUrl` — plus an optional `static` descriptor for disk-backed adapters that +serve files through Hono's `serveStatic`. + +## Usage + + + +### Create the adapter + +```ts title="src/utils/storage/my-storage.ts" +import type { StorageApiPlugin } from "@vitnode/core/api/models/storage"; + +export const MyStorageAdapter = ({ + bucket = "", +}: { + bucket: string | undefined; +}): StorageApiPlugin => {}; +``` + + + +### Implement the methods + +- `upload({ key, body, contentType })` — persist the `Buffer` and return the + stored `key` and its public `url`. +- `delete(key)` — remove a stored object. +- `getUrl(key)` — build the public URL for a key. + +```ts title="src/utils/storage/my-storage.ts" +import type { + StorageApiPlugin, + StorageUploadArgs, +} from "@vitnode/core/api/models/storage"; + +export const MyStorageAdapter = ({ + bucket = "", +}: { + bucket: string | undefined; +}): StorageApiPlugin => { + const getUrl = (key: string) => `https://cdn.example.com/${bucket}/${key}`; + + // [!code ++:12] + return { + getUrl, + upload: async ({ key, body, contentType }: StorageUploadArgs) => { + // ...persist `body` under `key` + return { key, url: getUrl(key) }; + }, + delete: async (key: string) => { + // ...remove `key` + }, + }; +}; +``` + + + + + + The framework builds the `month_{month}_{year}/{folder}/.` key + before calling `upload`, so an adapter just stores the `body` at the given + `key` — no path logic needed. + + +## Publish to NPM + +If you want to share your adapter, publish it as an NPM package. Reference +implementations: + +- [S3 / R2 Adapter](https://github.com/aXenDeveloper/vitnode/tree/canary/packages/s3) +- [Supabase Storage Adapter](https://github.com/aXenDeveloper/vitnode/tree/canary/packages/supabase-storage) diff --git a/apps/docs/content/docs/dev/storage/index.mdx b/apps/docs/content/docs/dev/storage/index.mdx new file mode 100644 index 000000000..5e1866c57 --- /dev/null +++ b/apps/docs/content/docs/dev/storage/index.mdx @@ -0,0 +1,212 @@ +--- +title: Storage +description: Upload and serve files with pluggable storage adapters. +--- + +VitNode gives you a pluggable **storage adapter**, exposed on every request as +`c.get("storage")`. Files are always stored under `month_{month}_{year}/{folder}/…` +and every upload returns a public URL. + +VitNode does **not** ship a generic upload endpoint — you build your own route +(in your app or plugin) so you control auth, validation, and where files go. The +`c.get("storage").upload()` helper does the heavy lifting: it builds the dated +key, validates the file, and returns the URL. + +Storage is **optional**. Without an adapter configured, `c.get("storage")` throws +and the AdminCP → System → Integrations "Storage" card shows as inactive. + +## Adapters + + + + + + + +or create your own [custom storage adapter](/docs/dev/storage/custom-adapter). + +## Image optimization + +Enable the `image` option to re-encode uploaded images with +[`sharp`](https://sharp.pixelplumbing.com/) before they're stored — a simple way +to cut file size. `quality` defaults to `85` (1–100): + +```ts title="vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + storage: { + adapter: LocalStorageAdapter(), + // [!code ++:3] + image: { + quality: 85, + }, + }, +}); +``` + +It runs automatically inside `c.get("storage").upload()`, so every upload route +benefits. Applies to JPEG, PNG, WebP, AVIF and TIFF; the format is preserved. +Other files — including SVG and GIF — are stored untouched. Leave `image` out to +store originals as-is. + +## Create your own upload endpoint + +Define a route that accepts `multipart/form-data`, then call +`c.get("storage").upload()`. Pass `maxBytes` / `allowedMimeTypes` to validate the +file — a violation throws a `400` automatically. + + + +### Define the route + +```ts title="upload-avatar.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +export const uploadAvatarRoute = buildRoute({ + pluginId: "@my-plugin/core", + route: { + method: "post", + path: "/upload-avatar", + request: { + body: { + required: true, + content: { + "multipart/form-data": { + schema: z.object({ + file: z + .instanceof(File) + .openapi({ format: "binary", type: "string" }), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ key: z.string(), url: z.string() }), + }, + }, + description: "File uploaded", + }, + }, + }, + handler: async c => { + // Guard however you like — here we require a signed-in user. + const user = c.get("user"); + if (!user) throw new HTTPException(401, { message: "Unauthorized" }); + + const { file } = c.req.valid("form"); + + // [!code ++:6] + const { url, key } = await c.get("storage").upload({ + file, + folder: "avatars", // -> month_7_2026/avatars/.png + maxBytes: 5 * 1024 * 1024, // 5 MB + allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"], + }); + + return c.json({ key, url }); + }, +}); +``` + +Register it in a module with `buildModule` and add that module to your plugin — +see [Plugins](/docs/dev/plugins). You can also `c.get("storage").delete(key)` and +`c.get("storage").getUrl(key)`. + + + +### Call it from the client + +Uploads run through TanStack Query, **not** Next.js server actions (which can't +stream large bodies). Build a `FormData` and pass it to `fetcherClient` as +`formData` — it drops the JSON `Content-Type` so the browser sets the multipart +boundary. Reference your route with `clientModule` and a **type-only** import of +its module, so the call stays fully typed without bundling any server code: + +```tsx title="use-upload-avatar.ts" +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { clientModule, fetcherClient } from "@vitnode/core/lib/fetcher-client"; +import type { myPluginModule } from "@/api/my-plugin.module"; + +export const useUploadAvatar = () => + useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + + const res = await fetcherClient( + clientModule("@my-plugin/core"), + { + module: "my-plugin", + path: "/upload-avatar", + method: "post", + formData, + options: { credentials: "include" }, + }, + ); + if (!res.ok) throw new Error(await res.text()); + + return await res.json(); // { key, url } + }, + }); +``` + + + + + + Uploads are cross-origin when the API runs on a different origin than the + site. Pass a `cors` option with `origin: WEB_URL` + `credentials: true` (and a + matching `csrf` option) to `VitNodeAPI`, and send `credentials: "include"` + from the client, so the session cookie reaches your route. + + +## Uploading multiple files in one endpoint + +Read the raw form and loop `upload()` over every file. `formData.getAll("files")` +returns each entry for the repeated `files` field: + +```ts title="upload-gallery.route.ts" +handler: async c => { + const form = await c.req.formData(); + const files = form.getAll("files").filter((f): f is File => f instanceof File); + + if (files.length === 0) { + throw new HTTPException(400, { message: "No files provided" }); + } + + // [!code ++:9] + const uploaded = await Promise.all( + files.map(file => + c.get("storage").upload({ + file, + folder: "gallery", + maxBytes: 5 * 1024 * 1024, + allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"], + }), + ), + ); + + return c.json({ files: uploaded }); // [{ key, url }, …] +}, +``` + +On the client, append each file under the same key: + +```ts +const formData = new FormData(); +for (const file of fileList) formData.append("files", file); +``` + + + `maxBytes` and `allowedMimeTypes` on `upload()` throw a `400` before anything + is written, so a rejected file in a batch never leaves a partial upload behind + for that file. Wrap the `Promise.all` in a transaction/cleanup if you need + all-or-nothing semantics across the whole batch. + diff --git a/apps/docs/content/docs/dev/storage/local.mdx b/apps/docs/content/docs/dev/storage/local.mdx new file mode 100644 index 000000000..e11aad465 --- /dev/null +++ b/apps/docs/content/docs/dev/storage/local.mdx @@ -0,0 +1,98 @@ +--- +title: Local (disk) +description: Zero-config storage that writes uploads to the local disk. +--- + +| Cloud | Self-Hosted | +| -------------- | ------------ | +| ⚠️ Not durable | ✅ Supported | + +The Local adapter ships with `@vitnode/core` — no extra package or cloud account +needed. It writes files to a `public/uploads` directory and serves them as +static files. + + + Serverless platforms (e.g. Vercel) have an ephemeral, read-only filesystem, so + locally stored files are lost between deploys and instances. Use a cloud + adapter ([S3 / R2](/docs/dev/storage/s3-r2) or + [Supabase](/docs/dev/storage/supabase)) in those environments. + + +## Usage + + + +### Import the adapter + +```ts title="vitnode.api.config.ts" +// [!code ++] +import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local"; +import { buildApiConfig } from "@vitnode/core/vitnode.config"; + +export const vitNodeApiConfig = buildApiConfig({ + storage: { + // [!code ++] + adapter: LocalStorageAdapter(), + }, +}); +``` + + + +### Serve the files + +On the standalone Node API, mount the adapter's `static` descriptor with Hono's +`serveStatic` **before** `VitNodeAPI` so uploads are served at `/api/uploads/*`: + +```ts title="src/index.ts" +import { serveStatic } from "@hono/node-server/serve-static"; + +const staticStorage = vitNodeApiConfig.storage?.adapter?.static; +// [!code ++:9] +if (staticStorage) { + app.get( + staticStorage.mountPath, + serveStatic({ + root: staticStorage.root, + rewriteRequestPath: path => path.replace(staticStorage.stripPrefix, ""), + }), + ); +} + +VitNodeAPI({ app, vitNodeApiConfig }); +``` + +Inside a **Next.js** app the `public/` directory is already served at the site +root, so no `serveStatic` is needed — just point the adapter's public path at it: + +```ts title="vitnode.api.config.ts" +adapter: LocalStorageAdapter({ publicPath: "/uploads" }), +``` + + + + +## Options + +import { TypeTable } from "fumadocs-ui/components/type-table"; + + diff --git a/apps/docs/content/docs/dev/storage/meta.json b/apps/docs/content/docs/dev/storage/meta.json new file mode 100644 index 000000000..dcc715a1c --- /dev/null +++ b/apps/docs/content/docs/dev/storage/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Storage", + "pages": [ + "index", + "local", + "---Adapters---", + "s3-r2", + "supabase", + "custom-adapter" + ] +} diff --git a/apps/docs/content/docs/dev/storage/s3-r2.mdx b/apps/docs/content/docs/dev/storage/s3-r2.mdx new file mode 100644 index 000000000..95bcbc73b --- /dev/null +++ b/apps/docs/content/docs/dev/storage/s3-r2.mdx @@ -0,0 +1,120 @@ +--- +title: AWS S3 / Cloudflare R2 +description: Store uploads in an S3-compatible bucket (AWS S3 or Cloudflare R2). +--- + +| Cloud | Self-Hosted | Links | +| ------------ | ------------ | --------------------------------------------------------------- | +| ✅ Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/@aws-sdk/client-s3) | + +A single adapter covers both **AWS S3** and **Cloudflare R2** — R2 is +S3-compatible, so you only add a custom `endpoint`. + +## Usage + + + +### Installation + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```bash tab="bun" +bun i @vitnode/s3 -D +``` + +```bash tab="pnpm" +pnpm i @vitnode/s3 -D +``` + +```bash tab="npm" +npm i @vitnode/s3 -D +``` + + + + + +### Import the adapter (AWS S3) + +```ts title="vitnode.api.config.ts" +// [!code ++] +import { S3StorageAdapter } from "@vitnode/s3"; +import { buildApiConfig } from "@vitnode/core/vitnode.config"; + +export const vitNodeApiConfig = buildApiConfig({ + storage: { + // [!code ++:6] + adapter: S3StorageAdapter({ + bucket: process.env.S3_BUCKET, + region: process.env.S3_REGION, + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + }), + }, +}); +``` + + + +### Environment variables + +```bash title=".env" +S3_BUCKET=your-bucket +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=your_access_key_id +S3_SECRET_ACCESS_KEY=your_secret_access_key +``` + + + +### Cloudflare R2 + +R2 uses the same adapter — add your account `endpoint` and (optionally) the +public bucket URL: + +```ts title="vitnode.api.config.ts" +adapter: S3StorageAdapter({ + bucket: process.env.S3_BUCKET, + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + // [!code ++:2] + endpoint: process.env.S3_ENDPOINT, // https://.r2.cloudflarestorage.com + publicUrl: process.env.S3_PUBLIC_URL, // https://cdn.example.com +}), +``` + + + + +## Options + +import { TypeTable } from "fumadocs-ui/components/type-table"; + + diff --git a/apps/docs/content/docs/dev/storage/supabase.mdx b/apps/docs/content/docs/dev/storage/supabase.mdx new file mode 100644 index 000000000..1915043d3 --- /dev/null +++ b/apps/docs/content/docs/dev/storage/supabase.mdx @@ -0,0 +1,95 @@ +--- +title: Supabase Storage +description: Store uploads in a Supabase Storage bucket. +--- + +| Cloud | Self-Hosted | Links | +| ------------ | ------------ | ----------------------------------------------------------------- | +| ✅ Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/@supabase/storage-js) | + +## Usage + + + +### Installation + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```bash tab="bun" +bun i @vitnode/supabase-storage -D +``` + +```bash tab="pnpm" +pnpm i @vitnode/supabase-storage -D +``` + +```bash tab="npm" +npm i @vitnode/supabase-storage -D +``` + + + + + +### Import the adapter + +```ts title="vitnode.api.config.ts" +// [!code ++] +import { SupabaseStorageAdapter } from "@vitnode/supabase-storage"; +import { buildApiConfig } from "@vitnode/core/vitnode.config"; + +export const vitNodeApiConfig = buildApiConfig({ + storage: { + // [!code ++:5] + adapter: SupabaseStorageAdapter({ + url: process.env.SUPABASE_URL, + serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY, + bucket: process.env.SUPABASE_STORAGE_BUCKET, + }), + }, +}); +``` + + + +### Environment variables + +The **service role key** is required so the server can write to the bucket. Keep +it secret — never expose it to the client. + +```bash title=".env" +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +SUPABASE_STORAGE_BUCKET=your-bucket +``` + + + `getUrl` returns the bucket's public URL. Make the bucket public (or serve + through a CDN) if you want the returned URLs to be directly accessible. + + + + + +## Options + +import { TypeTable } from "fumadocs-ui/components/type-table"; + + diff --git a/apps/docs/migrations/0008_silky_forgotten_one.sql b/apps/docs/migrations/0008_silky_forgotten_one.sql new file mode 100644 index 000000000..e7e037b00 --- /dev/null +++ b/apps/docs/migrations/0008_silky_forgotten_one.sql @@ -0,0 +1,17 @@ +CREATE TABLE "core_files" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "key" varchar(512) NOT NULL, + "folder" varchar(255) NOT NULL, + "mimeType" varchar(255), + "size" integer DEFAULT 0 NOT NULL, + "userId" integer, + "pluginId" varchar(100), + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "core_files_key_unique" UNIQUE("key") +); +--> statement-breakpoint +ALTER TABLE "core_files" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_files" ADD CONSTRAINT "core_files_userId_core_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."core_users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "core_files_user_id_idx" ON "core_files" USING btree ("userId"); \ No newline at end of file diff --git a/apps/docs/migrations/meta/0008_snapshot.json b/apps/docs/migrations/meta/0008_snapshot.json new file mode 100644 index 000000000..e842cdd49 --- /dev/null +++ b/apps/docs/migrations/meta/0008_snapshot.json @@ -0,0 +1,1880 @@ +{ + "id": "4829773c-6615-48c5-bebb-5ece6e9a38a5", + "prevId": "9546a008-f27f-462c-a449-e28a54650a5d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"unrestricted\":false,\"permissions\":[]}'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"unrestricted\":false,\"permissions\":[]}'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(19)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "titleSeo": { + "name": "titleSeo", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_categories_titleSeo_unique": { + "name": "blog_categories_titleSeo_unique", + "nullsNotDistinct": false, + "columns": [ + "titleSeo" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "titleSeo": { + "name": "titleSeo", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_posts_titleSeo_unique": { + "name": "blog_posts_titleSeo_unique", + "nullsNotDistinct": false, + "columns": [ + "titleSeo" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index 9f8a9704d..affe4d6d8 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1783194705286, "tag": "0007_huge_madelyne_pryor", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1783283845218, + "tag": "0008_silky_forgotten_one", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index b591e6eb4..dd8a0d98d 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -15,6 +15,7 @@ "@vitnode/core:system": "System", "@vitnode/core:system:can_view": "View integrations", "@vitnode/core:system:can_send_test_email": "Send test email", + "@vitnode/core:system:can_test_storage": "Test storage", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", "@vitnode/core:staff_moderators": "Staff: Moderators", @@ -553,6 +554,27 @@ "success": "Test email sent. Check the recipient's inbox to confirm your configuration." } }, + "storage": { + "title": "Storage", + "desc": "File uploads such as avatars and attachments, stored via your storage adapter.", + "test": { + "label": "Test storage", + "title": "Test file storage", + "desc": "Upload a file and run a round-trip check to confirm your storage adapter is working.", + "info": { + "title": "How to verify your configuration", + "desc": "Upload a test image. If it succeeds, your storage adapter is configured correctly. If it fails, double-check your adapter settings and the system logs." + }, + "upload": { + "label": "Upload a test image", + "desc": "Click to choose an image", + "pending": "Uploading…", + "done": "Uploaded", + "error": "Upload failed — try again", + "success": "Image uploaded — it's stored in your adapter." + } + } + }, "captcha": { "title": "Captcha", "desc": "Bot protection on public forms like sign-up and password reset.", @@ -566,13 +588,15 @@ "desc": "Scheduled background tasks such as clearing expired sessions and tokens.", "insecure": "Using the default CRON_SECRET — set a secure one in production.", "not_configured": "No cron adapter configured — jobs won't run automatically.", + "stale": "No job has run in over 6 hours — cron may be misconfigured or stopped. Check your cron setup.", "jobs": "{count, plural, =0 {No jobs scheduled} one {# job scheduled} other {# jobs scheduled}}" }, "queue": { "title": "Queue Tasks", "desc": "Background jobs enqueued in the database and drained by the cron worker.", "tasks": "{count, plural, =0 {No tasks registered} one {# task registered} other {# tasks registered}}", - "queued": "{pending} pending · {processing} running" + "queued": "{pending} pending · {processing} running", + "cron_stale": "Offline — the cron worker that drains the queue isn't running. Fix cron jobs to resume processing." } } }, diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts index eaf6bef50..2d723c964 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -3,6 +3,7 @@ import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord"; // import { ResendEmailAdapter } from "@vitnode/resend"; import { FacebookSSOApiPlugin } from "@vitnode/core/api/adapters/sso/facebook"; import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google"; +import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local"; import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { NodeCronAdapter } from "@vitnode/node-cron"; import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; @@ -51,6 +52,16 @@ export const vitNodeApiConfig = buildApiConfig({ src: "http://localhost:3000/logo_vitnode_dark.png", }, }, + storage: { + // Next.js serves `public/` at the site root, so files land in + // `public/uploads` and are reachable at `/uploads/`. Local disk is not + // durable on serverless — switch to a cloud adapter when deploying there. + adapter: LocalStorageAdapter({ publicPath: "/uploads" }), + // Re-encode uploaded images with sharp to shrink them before storing. + image: { + quality: 85, + }, + }, authorization: { ssoAdapters: [ DiscordSSOApiPlugin({ diff --git a/packages/s3/.npmignore b/packages/s3/.npmignore new file mode 100644 index 000000000..4acef1b9b --- /dev/null +++ b/packages/s3/.npmignore @@ -0,0 +1,6 @@ +/.turbo +/src +/node_modules +/tsconfig.json +/.swcrc +/eslint.config.mjs diff --git a/packages/s3/.swcrc b/packages/s3/.swcrc new file mode 100644 index 000000000..eba97079c --- /dev/null +++ b/packages/s3/.swcrc @@ -0,0 +1,25 @@ +{ + "$schema": "https://swc.rs/schema.json", + "minify": true, + "jsc": { + "baseUrl": "./", + "target": "esnext", + "paths": { + "@/*": ["./src/*"] + }, + "parser": { + "syntax": "typescript", + "tsx": true + }, + "transform": { + "react": { + "runtime": "automatic" + } + } + }, + "module": { + "type": "nodenext", + "strict": true, + "resolveFully": true + } +} diff --git a/packages/s3/README.md b/packages/s3/README.md new file mode 100644 index 000000000..052276076 --- /dev/null +++ b/packages/s3/README.md @@ -0,0 +1,22 @@ +# (VitNode) AWS S3 / Cloudflare R2 Storage Adapter + +This package provides an S3-compatible storage adapter for VitNode file uploads. +A single adapter serves both **AWS S3** and **Cloudflare R2** — R2 only needs a +custom `endpoint`. + +

+
+ + + + + VitNode Logo + + +
+
+

+ +| Cloud | Self-Hosted | Links | Documentation | +| ------------ | ------------ | ---------------------------------------------------------------- | ---------------------------------------------------- | +| ✅ Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/@aws-sdk/client-s3) | [Docs](https://vitnode.com/docs/dev/storage/s3-r2) | diff --git a/packages/s3/eslint.config.mjs b/packages/s3/eslint.config.mjs new file mode 100644 index 000000000..8c0f6171d --- /dev/null +++ b/packages/s3/eslint.config.mjs @@ -0,0 +1,17 @@ +import eslintVitNode from "@vitnode/config/eslint"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + ...eslintVitNode, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + }, + }, +]; diff --git a/packages/s3/package.json b/packages/s3/package.json new file mode 100644 index 000000000..547d26574 --- /dev/null +++ b/packages/s3/package.json @@ -0,0 +1,46 @@ +{ + "name": "@vitnode/s3", + "version": "1.2.0-canary.75", + "description": "AWS S3 and Cloudflare R2 storage adapter for VitNode file uploads.", + "author": "VitNode Team", + "license": "MIT", + "homepage": "https://vitnode.com", + "repository": { + "type": "git", + "url": "git+https://github.com/aXenDeveloper/vitnode.git", + "directory": "packages/s3" + }, + "keywords": [ + "vitnode", + "s3", + "storage", + "cloudflare-r2" + ], + "type": "module", + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "build:plugins": "tsc && swc src -d dist --config-file .swcrc && tsc-alias -p tsconfig.json", + "dev:plugins": "concurrently \"tsc -w --preserveWatchOutput\" \"swc src -d dist --config-file .swcrc -w\" \"tsc-alias -w\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.0.0" + }, + "devDependencies": { + "@swc/cli": "^0.8.1", + "@swc/core": "^1.15.43", + "@vitnode/config": "workspace:*", + "@vitnode/core": "workspace:*", + "concurrently": "^10.0.3", + "eslint": "^10.5.0", + "tsc-alias": "^1.8.17", + "typescript": "^6.0.3" + } +} diff --git a/packages/s3/src/index.ts b/packages/s3/src/index.ts new file mode 100644 index 000000000..e89920874 --- /dev/null +++ b/packages/s3/src/index.ts @@ -0,0 +1,81 @@ +import type { + StorageApiPlugin, + StorageUploadArgs, + StorageUploadResult, +} from "@vitnode/core/api/models/storage"; + +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; + +export const S3StorageAdapter = ({ + accessKeyId = "", + bucket = "", + endpoint, + forcePathStyle, + publicUrl, + region = "auto", + secretAccessKey = "", +}: { + accessKeyId: string | undefined; + bucket: string | undefined; + endpoint?: string; + forcePathStyle?: boolean; + publicUrl?: string; + region?: string; + secretAccessKey: string | undefined; +}): StorageApiPlugin => { + let client: S3Client | undefined; + + // A single client serves AWS S3 and Cloudflare R2 — R2 only needs a custom + // `endpoint` (and path-style addressing). Created lazily so a missing config + // fails on first use with a clear error, like the other adapters. + const getClient = (): S3Client => { + if (!(bucket && accessKeyId && secretAccessKey)) { + throw new Error("Missing S3 configuration"); + } + + client ??= new S3Client({ + region, + endpoint, + forcePathStyle: forcePathStyle ?? !!endpoint, + credentials: { accessKeyId, secretAccessKey }, + }); + + return client; + }; + + const getUrl = (key: string): string => { + if (publicUrl) return `${publicUrl.replace(/\/$/, "")}/${key}`; + if (endpoint) return `${endpoint.replace(/\/$/, "")}/${bucket}/${key}`; + + return `https://${bucket}.s3.${region}.amazonaws.com/${key}`; + }; + + return { + delete: async (key: string): Promise => { + await getClient().send( + new DeleteObjectCommand({ Bucket: bucket, Key: key }), + ); + }, + getUrl, + upload: async ({ + body, + contentType, + key, + }: StorageUploadArgs): Promise => { + await getClient().send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: contentType, + }), + ); + + return { key, url: getUrl(key) }; + }, + }; +}; diff --git a/packages/s3/tsconfig.json b/packages/s3/tsconfig.json new file mode 100644 index 000000000..bb2a35364 --- /dev/null +++ b/packages/s3/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vitnode/config/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "moduleResolution": "bundler", + "rootDir": "./", + "outDir": "./dist", + "incremental": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules"], + "include": ["src"] +} diff --git a/packages/supabase-storage/.npmignore b/packages/supabase-storage/.npmignore new file mode 100644 index 000000000..4acef1b9b --- /dev/null +++ b/packages/supabase-storage/.npmignore @@ -0,0 +1,6 @@ +/.turbo +/src +/node_modules +/tsconfig.json +/.swcrc +/eslint.config.mjs diff --git a/packages/supabase-storage/.swcrc b/packages/supabase-storage/.swcrc new file mode 100644 index 000000000..eba97079c --- /dev/null +++ b/packages/supabase-storage/.swcrc @@ -0,0 +1,25 @@ +{ + "$schema": "https://swc.rs/schema.json", + "minify": true, + "jsc": { + "baseUrl": "./", + "target": "esnext", + "paths": { + "@/*": ["./src/*"] + }, + "parser": { + "syntax": "typescript", + "tsx": true + }, + "transform": { + "react": { + "runtime": "automatic" + } + } + }, + "module": { + "type": "nodenext", + "strict": true, + "resolveFully": true + } +} diff --git a/packages/supabase-storage/README.md b/packages/supabase-storage/README.md new file mode 100644 index 000000000..eeb974271 --- /dev/null +++ b/packages/supabase-storage/README.md @@ -0,0 +1,20 @@ +# (VitNode) Supabase Storage Adapter + +This package provides a Supabase Storage adapter for VitNode file uploads. + +

+
+ + + + + VitNode Logo + + +
+
+

+ +| Cloud | Self-Hosted | Links | Documentation | +| ------------ | ------------ | ----------------------------------------------------------------- | ------------------------------------------------------ | +| ✅ Supported | ✅ Supported | [NPM Package](https://www.npmjs.com/package/@supabase/storage-js) | [Docs](https://vitnode.com/docs/dev/storage/supabase) | diff --git a/packages/supabase-storage/eslint.config.mjs b/packages/supabase-storage/eslint.config.mjs new file mode 100644 index 000000000..8c0f6171d --- /dev/null +++ b/packages/supabase-storage/eslint.config.mjs @@ -0,0 +1,17 @@ +import eslintVitNode from "@vitnode/config/eslint"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + ...eslintVitNode, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + }, + }, +]; diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json new file mode 100644 index 000000000..520ab2b59 --- /dev/null +++ b/packages/supabase-storage/package.json @@ -0,0 +1,45 @@ +{ + "name": "@vitnode/supabase-storage", + "version": "1.2.0-canary.75", + "description": "Supabase Storage adapter for VitNode file uploads.", + "author": "VitNode Team", + "license": "MIT", + "homepage": "https://vitnode.com", + "repository": { + "type": "git", + "url": "git+https://github.com/aXenDeveloper/vitnode.git", + "directory": "packages/supabase-storage" + }, + "keywords": [ + "vitnode", + "supabase", + "storage" + ], + "type": "module", + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "build:plugins": "tsc && swc src -d dist --config-file .swcrc && tsc-alias -p tsconfig.json", + "dev:plugins": "concurrently \"tsc -w --preserveWatchOutput\" \"swc src -d dist --config-file .swcrc -w\" \"tsc-alias -w\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "@supabase/storage-js": "^2.0.0" + }, + "devDependencies": { + "@swc/cli": "^0.8.1", + "@swc/core": "^1.15.43", + "@vitnode/config": "workspace:*", + "@vitnode/core": "workspace:*", + "concurrently": "^10.0.3", + "eslint": "^10.5.0", + "tsc-alias": "^1.8.17", + "typescript": "^6.0.3" + } +} diff --git a/packages/supabase-storage/src/index.ts b/packages/supabase-storage/src/index.ts new file mode 100644 index 000000000..59465d734 --- /dev/null +++ b/packages/supabase-storage/src/index.ts @@ -0,0 +1,55 @@ +import type { + StorageApiPlugin, + StorageUploadArgs, + StorageUploadResult, +} from "@vitnode/core/api/models/storage"; + +import { StorageClient } from "@supabase/storage-js"; + +export const SupabaseStorageAdapter = ({ + bucket = "", + serviceRoleKey = "", + url = "", +}: { + bucket: string | undefined; + serviceRoleKey: string | undefined; + url: string | undefined; +}): StorageApiPlugin => { + let client: StorageClient | undefined; + + const getClient = (): StorageClient => { + if (!(url && serviceRoleKey && bucket)) { + throw new Error("Missing Supabase Storage configuration"); + } + + client ??= new StorageClient(`${url.replace(/\/$/, "")}/storage/v1`, { + apikey: serviceRoleKey, + Authorization: `Bearer ${serviceRoleKey}`, + }); + + return client; + }; + + const getUrl = (key: string): string => + getClient().from(bucket).getPublicUrl(key).data.publicUrl; + + return { + delete: async (key: string): Promise => { + const { error } = await getClient().from(bucket).remove([key]); + if (error) throw error; + }, + getUrl, + upload: async ({ + body, + contentType, + key, + }: StorageUploadArgs): Promise => { + const { error } = await getClient() + .from(bucket) + .upload(key, body, { contentType, upsert: true }); + if (error) throw error; + + return { key, url: getUrl(key) }; + }, + }; +}; diff --git a/packages/supabase-storage/tsconfig.json b/packages/supabase-storage/tsconfig.json new file mode 100644 index 000000000..bb2a35364 --- /dev/null +++ b/packages/supabase-storage/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vitnode/config/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "moduleResolution": "bundler", + "rootDir": "./", + "outDir": "./dist", + "incremental": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules"], + "include": ["src"] +} diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index b58b3d551..0c09772fb 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -137,6 +137,7 @@ "recharts": "3.9.0", "server-only": "^0.0.1", "shadcn": "^4.11.0", + "sharp": "^0.35.3", "tailwind-merge": "^3.6.0", "use-debounce": "^10.1.1", "vaul": "^1.1.2" diff --git a/packages/vitnode/src/api/adapters/storage/local.ts b/packages/vitnode/src/api/adapters/storage/local.ts new file mode 100644 index 000000000..2006e9d7e --- /dev/null +++ b/packages/vitnode/src/api/adapters/storage/local.ts @@ -0,0 +1,60 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { + StorageApiPlugin, + StorageUploadArgs, + StorageUploadResult, +} from "@/api/models/storage"; + +import { CONFIG } from "@/lib/config"; + +/** + * Zero-config storage backend that writes uploads to the local disk. + * + * - On the standalone Node API (`@hono/node-server`) files are served by Hono's + * `serveStatic`, wired from the `static` descriptor below. The API is mounted + * under `/api`, so `publicPath` defaults to `/api/uploads`. + * - Inside a Next.js app the `public/` directory is served at the site root, so + * pass `publicPath: "/uploads"` there (the `static` descriptor is unused). + * + * Local disk is not durable on serverless platforms — use a cloud adapter there. + */ +export const LocalStorageAdapter = ({ + baseUrl, + publicPath = "/api/uploads", + uploadsDir = "public/uploads", +}: { + baseUrl?: string; + publicPath?: string; + uploadsDir?: string; +} = {}): StorageApiPlugin => { + const resolvePath = (key: string): string => + join(process.cwd(), uploadsDir, key); + const getUrl = (key: string): string => + `${baseUrl ?? CONFIG.api.origin}${publicPath}/${key}`; + // Route registered on the API app, which already carries the `/api` basePath. + const mountPath = `${publicPath.replace(/^\/api/, "")}/*`; + + return { + delete: async (key: string): Promise => { + await rm(resolvePath(key), { force: true }); + }, + getUrl, + static: { + mountPath, + root: `./${uploadsDir}`, + stripPrefix: publicPath, + }, + upload: async ({ + body, + key, + }: StorageUploadArgs): Promise => { + const filePath = resolvePath(key); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, body); + + return { key, url: getUrl(key) }; + }, + }; +}; diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index d046d7162..2fc1f190c 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -81,6 +81,7 @@ export function VitNodeAPI({ dbProvider: vitNodeApiConfig.dbProvider, captcha: vitNodeApiConfig.captcha, cron: vitNodeApiConfig.cron, + storage: vitNodeApiConfig.storage, plugins: [newBuildPluginApiCore, ...vitNodeApiConfig.plugins], cacheClient: redisClient, }), diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 046828ad8..f2c3c980d 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -11,6 +11,7 @@ import { EmailModel } from "@/api/models/email"; import { QueueModel } from "@/api/models/queue"; import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; +import { StorageModel } from "@/api/models/storage"; import { CONFIG } from "@/lib/config"; import { realtime } from "@/ws/registry"; @@ -78,6 +79,7 @@ export interface EnvVariablesVitNode { permissionStaff: PermissionStaffCatalogEntry[]; plugins: { id: string }[]; queue: (BuildQueueTaskReturn & { module: string; pluginId: string })[]; + storage?: VitNodeApiConfig["storage"]; webSockets: WebSocketConfig[]; }; db: Pick["dbProvider"]; @@ -89,6 +91,7 @@ export interface EnvVariablesVitNode { }; queue: QueueModel; realtime: VitNodeRealtime; + storage: StorageModel; user: null | { avatarColor: string; birthday: Date | null; @@ -112,6 +115,7 @@ export const globalMiddleware = ({ cron, plugins, pathToMessages, + storage, cacheClient, }: Pick< VitNodeApiConfig, @@ -122,6 +126,7 @@ export const globalMiddleware = ({ | "email" | "pathToMessages" | "plugins" + | "storage" > & Pick & { cacheClient: null | Redis }) => { const pluginsMetadata = plugins.map(plugin => ({ @@ -204,12 +209,14 @@ export const globalMiddleware = ({ c.set("cache", new CacheModel(cacheClient, c)); c.set("email", new EmailModel(c)); c.set("queue", new QueueModel(c)); + c.set("storage", new StorageModel(c)); c.set("realtime", realtime); c.set("core", { pathToMessages, metadata, email, + storage, authorization: { cookieName: authorization?.cookieName ?? "vitnode_auth", cookie_expires: diff --git a/packages/vitnode/src/api/models/storage-image.test.ts b/packages/vitnode/src/api/models/storage-image.test.ts new file mode 100644 index 000000000..c87589cb3 --- /dev/null +++ b/packages/vitnode/src/api/models/storage-image.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment node +// Runs in the Node environment so sharp and real image buffers behave as they do +// on the server. +import type { Context } from "hono"; + +import sharp from "sharp"; +import { describe, expect, it, vi } from "vitest"; + +import { StorageModel } from "./storage"; + +const makeCtx = (image?: { quality?: number }) => { + const upload = vi.fn( + ({ key }: { body: Buffer; contentType?: string; key: string }) => ({ + key, + url: `https://cdn.test/${key}`, + }), + ); + const store: Record = { + core: { + storage: { + adapter: { delete: vi.fn(), getUrl: (k: string) => k, upload }, + image, + }, + }, + db: { + insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })), + }, + plugin: { id: "@vitnode/core" }, + user: { id: 7 }, + }; + + return { + ctx: { get: (k: string) => store[k] } as unknown as Context, + upload, + }; +}; + +const makeJpeg = async (quality: number): Promise => + await sharp({ + create: { + background: { b: 50, g: 100, r: 200 }, + channels: 3, + height: 256, + noise: { mean: 128, sigma: 60, type: "gaussian" }, + width: 256, + }, + }) + .jpeg({ quality }) + .toBuffer(); + +const fileFrom = (buf: Buffer, name: string, type: string): File => + new File([new Uint8Array(buf)], name, { type }); + +describe("StorageModel image optimization", () => { + it("re-encodes an image at the configured quality when image config is set", async () => { + const original = await makeJpeg(100); + const { ctx, upload } = makeCtx({ quality: 40 }); + + await new StorageModel(ctx).upload({ + file: fileFrom(original, "photo.jpg", "image/jpeg"), + folder: "photos", + }); + + const stored = upload.mock.calls[0][0].body; + expect((await sharp(stored).metadata()).format).toBe("jpeg"); + expect(stored.equals(original)).toBe(false); + expect(stored.length).toBeLessThan(original.length); + }); + + it("stores the original bytes when image config is absent", async () => { + const original = await makeJpeg(100); + const { ctx, upload } = makeCtx(undefined); + + await new StorageModel(ctx).upload({ + file: fileFrom(original, "photo.jpg", "image/jpeg"), + folder: "photos", + }); + + expect(upload.mock.calls[0][0].body.equals(original)).toBe(true); + }); + + it("leaves non-image files untouched even when image config is set", async () => { + const original = Buffer.from("just some text"); + const { ctx, upload } = makeCtx({ quality: 40 }); + + await new StorageModel(ctx).upload({ + file: fileFrom(original, "notes.txt", "text/plain"), + folder: "docs", + }); + + expect(upload.mock.calls[0][0].body.equals(original)).toBe(true); + }); +}); diff --git a/packages/vitnode/src/api/models/storage.test.ts b/packages/vitnode/src/api/models/storage.test.ts new file mode 100644 index 000000000..153a248dc --- /dev/null +++ b/packages/vitnode/src/api/models/storage.test.ts @@ -0,0 +1,159 @@ +import type { Context } from "hono"; + +import { describe, expect, it, vi } from "vitest"; + +import { StorageModel } from "./storage"; + +const makeCtx = ( + overrides: { admin?: unknown; storage?: unknown } = {}, +): { + ctx: Context; + del: ReturnType; + insertValues: ReturnType; + upload: ReturnType; +} => { + const upload = vi + .fn() + .mockImplementation(async ({ key }: { key: string }) => + Promise.resolve({ key, url: `https://cdn.test/${key}` }), + ); + const del = vi.fn().mockResolvedValue(undefined); + const insertValues = vi.fn().mockResolvedValue(undefined); + const store: Record = { + admin: "admin" in overrides ? overrides.admin : null, + core: { + storage: + "storage" in overrides + ? overrides.storage + : { adapter: { delete: del, getUrl: (k: string) => k, upload } }, + }, + db: { insert: vi.fn(() => ({ values: insertValues })) }, + plugin: { id: "@vitnode/core" }, + user: { id: 7 }, + }; + + return { + ctx: { get: (k: string) => store[k] } as unknown as Context, + del, + insertValues, + upload, + }; +}; + +describe("StorageModel.upload", () => { + it("uploads under month_x_y/{folder} with a generated file name", async () => { + const { ctx, insertValues, upload } = makeCtx(); + const file = new File(["hello"], "photo.png", { type: "image/png" }); + + const result = await new StorageModel(ctx).upload({ + file, + folder: "avatars", + }); + + expect(upload).toHaveBeenCalledTimes(1); + const arg = upload.mock.calls[0][0]; + expect(arg.key).toMatch( + /^month_\d{1,2}_\d{4}\/avatars\/[0-9a-f-]{36}\.png$/, + ); + expect(arg.contentType).toBe("image/png"); + expect(Buffer.isBuffer(arg.body)).toBe(true); + expect(result.url).toContain(arg.key); + + // records file metadata in core_files + expect(insertValues).toHaveBeenCalledTimes(1); + const row = insertValues.mock.calls[0][0]; + expect(row).toMatchObject({ + folder: "avatars", + key: arg.key, + mimeType: "image/png", + name: "photo.png", + pluginId: "@vitnode/core", + userId: 7, + }); + expect(typeof row.size).toBe("number"); + }); + + it("records the admin's user id when uploaded from an admin session", async () => { + const { ctx, insertValues } = makeCtx({ admin: { user: { id: 42 } } }); + const file = new File(["hi"], "a.png", { type: "image/png" }); + + await new StorageModel(ctx).upload({ file, folder: "avatars" }); + + // admin.user.id (42) wins over the regular session user (7) + expect(insertValues.mock.calls[0][0].userId).toBe(42); + }); + + it("uses an explicit userId over the detected session user", async () => { + const { ctx, insertValues } = makeCtx({ admin: { user: { id: 42 } } }); + const file = new File(["hi"], "a.png", { type: "image/png" }); + + // Admin uploading on behalf of user 123 — explicit owner wins over admin (42). + await new StorageModel(ctx).upload({ + file, + folder: "avatars", + userId: 123, + }); + + expect(insertValues.mock.calls[0][0].userId).toBe(123); + }); + + it("throws when no storage provider is configured", async () => { + const { ctx, upload } = makeCtx({ storage: undefined }); + const file = new File(["hi"], "a.png", { type: "image/png" }); + + await expect( + new StorageModel(ctx).upload({ file, folder: "avatars" }), + ).rejects.toThrow(); + expect(upload).not.toHaveBeenCalled(); + }); + + it("rejects a file over maxBytes without uploading", async () => { + const { ctx, upload } = makeCtx(); + const file = new File([new Uint8Array(11)], "big.png", { + type: "image/png", + }); + + await expect( + new StorageModel(ctx).upload({ file, folder: "avatars", maxBytes: 10 }), + ).rejects.toThrow(); + expect(upload).not.toHaveBeenCalled(); + }); + + it("rejects a disallowed mime type without uploading", async () => { + const { ctx, upload } = makeCtx(); + const file = new File(["x"], "script.sh", { type: "application/x-sh" }); + + await expect( + new StorageModel(ctx).upload({ + file, + folder: "avatars", + allowedMimeTypes: ["image/png", "image/jpeg"], + }), + ).rejects.toThrow(); + expect(upload).not.toHaveBeenCalled(); + }); + + it("uploads when the file passes size and type validation", async () => { + const { ctx, upload } = makeCtx(); + const file = new File(["ok"], "photo.png", { type: "image/png" }); + + await new StorageModel(ctx).upload({ + file, + folder: "avatars", + maxBytes: 1024, + allowedMimeTypes: ["image/png"], + }); + + expect(upload).toHaveBeenCalledTimes(1); + }); +}); + +describe("StorageModel.delete", () => { + it("delegates to the adapter", async () => { + const { ctx, del } = makeCtx(); + + await new StorageModel(ctx).delete("month_7_2026/avatars/x.png"); + + expect(del).toHaveBeenCalledWith("month_7_2026/avatars/x.png"); + }); +}); diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts new file mode 100644 index 000000000..68b07159c --- /dev/null +++ b/packages/vitnode/src/api/models/storage.ts @@ -0,0 +1,181 @@ +import type { Context } from "hono"; + +import { HTTPException } from "hono/http-exception"; + +import { core_files } from "@/database/files"; +import { buildStorageKey, generateStorageFileName } from "@/lib/api/upload"; + +const DEFAULT_IMAGE_QUALITY = 85; + +// Formats sharp can lossily re-encode. SVG/GIF are intentionally excluded to +// preserve vectors and animation. +const PROCESSABLE_IMAGE_MIME_TYPES = new Set([ + "image/avif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", +]); + +export interface StorageUploadArgs { + body: Buffer; + contentType?: string; + key: string; +} + +export interface StorageUploadResult { + key: string; + url: string; +} + +/** + * Present only on disk-backed adapters (e.g. Local). The Node entry point reads + * it to mount `serveStatic` for the stored files. Cloud adapters omit it. + */ +export interface StorageStaticConfig { + mountPath: string; + root: string; + stripPrefix: string; +} + +export interface StorageApiPlugin { + delete: (key: string) => Promise; + getUrl: (key: string) => string; + static?: StorageStaticConfig; + upload: (args: StorageUploadArgs) => Promise; +} + +export interface StorageUploadOptions { + /** Allowed MIME types (e.g. `["image/png", "image/jpeg"]`). Omit to allow any. */ + allowedMimeTypes?: string[]; + file: File; + /** Sub-folder under the dated prefix, e.g. `avatars` -> `month_7_2026/avatars/…`. */ + folder: string; + /** Maximum file size in bytes. Omit for no limit. */ + maxBytes?: number; + /** Extra data stored in the `core_files.metadata` JSON column. */ + metadata?: Record; + /** + * Owner recorded in `core_files.userId`. When omitted it defaults to the + * request's admin user (on admin routes), then the frontend session user, else + * null. Pass it explicitly — including `null` — to override, e.g. when an admin + * uploads on behalf of another user. + */ + userId?: null | number; +} + +export class StorageModel { + constructor(c: Context) { + this.c = c; + } + + protected readonly c: Context; + + // Re-encodes images with sharp at the configured quality when + // `storage.image` is set. Keeps the same format, so the key/contentType stay + // valid. sharp is imported lazily so it is only loaded when actually used. + private async optimizeImage(body: Buffer, mimeType: string): Promise { + const image = this.c.get("core").storage?.image; + if (!image || !PROCESSABLE_IMAGE_MIME_TYPES.has(mimeType)) { + return body; + } + + const quality = image.quality ?? DEFAULT_IMAGE_QUALITY; + + try { + const { default: sharp } = await import("sharp"); + const format = (await sharp(body).metadata()).format; + if (!format) { + return body; + } + + return await sharp(body).toFormat(format, { quality }).toBuffer(); + } catch { + throw new HTTPException(400, { + message: "Invalid or corrupt image file", + }); + } + } + + private requireProvider(): StorageApiPlugin { + const provider = this.c.get("core").storage?.adapter; + if (!provider) { + throw new HTTPException(500, { + message: "Storage provider not found", + }); + } + + return provider; + } + + async delete(key: string): Promise { + await this.requireProvider().delete(key); + } + + getUrl(key: string): string { + return this.requireProvider().getUrl(key); + } + + async upload({ + allowedMimeTypes, + file, + folder, + maxBytes, + metadata, + userId, + }: StorageUploadOptions): Promise { + const provider = this.requireProvider(); + + if (maxBytes !== undefined && file.size > maxBytes) { + throw new HTTPException(400, { + message: `File exceeds the maximum size of ${maxBytes} bytes`, + }); + } + if (allowedMimeTypes && !allowedMimeTypes.includes(file.type)) { + throw new HTTPException(400, { + message: `Unsupported file type: ${file.type || "unknown"}`, + }); + } + + const key = buildStorageKey({ + folder, + fileName: generateStorageFileName(file.name), + }); + const body = await this.optimizeImage( + Buffer.from(await file.arrayBuffer()), + file.type, + ); + + const result = await provider.upload({ + key, + body, + contentType: file.type || undefined, + }); + + const ownerId = + userId !== undefined + ? userId + : (this.c.get("admin")?.user.id ?? this.c.get("user")?.id ?? null); + + try { + await this.c + .get("db") + .insert(core_files) + .values({ + name: file.name, + key: result.key, + folder, + mimeType: file.type || null, + size: body.length, + userId: ownerId, + pluginId: this.c.get("plugin")?.id ?? null, + metadata: metadata ?? {}, + }); + } catch (error) { + await provider.delete(result.key).catch(() => undefined); + throw error; + } + + return result; + } +} diff --git a/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts b/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts index 7baa013e2..97d4f2962 100644 --- a/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts @@ -4,6 +4,7 @@ import { integrationsDebugAdminRoute } from "./routes/integrations.route"; import { logsDebugAdminRoute } from "./routes/logs.route"; import { queueDebugAdminRoute } from "./routes/queue.route"; import { sendTestEmailDebugAdminRoute } from "./routes/send-test-email.route"; +import { testStorageUploadDebugAdminRoute } from "./routes/test-storage-upload.route"; export const debugAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, @@ -13,5 +14,6 @@ export const debugAdminModule = buildModule({ integrationsDebugAdminRoute, queueDebugAdminRoute, sendTestEmailDebugAdminRoute, + testStorageUploadDebugAdminRoute, ], }); diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts index 45fa5259e..abaad4cf2 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts @@ -2,7 +2,9 @@ import { inArray, sql } from "drizzle-orm"; import { z } from "zod"; import { CONFIG_PLUGIN } from "@/config"; +import { core_cron } from "@/database/cron"; import { core_queue } from "@/database/queue"; +import { isCronStale } from "@/lib/api/is-cron-stale"; import { INSECURE_DEFAULT_CRON_SECRET } from "@/lib/config"; import { isRealtimePubSubEnabled, isWebSocketEnabled } from "@/ws/registry"; @@ -33,16 +35,28 @@ export const integrationsDebugAdminRoute = buildRoute({ active: z.boolean(), // Registered cron jobs. Always >= 1 since core ships a job. jobs: z.number(), + // ISO timestamp of the most recent cron execution, or `null` + // when no job has run yet. + lastRun: z.string().nullable(), // `false` when `CRON_SECRET` is left at its well-known default, // which leaves the cron endpoint effectively unauthenticated. secure: z.boolean(), + // `true` when no job has run within the staleness window, i.e. + // the scheduler looks misconfigured or stopped even though jobs + // are registered. + stale: z.boolean(), }), email: z.object({ active: z.boolean(), }), queue: z.object({ - // `true` when at least one queue task handler is registered. + // `true` when at least one handler is registered AND the cron + // worker is running — the queue is drained by cron, so a stale + // scheduler means tasks pile up unprocessed. active: z.boolean(), + // `true` when handlers are registered but the queue is offline + // because the cron worker that drains it isn't running. + cronStale: z.boolean(), // Number of pending tasks waiting to be processed. pending: z.number(), // Number of tasks currently being processed. @@ -56,6 +70,11 @@ export const integrationsDebugAdminRoute = buildRoute({ // problem worth surfacing distinctly from "not set up at all". configuredButDown: z.boolean(), }), + storage: z.object({ + // `true` when a storage adapter is configured, i.e. file + // uploads are enabled. + active: z.boolean(), + }), websocket: z.object({ active: z.boolean(), crossInstance: z.boolean(), @@ -86,6 +105,25 @@ export const integrationsDebugAdminRoute = buildRoute({ const queueProcessing = queueGrouped.find(row => row.status === "processing")?.count ?? 0; + // Freshest cron heartbeat: a job's last successful run, or its creation + // time while it's never run so a brand-new install isn't flagged stale + // during the grace period before the first tick. + const [cronActivity] = await c + .get("db") + .select({ + lastRun: sql`max(${core_cron.lastRun})`, + lastActivity: sql`max(coalesce(${core_cron.lastRun}, ${core_cron.createdAt}))`, + }) + .from(core_cron); + + const cronLastRun = cronActivity?.lastRun + ? new Date(cronActivity.lastRun) + : null; + const cronStale = isCronStale( + cronActivity?.lastActivity ? new Date(cronActivity.lastActivity) : null, + ); + const queueActive = core.queue.length > 0; + return c.json( { captcha: { @@ -95,15 +133,18 @@ export const integrationsDebugAdminRoute = buildRoute({ cron: { active: core.hasCronAdapter, jobs: core.cron.length, + lastRun: cronLastRun ? cronLastRun.toISOString() : null, secure: !!core.cronSecret && core.cronSecret !== INSECURE_DEFAULT_CRON_SECRET, + stale: cronStale, }, email: { active: !!core.email?.adapter, }, queue: { - active: core.queue.length > 0, + active: queueActive && !cronStale, + cronStale: queueActive && cronStale, pending: queuePending, processing: queueProcessing, tasks: core.queue.length, @@ -112,6 +153,9 @@ export const integrationsDebugAdminRoute = buildRoute({ active: redis.configured && redis.connected, configuredButDown: redis.configured && !redis.connected, }, + storage: { + active: !!core.storage?.adapter, + }, websocket: { active: isWebSocketEnabled(), crossInstance: isRealtimePubSubEnabled(), diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/test-storage-upload.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/test-storage-upload.route.ts new file mode 100644 index 000000000..889d7204a --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/debug/routes/test-storage-upload.route.ts @@ -0,0 +1,71 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +const ADMIN_TEST_MAX_BYTES = 10 * 1024 * 1024; // 10 MB + +const ADMIN_TEST_MIME_TYPES = [ + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]; + +export const testStorageUploadDebugAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "system", permission: "can_test_storage" }, + route: { + method: "post", + description: + "Upload a test image to verify the storage adapter end-to-end from the admin panel.", + path: "/test-storage-upload", + request: { + body: { + required: true, + content: { + "multipart/form-data": { + schema: z.object({ + file: z + .instanceof(File) + .openapi({ format: "binary", type: "string" }), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ key: z.string(), url: z.string() }), + }, + }, + description: "Image uploaded", + }, + 400: { + description: "Storage adapter not configured or invalid file", + }, + }, + }, + handler: async c => { + if (!c.get("core").storage?.adapter) { + throw new HTTPException(400, { + message: "Storage adapter not configured", + }); + } + + const { file } = c.req.valid("form"); + + const { key, url } = await c.get("storage").upload({ + file, + folder: "admin-storage-test", + maxBytes: ADMIN_TEST_MAX_BYTES, + allowedMimeTypes: ADMIN_TEST_MIME_TYPES, + }); + + return c.json({ key, url }, 200); + }, +}); diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index da9d833e9..27cfe05a8 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -35,6 +35,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ system: [ "can_view", { permission: "can_send_test_email", dependsOn: ["can_view"] }, + { permission: "can_test_storage", dependsOn: ["can_view"] }, ], queue: ["can_view"], staff_moderators: [ diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx index 7e0baed80..fc1443a00 100644 --- a/packages/vitnode/src/components/table/filters.tsx +++ b/packages/vitnode/src/components/table/filters.tsx @@ -92,8 +92,6 @@ function FilterItem({ filter }: { filter: FilterDataTable }) { params.delete(filter.id); } - // The result set changes, so drop the pagination cursor to land on the - // first page instead of a now-invalid one. params.delete("cursor"); params.delete("first"); params.delete("last"); @@ -126,7 +124,6 @@ function FilterItem({ filter }: { filter: FilterDataTable }) {