diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx
index 4f05bdda9..bf1f76e72 100644
--- a/apps/docs/content/docs/dev/events/built-in-events.mdx
+++ b/apps/docs/content/docs/dev/events/built-in-events.mdx
@@ -8,20 +8,21 @@ to any of them from your own plugin with
[`buildEventListener`](/docs/dev/events#listening-to-an-event) - no imports from
the emitting plugin are needed, the event map is global.
-| Event | Payload | Emitted when |
-| ----------------------- | ---------------------------------------- | ------------------------------------------------------- |
-| `user.created` | `{ userId, email, name, emailVerified }` | A user is created - sign-up, AdminCP, or SSO first sign-in |
-| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile or roles) |
-| `user.deleted` | `{ userId, email }` | _Declared only_ - core has no user deletion flow yet |
-| `role.created` | `{ roleId }` | A role is created in the AdminCP |
-| `role.updated` | `{ roleId }` | A role is edited in the AdminCP |
-| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP |
-| `blog.post.created` | `{ postId, categoryId }` | A blog post is created |
-| `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited |
-| `blog.post.deleted` | `{ postId }` | A blog post is deleted |
-| `blog.category.created` | `{ categoryId }` | A blog category is created |
-| `blog.category.updated` | `{ categoryId }` | A blog category is edited |
-| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category is deleted (`postIds` is always empty) |
+| Event | Payload | Emitted when |
+| ----------------------- | -------------------------------------------------- | ---------------------------------------------------------- |
+| `user.created` | `{ userId, email, name, emailVerified }` | A user is created - sign-up, AdminCP, or SSO first sign-in |
+| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile or roles) |
+| `user.deleted` | `{ userId, email }` | _Declared only_ - core has no user deletion flow yet |
+| `role.created` | `{ roleId }` | A role is created in the AdminCP |
+| `role.updated` | `{ roleId }` | A role is edited in the AdminCP |
+| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP |
+| `file.uploaded` | `{ fileId, userId, name, size, mimeType, folder }` | A user uploads a file through the built-in upload endpoint |
+| `blog.post.created` | `{ postId, categoryId }` | A blog post is created |
+| `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited |
+| `blog.post.deleted` | `{ postId }` | A blog post is deleted |
+| `blog.category.created` | `{ categoryId }` | A blog category is created |
+| `blog.category.updated` | `{ categoryId }` | A blog category is edited |
+| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category is deleted (`postIds` is always empty) |
## Core
@@ -157,6 +158,48 @@ export const roleCleanupListener = buildEventListener({
});
```
+### file.uploaded
+
+Emitted once per stored file after a
+[user upload](/docs/dev/storage#user-uploads) commits - so a batch of five files
+fires five times, and a batch that was rejected or rolled back fires not at all.
+Your own upload routes don't emit it; emit it yourself if you want listeners to
+treat those files the same way.
+
+
+
+**Use cases:** index the file for search, kick off a
+[queue task](/docs/dev/advanced/queue) for thumbnails or virus scanning, or
+audit-log who uploaded what.
+
### user.deleted (declared only)
This event exists in the `VitNodeEvents` map so listeners and payloads are
@@ -167,13 +210,12 @@ themselves, and core will emit it once deletion lands.
## Blog (`@vitnode/blog`)
- The blog runs on the [Content
- Engine](/docs/dev/content-engine), so the events that describe what actually
- happened are `content.blog.post.*` and `content.blog.category.*` - they carry
- changed fields, revision ids, publication transitions, per-locale translation
- events and slug history. The four names below are re-emitted from those by
- listeners in the plugin, so existing consumers keep working. Prefer the
- `content.*` ones for anything new.
+ The blog runs on the [Content Engine](/docs/dev/content-engine), so the events
+ that describe what actually happened are `content.blog.post.*` and
+ `content.blog.category.*` - they carry changed fields, revision ids,
+ publication transitions, per-locale translation events and slug history. The
+ four names below are re-emitted from those by listeners in the plugin, so
+ existing consumers keep working. Prefer the `content.*` ones for anything new.
### blog.category.created / blog.category.updated
diff --git a/apps/docs/content/docs/dev/storage/index.mdx b/apps/docs/content/docs/dev/storage/index.mdx
index ea14f6a3a..5dcdd80c4 100644
--- a/apps/docs/content/docs/dev/storage/index.mdx
+++ b/apps/docs/content/docs/dev/storage/index.mdx
@@ -7,10 +7,13 @@ 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
+For **signed-in users uploading their own files** there is a ready-made endpoint
+and a form field - see [user uploads](#user-uploads). For everything else -
+an avatar, a plugin's import, anything with its own rules - you build the 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.
+key, validates the file, and returns the URL plus the `core_files` row it
+created.
Storage is **optional**. Without an adapter configured, `c.get("storage")` throws
and the AdminCP → System → Integrations "Storage" card shows as inactive.
@@ -64,6 +67,55 @@ export const vitNodeApiConfig = buildApiConfig({
});
```
+## User uploads
+
+Users uploading their **own** files is common enough that core ships it: a
+`POST /users/files` endpoint that takes any number of files at once, and the
+[`AutoFormFiles`](/docs/ui/files) field that talks to it.
+
+```tsx
+const formSchema = z.object({
+ attachments: uploadedFilesSchema({ max: 3 }),
+});
+
+;
+```
+
+**How much** a user may upload is a role setting (AdminCP → Roles → _Content_):
+whether they may upload at all, their total quota, and how much one submit may
+weigh. Several roles merge into the most generous of them, and `null` -
+"unlimited" - beats every number.
+
+**What** they may upload is app configuration:
+
+```ts title="vitnode.api.config.ts"
+export const vitNodeApiConfig = buildApiConfig({
+ storage: {
+ adapter: LocalStorageAdapter(),
+ // [!code ++:5]
+ uploads: {
+ allowedMimeTypes: ["image/*", "application/pdf"], // wildcards welcome
+ maxFiles: 5, // per submit
+ folder: "attachments", // -> month_7_2026/attachments/…
+ },
+ },
+});
+```
+
+Defaults: raster images, PDF and plain text, up to 10 files, in `uploads`. SVG
+is **not** in the default allowlist - it can carry script and stored files are
+served from the API origin - so add `image/svg+xml` yourself if you need it.
+
+The batch is validated as a whole _before_ anything is stored, and if an upload
+fails halfway the files that already landed are removed again - so a rejected
+batch never leaves a half-finished set of attachments behind. Two more routes
+round it off: `GET /users/files/upload-limits` (what this user may upload right
+now, which is what lets the field refuse a file without spending an upload) and
+`DELETE /users/files/{id}`.
+
+Every stored file emits [`file.uploaded`](/docs/dev/events/built-in-events#fileuploaded),
+so a plugin can index it, notify someone, or run its own processing.
+
## Create your own upload endpoint
Define a route that accepts `multipart/form-data`, then call
@@ -185,8 +237,10 @@ export const useUploadAvatar = () =>
## 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:
+If the files belong to the signed-in user, [user uploads](#user-uploads) already
+does this. For your own route, 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 => {
diff --git a/apps/docs/content/docs/ui/files.mdx b/apps/docs/content/docs/ui/files.mdx
new file mode 100644
index 000000000..a4d198a23
--- /dev/null
+++ b/apps/docs/content/docs/ui/files.mdx
@@ -0,0 +1,187 @@
+---
+title: Files
+description: Upload several files at once - by picking them or dropping them on the field - and keep the stored ones as the form value.
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import { z } from "zod";
+import { AutoForm } from "@vitnode/core/components/form/auto-form";
+import { AutoFormFiles } from "@vitnode/core/components/form/fields/files";
+import { uploadedFilesSchema } from "@vitnode/core/lib/helpers/files";
+```
+
+```ts
+const formSchema = z.object({
+ attachments: uploadedFilesSchema({ max: 3 }),
+});
+```
+
+```tsx
+ (
+
+ ),
+ },
+ ]}
+/>
+```
+
+That is the whole setup: the field uploads to the built-in
+[user upload endpoint](/docs/dev/storage#user-uploads), so you don't write a
+route, and it reads the signed-in user's limits from the same place the route
+enforces them.
+
+
+ The value is the list of files that are **already stored** - `{ id, name,
+ size, mimeType, url }` each - not the browser's `File` objects. Every
+ selection is uploaded as it is picked, so a submit handler only ever sees ids
+ that exist. Save those ids; the bytes are already safe.
+
+
+## What the user is allowed to upload
+
+Nothing here is configured on the field. Uploading is a **role** setting, in
+AdminCP → Roles → *Content*:
+
+- **Allow uploading files** - off by default, so a fresh role uploads nothing.
+- **Total storage** - the quota across all of the user's files.
+- **Storage per submit** - how much one batch may weigh.
+
+A user with several roles gets the most generous of them, and "unlimited" on any
+one role wins. *What* may be uploaded - which types, how many files per batch,
+which folder - is app configuration:
+
+```ts title="vitnode.api.config.ts"
+export const vitNodeApiConfig = buildApiConfig({
+ storage: {
+ adapter: LocalStorageAdapter(),
+ // [!code ++:5]
+ uploads: {
+ allowedMimeTypes: ["image/*", "application/pdf"],
+ maxFiles: 5,
+ folder: "attachments",
+ },
+ },
+});
+```
+
+The field reads all of it, so it refuses an oversized batch or an unsupported
+type **before** uploading, with the same rule the route applies - and shows what
+is left as a hint under the button. Files the field uploaded are deleted again
+when they are removed from it; a file the form was *opened* with is only
+detached, so cancelling an edit never destroys someone's upload.
+
+## Capping the field itself
+
+`maxFiles` narrows the endpoint's limit for one field - useful when a form wants
+a single logo even though the role may upload ten:
+
+```tsx
+
+```
+
+A `max()` on the schema does the same, and also fails validation if the value is
+tampered with:
+
+```ts
+z.object({ logo: uploadedFilesSchema({ max: 1, min: 1 }) });
+```
+
+## Pointing it at your own endpoint
+
+Pass `upload`, `remove` and `limits` to use a route of your own - the field then
+never calls the core one. Handy for a plugin that stores files somewhere
+specific, or for a preview like the one at the top of this page:
+
+```tsx
+ await uploadToMyRoute(files)}
+ remove={async file => await deleteFromMyRoute(file.id)}
+/>
+```
+
+## Props
+
+import { TypeTable } from "fumadocs-ui/components/type-table";
+
+ Promise",
+ default: "",
+ },
+ remove: {
+ description:
+ "Deletes a file this field uploaded. Defaults to the core delete endpoint.",
+ type: "(file: UploadedFile) => Promise",
+ default: "",
+ },
+ onUploaded: {
+ description:
+ "A batch landed - for refreshing whatever else lists the user's files.",
+ type: "(files: UploadedFile[]) => void",
+ default: "",
+ },
+ onRemoved: {
+ description: "A file the field uploaded was deleted again.",
+ type: "(file: UploadedFile) => void",
+ default: "",
+ },
+ }}
+/>
diff --git a/apps/docs/content/docs/ui/meta.json b/apps/docs/content/docs/ui/meta.json
index 71129e393..ffcfa4b41 100644
--- a/apps/docs/content/docs/ui/meta.json
+++ b/apps/docs/content/docs/ui/meta.json
@@ -18,6 +18,7 @@
"color",
"combobox",
"editor",
+ "files",
"input",
"input-group",
"nullable-number",
diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx
index f0bdf1474..cc928a561 100644
--- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx
+++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx
@@ -7,6 +7,7 @@ import { I18nProvider } from "@vitnode/core/components/i18n-provider";
import { DataTableSkeleton } from "@vitnode/core/components/table/data-table";
import { HeaderContent } from "@vitnode/core/components/ui/header-content";
import { getSessionApi } from "@vitnode/core/lib/api/get-session-api";
+import { UploadMyFiles } from "@vitnode/core/views/files/actions/upload-files";
const MyFilesTableView = dynamic(async () =>
import("@vitnode/core/views/files/my-files-table-view").then(module => ({
@@ -41,7 +42,9 @@ export default async function Page(
return (
-
+
+
+ }>
diff --git a/apps/docs/src/examples/files.tsx b/apps/docs/src/examples/files.tsx
new file mode 100644
index 000000000..ee9a1bfb4
--- /dev/null
+++ b/apps/docs/src/examples/files.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import type { UploadFieldLimits } from "@vitnode/core/components/form/fields/files";
+import type { UploadedFile } from "@vitnode/core/lib/helpers/files";
+
+import { AutoForm } from "@vitnode/core/components/form/auto-form";
+import { AutoFormFiles } from "@vitnode/core/components/form/fields/files";
+import { uploadedFilesSchema } from "@vitnode/core/lib/helpers/files";
+import { z } from "zod";
+
+const formSchema = z.object({
+ attachments: uploadedFilesSchema({ max: 3 }),
+});
+
+// The real field asks the API what the signed-in user may upload; this preview
+// hands it fixed limits instead so the docs work without a session.
+const limits: UploadFieldLimits = {
+ allowUpload: true,
+ allowedMimeTypes: [
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ "application/pdf",
+ ],
+ maxBytesPerSubmit: 5 * 1024 * 1024,
+ maxFiles: 3,
+ maxTotalBytes: 20 * 1024 * 1024,
+ remainingBytes: 16 * 1024 * 1024,
+ usedBytes: 4 * 1024 * 1024,
+};
+
+let previewId = 0;
+
+const upload = async (files: File[]): Promise =>
+ Promise.resolve(
+ files.map(file => ({
+ id: ++previewId,
+ mimeType: file.type,
+ name: file.name,
+ size: file.size,
+ url: URL.createObjectURL(file),
+ })),
+ );
+
+export default function FilesExample() {
+ return (
+ (
+ Promise.resolve()}
+ upload={upload}
+ />
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ />
+ );
+}
diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json
index 6c1886d04..867ee111f 100644
--- a/apps/docs/src/locales/@vitnode/core/pl.json
+++ b/apps/docs/src/locales/@vitnode/core/pl.json
@@ -151,6 +151,28 @@
"justify": "Wyjustuj"
}
},
+ "files": {
+ "browse": "Wybierz pliki",
+ "full": "Osiągnięto limit",
+ "drop": "…lub przeciągnij je tutaj.",
+ "not_allowed": "Przesyłanie plików nie jest dostępne dla Twojego konta.",
+ "uploading": "Przesyłanie…",
+ "remove": "Usuń {name}",
+ "hint": {
+ "types": "Dozwolone: {types}",
+ "files": "{count, plural, one {# plik} other {do # plików}}",
+ "space": "Pozostało {size} miejsca"
+ },
+ "errors": {
+ "empty": "Nie wybrano żadnych plików.",
+ "mime": "„{name}” to nieobsługiwany typ pliku.",
+ "not_allowed": "Przesyłanie plików nie jest dostępne dla Twojego konta.",
+ "quota": "Za mało miejsca - wolne {remaining} z {limit}.",
+ "submit_limit": "To {total}, a jednorazowo możesz przesłać {limit}.",
+ "too_many": "{count, plural, one {Można dołączyć tylko # plik.} few {Można dołączyć tylko # pliki.} other {Można dołączyć tylko # plików.}}",
+ "upload_failed": "Przesyłanie nie udało się. Spróbuj ponownie."
+ }
+ },
"theme_switcher": "Przełącz motyw",
"language_switcher": "Zmień język",
"toggle_sidebar": "Przełącz pasek boczny",
@@ -268,6 +290,14 @@
"noResults": {
"title": "Brak plików",
"description": "Przesłane pliki pojawią się tutaj."
+ },
+ "upload": {
+ "trigger": "Prześlij pliki",
+ "title": "Prześlij pliki",
+ "desc": "Dodaj pliki do swojego konta. Wybierz kilka naraz lub przeciągnij je na pole.",
+ "field": "Pliki",
+ "submit": "Zapisz",
+ "success": "{count, plural, one {Przesłano # plik.} few {Przesłano # pliki.} other {Przesłano # plików.}}"
}
},
"auth": {
diff --git a/packages/vitnode/src/api/lib/resolve-upload-limits.ts b/packages/vitnode/src/api/lib/resolve-upload-limits.ts
new file mode 100644
index 000000000..d6412e36d
--- /dev/null
+++ b/packages/vitnode/src/api/lib/resolve-upload-limits.ts
@@ -0,0 +1,76 @@
+import type { Context } from "hono";
+
+import { eq, inArray, sql } from "drizzle-orm";
+
+import type { UploadLimits } from "@/lib/upload-limits";
+
+import { core_files } from "@/database/files";
+import { core_roles } from "@/database/roles";
+import {
+ DEFAULT_UPLOAD_FOLDER,
+ DEFAULT_UPLOAD_MAX_FILES,
+ DEFAULT_UPLOAD_MIME_TYPES,
+ mergeRoleUploadLimits,
+ UNLIMITED_UPLOADS,
+} from "@/lib/upload-limits";
+
+import { getUserRoleIds } from "./check-staff-permission";
+
+export interface ResolvedUploadLimits extends UploadLimits {
+ /** What the user already stores, so a quota can be turned into "space left". */
+ usedBytes: number;
+}
+
+/**
+ * What this user is allowed to upload right now: the merged caps of their roles
+ * plus the space their existing files take.
+ *
+ * A `root` role is unlimited, exactly as it bypasses staff permissions - being
+ * root and being unable to attach a file would be a surprising combination.
+ */
+export const resolveUploadLimits = async (
+ c: Context,
+ user: { id: number; roleId: number },
+): Promise => {
+ const db = c.get("db");
+ const roleIds = await getUserRoleIds(c, user);
+
+ const [roles, [usage]] = await Promise.all([
+ db
+ .select({
+ allowUploadFiles: core_roles.allowUploadFiles,
+ maxStorageForSubmit: core_roles.maxStorageForSubmit,
+ root: core_roles.root,
+ totalMaxStorage: core_roles.totalMaxStorage,
+ })
+ .from(core_roles)
+ .where(inArray(core_roles.id, roleIds)),
+ db
+ .select({ used: sql`coalesce(sum(${core_files.size}), 0)::int` })
+ .from(core_files)
+ .where(eq(core_files.userId, user.id)),
+ ]);
+
+ const limits = roles.some(role => role.root)
+ ? UNLIMITED_UPLOADS
+ : mergeRoleUploadLimits(roles);
+
+ return { ...limits, usedBytes: usage?.used ?? 0 };
+};
+
+export interface UploadRules {
+ allowedMimeTypes: string[];
+ folder: string;
+ maxFiles: number;
+}
+
+/** The app's `storage.uploads` config, with the core defaults filled in. */
+export const resolveUploadRules = (c: Context): UploadRules => {
+ const uploads = c.get("core").storage?.uploads;
+
+ return {
+ allowedMimeTypes: uploads?.allowedMimeTypes ?? DEFAULT_UPLOAD_MIME_TYPES,
+ folder: uploads?.folder ?? DEFAULT_UPLOAD_FOLDER,
+ maxFiles: uploads?.maxFiles ?? DEFAULT_UPLOAD_MAX_FILES,
+ };
+};
diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts
index 96ad07aa2..391d4adb2 100644
--- a/packages/vitnode/src/api/models/events.ts
+++ b/packages/vitnode/src/api/models/events.ts
@@ -20,6 +20,14 @@ import type { EventListenerConfig } from "../lib/events";
* NATS, ...) serializes the envelope to move it between processes.
*/
export interface VitNodeEvents {
+ "file.uploaded": {
+ fileId: number;
+ folder: string;
+ mimeType: null | string;
+ name: string;
+ size: number;
+ userId: number;
+ };
"role.created": {
roleId: number;
};
diff --git a/packages/vitnode/src/api/models/storage-image.test.ts b/packages/vitnode/src/api/models/storage-image.test.ts
index c4659d9fe..614d29e2e 100644
--- a/packages/vitnode/src/api/models/storage-image.test.ts
+++ b/packages/vitnode/src/api/models/storage-image.test.ts
@@ -15,7 +15,9 @@ const makeCtx = (image?: { quality?: number; webp?: boolean }) => {
url: `https://cdn.test/${key}`,
}),
);
- const insertValues = vi.fn().mockResolvedValue(undefined);
+ const insertValues = vi.fn(() => ({
+ returning: vi.fn().mockResolvedValue([{ id: 11 }]),
+ }));
const store: Record = {
core: {
storage: {
diff --git a/packages/vitnode/src/api/models/storage.test.ts b/packages/vitnode/src/api/models/storage.test.ts
index c04df33ad..53da61bcd 100644
--- a/packages/vitnode/src/api/models/storage.test.ts
+++ b/packages/vitnode/src/api/models/storage.test.ts
@@ -18,7 +18,9 @@ const makeCtx = (
Promise.resolve({ key, url: `https://cdn.test/${key}` }),
);
const del = vi.fn().mockResolvedValue(undefined);
- const insertValues = vi.fn().mockResolvedValue(undefined);
+ const insertValues = vi.fn(() => ({
+ returning: vi.fn().mockResolvedValue([{ id: 11 }]),
+ }));
const store: Record = {
admin: "admin" in overrides ? overrides.admin : null,
core: {
@@ -188,6 +190,37 @@ describe("StorageModel.upload", () => {
expect(upload).toHaveBeenCalledTimes(1);
});
+
+ it("accepts a wildcard mime allowlist", async () => {
+ const { ctx, upload } = makeCtx();
+ const file = new File(["ok"], "photo.gif", { type: "image/gif" });
+
+ await new StorageModel(ctx).upload({
+ file,
+ folder: "avatars",
+ allowedMimeTypes: ["image/*"],
+ });
+
+ expect(upload).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns the created core_files row alongside the key and url", async () => {
+ const { ctx } = makeCtx();
+ const file = new File(["ok"], "photo.png", { type: "image/png" });
+
+ const result = await new StorageModel(ctx).upload({
+ file,
+ folder: "avatars",
+ });
+
+ expect(result).toMatchObject({
+ id: 11,
+ mimeType: "image/png",
+ name: "photo.png",
+ size: 2,
+ });
+ expect(result.url).toContain(result.key);
+ });
});
describe("StorageModel.delete", () => {
diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts
index f0e9b8324..51ac8593a 100644
--- a/packages/vitnode/src/api/models/storage.ts
+++ b/packages/vitnode/src/api/models/storage.ts
@@ -9,6 +9,7 @@ import {
generateStorageFileName,
replaceFileExtension,
} from "@/lib/api/upload";
+import { isMimeTypeAllowed } from "@/lib/upload-limits";
const DEFAULT_IMAGE_QUALITY = 85;
@@ -33,6 +34,19 @@ export interface StorageUploadResult {
url: string;
}
+/**
+ * An upload plus the `core_files` row it created - what a route hands back to a
+ * form so it can render the file and, later, delete it by id.
+ */
+export interface StorageFileResult extends StorageUploadResult {
+ id: number;
+ mimeType: null | string;
+ /** Stored display name, which differs from the original when a conversion changed the format. */
+ name: string;
+ /** Size after image processing, i.e. what counts against the user's quota. */
+ size: number;
+}
+
/**
* 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.
@@ -51,7 +65,10 @@ export interface StorageApiPlugin {
}
export interface StorageUploadOptions {
- /** Allowed MIME types (e.g. `["image/png", "image/jpeg"]`). Omit to allow any. */
+ /**
+ * Allowed MIME types (e.g. `["image/png", "image/jpeg"]`), `image/*` wildcards
+ * included. Omit to allow any.
+ */
allowedMimeTypes?: string[];
file: File;
/** Sub-folder under the dated prefix, e.g. `avatars` -> `month_7_2026/avatars/…`. */
@@ -196,7 +213,7 @@ export class StorageModel {
maxBytes,
metadata,
userId,
- }: StorageUploadOptions): Promise {
+ }: StorageUploadOptions): Promise {
const provider = this.requireProvider();
if (maxBytes !== undefined && file.size > maxBytes) {
@@ -204,7 +221,7 @@ export class StorageModel {
message: `File exceeds the maximum size of ${maxBytes} bytes`,
});
}
- if (allowedMimeTypes && !allowedMimeTypes.includes(file.type)) {
+ if (!isMimeTypeAllowed(file.type, allowedMimeTypes)) {
throw new HTTPException(400, {
message: `Unsupported file type: ${file.type || "unknown"}`,
});
@@ -240,7 +257,7 @@ export class StorageModel {
: (this.c.get("admin")?.user.id ?? this.c.get("user")?.id ?? null);
try {
- await this.c
+ const [row] = await this.c
.get("db")
.insert(core_files)
.values({
@@ -257,12 +274,19 @@ export class StorageModel {
? { dimensions: processed.dimensions }
: {}),
},
- });
+ })
+ .returning({ id: core_files.id });
+
+ return {
+ ...result,
+ id: row.id,
+ mimeType: processed.mimeType || null,
+ name: displayName,
+ size: processed.body.length,
+ };
} catch (error) {
await provider.delete(result.key).catch(() => undefined);
throw error;
}
-
- return result;
}
}
diff --git a/packages/vitnode/src/api/modules/users/files/files.module.ts b/packages/vitnode/src/api/modules/users/files/files.module.ts
index ce1724957..efc782992 100644
--- a/packages/vitnode/src/api/modules/users/files/files.module.ts
+++ b/packages/vitnode/src/api/modules/users/files/files.module.ts
@@ -4,9 +4,17 @@ import { CONFIG_PLUGIN } from "@/config";
import { deleteUserFileRoute } from "./routes/delete.route";
import { downloadUserFileRoute } from "./routes/download.route";
import { listUserFilesRoute } from "./routes/list.route";
+import { uploadLimitsUserFilesRoute } from "./routes/upload-limits.route";
+import { uploadUserFilesRoute } from "./routes/upload.route";
export const userFilesModule = buildModule({
pluginId: CONFIG_PLUGIN.pluginId,
name: "files",
- routes: [listUserFilesRoute, downloadUserFileRoute, deleteUserFileRoute],
+ routes: [
+ listUserFilesRoute,
+ uploadLimitsUserFilesRoute,
+ uploadUserFilesRoute,
+ downloadUserFileRoute,
+ deleteUserFileRoute,
+ ],
});
diff --git a/packages/vitnode/src/api/modules/users/files/routes/upload-limits.route.test.ts b/packages/vitnode/src/api/modules/users/files/routes/upload-limits.route.test.ts
new file mode 100644
index 000000000..ab86a066e
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/files/routes/upload-limits.route.test.ts
@@ -0,0 +1,136 @@
+// @vitest-environment node
+import type { MiddlewareHandler } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { describe, expect, it } from "vitest";
+
+import { core_files } from "@/database/files";
+import { core_roles } from "@/database/roles";
+import { core_users_secondary_roles } from "@/database/users";
+import {
+ DEFAULT_UPLOAD_MAX_FILES,
+ DEFAULT_UPLOAD_MIME_TYPES,
+ KILOBYTE,
+} from "@/lib/upload-limits";
+
+import { uploadLimitsUserFilesRoute } from "./upload-limits.route";
+
+interface RoleRow {
+ allowUploadFiles: boolean;
+ maxStorageForSubmit: null | number;
+ root: boolean;
+ totalMaxStorage: null | number;
+}
+
+const role = (settings: Partial = {}): RoleRow => ({
+ allowUploadFiles: true,
+ maxStorageForSubmit: null,
+ root: false,
+ totalMaxStorage: null,
+ ...settings,
+});
+
+const mount = ({
+ adapter = true,
+ roles = [role()],
+ secondaryRoleIds = [],
+ usedBytes = 0,
+ user = { id: 7, roleId: 1 },
+}: {
+ adapter?: boolean;
+ roles?: RoleRow[];
+ secondaryRoleIds?: number[];
+ usedBytes?: number;
+ user?: null | { id: number; roleId: number };
+} = {}) => {
+ const middleware: MiddlewareHandler = async (c, next) => {
+ c.set("user", user);
+ c.set("core", {
+ storage: adapter ? { adapter: {} } : undefined,
+ } as never);
+ c.set("db", {
+ select: () => ({
+ from: (table: unknown) => ({
+ where: async () =>
+ Promise.resolve(
+ table === core_users_secondary_roles
+ ? secondaryRoleIds.map(roleId => ({ roleId }))
+ : table === core_roles
+ ? roles
+ : table === core_files
+ ? [{ used: usedBytes }]
+ : [],
+ ),
+ }),
+ }),
+ } as never);
+ await next();
+ };
+
+ const app = new OpenAPIHono();
+ app.use("*", middleware);
+ app.openapi(
+ uploadLimitsUserFilesRoute.route,
+ uploadLimitsUserFilesRoute.handler,
+ );
+
+ return app;
+};
+
+describe("uploadLimitsUserFilesRoute", () => {
+ it("rejects a signed-out request", async () => {
+ const res = await mount({ user: null }).request("/upload-limits");
+
+ expect(res.status).toBe(401);
+ });
+
+ it("reports the merged role limits, usage and endpoint rules", async () => {
+ const res = await mount({
+ roles: [
+ role({ maxStorageForSubmit: 100, totalMaxStorage: 200 }),
+ role({ maxStorageForSubmit: 500, totalMaxStorage: 400 }),
+ ],
+ secondaryRoleIds: [2],
+ usedBytes: 50 * KILOBYTE,
+ }).request("/upload-limits");
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ allowUpload: true,
+ allowedMimeTypes: DEFAULT_UPLOAD_MIME_TYPES,
+ maxBytesPerSubmit: 500 * KILOBYTE,
+ maxFiles: DEFAULT_UPLOAD_MAX_FILES,
+ maxTotalBytes: 400 * KILOBYTE,
+ remainingBytes: 350 * KILOBYTE,
+ usedBytes: 50 * KILOBYTE,
+ });
+ });
+
+ it("leaves an unlimited quota without a remaining figure", async () => {
+ const res = await mount({ usedBytes: 10 }).request("/upload-limits");
+
+ expect(await res.json()).toMatchObject({
+ allowUpload: true,
+ maxTotalBytes: null,
+ remainingBytes: null,
+ });
+ });
+
+ it("refuses uploads when no storage adapter is configured", async () => {
+ const res = await mount({ adapter: false }).request("/upload-limits");
+
+ expect(await res.json()).toMatchObject({ allowUpload: false });
+ });
+
+ it("refuses uploads when no role grants them", async () => {
+ const res = await mount({
+ roles: [role({ allowUploadFiles: false })],
+ }).request("/upload-limits");
+
+ expect(await res.json()).toMatchObject({
+ allowUpload: false,
+ maxTotalBytes: 0,
+ remainingBytes: 0,
+ });
+ });
+});
diff --git a/packages/vitnode/src/api/modules/users/files/routes/upload-limits.route.ts b/packages/vitnode/src/api/modules/users/files/routes/upload-limits.route.ts
new file mode 100644
index 000000000..0a5490d1d
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/files/routes/upload-limits.route.ts
@@ -0,0 +1,64 @@
+import { z } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+
+import {
+ resolveUploadLimits,
+ resolveUploadRules,
+} from "@/api/lib/resolve-upload-limits";
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+import { remainingUploadBytes } from "@/lib/upload-limits";
+
+export const uploadLimitsUserFilesRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "What the current user may upload: the merged limits of their roles, the space they already use, and the rules of the upload endpoint. Lets a form refuse a file before spending an upload on it.",
+ path: "/upload-limits",
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ allowUpload: z.boolean(),
+ allowedMimeTypes: z.array(z.string()),
+ maxBytesPerSubmit: z.number().nullable(),
+ maxFiles: z.number(),
+ maxTotalBytes: z.number().nullable(),
+ remainingBytes: z.number().nullable(),
+ usedBytes: z.number(),
+ }),
+ },
+ },
+ description: "The current user's upload limits",
+ },
+ 401: {
+ description: "Not signed in",
+ },
+ },
+ },
+ handler: async c => {
+ const user = c.get("user");
+ if (!user) {
+ throw new HTTPException(401, { message: "Unauthorized" });
+ }
+
+ const rules = resolveUploadRules(c);
+ const { usedBytes, ...limits } = await resolveUploadLimits(c, user);
+ // Without an adapter there is nowhere to put a file, whatever the roles say.
+ const allowUpload = limits.allowUpload && !!c.get("core").storage?.adapter;
+
+ return c.json(
+ {
+ ...limits,
+ allowUpload,
+ allowedMimeTypes: rules.allowedMimeTypes,
+ maxFiles: rules.maxFiles,
+ remainingBytes: remainingUploadBytes({ limits, usedBytes }),
+ usedBytes,
+ },
+ 200,
+ );
+ },
+});
diff --git a/packages/vitnode/src/api/modules/users/files/routes/upload.route.test.ts b/packages/vitnode/src/api/modules/users/files/routes/upload.route.test.ts
new file mode 100644
index 000000000..93bcf1f24
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/files/routes/upload.route.test.ts
@@ -0,0 +1,310 @@
+// @vitest-environment node
+import type { MiddlewareHandler } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { describe, expect, it, vi } from "vitest";
+
+import { core_files } from "@/database/files";
+import { core_roles } from "@/database/roles";
+import { core_users_secondary_roles } from "@/database/users";
+import { KILOBYTE } from "@/lib/upload-limits";
+
+import { uploadUserFilesRoute } from "./upload.route";
+
+interface RoleRow {
+ allowUploadFiles: boolean;
+ maxStorageForSubmit: null | number;
+ root: boolean;
+ totalMaxStorage: null | number;
+}
+
+const role = (settings: Partial = {}): RoleRow => ({
+ allowUploadFiles: true,
+ maxStorageForSubmit: null,
+ root: false,
+ totalMaxStorage: null,
+ ...settings,
+});
+
+/**
+ * The three reads behind the guard - secondary roles, the roles themselves and
+ * the space already used - answered by which table they select from.
+ */
+const dbStub = ({
+ roles,
+ usedBytes,
+}: {
+ roles: RoleRow[];
+ usedBytes: number;
+}) => ({
+ select: () => ({
+ from: (table: unknown) => ({
+ where: async () =>
+ Promise.resolve(
+ table === core_users_secondary_roles
+ ? []
+ : table === core_roles
+ ? roles
+ : table === core_files
+ ? [{ used: usedBytes }]
+ : [],
+ ),
+ }),
+ }),
+});
+
+const mount = ({
+ adapter = true,
+ roles = [role()],
+ upload,
+ uploads,
+ usedBytes = 0,
+ user = { id: 7, roleId: 1 },
+}: {
+ adapter?: boolean;
+ roles?: RoleRow[];
+ upload?: ReturnType;
+ uploads?: Record;
+ usedBytes?: number;
+ user?: null | { id: number; roleId: number };
+} = {}) => {
+ let nextId = 100;
+ const uploadMock =
+ upload ??
+ vi.fn(async ({ file }: { file: File }) =>
+ Promise.resolve({
+ id: nextId++,
+ key: `month_1_2026/uploads/${file.name}`,
+ mimeType: file.type,
+ name: file.name,
+ size: file.size,
+ url: `https://cdn.test/${file.name}`,
+ }),
+ );
+ const deleteFile = vi.fn().mockResolvedValue(undefined);
+ const emit = vi.fn().mockResolvedValue(undefined);
+
+ const middleware: MiddlewareHandler = async (c, next) => {
+ c.set("user", user);
+ c.set("core", {
+ storage: adapter ? { adapter: {}, uploads } : undefined,
+ } as never);
+ c.set("db", dbStub({ roles, usedBytes }) as never);
+ c.set("storage", { deleteFile, upload: uploadMock } as never);
+ c.set("events", { emit } as never);
+ await next();
+ };
+
+ const app = new OpenAPIHono();
+ app.use("*", middleware);
+ app.openapi(uploadUserFilesRoute.route, uploadUserFilesRoute.handler);
+
+ return { app, deleteFile, emit, upload: uploadMock };
+};
+
+const body = (...files: [name: string, type: string, size: number][]) => {
+ const formData = new FormData();
+ for (const [name, type, size] of files) {
+ formData.append("files", new File([new Uint8Array(size)], name, { type }));
+ }
+
+ return { body: formData, method: "POST" };
+};
+
+describe("uploadUserFilesRoute", () => {
+ it("rejects a signed-out request", async () => {
+ const { app, upload } = mount({ user: null });
+
+ const res = await app.request("/", body(["a.png", "image/png", 10]));
+
+ expect(res.status).toBe(401);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("rejects a role that may not upload", async () => {
+ const { app, upload } = mount({
+ roles: [role({ allowUploadFiles: false })],
+ });
+
+ const res = await app.request("/", body(["a.png", "image/png", 10]));
+
+ expect(res.status).toBe(403);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("rejects when no storage adapter is configured", async () => {
+ const { app, upload } = mount({ adapter: false });
+
+ const res = await app.request("/", body(["a.png", "image/png", 10]));
+
+ expect(res.status).toBe(400);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("uploads every file of the batch and reports the new usage", async () => {
+ const { app, emit, upload } = mount({ usedBytes: 500 });
+
+ const res = await app.request(
+ "/",
+ body(["a.png", "image/png", 10], ["b.pdf", "application/pdf", 20]),
+ );
+
+ expect(res.status).toBe(200);
+ expect(upload).toHaveBeenCalledTimes(2);
+ // The owner is pinned to the session user, never taken from the request.
+ expect(upload.mock.calls[0][0]).toMatchObject({
+ folder: "uploads",
+ userId: 7,
+ });
+ expect(await res.json()).toEqual({
+ files: [
+ {
+ id: 100,
+ mimeType: "image/png",
+ name: "a.png",
+ size: 10,
+ url: "https://cdn.test/a.png",
+ },
+ {
+ id: 101,
+ mimeType: "application/pdf",
+ name: "b.pdf",
+ size: 20,
+ url: "https://cdn.test/b.pdf",
+ },
+ ],
+ usedBytes: 530,
+ });
+ expect(emit).toHaveBeenCalledTimes(2);
+ expect(emit.mock.calls[0]).toEqual([
+ "file.uploaded",
+ {
+ fileId: 100,
+ folder: "uploads",
+ mimeType: "image/png",
+ name: "a.png",
+ size: 10,
+ userId: 7,
+ },
+ ]);
+ });
+
+ it("accepts a single file, which arrives unwrapped", async () => {
+ const { app, upload } = mount();
+
+ const res = await app.request("/", body(["only.png", "image/png", 10]));
+
+ expect(res.status).toBe(200);
+ expect(upload).toHaveBeenCalledTimes(1);
+ });
+
+ it("stores nothing when one file in the batch has a disallowed type", async () => {
+ const { app, upload } = mount();
+
+ const res = await app.request(
+ "/",
+ body(["a.png", "image/png", 10], ["b.sh", "application/x-sh", 10]),
+ );
+
+ expect(res.status).toBe(400);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("honors a configured mime allowlist and folder", async () => {
+ const { app, upload } = mount({
+ uploads: { allowedMimeTypes: ["image/*"], folder: "gallery" },
+ });
+
+ expect(
+ (await app.request("/", body(["a.pdf", "application/pdf", 10]))).status,
+ ).toBe(400);
+
+ const res = await app.request("/", body(["a.gif", "image/gif", 10]));
+
+ expect(res.status).toBe(200);
+ expect(upload.mock.calls[0][0]).toMatchObject({ folder: "gallery" });
+ });
+
+ it("rejects more files than the configured maximum", async () => {
+ const { app, upload } = mount({ uploads: { maxFiles: 1 } });
+
+ const res = await app.request(
+ "/",
+ body(["a.png", "image/png", 10], ["b.png", "image/png", 10]),
+ );
+
+ expect(res.status).toBe(400);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("measures the per-submit limit against the whole batch", async () => {
+ const { app, upload } = mount({
+ roles: [role({ maxStorageForSubmit: 1 })],
+ });
+
+ const res = await app.request(
+ "/",
+ body(
+ ["a.png", "image/png", KILOBYTE - 1],
+ ["b.png", "image/png", KILOBYTE - 1],
+ ),
+ );
+
+ expect(res.status).toBe(400);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("rejects a batch that would run past the storage quota", async () => {
+ const { app, upload } = mount({
+ roles: [role({ totalMaxStorage: 2 })],
+ usedBytes: 2 * KILOBYTE - 5,
+ });
+
+ const res = await app.request("/", body(["a.png", "image/png", 10]));
+
+ expect(res.status).toBe(400);
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("lets a root role past every limit", async () => {
+ const { app, upload } = mount({
+ roles: [
+ role({
+ allowUploadFiles: false,
+ maxStorageForSubmit: 0,
+ root: true,
+ totalMaxStorage: 0,
+ }),
+ ],
+ usedBytes: 99 * KILOBYTE,
+ });
+
+ const res = await app.request("/", body(["a.png", "image/png", 4096]));
+
+ expect(res.status).toBe(200);
+ expect(upload).toHaveBeenCalledTimes(1);
+ });
+
+ it("removes what it already stored when a later file fails", async () => {
+ const upload = vi
+ .fn()
+ .mockResolvedValueOnce({
+ id: 100,
+ key: "k",
+ mimeType: "image/png",
+ name: "a.png",
+ size: 10,
+ url: "u",
+ })
+ .mockRejectedValueOnce(new Error("adapter is down"));
+ const { app, deleteFile } = mount({ upload });
+
+ const res = await app.request(
+ "/",
+ body(["a.png", "image/png", 10], ["b.png", "image/png", 10]),
+ );
+
+ expect(res.status).toBe(500);
+ expect(deleteFile).toHaveBeenCalledWith(100, 7);
+ });
+});
diff --git a/packages/vitnode/src/api/modules/users/files/routes/upload.route.ts b/packages/vitnode/src/api/modules/users/files/routes/upload.route.ts
new file mode 100644
index 000000000..89565e3ab
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/files/routes/upload.route.ts
@@ -0,0 +1,178 @@
+import { z } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+
+import type { StorageFileResult } from "@/api/models/storage";
+import type { UploadRejection } from "@/lib/upload-limits";
+
+import {
+ resolveUploadLimits,
+ resolveUploadRules,
+} from "@/api/lib/resolve-upload-limits";
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+import { validateUploadSelection } from "@/lib/upload-limits";
+
+/**
+ * Hono hands over a single `File` when the field appears once and an array when
+ * it repeats, so both shapes are accepted and normalized here.
+ */
+const normalizeFiles = (value: File | File[]): File[] =>
+ Array.isArray(value) ? value : [value];
+
+const rejectionMessage = (rejection: UploadRejection): string => {
+ switch (rejection.kind) {
+ case "empty":
+ return "No files provided";
+ case "mime":
+ return `Unsupported file type: ${rejection.fileName}`;
+ case "not_allowed":
+ return "Your role does not allow uploading files";
+ case "quota":
+ return `Not enough storage left: ${rejection.remainingBytes} bytes of ${rejection.limitBytes} available`;
+ case "submit_limit":
+ return `This upload is ${rejection.totalBytes} bytes, over the ${rejection.limitBytes} bytes allowed per submit`;
+ case "too_many":
+ return `Too many files - at most ${rejection.limit} per upload`;
+ }
+};
+
+const zodUploadedFile = z.object({
+ id: z.number(),
+ name: z.string(),
+ mimeType: z.string().nullable(),
+ size: z.number(),
+ url: z.string(),
+});
+
+export const uploadUserFilesRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description:
+ "Upload one or more files for the current user. Repeat the `files` field once per file. The role's upload permission and storage limits are enforced for the batch as a whole, so a rejected batch stores nothing.",
+ path: "/",
+ request: {
+ body: {
+ required: true,
+ content: {
+ "multipart/form-data": {
+ schema: z.object({
+ files: z
+ .union([z.instanceof(File), z.array(z.instanceof(File))])
+ .openapi({
+ items: { format: "binary", type: "string" },
+ type: "array",
+ }),
+ }),
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ files: z.array(zodUploadedFile),
+ usedBytes: z.number(),
+ }),
+ },
+ },
+ description: "Files uploaded",
+ },
+ 400: {
+ description: "Nothing to upload, an unsupported type, or over a limit",
+ },
+ 401: {
+ description: "Not signed in",
+ },
+ 403: {
+ description: "The user's roles do not allow uploading files",
+ },
+ },
+ },
+ handler: async c => {
+ const user = c.get("user");
+ if (!user) {
+ throw new HTTPException(401, { message: "Unauthorized" });
+ }
+ if (!c.get("core").storage?.adapter) {
+ throw new HTTPException(400, {
+ message: "Storage adapter not configured",
+ });
+ }
+
+ // The quota is measured before the batch is stored, so two requests racing
+ // each other can both pass and overshoot it by one batch. Worth knowing,
+ // not worth a lock: the next request sees the real total and refuses.
+ const files = normalizeFiles(c.req.valid("form").files);
+ const rules = resolveUploadRules(c);
+ const { usedBytes, ...limits } = await resolveUploadLimits(c, user);
+
+ const rejection = validateUploadSelection({
+ allowedMimeTypes: rules.allowedMimeTypes,
+ files,
+ limits,
+ maxFiles: rules.maxFiles,
+ usedBytes,
+ });
+ if (rejection) {
+ throw new HTTPException(rejection.kind === "not_allowed" ? 403 : 400, {
+ message: rejectionMessage(rejection),
+ });
+ }
+
+ // All-or-nothing: a batch that fails halfway would leave the user with
+ // files they never chose to keep and a quota they can't explain, so
+ // whatever landed before the failure is removed again.
+ const uploaded: StorageFileResult[] = [];
+ try {
+ for (const file of files) {
+ uploaded.push(
+ await c.get("storage").upload({
+ file,
+ folder: rules.folder,
+ userId: user.id,
+ }),
+ );
+ }
+ } catch (error) {
+ await Promise.all(
+ uploaded.map(async file => {
+ await c
+ .get("storage")
+ .deleteFile(file.id, user.id)
+ .catch(() => undefined);
+ }),
+ );
+
+ throw error;
+ }
+
+ for (const file of uploaded) {
+ await c.get("events").emit("file.uploaded", {
+ fileId: file.id,
+ folder: rules.folder,
+ mimeType: file.mimeType,
+ name: file.name,
+ size: file.size,
+ userId: user.id,
+ });
+ }
+
+ return c.json(
+ {
+ files: uploaded.map(({ id, mimeType, name, size, url }) => ({
+ id,
+ name,
+ mimeType,
+ size,
+ url,
+ })),
+ usedBytes:
+ usedBytes + uploaded.reduce((total, file) => total + file.size, 0),
+ },
+ 200,
+ );
+ },
+});
diff --git a/packages/vitnode/src/components/form/fields/files.tsx b/packages/vitnode/src/components/form/fields/files.tsx
new file mode 100644
index 000000000..730b3e8ec
--- /dev/null
+++ b/packages/vitnode/src/components/form/fields/files.tsx
@@ -0,0 +1,422 @@
+"use client";
+
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { FileIcon, LoaderCircleIcon, UploadIcon, XIcon } from "lucide-react";
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import type { userFilesModule } from "@/api/modules/users/files/files.module";
+import type { UploadedFile } from "@/lib/helpers/files";
+import type { UploadLimits, UploadRejection } from "@/lib/upload-limits";
+
+import {
+ Attachment,
+ AttachmentAction,
+ AttachmentActions,
+ AttachmentContent,
+ AttachmentDescription,
+ AttachmentGroup,
+ AttachmentMedia,
+ AttachmentTitle,
+} from "@/components/ui/attachment";
+import { Button } from "@/components/ui/button";
+import { FormControl, FormMessage } from "@/components/ui/form";
+import { CONFIG_PLUGIN } from "@/config";
+import { clientModule, fetcherClient } from "@/lib/fetcher-client";
+import { formatBytes } from "@/lib/format-bytes";
+import { toUploadedFiles } from "@/lib/helpers/files";
+import { validateUploadSelection } from "@/lib/upload-limits";
+import { cn } from "@/lib/utils";
+
+import type { ItemAutoFormComponentProps } from "../auto-form";
+
+import { AutoFormDesc } from "../common/desc";
+import { AutoFormLabel } from "../common/label";
+
+/** What the field needs to know before it lets a file through. */
+export interface UploadFieldLimits extends UploadLimits {
+ allowedMimeTypes: string[];
+ maxFiles: number;
+ remainingBytes: null | number;
+ usedBytes: number;
+}
+
+const filesModule = () =>
+ clientModule(CONFIG_PLUGIN.pluginId);
+
+/** The current user's limits, straight from the core `files` module. */
+export const fetchUploadLimits = async (): Promise => {
+ const res = await fetcherClient(filesModule(), {
+ prefixPath: "/users",
+ module: "files",
+ path: "/upload-limits",
+ method: "get",
+ options: { credentials: "include" },
+ });
+ if (!res.ok) throw new Error(await res.text());
+
+ return await res.json();
+};
+
+/** Uploads a batch through the core endpoint - all of it, or none of it. */
+export const uploadFiles = async (files: File[]): Promise => {
+ const formData = new FormData();
+ for (const file of files) {
+ formData.append("files", file);
+ }
+
+ const res = await fetcherClient(filesModule(), {
+ prefixPath: "/users",
+ module: "files",
+ path: "/",
+ method: "post",
+ formData,
+ options: { credentials: "include" },
+ });
+ if (!res.ok) throw new Error(await res.text());
+
+ return (await res.json()).files;
+};
+
+const deleteFile = async ({ id }: UploadedFile): Promise => {
+ const res = await fetcherClient(filesModule(), {
+ prefixPath: "/users",
+ module: "files",
+ path: "/{id}",
+ method: "delete",
+ args: { params: { id: String(id) } },
+ options: { credentials: "include" },
+ });
+ if (!res.ok) throw new Error(await res.text());
+};
+
+/** `image/png` -> `png`, `image/*` -> `image`: the half that carries meaning. */
+const mimeTypeLabel = (mimeType: string): string => {
+ const [type, subtype] = mimeType.split("/");
+
+ return !subtype || subtype === "*" ? type : subtype;
+};
+
+const PENDING_LIMITS: UploadFieldLimits = {
+ allowUpload: false,
+ allowedMimeTypes: [],
+ maxBytesPerSubmit: 0,
+ maxFiles: 0,
+ maxTotalBytes: 0,
+ remainingBytes: 0,
+ usedBytes: 0,
+};
+
+/**
+ * Attaches files to an `AutoForm` field - several at once, by picking them or by
+ * dropping them on the field.
+ *
+ * The value is the list of files that are **already stored**
+ * ([`uploadedFilesSchema`](../../../lib/helpers/files.ts)), not the browser's
+ * `File` objects: each selection is uploaded straight away, so a submit handler
+ * only ever deals with ids that exist.
+ *
+ * ```ts
+ * z.object({ attachments: uploadedFilesSchema({ max: 5 }) })
+ * ```
+ *
+ * How much a user may upload comes from their roles, and the field asks the API
+ * for it rather than guessing - so an over-quota batch is refused before it is
+ * sent, with the same rule the route enforces. Pass `limits` and `upload` to
+ * point the field at your own endpoint instead.
+ */
+export const AutoFormFiles = ({
+ accept,
+ description,
+ disabled,
+ field,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ itemParams,
+ label,
+ labelRight,
+ limits: limitsProp,
+ maxFiles: maxFilesProp,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ multiLang,
+ onRemoved,
+ onUploaded,
+ otherProps,
+ remove = deleteFile,
+ upload = uploadFiles,
+}: ItemAutoFormComponentProps & {
+ /** `accept` for the file input. Defaults to the allowed MIME types. */
+ accept?: string;
+ disabled?: boolean;
+ /** Skips the request for the user's limits - for previews and custom endpoints. */
+ limits?: UploadFieldLimits;
+ /** Caps the field on top of whatever the role and the endpoint allow. */
+ maxFiles?: number;
+ /** A file the field uploaded was removed again, and is gone from storage. */
+ onRemoved?: (file: UploadedFile) => void;
+ /** A batch landed - for refreshing whatever else lists the user's files. */
+ onUploaded?: (files: UploadedFile[]) => void;
+ /** Deletes a file this field uploaded. Pair it with a custom `upload`. */
+ remove?: (file: UploadedFile) => Promise;
+ upload?: (files: File[]) => Promise;
+}) => {
+ const t = useTranslations("core.global.files");
+ const inputRef = React.useRef(null);
+ const [isDragging, setIsDragging] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ // Files uploaded by this field, so removing one cleans up after itself while
+ // removing a file the form was opened with only detaches it.
+ const uploadedHereRef = React.useRef(new Set());
+
+ const remoteLimits = useQuery({
+ queryKey: ["vitnode", "files", "upload-limits"],
+ queryFn: fetchUploadLimits,
+ enabled: !limitsProp,
+ staleTime: 60 * 1000,
+ });
+ const limits = limitsProp ?? remoteLimits.data ?? PENDING_LIMITS;
+
+ const value = toUploadedFiles(field.value);
+ const maxFiles = Math.min(
+ ...[
+ maxFilesProp,
+ otherProps.maxItems,
+ limits.maxFiles > 0 ? limits.maxFiles : undefined,
+ ].filter((item): item is number => typeof item === "number"),
+ );
+ const isFull = Number.isFinite(maxFiles) && value.length >= maxFiles;
+ const isLoadingLimits = !limitsProp && remoteLimits.isPending;
+ const isDisabled =
+ !!disabled || isFull || isLoadingLimits || !limits.allowUpload;
+
+ const uploadMutation = useMutation({
+ mutationFn: upload,
+ onSuccess: uploaded => {
+ for (const file of uploaded) {
+ uploadedHereRef.current.add(file.id);
+ }
+ field.onChange([...value, ...uploaded]);
+ void remoteLimits.refetch();
+ onUploaded?.(uploaded);
+ },
+ onError: () => setError(t("errors.upload_failed")),
+ });
+
+ const messageFor = (rejection: UploadRejection): string => {
+ switch (rejection.kind) {
+ case "empty":
+ return t("errors.empty");
+ case "mime":
+ return t("errors.mime", { name: rejection.fileName });
+ case "not_allowed":
+ return t("errors.not_allowed");
+ case "quota":
+ return t("errors.quota", {
+ limit: formatBytes(rejection.limitBytes),
+ remaining: formatBytes(rejection.remainingBytes),
+ });
+ case "submit_limit":
+ return t("errors.submit_limit", {
+ limit: formatBytes(rejection.limitBytes),
+ total: formatBytes(rejection.totalBytes),
+ });
+ case "too_many":
+ // The cap on the field, not the slots left - "only 5 files" is easier
+ // to act on than "only 2 more".
+ return t("errors.too_many", { count: maxFiles });
+ }
+ };
+
+ const add = (selected: File[]) => {
+ setError(null);
+ const rejection = validateUploadSelection({
+ allowedMimeTypes: limits.allowedMimeTypes,
+ files: selected,
+ limits,
+ maxFiles: Number.isFinite(maxFiles) ? maxFiles - value.length : undefined,
+ usedBytes: limits.usedBytes,
+ });
+
+ if (rejection) {
+ setError(messageFor(rejection));
+
+ return;
+ }
+
+ uploadMutation.mutate(selected);
+ };
+
+ const detach = async (file: UploadedFile) => {
+ field.onChange(value.filter(item => item.id !== file.id));
+ // Only files this field stored are deleted: one the form was opened with
+ // belongs to whatever saved it, and a cancelled edit must not destroy it.
+ if (!uploadedHereRef.current.delete(file.id)) return;
+
+ try {
+ await remove(file);
+ void remoteLimits.refetch();
+ onRemoved?.(file);
+ } catch {
+ // The form no longer references it; a failed cleanup is the admin panel's
+ // problem, not something to fail the user's form over.
+ }
+ };
+
+ const hint = isLoadingLimits
+ ? ""
+ : [
+ limits.allowedMimeTypes.length > 0 &&
+ t("hint.types", {
+ types: [
+ ...new Set(limits.allowedMimeTypes.map(mimeTypeLabel)),
+ ].join(", "),
+ }),
+ Number.isFinite(maxFiles) && t("hint.files", { count: maxFiles }),
+ limits.remainingBytes !== null &&
+ t("hint.space", { size: formatBytes(limits.remainingBytes) }),
+ ]
+ .filter(Boolean)
+ .join(" · ");
+
+ return (
+ <>
+ {!!label && (
+
+ {label}
+
+ )}
+
+
{
+ event.preventDefault();
+ // Moving onto a child fires `dragleave` on the parent too, which
+ // would flicker the highlight the whole way in.
+ if (event.currentTarget.contains(event.relatedTarget as Node)) return;
+ setIsDragging(false);
+ }}
+ onDragOver={event => {
+ event.preventDefault();
+ if (!isDisabled) setIsDragging(true);
+ }}
+ onDrop={event => {
+ event.preventDefault();
+ setIsDragging(false);
+ if (isDisabled) return;
+ add([...event.dataTransfer.files]);
+ }}
+ >
+
+
+
+
+
+
+
+ {limits.allowUpload || isLoadingLimits ? t("drop") : t("not_allowed")}
+
+ {!!hint && (
+
+ {hint}
+
+ )}
+
+ {
+ add([...(event.target.files ?? [])]);
+ event.target.value = "";
+ }}
+ ref={inputRef}
+ tabIndex={-1}
+ type="file"
+ />
+