diff --git a/CLAUDE.md b/CLAUDE.md index 673e099ae..ca837dab2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,4 +6,4 @@ - Use `AutoForm` for forms instead of manually creating form components. - After create/edit/delete operations, always refresh the data in the table to reflect the changes with notification using toast `sonner`. - Use `React.lazy` and `Suspense` for code splitting and lazy loading for content-heavy dialogs like dialogs in forms. -- Don't use big comments. Remember that code is self-documenting. Use comments only when necessary to explain complex logic or decisions. +- Don't use big comments. Remember that code is self-documenting. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ba1029901..b3742411d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,7 +26,10 @@ if (staticStorage) { staticStorage.mountPath, serveStatic({ root: staticStorage.root, - rewriteRequestPath: path => path.replace(staticStorage.stripPrefix, ""), + rewriteRequestPath: path => + path.startsWith(staticStorage.stripPrefix) + ? path.slice(staticStorage.stripPrefix.length) + : path, }), ); } diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index dd8a0d98d..4b1e115ba 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -16,6 +16,10 @@ "@vitnode/core:system:can_view": "View integrations", "@vitnode/core:system:can_send_test_email": "Send test email", "@vitnode/core:system:can_test_storage": "Test storage", + "@vitnode/core:files": "Files", + "@vitnode/core:files:can_view": "View uploaded files", + "@vitnode/core:files:can_download": "Download files", + "@vitnode/core:files:can_delete": "Delete files", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", "@vitnode/core:staff_moderators": "Staff: Moderators", @@ -132,7 +136,41 @@ "log_out": "Log out", "mod_cp": "Moderator CP", "admin_cp": "Admin CP", - "settings": "Settings" + "settings": "Settings", + "files": "My Files" + } + }, + "files": { + "title": "My Files", + "desc": "Files you've uploaded to your account.", + "list": { + "preview": "Preview", + "name": "Name", + "size": "Size", + "dimensions": "Dimensions", + "metadata": "Metadata", + "createdAt": "Uploaded" + }, + "metadata": { + "title": "Metadata", + "empty": "—" + }, + "actions": { + "download": "Download", + "delete": "Delete" + }, + "download": { + "error": "Failed to download the file." + }, + "delete": { + "title": "Delete file", + "desc": "Are you sure you want to delete this file? This permanently removes it and cannot be undone.", + "confirm": "Delete", + "success": "File deleted." + }, + "noResults": { + "title": "No files yet", + "description": "Files you upload will appear here." } }, "auth": { @@ -273,7 +311,8 @@ }, "system": { "title": "System", - "integrations": "Integrations" + "integrations": "Integrations", + "files": "Files" }, "advanced": { "title": "Advanced", @@ -598,6 +637,42 @@ "queued": "{pending} pending · {processing} running", "cron_stale": "Offline — the cron worker that drains the queue isn't running. Fix cron jobs to resume processing." } + }, + "files": { + "title": "Files", + "desc": "Browse, download, and delete files uploaded to this instance.", + "list": { + "preview": "Preview", + "name": "Name", + "size": "Size", + "dimensions": "Dimensions", + "uploadedBy": "Uploaded by", + "metadata": "Metadata", + "createdAt": "Uploaded", + "no_preview": "No preview" + }, + "anonymous": "—", + "metadata": { + "empty": "—", + "title": "Metadata" + }, + "actions": { + "download": "Download", + "delete": "Delete" + }, + "download": { + "error": "Failed to download the file." + }, + "delete": { + "title": "Delete file", + "desc": "Are you sure you want to delete this file? This permanently removes it from storage and cannot be undone.", + "confirm": "Delete", + "success": "File deleted." + }, + "noResults": { + "title": "No files uploaded", + "description": "Files uploaded across the site will appear here." + } } }, "debug": { diff --git a/apps/docs/content/docs/dev/storage/index.mdx b/apps/docs/content/docs/dev/storage/index.mdx index 5e1866c57..89b27de39 100644 --- a/apps/docs/content/docs/dev/storage/index.mdx +++ b/apps/docs/content/docs/dev/storage/index.mdx @@ -44,9 +44,25 @@ export const vitNodeApiConfig = buildApiConfig({ ``` It runs automatically inside `c.get("storage").upload()`, so every upload route -benefits. Applies to JPEG, PNG, WebP, AVIF and TIFF; the format is preserved. -Other files — including SVG and GIF — are stored untouched. Leave `image` out to -store originals as-is. +benefits. Applies to JPEG, PNG, WebP, AVIF and TIFF. Other files — including SVG +and GIF — are stored untouched. Leave `image` out to store originals as-is. + +Processed images are also **converted to WebP** by default — usually much +smaller than JPEG or PNG at the same quality — and their pixel dimensions are +recorded in `core_files.metadata.dimensions` (shown in the admin **Files** +panel). Set `webp: false` to keep each image in its original format: + +```ts title="vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + storage: { + adapter: LocalStorageAdapter(), + image: { + quality: 85, + webp: false, // [!code ++] keep the original format + }, + }, +}); +``` ## Create your own upload endpoint diff --git a/apps/docs/content/docs/dev/storage/local.mdx b/apps/docs/content/docs/dev/storage/local.mdx index f16b00f66..8f1d3f8e5 100644 --- a/apps/docs/content/docs/dev/storage/local.mdx +++ b/apps/docs/content/docs/dev/storage/local.mdx @@ -54,7 +54,10 @@ if (staticStorage) { staticStorage.mountPath, serveStatic({ root: staticStorage.root, - rewriteRequestPath: path => path.replace(staticStorage.stripPrefix, ""), + rewriteRequestPath: path => + path.startsWith(staticStorage.stripPrefix) + ? path.slice(staticStorage.stripPrefix.length) + : path, }), ); } 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 new file mode 100644 index 000000000..9c8e2a64e --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx @@ -0,0 +1,50 @@ +import { getTranslations } from "next-intl/server"; +import dynamic from "next/dynamic"; +import { notFound } from "next/navigation"; +import React from "react"; + +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"; + +const MyFilesTableView = dynamic(async () => + import("@vitnode/core/views/files/my-files-table-view").then(module => ({ + default: module.MyFilesTableView, + })), +); + +export const generateMetadata = async () => { + const t = await getTranslations("core.files"); + + return { + title: t("title"), + description: t("desc"), + robots: { index: false, follow: false }, + }; +}; + +export default async function Page( + props: React.ComponentProps, +) { + const [t, session] = await Promise.all([ + getTranslations("core.files"), + getSessionApi(), + ]); + + if (!session.user) { + notFound(); + } + + return ( + +
+ + + }> + + +
+
+ ); +} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/files/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/files/page.tsx new file mode 100644 index 000000000..d89bc8204 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/files/page.tsx @@ -0,0 +1,51 @@ +import { getTranslations } from "next-intl/server"; +import dynamic from "next/dynamic"; +import { notFound } from "next/navigation"; +import React from "react"; + +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 { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; + +const FilesTableView = dynamic(async () => + import("@vitnode/core/views/admin/views/core/system/files/files-table-view").then( + module => ({ + default: module.FilesTableView, + }), + ), +); + +export const generateMetadata = async () => { + const t = await getTranslations("admin.system.files"); + + return { + title: t("title"), + description: t("desc"), + }; +}; + +export default async function Page( + props: React.ComponentProps, +) { + const [t, canView] = await Promise.all([ + getTranslations("admin.system.files"), + checkAdminPermissionApi({ module: "files", permission: "can_view" }), + ]); + + if (!canView) { + notFound(); + } + + return ( + +
+ + + }> + + +
+
+ ); +} diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index dd8a0d98d..4b1e115ba 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -16,6 +16,10 @@ "@vitnode/core:system:can_view": "View integrations", "@vitnode/core:system:can_send_test_email": "Send test email", "@vitnode/core:system:can_test_storage": "Test storage", + "@vitnode/core:files": "Files", + "@vitnode/core:files:can_view": "View uploaded files", + "@vitnode/core:files:can_download": "Download files", + "@vitnode/core:files:can_delete": "Delete files", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", "@vitnode/core:staff_moderators": "Staff: Moderators", @@ -132,7 +136,41 @@ "log_out": "Log out", "mod_cp": "Moderator CP", "admin_cp": "Admin CP", - "settings": "Settings" + "settings": "Settings", + "files": "My Files" + } + }, + "files": { + "title": "My Files", + "desc": "Files you've uploaded to your account.", + "list": { + "preview": "Preview", + "name": "Name", + "size": "Size", + "dimensions": "Dimensions", + "metadata": "Metadata", + "createdAt": "Uploaded" + }, + "metadata": { + "title": "Metadata", + "empty": "—" + }, + "actions": { + "download": "Download", + "delete": "Delete" + }, + "download": { + "error": "Failed to download the file." + }, + "delete": { + "title": "Delete file", + "desc": "Are you sure you want to delete this file? This permanently removes it and cannot be undone.", + "confirm": "Delete", + "success": "File deleted." + }, + "noResults": { + "title": "No files yet", + "description": "Files you upload will appear here." } }, "auth": { @@ -273,7 +311,8 @@ }, "system": { "title": "System", - "integrations": "Integrations" + "integrations": "Integrations", + "files": "Files" }, "advanced": { "title": "Advanced", @@ -598,6 +637,42 @@ "queued": "{pending} pending · {processing} running", "cron_stale": "Offline — the cron worker that drains the queue isn't running. Fix cron jobs to resume processing." } + }, + "files": { + "title": "Files", + "desc": "Browse, download, and delete files uploaded to this instance.", + "list": { + "preview": "Preview", + "name": "Name", + "size": "Size", + "dimensions": "Dimensions", + "uploadedBy": "Uploaded by", + "metadata": "Metadata", + "createdAt": "Uploaded", + "no_preview": "No preview" + }, + "anonymous": "—", + "metadata": { + "empty": "—", + "title": "Metadata" + }, + "actions": { + "download": "Download", + "delete": "Delete" + }, + "download": { + "error": "Failed to download the file." + }, + "delete": { + "title": "Delete file", + "desc": "Are you sure you want to delete this file? This permanently removes it from storage and cannot be undone.", + "confirm": "Delete", + "success": "File deleted." + }, + "noResults": { + "title": "No files uploaded", + "description": "Files uploaded across the site will appear here." + } } }, "debug": { diff --git a/packages/vitnode/src/api/adapters/storage/local.ts b/packages/vitnode/src/api/adapters/storage/local.ts index 444b1fa17..4ad44a6b9 100644 --- a/packages/vitnode/src/api/adapters/storage/local.ts +++ b/packages/vitnode/src/api/adapters/storage/local.ts @@ -32,8 +32,12 @@ export const LocalStorageAdapter = ({ // trace to `public/uploads` instead of the whole project. const resolvePath = (key: string): string => join(process.cwd(), "public", "uploads", key); - const getUrl = (key: string): string => - `${baseUrl ?? CONFIG.api.origin}${publicPath}/${key}`; + const getUrl = (key: string): string => { + const base = (baseUrl ?? CONFIG.api.origin).replace(/\/$/, ""); + const path = publicPath.replace(/^\/|\/$/g, ""); + + return `${base}/${path}/${key}`; + }; // Route registered on the API app, which already carries the `/api` basePath. const mountPath = `${publicPath.replace(/^\/api/, "")}/*`; diff --git a/packages/vitnode/src/api/models/storage-image.test.ts b/packages/vitnode/src/api/models/storage-image.test.ts index c87589cb3..c4659d9fe 100644 --- a/packages/vitnode/src/api/models/storage-image.test.ts +++ b/packages/vitnode/src/api/models/storage-image.test.ts @@ -8,13 +8,14 @@ import { describe, expect, it, vi } from "vitest"; import { StorageModel } from "./storage"; -const makeCtx = (image?: { quality?: number }) => { +const makeCtx = (image?: { quality?: number; webp?: boolean }) => { const upload = vi.fn( ({ key }: { body: Buffer; contentType?: string; key: string }) => ({ key, url: `https://cdn.test/${key}`, }), ); + const insertValues = vi.fn().mockResolvedValue(undefined); const store: Record = { core: { storage: { @@ -23,7 +24,7 @@ const makeCtx = (image?: { quality?: number }) => { }, }, db: { - insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })), + insert: vi.fn(() => ({ values: insertValues })), }, plugin: { id: "@vitnode/core" }, user: { id: 7 }, @@ -31,6 +32,7 @@ const makeCtx = (image?: { quality?: number }) => { return { ctx: { get: (k: string) => store[k] } as unknown as Context, + insertValues, upload, }; }; @@ -40,9 +42,9 @@ const makeJpeg = async (quality: number): Promise => create: { background: { b: 50, g: 100, r: 200 }, channels: 3, - height: 256, + height: 180, noise: { mean: 128, sigma: 60, type: "gaussian" }, - width: 256, + width: 320, }, }) .jpeg({ quality }) @@ -52,24 +54,80 @@ const fileFrom = (buf: Buffer, name: string, type: string): File => new File([new Uint8Array(buf)], name, { type }); describe("StorageModel image optimization", () => { - it("re-encodes an image at the configured quality when image config is set", async () => { + it("converts a processed image to WebP by default", async () => { const original = await makeJpeg(100); - const { ctx, upload } = makeCtx({ quality: 40 }); + const { ctx, upload, insertValues } = makeCtx({ quality: 40 }); + + await new StorageModel(ctx).upload({ + file: fileFrom(original, "photo.jpg", "image/jpeg"), + folder: "photos", + }); + + const call = upload.mock.calls[0][0]; + expect((await sharp(call.body).metadata()).format).toBe("webp"); + expect(call.contentType).toBe("image/webp"); + expect(call.key).toMatch(/\.webp$/); + + const row = insertValues.mock.calls[0][0]; + expect(row.name).toBe("photo.webp"); + expect(row.mimeType).toBe("image/webp"); + }); + + it("records the pixel dimensions in metadata", async () => { + const original = await makeJpeg(90); + const { ctx, insertValues } = makeCtx({ quality: 80 }); + + await new StorageModel(ctx).upload({ + file: fileFrom(original, "photo.jpg", "image/jpeg"), + folder: "photos", + }); + + expect(insertValues.mock.calls[0][0].metadata).toMatchObject({ + dimensions: { width: 320, height: 180 }, + }); + }); + + it("merges dimensions with caller-provided metadata", async () => { + const original = await makeJpeg(90); + const { ctx, insertValues } = makeCtx({ quality: 80 }); await new StorageModel(ctx).upload({ file: fileFrom(original, "photo.jpg", "image/jpeg"), folder: "photos", + metadata: { alt: "a cat" }, }); - const stored = upload.mock.calls[0][0].body; - expect((await sharp(stored).metadata()).format).toBe("jpeg"); - expect(stored.equals(original)).toBe(false); - expect(stored.length).toBeLessThan(original.length); + expect(insertValues.mock.calls[0][0].metadata).toEqual({ + alt: "a cat", + dimensions: { width: 320, height: 180 }, + }); + }); + + it("keeps the original format when webp is disabled", async () => { + const original = await makeJpeg(100); + const { ctx, upload, insertValues } = makeCtx({ quality: 40, webp: false }); + + await new StorageModel(ctx).upload({ + file: fileFrom(original, "photo.jpg", "image/jpeg"), + folder: "photos", + }); + + const call = upload.mock.calls[0][0]; + expect((await sharp(call.body).metadata()).format).toBe("jpeg"); + expect(call.contentType).toBe("image/jpeg"); + expect(call.key).toMatch(/\.jpg$/); + // still smaller than the original (re-encoded at lower quality) + expect(call.body.length).toBeLessThan(original.length); + // dimensions are still recorded regardless of the target format + expect(insertValues.mock.calls[0][0].metadata).toMatchObject({ + dimensions: { width: 320, height: 180 }, + }); + expect(insertValues.mock.calls[0][0].name).toBe("photo.jpg"); }); it("stores the original bytes when image config is absent", async () => { const original = await makeJpeg(100); - const { ctx, upload } = makeCtx(undefined); + const { ctx, upload, insertValues } = makeCtx(undefined); await new StorageModel(ctx).upload({ file: fileFrom(original, "photo.jpg", "image/jpeg"), @@ -77,6 +135,8 @@ describe("StorageModel image optimization", () => { }); expect(upload.mock.calls[0][0].body.equals(original)).toBe(true); + // no image processing means no recorded dimensions + expect(insertValues.mock.calls[0][0].metadata).toEqual({}); }); it("leaves non-image files untouched even when image config is set", async () => { diff --git a/packages/vitnode/src/api/models/storage.test.ts b/packages/vitnode/src/api/models/storage.test.ts index 153a248dc..3b9400093 100644 --- a/packages/vitnode/src/api/models/storage.test.ts +++ b/packages/vitnode/src/api/models/storage.test.ts @@ -40,6 +40,48 @@ const makeCtx = ( }; }; +const makeDeleteCtx = ( + row: undefined | { key: string }, + overrides: { storage?: unknown } = {}, +): { + ctx: Context; + del: ReturnType; + deleteWhere: ReturnType; +} => { + const del = vi.fn().mockResolvedValue(undefined); + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const store: Record = { + core: { + storage: + "storage" in overrides + ? overrides.storage + : { + adapter: { + delete: del, + getUrl: (k: string) => k, + upload: vi.fn(), + }, + }, + }, + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue(row ? [row] : []), + })), + })), + })), + delete: vi.fn(() => ({ where: deleteWhere })), + }, + }; + + return { + ctx: { get: (k: string) => store[k] } as unknown as Context, + del, + deleteWhere, + }; +}; + describe("StorageModel.upload", () => { it("uploads under month_x_y/{folder} with a generated file name", async () => { const { ctx, insertValues, upload } = makeCtx(); @@ -157,3 +199,74 @@ describe("StorageModel.delete", () => { expect(del).toHaveBeenCalledWith("month_7_2026/avatars/x.png"); }); }); + +describe("StorageModel.deleteFile", () => { + it("deletes the storage object then the database row", async () => { + const key = "month_7_2026/avatars/x.png"; + const { ctx, del, deleteWhere } = makeDeleteCtx({ key }); + + await new StorageModel(ctx).deleteFile(1); + + expect(del).toHaveBeenCalledWith(key); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("throws 404 when the file does not exist", async () => { + const { ctx, del, deleteWhere } = makeDeleteCtx(undefined); + + await expect(new StorageModel(ctx).deleteFile(999)).rejects.toThrow(); + expect(del).not.toHaveBeenCalled(); + expect(deleteWhere).not.toHaveBeenCalled(); + }); + + it("still removes the row when no storage adapter is configured", async () => { + const { ctx, del, deleteWhere } = makeDeleteCtx( + { key: "a/b.png" }, + { storage: undefined }, + ); + + await new StorageModel(ctx).deleteFile(1); + + expect(del).not.toHaveBeenCalled(); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("deletes when scoped to the owning user", async () => { + const { ctx, del, deleteWhere } = makeDeleteCtx({ key: "a/b.png" }); + + await new StorageModel(ctx).deleteFile(1, 7); + + expect(del).toHaveBeenCalledWith("a/b.png"); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("throws 404 when the file is not owned by the user", async () => { + // The scoped lookup returns nothing, mirroring a row owned by someone else. + const { ctx, del, deleteWhere } = makeDeleteCtx(undefined); + + await expect(new StorageModel(ctx).deleteFile(1, 7)).rejects.toThrow(); + expect(del).not.toHaveBeenCalled(); + expect(deleteWhere).not.toHaveBeenCalled(); + }); + + it("removes the row even when the storage delete fails", async () => { + const failing = vi.fn().mockRejectedValue(new Error("gone")); + const { ctx, deleteWhere } = makeDeleteCtx( + { key: "a/b.png" }, + { + storage: { + adapter: { + delete: failing, + getUrl: (k: string) => k, + upload: vi.fn(), + }, + }, + }, + ); + + await new StorageModel(ctx).deleteFile(1); + + expect(failing).toHaveBeenCalledWith("a/b.png"); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts index 68b07159c..f0e64c1fd 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -1,9 +1,14 @@ import type { Context } from "hono"; +import { and, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { core_files } from "@/database/files"; -import { buildStorageKey, generateStorageFileName } from "@/lib/api/upload"; +import { + buildStorageKey, + generateStorageFileName, + replaceFileExtension, +} from "@/lib/api/upload"; const DEFAULT_IMAGE_QUALITY = 85; @@ -64,6 +69,14 @@ export interface StorageUploadOptions { userId?: null | number; } +interface ProcessedImage { + body: Buffer; + dimensions: null | { height: number; width: number }; + // New extension (incl. leading dot) when the format changed, else null. + extension: null | string; + mimeType: string; +} + export class StorageModel { constructor(c: Context) { this.c = c; @@ -71,25 +84,53 @@ export class StorageModel { protected readonly c: Context; - // Re-encodes images with sharp at the configured quality when - // `storage.image` is set. Keeps the same format, so the key/contentType stay - // valid. sharp is imported lazily so it is only loaded when actually used. - private async optimizeImage(body: Buffer, mimeType: string): Promise { - const image = this.c.get("core").storage?.image; + // Re-encodes images with sharp when `storage.image` is set: shrinks them at + // the configured quality, converts to WebP unless disabled, and reads their + // pixel dimensions. Non-image files (and everything when `image` is off) pass + // through untouched. sharp is imported lazily so it only loads when used. + private async processImage( + body: Buffer, + mimeType: string, + ): Promise { + const image = this.c.get("core")?.storage?.image; if (!image || !PROCESSABLE_IMAGE_MIME_TYPES.has(mimeType)) { - return body; + return { body, mimeType, extension: null, dimensions: null }; } const quality = image.quality ?? DEFAULT_IMAGE_QUALITY; + const toWebp = image.webp !== false; + + let sharp; + try { + const { default: s } = await import("sharp"); + sharp = s; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (err) { + throw new HTTPException(500, { + message: "Image optimization library (sharp) failed to load", + }); + } try { - const { default: sharp } = await import("sharp"); - const format = (await sharp(body).metadata()).format; - if (!format) { - return body; + const metadata = await sharp(body).metadata(); + if (!metadata.format) { + return { body, mimeType, extension: null, dimensions: null }; } - return await sharp(body).toFormat(format, { quality }).toBuffer(); + const targetFormat = toWebp ? "webp" : metadata.format; + const output = await sharp(body) + .toFormat(targetFormat, { quality }) + .toBuffer(); + + return { + body: output, + mimeType: toWebp ? "image/webp" : mimeType, + extension: toWebp ? ".webp" : null, + dimensions: + metadata.width && metadata.height + ? { width: metadata.width, height: metadata.height } + : null, + }; } catch { throw new HTTPException(400, { message: "Invalid or corrupt image file", @@ -112,6 +153,38 @@ export class StorageModel { await this.requireProvider().delete(key); } + /** + * Removes a stored file by its `core_files` id: deletes the underlying object + * from the storage provider (best-effort — a missing object doesn't block the + * record removal), then deletes the database row. Throws a 404 when no file + * with that id exists. Pass `ownerId` to scope the delete to that user's files + * (so a user can only remove their own uploads). + */ + async deleteFile(id: number, ownerId?: number): Promise { + const db = this.c.get("db"); + const where = + ownerId === undefined + ? eq(core_files.id, id) + : and(eq(core_files.id, id), eq(core_files.userId, ownerId)); + + const [row] = await db + .select({ key: core_files.key }) + .from(core_files) + .where(where) + .limit(1); + + if (!row) { + throw new HTTPException(404, { message: "File not found" }); + } + + const provider = this.c.get("core").storage?.adapter; + if (provider) { + await provider.delete(row.key).catch(() => undefined); + } + + await db.delete(core_files).where(where); + } + getUrl(key: string): string { return this.requireProvider().getUrl(key); } @@ -137,19 +210,28 @@ export class StorageModel { }); } - const key = buildStorageKey({ - folder, - fileName: generateStorageFileName(file.name), - }); - const body = await this.optimizeImage( + const processed = await this.processImage( Buffer.from(await file.arrayBuffer()), file.type, ); + // When a conversion changed the format, reflect the new extension in both + // the stored key and the display name so downloads and previews are honest. + const displayName = processed.extension + ? replaceFileExtension(file.name, processed.extension) + : file.name; + const key = buildStorageKey({ + folder, + fileName: generateStorageFileName( + file.name, + processed.extension ?? undefined, + ), + }); + const result = await provider.upload({ key, - body, - contentType: file.type || undefined, + body: processed.body, + contentType: processed.mimeType || undefined, }); const ownerId = @@ -162,14 +244,19 @@ export class StorageModel { .get("db") .insert(core_files) .values({ - name: file.name, + name: displayName, key: result.key, folder, - mimeType: file.type || null, - size: body.length, + mimeType: processed.mimeType || null, + size: processed.body.length, userId: ownerId, pluginId: this.c.get("plugin")?.id ?? null, - metadata: metadata ?? {}, + metadata: { + ...(metadata ?? {}), + ...(processed.dimensions + ? { dimensions: processed.dimensions } + : {}), + }, }); } catch (error) { await provider.delete(result.key).catch(() => undefined); diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index 843d39ca5..ce8bee229 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -3,6 +3,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { advancedAdminModule } from "./advanced/advanced.admin.module"; import { debugAdminModule } from "./debug/debug.admin.module"; +import { filesAdminModule } from "./files/files.admin.module"; import { rolesAdminModule } from "./roles/roles.admin.module"; import { sendNotificationRoute } from "./routes/notifications.route"; import { sessionAdminRoute } from "./routes/session.route"; @@ -19,6 +20,7 @@ export const adminModule = buildModule({ staffAdminModule, debugAdminModule, advancedAdminModule, + filesAdminModule, ], cronJobs: [], }); diff --git a/packages/vitnode/src/api/modules/admin/files/files.admin.module.ts b/packages/vitnode/src/api/modules/admin/files/files.admin.module.ts new file mode 100644 index 000000000..cc36b1778 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/files/files.admin.module.ts @@ -0,0 +1,12 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { deleteFileAdminRoute } from "./routes/delete.route"; +import { downloadFileAdminRoute } from "./routes/download.route"; +import { listFilesAdminRoute } from "./routes/list.route"; + +export const filesAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "files", + routes: [listFilesAdminRoute, downloadFileAdminRoute, deleteFileAdminRoute], +}); diff --git a/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts new file mode 100644 index 000000000..08061f148 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts @@ -0,0 +1,43 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +export const deleteFileAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "files", permission: "can_delete" }, + route: { + method: "delete", + description: "Delete an uploaded file and its record (Admin only).", + path: "/{id}", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + }, + responses: { + 200: { + description: "File deleted", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "File not found", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const fileId = Number(id); + if (!Number.isInteger(fileId)) { + return c.json({ error: "File not found" }, 404); + } + + await c.get("storage").deleteFile(fileId); + + return c.body(null, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/files/routes/download.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/download.route.ts new file mode 100644 index 000000000..0e47fc466 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/files/routes/download.route.ts @@ -0,0 +1,82 @@ +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_files } from "@/database/files"; + +export const downloadFileAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "files", permission: "can_download" }, + route: { + method: "get", + description: + "Download an uploaded file as an attachment with its original name (Admin only).", + path: "/{id}/download", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + }, + responses: { + 200: { + content: { + "application/octet-stream": { + schema: z.string().openapi({ format: "binary", type: "string" }), + }, + }, + description: "File contents", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "File not found", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const fileId = Number(id); + if (!Number.isInteger(fileId)) { + return c.json({ error: "File not found" }, 404); + } + + const [file] = await c + .get("db") + .select({ + name: core_files.name, + key: core_files.key, + mimeType: core_files.mimeType, + }) + .from(core_files) + .where(eq(core_files.id, fileId)) + .limit(1); + + if (!file || !c.get("core").storage?.adapter) { + return c.json({ error: "File not found" }, 404); + } + + // Adapter-agnostic: fetch the stored object from its (server-reachable) + // public URL and re-stream it as an attachment, so the browser saves it + // with the original file name instead of opening it inline. + const upstream = await fetch(c.get("storage").getUrl(file.key)); + if (!upstream.ok || !upstream.body) { + return c.json({ error: "File not found" }, 404); + } + + c.header("Content-Type", file.mimeType ?? "application/octet-stream"); + c.header( + "Content-Disposition", + `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`, + ); + const length = upstream.headers.get("content-length"); + if (length) { + c.header("Content-Length", length); + } + + return c.body(upstream.body, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts new file mode 100644 index 000000000..873605bf3 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts @@ -0,0 +1,168 @@ +import { eq } from "drizzle-orm"; +import z from "zod"; + +import { resolveRoleNames } from "@/api/lib/resolve-role-names"; +import { buildRoute } from "@/api/lib/route"; +import { + withPagination, + zodPaginationPageInfo, + zodPaginationQuery, +} from "@/api/lib/with-pagination"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_files } from "@/database/files"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { parseImageDimensions } from "@/lib/api/upload"; + +export const listFilesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "files", permission: "can_view" }, + route: { + method: "get", + description: "List uploaded files stored in core_files (Admin only).", + path: "/", + request: { + query: zodPaginationQuery.extend({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(["name", "size", "createdAt"]).optional(), + search: z.string().optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + edges: z.array( + z.object({ + id: z.number(), + name: z.string(), + folder: z.string(), + mimeType: z.string().nullable(), + size: z.number(), + metadata: z.record(z.string(), z.unknown()), + createdAt: z.date(), + // Pixel dimensions for images, or `null` for non-images and + // files uploaded without image processing. + dimensions: z + .object({ width: z.number(), height: z.number() }) + .nullable(), + // The uploader, or `null` when uploaded anonymously or the + // user has since been removed. `role.name` carries every + // translation; the frontend resolves the active locale. + user: z + .object({ + id: z.number(), + name: z.string(), + nameCode: z.string(), + role: z.object({ + id: z.number(), + color: z.string().nullable(), + name: z.array( + z.object({ + name: z.string(), + languageCode: z.string(), + }), + ), + }), + }) + .nullable(), + // Public URL for the stored object, or `null` when no storage + // adapter is currently configured. + url: z.string().nullable(), + }), + ), + pageInfo: zodPaginationPageInfo, + }), + }, + }, + description: "List of uploaded files", + }, + }, + }, + handler: async c => { + const query = c.req.valid("query"); + const hasAdapter = !!c.get("core").storage?.adapter; + const storage = c.get("storage"); + + const data = await withPagination({ + params: { query }, + c, + primaryCursor: core_files.id, + search: [core_files.name], + query: async ({ limit, where, orderBy }) => + await c + .get("db") + .select({ + id: core_files.id, + name: core_files.name, + key: core_files.key, + folder: core_files.folder, + mimeType: core_files.mimeType, + size: core_files.size, + metadata: core_files.metadata, + createdAt: core_files.createdAt, + userId: core_files.userId, + userName: core_users.name, + userNameCode: core_users.nameCode, + userRoleId: core_users.roleId, + userRoleColor: core_roles.color, + }) + .from(core_files) + .leftJoin(core_users, eq(core_users.id, core_files.userId)) + .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) + .where(where) + .orderBy(orderBy) + .limit(limit), + table: core_files, + orderBy: { + column: query.orderBy + ? core_files[query.orderBy] + : core_files.createdAt, + order: query.order ?? "desc", + }, + }); + + const roleNames = await resolveRoleNames( + c, + data.edges + .map(edge => edge.userRoleId) + .filter((id): id is number => id != null), + ); + + return c.json({ + ...data, + edges: data.edges.map( + ({ + key, + userId, + userName, + userNameCode, + userRoleId, + userRoleColor, + ...file + }) => ({ + ...file, + url: hasAdapter ? storage.getUrl(key) : null, + dimensions: parseImageDimensions(file.metadata), + user: + userId != null && userName != null + ? { + id: userId, + name: userName, + nameCode: userNameCode ?? "", + role: { + id: userRoleId ?? 0, + color: userRoleColor ?? null, + name: + userRoleId != null + ? (roleNames.get(userRoleId) ?? []) + : [], + }, + } + : null, + }), + ), + }); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/files/files.module.ts b/packages/vitnode/src/api/modules/users/files/files.module.ts new file mode 100644 index 000000000..ce1724957 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/files/files.module.ts @@ -0,0 +1,12 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { deleteUserFileRoute } from "./routes/delete.route"; +import { downloadUserFileRoute } from "./routes/download.route"; +import { listUserFilesRoute } from "./routes/list.route"; + +export const userFilesModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "files", + routes: [listUserFilesRoute, downloadUserFileRoute, deleteUserFileRoute], +}); diff --git a/packages/vitnode/src/api/modules/users/files/routes/delete.route.ts b/packages/vitnode/src/api/modules/users/files/routes/delete.route.ts new file mode 100644 index 000000000..712dadc4b --- /dev/null +++ b/packages/vitnode/src/api/modules/users/files/routes/delete.route.ts @@ -0,0 +1,51 @@ +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +export const deleteUserFileRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "delete", + description: "Delete one of the current user's files.", + path: "/{id}", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + }, + responses: { + 200: { + description: "File deleted", + }, + 401: { + description: "Not signed in", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "File not found", + }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { id } = c.req.valid("param"); + const fileId = Number(id); + if (!Number.isInteger(fileId)) { + return c.json({ error: "File not found" }, 404); + } + + await c.get("storage").deleteFile(fileId, user.id); + + return c.body(null, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/files/routes/download.route.ts b/packages/vitnode/src/api/modules/users/files/routes/download.route.ts new file mode 100644 index 000000000..0a4e72e8d --- /dev/null +++ b/packages/vitnode/src/api/modules/users/files/routes/download.route.ts @@ -0,0 +1,87 @@ +import { z } from "@hono/zod-openapi"; +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_files } from "@/database/files"; + +export const downloadUserFileRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: + "Download one of the current user's files as an attachment with its original name.", + path: "/{id}/download", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + }, + responses: { + 200: { + content: { + "application/octet-stream": { + schema: z.string().openapi({ format: "binary", type: "string" }), + }, + }, + description: "File contents", + }, + 401: { + description: "Not signed in", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "File not found", + }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { id } = c.req.valid("param"); + const fileId = Number(id); + if (!Number.isInteger(fileId)) { + return c.json({ error: "File not found" }, 404); + } + + const [file] = await c + .get("db") + .select({ + name: core_files.name, + key: core_files.key, + mimeType: core_files.mimeType, + }) + .from(core_files) + .where(and(eq(core_files.id, fileId), eq(core_files.userId, user.id))) + .limit(1); + + if (!file || !c.get("core").storage?.adapter) { + return c.json({ error: "File not found" }, 404); + } + + const upstream = await fetch(c.get("storage").getUrl(file.key)); + if (!upstream.ok || !upstream.body) { + return c.json({ error: "File not found" }, 404); + } + + c.header("Content-Type", file.mimeType ?? "application/octet-stream"); + c.header( + "Content-Disposition", + `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`, + ); + const length = upstream.headers.get("content-length"); + if (length) { + c.header("Content-Length", length); + } + + return c.body(upstream.body, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/files/routes/list.route.ts b/packages/vitnode/src/api/modules/users/files/routes/list.route.ts new file mode 100644 index 000000000..d9a40c7d4 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/files/routes/list.route.ts @@ -0,0 +1,110 @@ +import { eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import z from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + withPagination, + zodPaginationPageInfo, + zodPaginationQuery, +} from "@/api/lib/with-pagination"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_files } from "@/database/files"; +import { parseImageDimensions } from "@/lib/api/upload"; + +export const listUserFilesRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "List files uploaded by the current user.", + path: "/", + request: { + query: zodPaginationQuery.extend({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(["name", "size", "createdAt"]).optional(), + search: z.string().optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + edges: z.array( + z.object({ + id: z.number(), + name: z.string(), + folder: z.string(), + mimeType: z.string().nullable(), + size: z.number(), + metadata: z.record(z.string(), z.unknown()), + createdAt: z.date(), + dimensions: z + .object({ width: z.number(), height: z.number() }) + .nullable(), + url: z.string().nullable(), + }), + ), + pageInfo: zodPaginationPageInfo, + }), + }, + }, + description: "List of the current user's files", + }, + 401: { + description: "Not signed in", + }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const query = c.req.valid("query"); + const hasAdapter = !!c.get("core").storage?.adapter; + const storage = c.get("storage"); + + const data = await withPagination({ + params: { query }, + c, + primaryCursor: core_files.id, + search: [core_files.name], + where: eq(core_files.userId, user.id), + query: async ({ limit, where, orderBy }) => + await c + .get("db") + .select({ + id: core_files.id, + name: core_files.name, + key: core_files.key, + folder: core_files.folder, + mimeType: core_files.mimeType, + size: core_files.size, + metadata: core_files.metadata, + createdAt: core_files.createdAt, + }) + .from(core_files) + .where(where) + .orderBy(orderBy) + .limit(limit), + table: core_files, + orderBy: { + column: query.orderBy + ? core_files[query.orderBy] + : core_files.createdAt, + order: query.order ?? "desc", + }, + }); + + return c.json({ + ...data, + edges: data.edges.map(({ key, ...file }) => ({ + ...file, + url: hasAdapter ? storage.getUrl(key) : null, + dimensions: parseImageDimensions(file.metadata), + })), + }); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/users.module.ts b/packages/vitnode/src/api/modules/users/users.module.ts index 0af6965ac..469f39154 100644 --- a/packages/vitnode/src/api/modules/users/users.module.ts +++ b/packages/vitnode/src/api/modules/users/users.module.ts @@ -1,6 +1,7 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { userFilesModule } from "./files/files.module"; import { changePasswordRoute } from "./routes/change-password.route"; import { permissionsRoute } from "./routes/permissions.route"; import { resetPasswordRoute } from "./routes/reset-passowrd.route"; @@ -24,5 +25,5 @@ export const usersModule = buildModule({ changePasswordRoute, permissionsRoute, ], - modules: [ssoUserModule], + modules: [ssoUserModule, userFilesModule], }); diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 27cfe05a8..0bdd66379 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -37,6 +37,11 @@ export const newBuildPluginApiCore = buildApiPlugin({ { permission: "can_send_test_email", dependsOn: ["can_view"] }, { permission: "can_test_storage", dependsOn: ["can_view"] }, ], + files: [ + "can_view", + { permission: "can_download", dependsOn: ["can_view"] }, + { permission: "can_delete", dependsOn: ["can_view"] }, + ], queue: ["can_view"], staff_moderators: [ "can_view", diff --git a/packages/vitnode/src/components/files/file-preview.tsx b/packages/vitnode/src/components/files/file-preview.tsx new file mode 100644 index 000000000..3f3ae059c --- /dev/null +++ b/packages/vitnode/src/components/files/file-preview.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { FileIcon } from "lucide-react"; +import React from "react"; + +export const FilePreview = ({ + mimeType, + name, + url, +}: { + mimeType: null | string; + name: string; + url: null | string; +}) => { + const [failed, setFailed] = React.useState(false); + const isImage = !!url && !failed && (mimeType?.startsWith("image/") ?? false); + + if (!isImage) { + return ( +
+ +
+ ); + } + + return ( + {name} setFailed(true)} + src={url} + /> + ); +}; diff --git a/packages/vitnode/src/components/files/metadata-cell.tsx b/packages/vitnode/src/components/files/metadata-cell.tsx new file mode 100644 index 000000000..cf946f604 --- /dev/null +++ b/packages/vitnode/src/components/files/metadata-cell.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { BracesIcon } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; + +export const MetadataCell = ({ + emptyLabel, + metadata, + title, +}: { + emptyLabel: string; + metadata: Record; + title: string; +}) => { + const keys = Object.keys(metadata ?? {}); + + if (keys.length === 0) { + return {emptyLabel}; + } + + return ( + + } + > + + {keys.length} + + + {title} +
+          {JSON.stringify(metadata, null, 2)}
+        
+
+
+ ); +}; diff --git a/packages/vitnode/src/components/ui/attachment.tsx b/packages/vitnode/src/components/ui/attachment.tsx index 176895e4a..91d34aaea 100644 --- a/packages/vitnode/src/components/ui/attachment.tsx +++ b/packages/vitnode/src/components/ui/attachment.tsx @@ -1,10 +1,10 @@ -import * as React from "react"; import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; -import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; const attachmentVariants = cva( "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed", @@ -32,15 +32,15 @@ function Attachment({ ...props }: React.ComponentProps<"div"> & VariantProps & { - state?: "idle" | "uploading" | "processing" | "error" | "done"; + state?: "done" | "error" | "idle" | "processing" | "uploading"; }) { return (
); @@ -69,9 +69,9 @@ function AttachmentMedia({ }: React.ComponentProps<"div"> & VariantProps) { return (
); @@ -83,11 +83,11 @@ function AttachmentContent({ }: React.ComponentProps<"div">) { return (
); @@ -99,11 +99,11 @@ function AttachmentTitle({ }: React.ComponentProps<"span">) { return ( ); @@ -115,12 +115,12 @@ function AttachmentDescription({ }: React.ComponentProps<"span">) { return ( ); @@ -132,11 +132,11 @@ function AttachmentActions({ }: React.ComponentProps<"div">) { return (
); @@ -150,11 +150,11 @@ function AttachmentAction({ }: React.ComponentProps) { return ( + + )} + + {canDelete && ( + { + const result = await deleteFileAction({ id }); + if (result.error) { + toast.error(tGlobal("title"), { + description: tGlobal("internal_server_error"), + }); + + return; + } + + toast.success(t("delete.success")); + onClose(); + }} + textSubmit={t("delete.confirm")} + title={t("delete.title")} + > + + + )} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/files/files-table-view.tsx b/packages/vitnode/src/views/admin/views/core/system/files/files-table-view.tsx new file mode 100644 index 000000000..4729d8844 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/files/files-table-view.tsx @@ -0,0 +1,147 @@ +import { FileIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +import { filesAdminModule } from "@/api/modules/admin/files/files.admin.module"; +import { DateFormat } from "@/components/date-format"; +import { FilePreview } from "@/components/files/file-preview"; +import { MetadataCell } from "@/components/files/metadata-cell"; +import { + DataTable, + type SearchParamsDataTable, +} from "@/components/table/data-table"; +import { UserFormat } from "@/components/user-format"; +import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; +import { fetcher } from "@/lib/fetcher"; +import { formatBytes } from "@/lib/format-bytes"; + +import { FileRowActions } from "./actions/file-row-actions"; + +export const FilesTableView = async ({ + searchParams, +}: { + searchParams: Promise; +}) => { + const query = await searchParams; + const [t, canDownload, canDelete, res] = await Promise.all([ + getTranslations("admin.system.files"), + checkAdminPermissionApi({ module: "files", permission: "can_download" }), + checkAdminPermissionApi({ module: "files", permission: "can_delete" }), + fetcher(filesAdminModule, { + path: "/", + method: "get", + module: "files", + prefixPath: "/admin", + args: { query }, + withPagination: true, + }), + ]); + + if (res.status !== 200) { + return notFound(); + } + + const data = await res.json(); + + return ( + ( + + ), + }, + { + id: "name", + label: t("list.name"), + cell: ({ row }) => ( +
+ {row.name} +

+ {row.mimeType ?? row.folder} +

+
+ ), + }, + { + id: "size", + label: t("list.size"), + cell: ({ row }) => formatBytes(row.size), + }, + { + id: "dimensions", + label: t("list.dimensions"), + cell: ({ row }) => + row.dimensions ? ( + `${row.dimensions.width}x${row.dimensions.height}` + ) : ( + + ), + }, + { + id: "user", + label: t("list.uploadedBy"), + cell: ({ row }) => + row.user ? ( + + ) : ( + {t("anonymous")} + ), + }, + { + id: "metadata", + label: t("list.metadata"), + cell: ({ row }) => ( + + ), + }, + { + id: "createdAt", + label: t("list.createdAt"), + cell: ({ row }) => , + }, + { + id: "actions", + label: "", + className: "w-10", + cell: ({ row }) => + canDownload || canDelete ? ( + + ) : null, + }, + ]} + customNoResults={{ + title: t("noResults.title"), + description: t("noResults.description"), + icon: , + }} + edges={data.edges} + id="files-table" + order={{ + columns: ["name", "size", "createdAt"], + defaultOrder: { + column: "createdAt", + order: "desc", + }, + }} + pageInfo={data.pageInfo} + search + /> + ); +}; diff --git a/packages/vitnode/src/views/files/actions/delete-action.ts b/packages/vitnode/src/views/files/actions/delete-action.ts new file mode 100644 index 000000000..2d81ea664 --- /dev/null +++ b/packages/vitnode/src/views/files/actions/delete-action.ts @@ -0,0 +1,30 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { userFilesModule } from "@/api/modules/users/files/files.module"; +import { fetcher } from "@/lib/fetcher"; + +export const deleteMyFileAction = async ({ + id, +}: { + id: number; +}): Promise<{ data?: true; error?: { status: number } }> => { + const res = await fetcher(userFilesModule, { + path: "/{id}", + method: "delete", + module: "files", + prefixPath: "/users", + args: { + params: { id: String(id) }, + }, + }); + + if (res.status !== 200) { + return { error: { status: res.status } }; + } + + revalidatePath("/[locale]/(main)", "layout"); + + return { data: true }; +}; diff --git a/packages/vitnode/src/views/files/actions/file-row-actions.tsx b/packages/vitnode/src/views/files/actions/file-row-actions.tsx new file mode 100644 index 000000000..4ad5f5eb3 --- /dev/null +++ b/packages/vitnode/src/views/files/actions/file-row-actions.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { DownloadIcon, LoaderCircleIcon, Trash2Icon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; + +import type { userFilesModule } from "@/api/modules/users/files/files.module"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { Button } from "@/components/ui/button"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { CONFIG_PLUGIN } from "@/config"; +import { clientModule, fetcherClient } from "@/lib/fetcher-client"; + +import { deleteMyFileAction } from "./delete-action"; + +export const MyFileRowActions = ({ + id, + name, +}: { + id: number; + name: string; +}) => { + const t = useTranslations("core.files"); + const tGlobal = useTranslations("core.global.errors"); + const [isDownloading, setIsDownloading] = React.useState(false); + + const handleDownload = async () => { + setIsDownloading(true); + try { + const res = await fetcherClient( + clientModule(CONFIG_PLUGIN.pluginId), + { + prefixPath: "/users", + module: "files", + path: "/{id}/download", + method: "get", + args: { params: { id: String(id) } }, + options: { credentials: "include" }, + }, + ); + if (!res.ok) throw new Error(await res.text()); + + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = name; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); + } catch { + toast.error(tGlobal("title"), { + description: t("download.error"), + }); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+ + + + + { + const result = await deleteMyFileAction({ id }); + if (result.error) { + toast.error(tGlobal("title"), { + description: tGlobal("internal_server_error"), + }); + + return; + } + + toast.success(t("delete.success")); + onClose(); + }} + textSubmit={t("delete.confirm")} + title={t("delete.title")} + > + + +
+ ); +}; diff --git a/packages/vitnode/src/views/files/my-files-table-view.tsx b/packages/vitnode/src/views/files/my-files-table-view.tsx new file mode 100644 index 000000000..1f8b01b32 --- /dev/null +++ b/packages/vitnode/src/views/files/my-files-table-view.tsx @@ -0,0 +1,125 @@ +import { FileIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +import { userFilesModule } from "@/api/modules/users/files/files.module"; +import { DateFormat } from "@/components/date-format"; +import { FilePreview } from "@/components/files/file-preview"; +import { MetadataCell } from "@/components/files/metadata-cell"; +import { + DataTable, + type SearchParamsDataTable, +} from "@/components/table/data-table"; +import { fetcher } from "@/lib/fetcher"; +import { formatBytes } from "@/lib/format-bytes"; + +import { MyFileRowActions } from "./actions/file-row-actions"; + +export const MyFilesTableView = async ({ + searchParams, +}: { + searchParams: Promise; +}) => { + const query = await searchParams; + const [t, res] = await Promise.all([ + getTranslations("core.files"), + fetcher(userFilesModule, { + path: "/", + method: "get", + module: "files", + prefixPath: "/users", + args: { query }, + withPagination: true, + }), + ]); + + if (res.status !== 200) { + return notFound(); + } + + const data = await res.json(); + + return ( + ( + + ), + }, + { + id: "name", + label: t("list.name"), + cell: ({ row }) => ( +
+ {row.name} +

+ {row.mimeType ?? row.folder} +

+
+ ), + }, + { + id: "size", + label: t("list.size"), + cell: ({ row }) => formatBytes(row.size), + }, + { + id: "dimensions", + label: t("list.dimensions"), + cell: ({ row }) => + row.dimensions ? ( + `${row.dimensions.width}x${row.dimensions.height}` + ) : ( + + ), + }, + { + id: "metadata", + label: t("list.metadata"), + cell: ({ row }) => ( + + ), + }, + { + id: "createdAt", + label: t("list.createdAt"), + cell: ({ row }) => , + }, + { + id: "actions", + label: "", + className: "w-10", + cell: ({ row }) => , + }, + ]} + customNoResults={{ + title: t("noResults.title"), + description: t("noResults.description"), + icon: , + }} + edges={data.edges} + id="my-files-table" + order={{ + columns: ["name", "size", "createdAt"], + defaultOrder: { + column: "createdAt", + order: "desc", + }, + }} + pageInfo={data.pageInfo} + search + /> + ); +}; diff --git a/packages/vitnode/src/views/layouts/theme/header/user/auth/client.tsx b/packages/vitnode/src/views/layouts/theme/header/user/auth/client.tsx index e87717a0a..f2cfcc40b 100644 --- a/packages/vitnode/src/views/layouts/theme/header/user/auth/client.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/user/auth/client.tsx @@ -1,6 +1,7 @@ "use client"; import { + FileIcon, KeyRoundIcon, LogOutIcon, Settings, @@ -35,6 +36,11 @@ export const ClientAuthUserHeader = ({ {t("my_profile")} + }> + + {t("files")} + + }> {t("settings")} diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index cd83739d4..18efc0220 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -86,9 +86,15 @@ export interface VitNodeApiConfig { * Re-encode uploaded images with `sharp` before storing them, to shrink file * size. Set to enable; `quality` defaults to 85 (1–100). Applies to JPEG, * PNG, WebP, AVIF and TIFF — other files (incl. SVG/GIF) are stored as-is. + * + * Processed images are also converted to WebP by default (smaller than JPEG + * or PNG at the same quality); set `webp: false` to keep each image in its + * original format. Their pixel dimensions are recorded in + * `core_files.metadata.dimensions` for display in the admin panel. */ image?: { quality?: number; + webp?: boolean; }; }; }