Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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://<account>.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
Expand Down
5 changes: 4 additions & 1 deletion apps/api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ yarn-error.log*
.env.production.local

# Others
.vercel
.vercel

# Local storage adapter uploads
/public/uploads
30 changes: 30 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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, ""),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

path.replace(staticStorage.stripPrefix, "") replaces the first occurrence of stripPrefix anywhere in the path. If the prefix happens to be a substring of a folder or file name later in the path, it will corrupt the path.

It is much safer to only strip the prefix if the path starts with it.

      rewriteRequestPath: path =>
        path.startsWith(staticStorage.stripPrefix)
          ? path.slice(staticStorage.stripPrefix.length)
          : path,

}),
);
}

// 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 });
Expand Down
26 changes: 25 additions & 1 deletion apps/api/src/locales/@vitnode/core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand All @@ -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."
}
}
},
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/vitnode.api.config.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ yarn-error.log*
.vercel
next-env.d.ts
.env*.local

# Local storage adapter uploads
/public/uploads
1 change: 1 addition & 0 deletions apps/docs/content/docs/dev/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"---Adapters---",
"captcha",
"email",
"storage",
"sso",
"cron",
"websocket",
Expand Down
78 changes: 78 additions & 0 deletions apps/docs/content/docs/dev/storage/custom-adapter.mdx
Original file line number Diff line number Diff line change
@@ -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

<Steps>
<Step>
### 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 => {};
```

</Step>
<Step>
### 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`
},
};
};
```

</Step>
</Steps>

<Callout type="info" title="Keys are pre-built for you">
The framework builds the `month_{month}_{year}/{folder}/<uuid>.<ext>` key
before calling `upload`, so an adapter just stores the `body` at the given
`key` — no path logic needed.
</Callout>

## 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)
Loading
Loading