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" + /> +
+ + {(value.length > 0 || uploadMutation.isPending) && ( + + {value.map(file => ( + + + {file.mimeType?.startsWith("image/") ? ( + {file.name} + ) : ( + + )} + + + {file.name} + + {formatBytes(file.size)} + + + + void detach(file)} + type="button" + > + + + + + ))} + + {uploadMutation.isPending && + uploadMutation.variables.map(file => ( + + + + + + {file.name} + + {t("uploading")} + + + + ))} + + )} + + {!!error && ( +

+ {error} +

+ )} + {!!description && {description}} + + + ); +}; diff --git a/packages/vitnode/src/lib/helpers/files.ts b/packages/vitnode/src/lib/helpers/files.ts new file mode 100644 index 000000000..0ab6de8b0 --- /dev/null +++ b/packages/vitnode/src/lib/helpers/files.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +/** + * A file the form already holds: uploaded, stored, and referenced by its + * `core_files` id. `AutoFormFiles` produces these, so a submit handler saves + * ids and nothing else has to know how the bytes got there. + */ +export const uploadedFileSchema = z.object({ + id: z.number(), + mimeType: z.string().nullable(), + name: z.string(), + size: z.number(), + url: z.string(), +}); + +export type UploadedFile = z.infer; + +/** + * Schema for an `AutoFormFiles` field. + * + * ```ts + * z.object({ attachments: uploadedFilesSchema({ max: 5 }) }) + * ``` + */ +export const uploadedFilesSchema = ({ + max, + min, +}: { + max?: number; + min?: number; +} = {}) => { + let value = z.array(uploadedFileSchema); + if (min !== undefined) { + value = value.min(min); + } + if (max !== undefined) { + value = value.max(max); + } + + return value.default([]); +}; + +/** The field value, whatever `react-hook-form` currently holds in it. */ +export const toUploadedFiles = (value: unknown): UploadedFile[] => { + const parsed = z.array(uploadedFileSchema).safeParse(value); + + return parsed.success ? parsed.data : []; +}; diff --git a/packages/vitnode/src/lib/upload-limits.test.ts b/packages/vitnode/src/lib/upload-limits.test.ts new file mode 100644 index 000000000..6add56fda --- /dev/null +++ b/packages/vitnode/src/lib/upload-limits.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; + +import type { RoleUploadSettings, UploadLimits } from "./upload-limits"; + +import { + isMimeTypeAllowed, + KILOBYTE, + mergeRoleUploadLimits, + NO_UPLOADS, + remainingUploadBytes, + UNLIMITED_UPLOADS, + validateUploadSelection, +} from "./upload-limits"; + +const role = ( + settings: Partial = {}, +): RoleUploadSettings => ({ + allowUploadFiles: true, + maxStorageForSubmit: null, + totalMaxStorage: null, + ...settings, +}); + +const file = (size: number, type = "image/png", name = "file.png") => ({ + name, + size, + type, +}); + +describe("mergeRoleUploadLimits", () => { + it("refuses uploads when no role allows them", () => { + expect( + mergeRoleUploadLimits([ + role({ allowUploadFiles: false, maxStorageForSubmit: 100 }), + role({ allowUploadFiles: false, totalMaxStorage: 100 }), + ]), + ).toEqual(NO_UPLOADS); + expect(mergeRoleUploadLimits([])).toEqual(NO_UPLOADS); + }); + + it("converts kB limits to bytes", () => { + expect( + mergeRoleUploadLimits([ + role({ maxStorageForSubmit: 512, totalMaxStorage: 2048 }), + ]), + ).toEqual({ + allowUpload: true, + maxBytesPerSubmit: 512 * KILOBYTE, + maxTotalBytes: 2048 * KILOBYTE, + }); + }); + + it("takes the highest cap across roles", () => { + expect( + mergeRoleUploadLimits([ + role({ maxStorageForSubmit: 100, totalMaxStorage: 900 }), + role({ maxStorageForSubmit: 300, totalMaxStorage: 200 }), + ]), + ).toEqual({ + allowUpload: true, + maxBytesPerSubmit: 300 * KILOBYTE, + maxTotalBytes: 900 * KILOBYTE, + }); + }); + + it("lets an unlimited role win over a capped one", () => { + expect( + mergeRoleUploadLimits([ + role({ maxStorageForSubmit: 100, totalMaxStorage: 100 }), + role({ maxStorageForSubmit: null, totalMaxStorage: 100 }), + ]), + ).toEqual({ + allowUpload: true, + maxBytesPerSubmit: null, + maxTotalBytes: 100 * KILOBYTE, + }); + }); + + it("ignores the caps of roles that forbid uploading", () => { + expect( + mergeRoleUploadLimits([ + role({ maxStorageForSubmit: 100, totalMaxStorage: 100 }), + role({ + allowUploadFiles: false, + maxStorageForSubmit: null, + totalMaxStorage: null, + }), + ]), + ).toEqual({ + allowUpload: true, + maxBytesPerSubmit: 100 * KILOBYTE, + maxTotalBytes: 100 * KILOBYTE, + }); + }); +}); + +describe("remainingUploadBytes", () => { + it("is null when the quota is unlimited", () => { + expect( + remainingUploadBytes({ limits: UNLIMITED_UPLOADS, usedBytes: 999 }), + ).toBeNull(); + }); + + it("never drops below zero", () => { + const limits: UploadLimits = { + allowUpload: true, + maxBytesPerSubmit: null, + maxTotalBytes: 100, + }; + + expect(remainingUploadBytes({ limits, usedBytes: 40 })).toBe(60); + expect(remainingUploadBytes({ limits, usedBytes: 400 })).toBe(0); + }); +}); + +describe("isMimeTypeAllowed", () => { + it("allows anything without an allowlist", () => { + expect(isMimeTypeAllowed("application/zip")).toBe(true); + expect(isMimeTypeAllowed("application/zip", [])).toBe(true); + expect(isMimeTypeAllowed("application/zip", ["*"])).toBe(true); + }); + + it("matches exact types case-insensitively", () => { + expect(isMimeTypeAllowed("IMAGE/PNG", ["image/png"])).toBe(true); + expect(isMimeTypeAllowed("image/gif", ["image/png"])).toBe(false); + }); + + it("matches a subtype wildcard", () => { + expect(isMimeTypeAllowed("image/gif", ["image/*"])).toBe(true); + expect(isMimeTypeAllowed("video/mp4", ["image/*"])).toBe(false); + }); + + it("rejects a missing mime type when an allowlist exists", () => { + expect(isMimeTypeAllowed("", ["image/png"])).toBe(false); + }); +}); + +describe("validateUploadSelection", () => { + const limits: UploadLimits = { + allowUpload: true, + maxBytesPerSubmit: 1000, + maxTotalBytes: 5000, + }; + + it("accepts a selection that fits", () => { + expect( + validateUploadSelection({ + files: [file(400), file(500)], + limits, + usedBytes: 1000, + }), + ).toBeNull(); + }); + + it("rejects everything when uploads are off", () => { + expect( + validateUploadSelection({ + files: [file(1)], + limits: NO_UPLOADS, + usedBytes: 0, + }), + ).toEqual({ kind: "not_allowed" }); + }); + + it("rejects an empty selection", () => { + expect( + validateUploadSelection({ files: [], limits, usedBytes: 0 }), + ).toEqual({ kind: "empty" }); + }); + + it("rejects too many files", () => { + expect( + validateUploadSelection({ + files: [file(1), file(1), file(1)], + limits, + maxFiles: 2, + usedBytes: 0, + }), + ).toEqual({ kind: "too_many", limit: 2 }); + }); + + it("names the file with the unsupported type", () => { + expect( + validateUploadSelection({ + allowedMimeTypes: ["image/*"], + files: [file(1), file(1, "application/zip", "archive.zip")], + limits, + usedBytes: 0, + }), + ).toEqual({ kind: "mime", fileName: "archive.zip" }); + }); + + it("rejects a batch over the per-submit limit", () => { + expect( + validateUploadSelection({ + files: [file(600), file(600)], + limits, + usedBytes: 0, + }), + ).toEqual({ kind: "submit_limit", limitBytes: 1000, totalBytes: 1200 }); + }); + + it("rejects a batch that would exceed the quota", () => { + expect( + validateUploadSelection({ + files: [file(600)], + limits, + usedBytes: 4600, + }), + ).toEqual({ kind: "quota", limitBytes: 5000, remainingBytes: 400 }); + }); + + it("counts the batch as a whole against both limits", () => { + // Each file fits on its own; together they do not. + expect( + validateUploadSelection({ + files: [file(300), file(300), file(300)], + limits: { ...limits, maxBytesPerSubmit: null, maxTotalBytes: 800 }, + usedBytes: 0, + }), + ).toEqual({ kind: "quota", limitBytes: 800, remainingBytes: 800 }); + }); +}); diff --git a/packages/vitnode/src/lib/upload-limits.ts b/packages/vitnode/src/lib/upload-limits.ts new file mode 100644 index 000000000..f0d7daa21 --- /dev/null +++ b/packages/vitnode/src/lib/upload-limits.ts @@ -0,0 +1,203 @@ +/** + * Upload rules shared by the client and the API. + * + * The browser needs the same answers the route does - is this file type + * allowed, does the batch fit in the quota - so the logic lives here and both + * sides call it. The client uses it to reject a selection before spending an + * upload; the route uses it as the real guard. + */ + +/** Role limits are stored in kB; every other size in VitNode counts bytes. */ +export const KILOBYTE = 1024; + +/** + * Default for the built-in user upload endpoint: raster images, PDF and plain + * text. SVG is deliberately absent - it can carry script, and stored files are + * served from the API origin - so allow it explicitly if you want it. + */ +export const DEFAULT_UPLOAD_MIME_TYPES = [ + "application/pdf", + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "text/plain", +]; + +export const DEFAULT_UPLOAD_MAX_FILES = 10; + +export const DEFAULT_UPLOAD_FOLDER = "uploads"; + +export interface RoleUploadSettings { + allowUploadFiles: boolean; + /** kB per submit, `null` for unlimited. */ + maxStorageForSubmit: null | number; + /** kB in total, `null` for unlimited. */ + totalMaxStorage: null | number; +} + +export interface UploadLimits { + allowUpload: boolean; + /** Bytes allowed in one submit, `null` for unlimited, `0` when uploads are off. */ + maxBytesPerSubmit: null | number; + /** Bytes the user may store in total, `null` for unlimited, `0` when uploads are off. */ + maxTotalBytes: null | number; +} + +export const NO_UPLOADS: UploadLimits = { + allowUpload: false, + maxBytesPerSubmit: 0, + maxTotalBytes: 0, +}; + +export const UNLIMITED_UPLOADS: UploadLimits = { + allowUpload: true, + maxBytesPerSubmit: null, + maxTotalBytes: null, +}; + +/** + * The most permissive limit across the roles that allow uploading at all. + * + * A user holds a primary role plus any number of secondary ones, and a role + * that forbids uploads is not a veto - it simply grants nothing, so it never + * lowers a cap another role granted. `null` (unlimited) beats every number. + */ +export const mergeRoleUploadLimits = ( + roles: RoleUploadSettings[], +): UploadLimits => { + const allowed = roles.filter(role => role.allowUploadFiles); + if (allowed.length === 0) { + return NO_UPLOADS; + } + + const mostPermissive = ( + pick: (role: RoleUploadSettings) => null | number, + ): null | number => { + let best = 0; + for (const role of allowed) { + const value = pick(role); + if (value === null) { + return null; + } + best = Math.max(best, value); + } + + return best * KILOBYTE; + }; + + return { + allowUpload: true, + maxBytesPerSubmit: mostPermissive(role => role.maxStorageForSubmit), + maxTotalBytes: mostPermissive(role => role.totalMaxStorage), + }; +}; + +/** Bytes left in the user's quota, or `null` when the quota is unlimited. */ +export const remainingUploadBytes = ({ + limits, + usedBytes, +}: { + limits: UploadLimits; + usedBytes: number; +}): null | number => + limits.maxTotalBytes === null + ? null + : Math.max(0, limits.maxTotalBytes - usedBytes); + +/** `image/png` against `["image/*"]` - a `*` on either half matches anything. */ +export const isMimeTypeAllowed = ( + mimeType: string, + allowedMimeTypes?: string[], +): boolean => { + if (!allowedMimeTypes || allowedMimeTypes.length === 0) { + return true; + } + + const [type, subtype] = mimeType.toLowerCase().split("/"); + + return allowedMimeTypes.some(allowed => { + if (allowed === "*" || allowed === "*/*") return true; + const [allowedType, allowedSubtype] = allowed.toLowerCase().split("/"); + + return ( + allowedType === type && + (allowedSubtype === "*" || allowedSubtype === subtype) + ); + }); +}; + +export interface UploadCandidate { + name: string; + size: number; + type: string; +} + +export type UploadRejection = + | { fileName: string; kind: "mime" } + | { kind: "empty" } + | { kind: "not_allowed" } + | { kind: "quota"; limitBytes: number; remainingBytes: number } + | { kind: "submit_limit"; limitBytes: number; totalBytes: number } + | { kind: "too_many"; limit: number }; + +/** + * First reason the selection can't be uploaded, or `null` when it can. + * + * One rejection rather than a list: the batch is refused whole, so the first + * problem is the whole answer and the user fixes one thing at a time. + */ +export const validateUploadSelection = ({ + allowedMimeTypes, + files, + limits, + maxFiles, + usedBytes, +}: { + allowedMimeTypes?: string[]; + files: UploadCandidate[]; + limits: UploadLimits; + maxFiles?: number; + usedBytes: number; +}): null | UploadRejection => { + if (!limits.allowUpload) { + return { kind: "not_allowed" }; + } + if (files.length === 0) { + return { kind: "empty" }; + } + if (maxFiles !== undefined && files.length > maxFiles) { + return { kind: "too_many", limit: maxFiles }; + } + + const rejected = files.find( + file => !isMimeTypeAllowed(file.type, allowedMimeTypes), + ); + if (rejected) { + return { kind: "mime", fileName: rejected.name }; + } + + const totalBytes = files.reduce((total, file) => total + file.size, 0); + if ( + limits.maxBytesPerSubmit !== null && + totalBytes > limits.maxBytesPerSubmit + ) { + return { + kind: "submit_limit", + limitBytes: limits.maxBytesPerSubmit, + totalBytes, + }; + } + + const remaining = remainingUploadBytes({ limits, usedBytes }); + if (remaining !== null && totalBytes > remaining) { + return { + kind: "quota", + limitBytes: limits.maxTotalBytes ?? 0, + remainingBytes: remaining, + }; + } + + return null; +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index a34948fda..543c84394 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -164,6 +164,28 @@ "justify": "Justify align" } }, + "files": { + "browse": "Choose files", + "full": "Limit reached", + "drop": "…or drop them here.", + "not_allowed": "Uploading files isn't available for your account.", + "uploading": "Uploading…", + "remove": "Remove {name}", + "hint": { + "types": "Accepted: {types}", + "files": "{count, plural, one {# file} other {up to # files}}", + "space": "{size} of space left" + }, + "errors": { + "empty": "No files selected.", + "mime": "“{name}” is not a supported file type.", + "not_allowed": "Uploading files isn't available for your account.", + "quota": "Not enough space left - {remaining} free of {limit}.", + "submit_limit": "That is {total}, and you can upload {limit} at a time.", + "too_many": "{count, plural, one {Only # file can be attached here.} other {Only # files can be attached here.}}", + "upload_failed": "The upload failed. Please try again." + } + }, "theme_switcher": "Toggle Theme", "language_switcher": "Switch Language", "toggle_sidebar": "Toggle Sidebar", @@ -281,6 +303,14 @@ "noResults": { "title": "No files yet", "description": "Files you upload will appear here." + }, + "upload": { + "trigger": "Upload files", + "title": "Upload files", + "desc": "Add files to your account. Pick several at once, or drop them on the field.", + "field": "Files", + "submit": "Save", + "success": "{count, plural, one {# file uploaded.} other {# files uploaded.}}" } }, "auth": { diff --git a/packages/vitnode/src/routes/main/files/page.tsx b/packages/vitnode/src/routes/main/files/page.tsx index 14b981e1c..73084d09c 100644 --- a/packages/vitnode/src/routes/main/files/page.tsx +++ b/packages/vitnode/src/routes/main/files/page.tsx @@ -7,6 +7,7 @@ import { I18nProvider } from "@/components/i18n-provider"; import { DataTableSkeleton } from "@/components/table/data-table"; import { HeaderContent } from "@/components/ui/header-content"; import { getSessionApi } from "@/lib/api/get-session-api"; +import { UploadMyFiles } from "@/views/files/actions/upload-files"; const MyFilesTableView = dynamic(async () => import("@/views/files/my-files-table-view").then(module => ({ @@ -41,7 +42,9 @@ export default async function Page( return (
- + + + }> diff --git a/packages/vitnode/src/views/files/actions/upload-files-form.tsx b/packages/vitnode/src/views/files/actions/upload-files-form.tsx new file mode 100644 index 000000000..c295a2f40 --- /dev/null +++ b/packages/vitnode/src/views/files/actions/upload-files-form.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; +import { AutoFormFiles } from "@/components/form/fields/files"; +import { useDialog } from "@/components/ui/dialog"; +import { uploadedFilesSchema } from "@/lib/helpers/files"; +import { useRouter } from "@/lib/navigation"; + +const formSchema = z.object({ + files: uploadedFilesSchema({ min: 1 }), +}); + +export const UploadMyFilesForm = () => { + const t = useTranslations("core.files.upload"); + const { setOpen } = useDialog(); + const router = useRouter(); + + const onSubmit: AutoFormOnSubmit = ({ files }) => { + toast.success(t("success", { count: files.length })); + setOpen?.(false); + router.refresh(); + }; + + return ( + ( + router.refresh()} + onUploaded={() => router.refresh()} + /> + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + /> + ); +}; diff --git a/packages/vitnode/src/views/files/actions/upload-files.tsx b/packages/vitnode/src/views/files/actions/upload-files.tsx new file mode 100644 index 000000000..3edc3e3dd --- /dev/null +++ b/packages/vitnode/src/views/files/actions/upload-files.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { UploadIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; + +const UploadMyFilesForm = dynamic(async () => + import("./upload-files-form").then(mod => ({ + default: mod.UploadMyFilesForm, + })), +); + +export const UploadMyFiles = () => { + const t = useTranslations("core.files.upload"); + + return ( + + }> + + {t("trigger")} + + + + + {t("title")} + {t("desc")} + + + }> + + + + + ); +}; diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 6166f6756..6bc7116c7 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -146,6 +146,25 @@ export interface VitNodeApiConfig { quality?: number; webp?: boolean; }; + /** + * Rules for the built-in user upload endpoint (`POST /users/files`) that + * backs the `AutoFormFiles` field. *How much* a user may upload comes from + * their roles (AdminCP -> Roles -> Content); this is *what* may be uploaded + * and where it lands. Your own upload routes are unaffected - they validate + * per call with `upload()`. + */ + uploads?: { + /** + * Accepted MIME types, `image/*` wildcards included. Defaults to raster + * images, PDF and plain text - SVG is left out on purpose, since it can + * carry script and stored files are served from the API origin. + */ + allowedMimeTypes?: string[]; + /** Sub-folder under the dated prefix. Defaults to `uploads`. */ + folder?: string; + /** Files accepted in one submit. Defaults to 10. */ + maxFiles?: number; + }; }; }