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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion scripts/init-buckets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
3 changes: 2 additions & 1 deletion scripts/sync-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/app/(dashboard)/explorer/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default async function ExplorerPage({ params }: ExplorerPageProps) {
const explorerPath = currentPath || defaultPath;

return (
<div className="h-[calc(100vh-8rem)] bg-card rounded-lg border shadow-sm">
<div className="h-[calc(100dvh-5.5rem)] overflow-hidden rounded-md border bg-card shadow-sm sm:h-[calc(100dvh-7rem)]">
<FileExplorer initialPath={explorerPath} />
</div>
);
Expand Down
22 changes: 14 additions & 8 deletions src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,18 +22,20 @@ export default async function DashboardLayout({
const displayName = user.username || user.email || "User";

return (
<div className="min-h-screen bg-background">
<div className="min-h-dvh bg-background">
{/* Header */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<div className="container flex h-14 items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-xl font-bold">🚀 S3-Browser</h1>
<div className="mx-auto flex min-h-14 w-full max-w-screen-2xl items-center justify-between gap-3 px-3 py-2 sm:px-4 lg:px-6">
<div className="flex min-w-0 items-center gap-4">
<h1 className="truncate text-base font-bold sm:text-xl">🚀 S3-Browser</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
<div className="flex shrink-0 items-center gap-3 sm:gap-4">
<span className="max-w-28 truncate text-sm text-muted-foreground sm:max-w-48">
{displayName}
</span>
<ThemeToggle />
<form
suppressHydrationWarning
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
Expand All @@ -42,15 +45,18 @@ export default async function DashboardLayout({
type="submit"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Sign out
<span className="hidden sm:inline">Sign out</span>
<span className="sm:hidden">Exit</span>
</button>
</form>
</div>
</div>
</header>

{/* Main content */}
<main className="container py-6">{children}</main>
<main className="mx-auto w-full max-w-screen-2xl px-2 py-3 sm:px-4 sm:py-5 lg:px-6">
{children}
</main>
</div>
);
}
137 changes: 137 additions & 0 deletions src/app/api/files/folders/route.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse>(
{ 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<ApiResponse<{ folders: FolderDestination[] }>>(
{
success: true,
data: { folders: uniqueFolders },
},
{ headers: noStoreHeaders }
);
} catch (error) {
console.error("[API] List folders error:", error);
return NextResponse.json<ApiResponse>(
{
success: false,
error: "Failed to list folders",
message: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
Loading