diff --git a/package-lock.json b/package-lock.json index 380933e..24fdaf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "s3-browser", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "s3-browser", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@auth/core": "^0.41.0", "@aws-sdk/client-s3": "^3.967.0", @@ -1049,6 +1049,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -4488,6 +4489,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4498,6 +4500,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4547,6 +4550,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -5046,6 +5050,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5404,6 +5409,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6068,6 +6074,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6253,6 +6260,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8501,6 +8509,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -8573,6 +8582,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8582,6 +8592,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9387,6 +9398,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9568,6 +9580,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9900,6 +9913,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/scripts/init-buckets.ts b/scripts/init-buckets.ts index c92e7d5..8fa2756 100644 --- a/scripts/init-buckets.ts +++ b/scripts/init-buckets.ts @@ -6,7 +6,8 @@ // Or: npm run init-buckets import { config } from "dotenv"; -config({ path: ".env.local" }); +config({ path: ".env" }); +config({ path: ".env.local", override: true }); import { initializeBuckets } from "../src/services/s3Service"; diff --git a/scripts/sync-permissions.ts b/scripts/sync-permissions.ts index 5757242..011f9c4 100644 --- a/scripts/sync-permissions.ts +++ b/scripts/sync-permissions.ts @@ -6,7 +6,8 @@ // Or: npm run sync-permissions import { config } from "dotenv"; -config({ path: ".env.local" }); +config({ path: ".env" }); +config({ path: ".env.local", override: true }); import { syncPermissions, validatePermissionsFile } from "../src/services/syncService"; import { disconnectRedis } from "../src/lib/redis"; diff --git a/src/app/(dashboard)/explorer/[[...path]]/page.tsx b/src/app/(dashboard)/explorer/[[...path]]/page.tsx index 7fd8c95..1e8de88 100644 --- a/src/app/(dashboard)/explorer/[[...path]]/page.tsx +++ b/src/app/(dashboard)/explorer/[[...path]]/page.tsx @@ -27,7 +27,7 @@ export default async function ExplorerPage({ params }: ExplorerPageProps) { const explorerPath = currentPath || defaultPath; return ( -
+
); diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index f4c7ce6..9253490 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { auth, signOut } from "@/lib/auth"; +import { ThemeToggle } from "@/components/theme/ThemeToggle"; export default async function DashboardLayout({ children, @@ -21,18 +22,20 @@ export default async function DashboardLayout({ const displayName = user.username || user.email || "User"; return ( -
+
{/* Header */}
-
-
-

🚀 S3-Browser

+
+
+

🚀 S3-Browser

-
- +
+ {displayName} +
{ "use server"; await signOut({ redirectTo: "/" }); @@ -42,7 +45,8 @@ export default async function DashboardLayout({ type="submit" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Sign out + Sign out + Exit
@@ -50,7 +54,9 @@ export default async function DashboardLayout({
{/* Main content */} -
{children}
+
+ {children} +
); } diff --git a/src/app/api/files/folders/route.ts b/src/app/api/files/folders/route.ts new file mode 100644 index 0000000..4cb6403 --- /dev/null +++ b/src/app/api/files/folders/route.ts @@ -0,0 +1,137 @@ +// ================================ +// API Route: List Move Destinations +// ================================ + +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { listFiles } from "@/services/s3Service"; +import { checkPermission } from "@/services/permissionService"; +import type { ApiResponse } from "@/types"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const noStoreHeaders = { + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", +}; + +interface FolderDestination { + path: string; + name: string; + depth: number; +} + +function getName(path: string): string { + return path.split("/").filter(Boolean).pop() || path; +} + +function isValidDestination( + folderPath: string, + sourcePath: string | null, + sourceType: string | null +): boolean { + if (!sourcePath) return true; + + const normalizedSource = sourcePath.replace(/^\/+|\/+$/g, ""); + const sourceParent = normalizedSource.split("/").slice(0, -1).join("/"); + + if (folderPath === sourceParent) return false; + if (folderPath === normalizedSource) return false; + + if ( + sourceType === "folder" && + folderPath.startsWith(`${normalizedSource}/`) + ) { + return false; + } + + return true; +} + +async function canWriteToPath( + user: { email: string; username: string; roles: string[] }, + path: string +) { + const result = await checkPermission({ + userId: user.email, + username: user.username, + roles: user.roles, + path, + requiredLevel: "EDITOR", + }); + + return result.allowed; +} + +async function collectFolders( + path: string, + user: { email: string; username: string; roles: string[] }, + folders: FolderDestination[], + depth = 0, + maxDepth = 8 +) { + if (await canWriteToPath(user, path)) { + folders.push({ + path, + name: path === "home" ? "home" : getName(path), + depth, + }); + } + + if (depth >= maxDepth) return; + + const listing = await listFiles(path, { maxItems: 1000 }); + const childFolders = listing.items.filter((item) => item.type === "folder"); + + for (const folder of childFolders) { + await collectFolders(folder.key, user, folders, depth + 1, maxDepth); + } +} + +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const user = session.user as { + id: string; + email: string; + username: string; + roles: string[]; + }; + + const sourcePath = request.nextUrl.searchParams.get("sourcePath"); + const sourceType = request.nextUrl.searchParams.get("sourceType"); + const folders: FolderDestination[] = []; + await collectFolders("home", user, folders); + + const uniqueFolders = Array.from( + new Map(folders.map((folder) => [folder.path, folder])).values() + ) + .filter((folder) => isValidDestination(folder.path, sourcePath, sourceType)) + .sort((a, b) => a.path.localeCompare(b.path)); + + return NextResponse.json>( + { + success: true, + data: { folders: uniqueFolders }, + }, + { headers: noStoreHeaders } + ); + } catch (error) { + console.error("[API] List folders error:", error); + return NextResponse.json( + { + success: false, + error: "Failed to list folders", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/files/list/route.ts b/src/app/api/files/list/route.ts index d359f6b..dac9fef 100644 --- a/src/app/api/files/list/route.ts +++ b/src/app/api/files/list/route.ts @@ -4,11 +4,18 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { listFiles } from "@/services/s3Service"; +import { listFiles, pathExists } from "@/services/s3Service"; import { checkPermission, getPermissionLevel } from "@/services/permissionService"; import { audit } from "@/services/auditService"; import type { ApiResponse, PaginatedResponse, FileItem } from "@/types"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const noStoreHeaders = { + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", +}; + export async function GET(request: NextRequest) { try { // Authenticate user @@ -53,6 +60,17 @@ export async function GET(request: NextRequest) { ); } + if (path && !(await pathExists(path))) { + return NextResponse.json( + { + success: false, + error: "Folder not found", + message: "This folder does not exist or has been moved.", + }, + { status: 404, headers: noStoreHeaders } + ); + } + // List files const result = await listFiles(path, { maxItems, continuationToken }); @@ -72,14 +90,17 @@ export async function GET(request: NextRequest) { // Log audit await audit.list(user.id, user.username, path); - return NextResponse.json>>({ - success: true, - data: { - items: enrichedItems, - nextContinuationToken: result.nextContinuationToken, - hasMore: result.hasMore, + return NextResponse.json>>( + { + success: true, + data: { + items: enrichedItems, + nextContinuationToken: result.nextContinuationToken, + hasMore: result.hasMore, + }, }, - }); + { headers: noStoreHeaders } + ); } catch (error) { console.error("[API] List files error:", error); return NextResponse.json( diff --git a/src/app/api/files/move/route.ts b/src/app/api/files/move/route.ts new file mode 100644 index 0000000..3068b67 --- /dev/null +++ b/src/app/api/files/move/route.ts @@ -0,0 +1,142 @@ +// ================================ +// API Route: Move File/Folder +// ================================ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { resolvePath } from "@/lib/s3-client"; +import { moveObject } from "@/services/s3Service"; +import { checkPermission } from "@/services/permissionService"; +import { audit } from "@/services/auditService"; +import { invalidatePathListingCache } from "@/services/cacheService"; +import type { ApiResponse } from "@/types"; + +const moveSchema = z.object({ + sourcePath: z.string().min(1, "Source path is required"), + destinationPath: z.string().min(1, "Destination path is required"), + sourceType: z.enum(["file", "folder"]).default("file"), +}); + +function getParentKey(path: string): { bucket: string; key: string } { + const { bucket, key } = resolvePath(path); + return { + bucket, + key: key.split("/").slice(0, -1).join("/"), + }; +} + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const user = session.user as { + id: string; + email: string; + username: string; + roles: string[]; + }; + + const body = await request.json(); + const validation = moveSchema.safeParse(body); + + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: "Invalid request", + message: validation.error.issues[0].message, + }, + { status: 400 } + ); + } + + const { sourcePath, destinationPath, sourceType } = validation.data; + const normalizedDestination = destinationPath.replace(/^\/+|\/+$/g, ""); + + if (!normalizedDestination) { + return NextResponse.json( + { success: false, error: "Destination path is required" }, + { status: 400 } + ); + } + + const sourcePermission = await checkPermission({ + userId: user.email, + username: user.username, + roles: user.roles, + path: sourcePath, + requiredLevel: "EDITOR", + }); + + if (!sourcePermission.allowed) { + await audit.error(user.id, user.username, "MOVE", sourcePath, "Access denied"); + return NextResponse.json( + { + success: false, + error: "Access denied", + message: sourcePermission.denyReason, + }, + { status: 403 } + ); + } + + const destinationPermission = await checkPermission({ + userId: user.email, + username: user.username, + roles: user.roles, + path: normalizedDestination, + requiredLevel: "EDITOR", + }); + + if (!destinationPermission.allowed) { + await audit.error( + user.id, + user.username, + "MOVE", + normalizedDestination, + "Access denied" + ); + return NextResponse.json( + { + success: false, + error: "Access denied", + message: destinationPermission.denyReason, + }, + { status: 403 } + ); + } + + const newPath = await moveObject(sourcePath, normalizedDestination, sourceType); + + const sourceParent = getParentKey(sourcePath); + await invalidatePathListingCache(sourceParent.bucket, sourceParent.key); + + const destination = resolvePath(normalizedDestination); + await invalidatePathListingCache(destination.bucket, destination.key); + + await audit.move(user.id, user.username, sourcePath, newPath); + + return NextResponse.json>({ + success: true, + data: { oldPath: sourcePath, newPath }, + message: "Moved successfully", + }); + } catch (error) { + console.error("[API] Move error:", error); + return NextResponse.json( + { + success: false, + error: "Failed to move", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/files/upload-url/route.ts b/src/app/api/files/upload-url/route.ts index ffd8a2e..d236754 100644 --- a/src/app/api/files/upload-url/route.ts +++ b/src/app/api/files/upload-url/route.ts @@ -125,10 +125,10 @@ export async function POST(request: NextRequest) { contentType, expiresIn: 3600, metadata: { - "x-amz-meta-owner-id": user.id, - "x-amz-meta-owner-username": user.username, - "x-amz-meta-created-at": new Date().toISOString(), - "x-amz-meta-original-name": fileName, + "owner-id": user.id, + "owner-username": user.username, + "created-at": new Date().toISOString(), + "original-name": fileName, }, }); diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts new file mode 100644 index 0000000..31d5e8b --- /dev/null +++ b/src/app/api/files/upload/route.ts @@ -0,0 +1,145 @@ +// ================================ +// API Route: Server-side Upload +// ================================ + +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { + getMaxFileSize, + isAllowedFileType, + validateFileName, +} from "@/lib/s3-client"; +import { uploadObject } from "@/services/s3Service"; +import { checkPermission } from "@/services/permissionService"; +import { audit } from "@/services/auditService"; +import type { ApiResponse } from "@/types"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +function buildFinalPath(path: string, fileName: string) { + const normalizedPath = path.replace(/\/+$/, ""); + return normalizedPath ? `${normalizedPath}/${fileName}` : fileName; +} + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const user = session.user as { + id: string; + email: string; + username: string; + roles: string[]; + }; + + const formData = await request.formData(); + const file = formData.get("file"); + const fileName = String(formData.get("fileName") || "").trim(); + const path = String(formData.get("path") || "").trim(); + + if (!(file instanceof File)) { + return NextResponse.json( + { success: false, error: "File is required" }, + { status: 400 } + ); + } + + if (!fileName) { + return NextResponse.json( + { success: false, error: "File name is required" }, + { status: 400 } + ); + } + + const fileNameError = validateFileName(fileName); + if (fileNameError) { + return NextResponse.json( + { success: false, error: fileNameError }, + { status: 400 } + ); + } + + if (!isAllowedFileType(fileName)) { + return NextResponse.json( + { success: false, error: "File type not allowed" }, + { status: 400 } + ); + } + + const maxSize = getMaxFileSize(); + if (file.size > maxSize) { + return NextResponse.json( + { + success: false, + error: `File size exceeds maximum allowed (${Math.round(maxSize / 1024 / 1024)}MB)`, + }, + { status: 400 } + ); + } + + const finalPath = buildFinalPath(path, fileName); + const permissionResult = await checkPermission({ + userId: user.email, + username: user.username, + roles: user.roles, + path: finalPath, + requiredLevel: "EDITOR", + }); + + if (!permissionResult.allowed) { + await audit.error( + user.id, + user.username, + "UPLOAD", + finalPath, + "Access denied" + ); + + return NextResponse.json( + { + success: false, + error: "Access denied", + message: permissionResult.denyReason, + }, + { status: 403 } + ); + } + + const body = new Uint8Array(await file.arrayBuffer()); + await uploadObject(finalPath, { + body, + contentType: file.type || "application/octet-stream", + metadata: { + "owner-id": user.id, + "owner-username": user.username, + "created-at": new Date().toISOString(), + "original-name": file.name, + }, + }); + + await audit.upload(user.id, user.username, finalPath); + + return NextResponse.json>({ + success: true, + data: { path: finalPath }, + message: "File uploaded successfully", + }); + } catch (error) { + console.error("[API] Upload error:", error); + return NextResponse.json( + { + success: false, + error: "Failed to upload file", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 1685ea7..f649394 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ @import "tailwindcss"; :root { + color-scheme: light; --background: 0 0% 100%; --foreground: 0 0% 3.9%; --card: 0 0% 100%; @@ -47,8 +48,36 @@ --font-mono: var(--font-geist-mono); } +:root[data-theme="dark"] { + color-scheme: dark; + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; +} + +:root[data-theme="light"] { + color-scheme: light; +} + @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { + color-scheme: dark; --background: 0 0% 3.9%; --foreground: 0 0% 98%; --card: 0 0% 3.9%; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..e6dfb36 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,8 +22,20 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const themeScript = ` + try { + var theme = localStorage.getItem("s3-browser-theme"); + if (theme === "light" || theme === "dark") { + document.documentElement.dataset.theme = theme; + } + } catch (_) {} + `; + return ( - + + +