diff --git a/.claude/settings.json b/.claude/settings.json index 71bad7d..959ae08 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Bash(ls /home/raseel/code/s3-browser/graphify-out/ 2>/dev/null || mkdir -p /home/raseel/code/s3-browser/graphify-out)" + "Bash(ls /home/raseel/code/s3-browser/graphify-out/ 2>/dev/null || mkdir -p /home/raseel/code/s3-browser/graphify-out)", + "WebFetch(domain:www.avanse.com)", + "WebSearch", + "Bash(curl -sL --max-time 25 -A \"Mozilla/5.0\" https://www.avanse.com/ -o avanse.html)" ], "additionalDirectories": [ "/home/raseel/code/s3-browser/graphify-out" diff --git a/.env.example b/.env.example index 91a5b48..d9c5608 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,12 @@ ENCRYPTION_KEY="your-encryption-key-here-change-this" # LOGO_S3_REGION="" # LOGO_AWS_ACCESS_KEY_ID="" # LOGO_AWS_SECRET_ACCESS_KEY="" + +# Optional: Malware scanning of uploads via ClamAV (clamd daemon). +# When disabled or unreachable, uploads proceed but are flagged "unscanned" (fail-open). +# Requires clamd, e.g. `apt install clamav clamav-daemon` on the host. +# CLAMAV_ENABLED="false" +# Use a Unix socket (Debian/Ubuntu clamav-daemon default) OR host+port: +# CLAMAV_SOCKET="/var/run/clamav/clamd.ctl" +# CLAMAV_HOST="127.0.0.1" +# CLAMAV_PORT="3310" diff --git a/.gitignore b/.gitignore index 4c3b2b9..f04baf5 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,10 @@ backup_*.sql # firebase firebase-debug.log firestore-debug.loglogs/ + +# Playwright +test-results/ +playwright-report/ + +# public +/public diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a5cd0c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +S3 Navigator is a self-hosted, database-backed S3 bucket browser built with Next.js 15 (App Router) + PostgreSQL. Users browse/upload/download files across multiple S3-compatible buckets. All AWS SDK calls run server-side (Server Actions) — credentials are never sent to the browser. + +> Note: The README's "Architecture" and "Limitations" sections describe an older **localStorage** design. The current codebase is **PostgreSQL-backed** (users, sessions, encrypted bucket credentials, audit logs). Trust the code in `src/lib/` and `scripts/schema.sql` over the README when they conflict. + +## Commands + +```bash +npm run dev # dev server on http://localhost:5000 (port 5000, not 3000) +npm run build # production build +npm run start # start production build (README uses: pm2 start npm --name s3-browser -- run start) +npm run lint # next lint +npm run typecheck # tsc --noEmit + +npm run generate-keys # generate ENCRYPTION_KEY / secrets for .env + +# Database (wraps docker-compose.db.yml: Postgres 16 + pgAdmin) +./db.sh setup # start db + run migrations + seed (first-time setup) +./db.sh start|stop|status|logs|backup|restore|reset +npm run db:migrate # scripts/migrate.js (applies scripts/schema.sql) +npm run db:migrate:assignments # bucket_assignments migration +npm run db:seed # seed default admin (admin/admin) +npm run db:reset # drop + recreate + +# E2E tests (Playwright, Chromium, serial — specs share DB state) +npx playwright test # all specs (auto-starts dev server on :5000) +npx playwright test e2e/bucket-management.spec.ts # single spec +npx playwright test -g "test name" # single test by title +``` + +There is no unit-test runner configured — only Playwright e2e tests in `e2e/`. + +## Environment + +Required in `.env` (see `.env.example`): `DATABASE_URL`, `ENCRYPTION_KEY` (base64, exactly 32 bytes decoded), `NEXTAUTH_SECRET`. Losing `ENCRYPTION_KEY` makes stored AWS credentials unrecoverable. Optional `LOGO_S3_*` vars enable S3-hosted app logo. + +Ensure every new code changes, like a Bug fix or a new feature implementation, is done on a new branch which is forked from `master` + +## Architecture + +**Auth & session flow** (single source of truth is the DB, not the client): +- `src/middleware.ts` — only checks that a `session_token` cookie *exists*; it does NOT validate. Public routes: `/login`, `/api/auth/login`. Auth-check routes (bypass validation): `/api/auth/session`, `/api/auth/change-password`, `/change-password`. Real validation happens downstream. +- `src/lib/auth.ts` — bcrypt hashing, `authenticate()`, `validateSession()`, sessions table (24h expiry). Enforces `must_change_password`. +- `src/lib/session.ts` — server-side helpers `getCurrentUser()` (redirects to `/login`) and `getCurrentUserOptional()` (returns null). Use these in Server Components and API routes to get the authenticated user. +- `src/context/AuthContext.tsx` — client-side auth state, hydrated from `/api/auth/session`. Client permission gating via `src/hooks/use-permission.ts`. **Client permission checks are UX-only; always re-check authorization server-side.** + +**RBAC roles** (ascending): `viewer` → `uploader` → `bucket-creator` → `admin`. +- `canDownload`: everyone. `canUpload`: uploader+. `canCreateBucket`: bucket-creator+. `canManageUsers`: admin only. + +**S3 operations** — `src/actions/s3.ts` (`'use server'`). All list/download/upload go through Server Actions using `@aws-sdk/client-s3`. Downloads (single/multi/folder-ZIP via `jszip`) are buffered in server memory and returned base64 — large files can time out. Supports optional AWS session tokens (STS/SSO temporary creds). Credentials come decrypted from the DB, never from the client. + +**Credential encryption** — `src/lib/encryption.ts`, AES-256-GCM. Bucket AWS keys are encrypted at rest; `src/lib/buckets.ts` encrypts on write / decrypts on read. + +**Bucket access model** — a bucket is owned by its creator (`buckets.user_id`) but can be shared with other users via `bucket_assignments` (with a `permission` level). API responses populate `owner_username` / `is_owned` / `permission` on `Bucket`. Context: `src/context/BucketContext.tsx` and `BucketAssignmentContext.tsx`. + +**Audit logging** — `src/lib/audit.ts` + `src/actions/audit-record.ts`. All significant operations (auth, bucket CRUD, S3 access) write to `audit_logs`. Viewable at `/admin/audit`. + +**Data layer** — `src/lib/db.ts` exposes `query()` and `transaction()` over a shared `pg` Pool. Domain modules `src/lib/{users,buckets,audit,auth}.ts` wrap it. Schema in `scripts/schema.sql` (tables: `users`, `buckets`, `bucket_assignments`, `audit_logs`, `app_settings`, `sessions`). + +**Routing** — App Router. Pages under `src/app/*` (e.g. `buckets/[id]`, `admin/audit`, `users`, `bucket-assignments`). REST endpoints under `src/app/api/**/route.ts`. + +**AI** — `src/ai/` uses Genkit (`@genkit-ai/googleai`); run with `npm run genkit:dev`. Peripheral to the core app. + +## Conventions + +- Path alias `@/*` → `./src/*`. +- UI: Tailwind + shadcn/ui components live in `src/components/ui/` (`components.json` config). `lucide-react` icons. +- Forms: `react-hook-form` + `zod` (with `@hookform/resolvers`). Zod also validates Server Action inputs (see `S3ConfigSchema` in `src/actions/s3.ts`). +- App version is read from the `VERSION` file at runtime in `src/app/layout.tsx`. +- File upload cap: 100MB per file. diff --git a/QUICKSTART.md b/QUICKSTART.md index 640a66f..202217b 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -41,6 +41,11 @@ Wait for: "🎉 Database setup completed!" ```bash npm run dev ``` +Or if you want to build +```bash +npm run build +pm2 start npm --name "s3-browser" -- run start +``` ### 6️⃣ Login Open http://localhost:5000 diff --git a/README.md b/README.md index 74bbd55..bdedd27 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ cp .env.example .env # 5. Start application npm run dev ``` +Or if you want to Build and run +```bash +npm run build +pm2 start npm --name "s3-browser" -- run start +``` Open [http://localhost:5000](http://localhost:5000) @@ -136,6 +141,38 @@ Session tokens are required when using temporary credentials from: All three fields (Access Key, Secret Key, Session Token) must be filled in together for temporary credentials to work. +### Per-Bucket Upload Size Limit + +Each bucket has a configurable maximum file size (default **10MB**). Only **admins** can change it via the bucket create/edit form ("Max Upload Size (MB)"). The limit is capped at 50MB to stay within the Next.js Server Action `bodySizeLimit`. It is enforced both client-side (UX) and server-side (authoritative) in `uploadObject`. + +### Malware Scanning (ClamAV) + +Uploads can be scanned for malware **before** they reach S3 using a ClamAV `clamd` daemon. + +- **Infected files are rejected** and never uploaded (audit action `file.upload.blocked`). +- **Fail-open:** if the scanner is disabled or unreachable, the upload still proceeds, a warning is logged, an audit entry records `scan_status: unscanned`, and the file is flagged in the browser with an amber ⚠ "not scanned" indicator. + +Setup on the host (PM2/EC2 deploy): + +```bash +sudo apt install clamav clamav-daemon +sudo freshclam # update signature database +sudo systemctl enable --now clamav-daemon +``` + +Then set in `.env`: + +```bash +CLAMAV_ENABLED="true" +# Debian/Ubuntu clamav-daemon uses a Unix socket by default: +CLAMAV_SOCKET="/var/run/clamav/clamd.ctl" +# ...or TCP if you configure clamd for it: +# CLAMAV_HOST="127.0.0.1" +# CLAMAV_PORT="3310" +``` + +> **Docker note:** the Alpine app image does not include ClamAV. Run a ClamAV sidecar container and point `CLAMAV_HOST`/`CLAMAV_PORT` (or a shared socket) at it. Leaving `CLAMAV_ENABLED` unset means all uploads are treated as unscanned (fail-open). + ## Architecture All S3 operations run exclusively as **Next.js Server Actions** — no AWS SDK code runs in the browser. This eliminates: @@ -155,7 +192,7 @@ Bucket configurations (names, credentials) are stored in **browser localStorage* ## Limitations -- **File upload limit:** 100MB per file +- **File upload limit:** configurable per bucket (default 10MB, max 50MB) - **Large file downloads:** Files are buffered in server memory before streaming to the client. Very large files (>500MB) may be slow or time out. - **No persistence:** Bucket configs are stored in localStorage — clearing browser data removes them. - **Authentication:** Basic username/password stored in memory (not production-grade auth). diff --git a/VERSION b/VERSION index 60fe1f2..a85e614 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2 +v0.3 diff --git a/e2e/bucket-management.spec.ts b/e2e/bucket-management.spec.ts new file mode 100644 index 0000000..d10d20e --- /dev/null +++ b/e2e/bucket-management.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from '@playwright/test'; +import { login, logout } from './helpers'; +import { ADMIN, VIEWER, TEST_BUCKET, TEST_BUCKET_ALIAS } from './fixtures'; +import { + getBucketRowsByAlias, + getUserByUsername, + getAssignment, + closePool, +} from './db'; + +/** + * End-to-end: admin creates a bucket, assigns it to a non-admin (viewer), + * and the viewer can see the assigned bucket. + * + * Each step asserts BOTH the UI and the database row, so the suite directly + * covers the two reported production bugs: + * 1. deleting a bucket didn't remove it from the DB -> confirmed as an + * intentional soft-delete (is_active=false); asserted in the last test. + * 2. creating a bucket didn't persist to the DB (EC2) -> asserted by reading + * the `buckets` table right after the UI create. + * + * Serial: the tests share one bucket row and must run in order. + */ +test.describe.serial('Bucket create / assign / visibility', () => { + test.afterAll(async () => { + await closePool(); + }); + + test('admin can create a bucket and it persists in the database', async ({ page }) => { + await login(page, ADMIN); + + await page.getByRole('button', { name: 'Add S3 Bucket' }).first().click(); + + // Fill the credentials form (placeholders are unique per field). + await page.getByPlaceholder('My Work Bucket').fill(TEST_BUCKET.alias); + await page.getByPlaceholder('my-awesome-bucket').fill(TEST_BUCKET.bucketName); + await page.getByPlaceholder('us-east-1').fill(TEST_BUCKET.region); + await page.getByPlaceholder('AKIA...').fill(TEST_BUCKET.accessKeyId); + await page.getByPlaceholder('Your secret key').fill(TEST_BUCKET.secretAccessKey); + + await page.getByRole('button', { name: 'Add Bucket' }).click(); + + // UI: the new bucket appears in the list. + await expect(page.getByText(TEST_BUCKET_ALIAS, { exact: true })).toBeVisible({ timeout: 15000 }); + + // DB: exactly one active row was created with the right owner. + await expect + .poll(async () => (await getBucketRowsByAlias(TEST_BUCKET_ALIAS)).length, { timeout: 10000 }) + .toBe(1); + + const [row] = await getBucketRowsByAlias(TEST_BUCKET_ALIAS); + expect(row.is_active).toBe(true); + expect(row.bucket_name).toBe(TEST_BUCKET.bucketName); + expect(row.region).toBe(TEST_BUCKET.region); + + const adminUser = await getUserByUsername(ADMIN.username); + expect(row.user_id).toBe(adminUser!.id); + }); + + test('admin can assign the bucket to a non-admin user', async ({ page }) => { + await login(page, ADMIN); + await page.goto('/bucket-assignments'); + + // Select the bucket. + await page.getByRole('combobox').filter({ hasText: 'Choose bucket' }).click(); + await page.getByRole('option', { name: TEST_BUCKET_ALIAS }).click(); + + // Select the viewer user. + await page.getByRole('combobox').filter({ hasText: 'Choose user' }).click(); + await page.getByRole('option', { name: new RegExp(VIEWER.username) }).click(); + + await page.getByRole('button', { name: 'Assign User' }).click(); + + // UI: the viewer now shows up under the bucket's current assignments. + await expect(page.getByText(VIEWER.username).first()).toBeVisible({ timeout: 15000 }); + + // DB: the assignment row exists. + const [bucket] = await getBucketRowsByAlias(TEST_BUCKET_ALIAS); + const viewer = await getUserByUsername(VIEWER.username); + await expect + .poll(async () => !!(await getAssignment(bucket.id, viewer!.id)), { timeout: 10000 }) + .toBe(true); + }); + + test('non-admin user can see the bucket assigned to them', async ({ page }) => { + await logout(page); + await login(page, VIEWER); + + // Substring match (not exact): the viewer's card title also contains an + // "R/W" permission badge alongside the alias. + const bucket = page.getByText(TEST_BUCKET_ALIAS).first(); + + // No reload workaround: the page now gates on AuthContext + bucket loading + // state (single source of truth), so the assigned bucket must appear on + // first load. + await expect(bucket).toBeVisible({ timeout: 15000 }); + // ...and marked as shared (not owned by the viewer). + await expect(page.getByText(/Shared by/i).first()).toBeVisible(); + }); + + test('deleting a bucket as admin is a soft delete (row stays, is_active=false)', async ({ page }) => { + await login(page, ADMIN); + + await expect(page.getByText(TEST_BUCKET_ALIAS, { exact: true })).toBeVisible({ timeout: 15000 }); + + // The delete (trash) button is an icon-only button with the destructive + // class inside the bucket's card — target it via its lucide-trash icon. + await page.locator('button.text-destructive:has(svg.lucide-trash)').first().click(); + // Confirm in the AlertDialog. + await page.getByRole('button', { name: 'Delete' }).click(); + + // UI: the bucket disappears from the admin's list. + await expect(page.getByText(TEST_BUCKET_ALIAS, { exact: true })).toHaveCount(0, { timeout: 15000 }); + + // DB: the row is NOT hard-deleted — it remains with is_active = false. + // This documents the reported "not deleted from DB" behaviour as intended. + const rows = await getBucketRowsByAlias(TEST_BUCKET_ALIAS); + expect(rows.length).toBe(1); + expect(rows[0].is_active).toBe(false); + }); +}); diff --git a/e2e/db.ts b/e2e/db.ts new file mode 100644 index 0000000..01f5daf --- /dev/null +++ b/e2e/db.ts @@ -0,0 +1,64 @@ +/** + * Test DB helper — connects to the same PostgreSQL the app uses so specs can + * assert what actually landed in the `buckets` / `bucket_assignments` tables. + * + * This is what makes these tests catch the reported bugs: + * - "bucket not deleted from DB" -> we assert is_active flips (soft delete) + * - "bucket not created on EC2" -> we assert a row exists after create + */ +import { Pool } from 'pg'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +function loadDatabaseUrl(): string { + if (process.env.DATABASE_URL) return process.env.DATABASE_URL; + // Fall back to parsing .env (tests may run without Next's env loading). + try { + const envFile = readFileSync(join(process.cwd(), '.env'), 'utf8'); + for (const line of envFile.split('\n')) { + const m = line.match(/^\s*DATABASE_URL\s*=\s*"?([^"\n]+)"?\s*$/); + if (m) return m[1]; + } + } catch { + /* ignore */ + } + throw new Error('DATABASE_URL not found in env or .env'); +} + +export const pool = new Pool({ connectionString: loadDatabaseUrl() }); + +export async function closePool() { + await pool.end(); +} + +/** All rows for a bucket alias, regardless of is_active (so we can see soft-deletes). */ +export async function getBucketRowsByAlias(alias: string) { + const { rows } = await pool.query( + 'SELECT id, alias, bucket_name, region, user_id, is_active FROM buckets WHERE alias = $1 ORDER BY id', + [alias] + ); + return rows as Array<{ + id: number; + alias: string; + bucket_name: string; + region: string; + user_id: number; + is_active: boolean; + }>; +} + +export async function getUserByUsername(username: string) { + const { rows } = await pool.query( + 'SELECT id, username, role, is_active FROM users WHERE username = $1', + [username] + ); + return rows[0] as { id: number; username: string; role: string; is_active: boolean } | undefined; +} + +export async function getAssignment(bucketId: number, userId: number) { + const { rows } = await pool.query( + 'SELECT id, bucket_id, user_id, permission FROM bucket_assignments WHERE bucket_id = $1 AND user_id = $2', + [bucketId, userId] + ); + return rows[0] as { id: number; bucket_id: number; user_id: number; permission: string } | undefined; +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..661a276 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,23 @@ +/** Shared test fixtures / constants. */ + +export const ADMIN = { + username: 'admin', + password: 'AdminPass123', +}; + +export const VIEWER = { + username: 'e2e_viewer', + password: 'ViewerPass123', +}; + +export const TEST_BUCKET_ALIAS = 'E2E Test Bucket'; + +export const TEST_BUCKET = { + alias: TEST_BUCKET_ALIAS, + bucketName: 'e2e-test-bucket', + region: 'us-east-1', + // Dummy credentials — we never actually connect to S3; we only verify + // the config is persisted and visible in the UI / DB. + accessKeyId: 'AKIAE2ETESTKEY000000', + secretAccessKey: 'e2eSecretKeyDoNotUse0000000000000000000000', +}; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..649b622 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,43 @@ +/** + * Global setup: provision deterministic test accounts so specs can log in + * without going through the one-time forced-password-change flow (that flow + * is not what these tests exercise). + * + * - admin : reset to a known password, must_change_password = false + * - E2E_VIEWER : created/reset as a `viewer` (non-admin), password known, + * must_change_password = false + * + * It also clears any leftover test buckets/assignments from prior runs so the + * suite is idempotent. + */ +import bcrypt from 'bcrypt'; +import { pool, closePool } from './db'; +import { ADMIN, VIEWER, TEST_BUCKET_ALIAS } from './fixtures'; + +const SALT_ROUNDS = 10; // matches src/lib/auth.ts + +async function upsertUser(username: string, password: string, role: string) { + const hash = await bcrypt.hash(password, SALT_ROUNDS); + await pool.query( + `INSERT INTO users (username, password_hash, role, is_active, must_change_password) + VALUES ($1, $2, $3, true, false) + ON CONFLICT (username) + DO UPDATE SET password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + is_active = true, + must_change_password = false`, + [username, hash, role] + ); +} + +async function globalSetup() { + // Clean up test buckets (and their assignments cascade) from previous runs. + await pool.query('DELETE FROM buckets WHERE alias = $1', [TEST_BUCKET_ALIAS]); + + await upsertUser(ADMIN.username, ADMIN.password, 'admin'); + await upsertUser(VIEWER.username, VIEWER.password, 'viewer'); + + await closePool(); +} + +export default globalSetup; diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..b1e2828 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,18 @@ +import { expect, type Page } from '@playwright/test'; + +/** Log in through the real login form and wait for the dashboard. */ +export async function login(page: Page, user: { username: string; password: string }) { + await page.goto('/login'); + await page.getByPlaceholder('Enter username').fill(user.username); + await page.getByPlaceholder('Enter password').fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // login page redirects via window.location after a ~1s toast delay + await page.waitForURL('**/', { timeout: 15000 }); + await expect(page.getByRole('heading', { name: 'Bucket List' })).toBeVisible({ timeout: 15000 }); +} + +/** Log out by clearing the session cookie (HttpOnly — cleared at context level). */ +export async function logout(page: Page) { + await page.context().clearCookies(); +} diff --git a/next.config.ts b/next.config.ts index f22a66d..9312683 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,11 @@ import type {NextConfig} from 'next'; const nextConfig: NextConfig = { + experimental: { + serverActions: { + bodySizeLimit: '50mb', // Acceptable formats: '10mb', '500kb', or bytes as a number + }, + }, /* config options here */ output: 'standalone', typescript: { @@ -29,6 +34,7 @@ const nextConfig: NextConfig = { 'pg', 'pg-native', 'mock-aws-s3', + 'clamscan', ], images: { remotePatterns: [ @@ -60,6 +66,7 @@ const nextConfig: NextConfig = { }, ] }, + }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index bd02c87..fcbb8a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "bcrypt": "^5.1.1", + "clamscan": "^2.4.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -57,7 +58,9 @@ "zod": "^3.24.2" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/bcrypt": "^5.0.2", + "@types/clamscan": "^2.4.1", "@types/jszip": "^3.4.1", "@types/node": "^20", "@types/pg": "^8.11.10", @@ -3442,6 +3445,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -5717,6 +5736,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/clamscan": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.4.1.tgz", + "integrity": "sha512-KhBHhXsMGDoEkk87VRtHtDsjoqXD3epu+a09c1sjW7xqpvoihScxhZNdNIPegVCLDvPOp4khiIpy02XabseztQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -6519,6 +6548,15 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, + "node_modules/clamscan": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.4.0.tgz", + "integrity": "sha512-XBOxUiGOcQGuKmCn5qaM5rIK153fGCwsvJMbjVtcnNJ+j/YHrSj2gKNjyP65yr/E8JsKTTDtKYFG++p7Lzigyw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -10090,6 +10128,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", diff --git a/package.json b/package.json index 207a4dc..59552b6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "lint": "next lint", "typecheck": "tsc --noEmit", "db:migrate": "node scripts/migrate.js", + "db:migrate:assignments": "node scripts/migrate-bucket-assignments.js", + "db:migrate:upload-controls": "node scripts/migrate-upload-controls.js", "db:seed": "node scripts/seed.js", "db:reset": "node scripts/reset.js", "generate-keys": "node scripts/generate-keys.js" @@ -44,6 +46,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "bcrypt": "^5.1.1", + "clamscan": "^2.4.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -66,7 +69,9 @@ "zod": "^3.24.2" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/bcrypt": "^5.0.2", + "@types/clamscan": "^2.4.1", "@types/jszip": "^3.4.1", "@types/node": "^20", "@types/pg": "^8.11.10", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b88d7c0 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright config for S3 Browser e2e tests. + * + * - Uses the already-installed Chromium (no Google Chrome channel required). + * - Reuses a dev server on :5000 if one is already running, otherwise starts one. + * - Serial (1 worker) because the specs share database state. + */ +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/global-setup.ts', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['list']], + timeout: 60_000, + use: { + baseURL: 'http://localhost:5000', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], channel: undefined }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5000', + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/public/uploads/logo-meta.json b/public/uploads/logo-meta.json new file mode 100644 index 0000000..9a44071 --- /dev/null +++ b/public/uploads/logo-meta.json @@ -0,0 +1 @@ +{"url":"/uploads/logo.jpeg","updatedAt":"2026-07-23T18:12:32.480Z"} \ No newline at end of file diff --git a/scripts/migrate-bucket-assignments.js b/scripts/migrate-bucket-assignments.js new file mode 100644 index 0000000..1b5303f --- /dev/null +++ b/scripts/migrate-bucket-assignments.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +/** + * Targeted migration: ensure bucket_assignments table and its indexes exist. + * + * Safe to run on an existing database — uses CREATE TABLE IF NOT EXISTS + * and CREATE INDEX IF NOT EXISTS so no existing data is modified. + * + * Usage: + * node scripts/migrate-bucket-assignments.js + * # or + * npm run db:migrate:assignments + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +async function migrate() { + if (!process.env.DATABASE_URL) { + console.error('❌ DATABASE_URL is not set.'); + console.error(' Copy .env.example to .env and fill in your database details.'); + process.exit(1); + } + + const client = new Client({ connectionString: process.env.DATABASE_URL }); + + try { + console.log('🔌 Connecting to PostgreSQL…'); + await client.connect(); + console.log('✅ Connected'); + + // --- 1. Create bucket_assignments table -------------------------------- + console.log('\n📦 Creating bucket_assignments table (if not exists)…'); + await client.query(` + CREATE TABLE IF NOT EXISTS bucket_assignments ( + id SERIAL PRIMARY KEY, + bucket_id INTEGER NOT NULL REFERENCES buckets(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission VARCHAR(20) NOT NULL DEFAULT 'read', + assigned_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(bucket_id, user_id) + ) + `); + console.log(' ✅ bucket_assignments table ready'); + + // --- 2. Create indexes -------------------------------------------------- + console.log('\n🔍 Creating indexes (if not exists)…'); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_bucket_assignments_bucket_id + ON bucket_assignments(bucket_id) + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_bucket_assignments_user_id + ON bucket_assignments(user_id) + `); + console.log(' ✅ Indexes ready'); + + // --- 3. Verify ---------------------------------------------------------- + const check = await client.query(` + SELECT COUNT(*) AS rows FROM bucket_assignments + `); + console.log(`\n📊 bucket_assignments rows: ${check.rows[0].rows}`); + + console.log('\n🎉 Migration completed successfully!\n'); + } catch (err) { + console.error('\n❌ Migration failed:', err.message); + if (err.code === '42P01') { + console.error( + ' Hint: the "buckets" or "users" table is missing. ' + + 'Run `npm run db:migrate` first to apply the full schema.' + ); + } + process.exit(1); + } finally { + await client.end(); + } +} + +migrate(); diff --git a/scripts/migrate-upload-controls.js b/scripts/migrate-upload-controls.js new file mode 100644 index 0000000..737bf94 --- /dev/null +++ b/scripts/migrate-upload-controls.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +/** + * Targeted migration: upload controls. + * 1. Add buckets.max_upload_size (per-bucket max file size in bytes). + * 2. Create unscanned_objects table + index (fail-open malware scan flags). + * 3. Create scanned_clean_objects table + index (positive clean-scan records). + * + * Safe to run on an existing database — uses ADD COLUMN IF NOT EXISTS, + * CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS. + * + * Usage: + * node scripts/migrate-upload-controls.js + * # or + * npm run db:migrate:upload-controls + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +async function migrate() { + if (!process.env.DATABASE_URL) { + console.error('❌ DATABASE_URL is not set.'); + console.error(' Copy .env.example to .env and fill in your database details.'); + process.exit(1); + } + + const client = new Client({ connectionString: process.env.DATABASE_URL }); + + try { + console.log('🔌 Connecting to PostgreSQL…'); + await client.connect(); + console.log('✅ Connected'); + + // --- 1. Add buckets.max_upload_size ------------------------------------ + console.log('\n📦 Adding buckets.max_upload_size (if not exists)…'); + await client.query(` + ALTER TABLE buckets ADD COLUMN IF NOT EXISTS max_upload_size BIGINT + `); + console.log(' ✅ buckets.max_upload_size ready'); + + // --- 2. Create unscanned_objects table --------------------------------- + console.log('\n📦 Creating unscanned_objects table (if not exists)…'); + await client.query(` + CREATE TABLE IF NOT EXISTS unscanned_objects ( + id SERIAL PRIMARY KEY, + bucket_id INTEGER NOT NULL REFERENCES buckets(id) ON DELETE CASCADE, + object_key TEXT NOT NULL, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(bucket_id, object_key) + ) + `); + console.log(' ✅ unscanned_objects table ready'); + + // --- 3. Create index ---------------------------------------------------- + console.log('\n🔍 Creating index (if not exists)…'); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_unscanned_objects_lookup + ON unscanned_objects(bucket_id, object_key) + `); + console.log(' ✅ Index ready'); + + // --- 4. Create scanned_clean_objects table + index --------------------- + console.log('\n📦 Creating scanned_clean_objects table (if not exists)…'); + await client.query(` + CREATE TABLE IF NOT EXISTS scanned_clean_objects ( + id SERIAL PRIMARY KEY, + bucket_id INTEGER NOT NULL REFERENCES buckets(id) ON DELETE CASCADE, + object_key TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(bucket_id, object_key) + ) + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_scanned_clean_objects_lookup + ON scanned_clean_objects(bucket_id, object_key) + `); + console.log(' ✅ scanned_clean_objects table ready'); + + console.log('\n🎉 Migration completed successfully!\n'); + } catch (err) { + console.error('\n❌ Migration failed:', err.message); + if (err.code === '42P01') { + console.error( + ' Hint: the "buckets" table is missing. ' + + 'Run `npm run db:migrate` first to apply the full schema.' + ); + } + process.exit(1); + } finally { + await client.end(); + } +} + +migrate(); diff --git a/scripts/schema.sql b/scripts/schema.sql index e8b2b04..fd3acd7 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS buckets ( access_key_id TEXT, secret_access_key TEXT, session_token TEXT, + max_upload_size BIGINT, -- per-bucket max file size in bytes; NULL = app default (10MB) is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -39,6 +40,32 @@ CREATE TABLE IF NOT EXISTS buckets ( CREATE INDEX idx_buckets_user_id ON buckets(user_id); CREATE INDEX idx_buckets_alias ON buckets(alias); +-- Unscanned Objects Table (fail-open malware scan: records objects uploaded +-- without a successful scan so the UI can flag them). Absence of a row = clean/normal. +CREATE TABLE IF NOT EXISTS unscanned_objects ( + id SERIAL PRIMARY KEY, + bucket_id INTEGER NOT NULL REFERENCES buckets(id) ON DELETE CASCADE, + object_key TEXT NOT NULL, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(bucket_id, object_key) +); + +CREATE INDEX idx_unscanned_objects_lookup ON unscanned_objects(bucket_id, object_key); + +-- Scanned-Clean Objects Table (positive record: object was malware-scanned and +-- came back clean, so the UI can show a "safe" icon). Absence of a row here AND +-- in unscanned_objects = unknown (e.g. uploaded before scanning existed). +CREATE TABLE IF NOT EXISTS scanned_clean_objects ( + id SERIAL PRIMARY KEY, + bucket_id INTEGER NOT NULL REFERENCES buckets(id) ON DELETE CASCADE, + object_key TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(bucket_id, object_key) +); + +CREATE INDEX idx_scanned_clean_objects_lookup ON scanned_clean_objects(bucket_id, object_key); + -- Bucket Assignments Table (for sharing buckets between users) CREATE TABLE IF NOT EXISTS bucket_assignments ( id SERIAL PRIMARY KEY, diff --git a/src/actions/logo.ts b/src/actions/logo.ts index 9ab4271..b5c7fb8 100644 --- a/src/actions/logo.ts +++ b/src/actions/logo.ts @@ -1,46 +1,42 @@ 'use server'; -import { writeFile, readFile, mkdir } from 'fs/promises'; -import { join } from 'path'; -import { existsSync } from 'fs'; import { getCurrentUserOptional } from '@/lib/session'; import { createAuditLog } from '@/lib/audit'; +import { getLogoRecord, setLogoRecord, deleteLogoRecord } from '@/lib/settings'; -const LOGO_DIR = join(process.cwd(), 'public', 'uploads'); -const LOGO_PATH = join(LOGO_DIR, 'logo.png'); -const LOGO_PUBLIC_URL = '/uploads/logo.png'; -const META_PATH = join(process.cwd(), 'public', 'uploads', 'logo-meta.json'); +const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp']; +const MAX_SIZE = 2 * 1024 * 1024; // 2MB -export async function uploadLogo(formData: FormData, actor: string): Promise<{ success: boolean; url?: string; error?: string }> { +// Served by the /api/logo route handler (DB-backed). +const LOGO_PUBLIC_URL = '/api/logo'; + +export async function uploadLogo( + formData: FormData, + actor: string +): Promise<{ success: boolean; url?: string; error?: string }> { try { const file = formData.get('logo') as File; if (!file || file.size === 0) return { success: false, error: 'No file provided' }; - const allowed = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp']; - if (!allowed.includes(file.type)) return { success: false, error: 'Invalid file type. Use PNG, JPG, SVG or WebP.' }; - - if (file.size > 2 * 1024 * 1024) return { success: false, error: 'File too large. Max 2MB.' }; + if (!ALLOWED_TYPES.includes(file.type)) { + return { success: false, error: 'Invalid file type. Use PNG, JPG, SVG or WebP.' }; + } + if (file.size > MAX_SIZE) return { success: false, error: 'File too large. Max 2MB.' }; - await mkdir(LOGO_DIR, { recursive: true }); - const bytes = await file.arrayBuffer(); - const ext = file.type === 'image/svg+xml' ? 'svg' : file.type.split('/')[1]; - const filename = `logo.${ext}`; - const filePath = join(LOGO_DIR, filename); - await writeFile(filePath, Buffer.from(bytes)); - - // Save meta - await writeFile(META_PATH, JSON.stringify({ url: `/uploads/${filename}`, updatedAt: new Date().toISOString() })); + const buffer = Buffer.from(await file.arrayBuffer()); const user = await getCurrentUserOptional(); + await setLogoRecord(file.type, buffer, user?.id); + await createAuditLog({ user_id: user?.id, username: actor, action: 'settings.logo_upload', resource_type: 'settings', - details: { filename, size_kb: parseFloat((file.size / 1024).toFixed(1)) }, + details: { content_type: file.type, size_kb: parseFloat((file.size / 1024).toFixed(1)) }, status: 'success', }); - return { success: true, url: `/uploads/${filename}` }; + return { success: true, url: LOGO_PUBLIC_URL }; } catch (err) { console.error('[Logo] Upload error:', err); return { success: false, error: 'Upload failed' }; @@ -49,9 +45,8 @@ export async function uploadLogo(formData: FormData, actor: string): Promise<{ s export async function getLogoUrl(): Promise { try { - if (!existsSync(META_PATH)) return null; - const meta = JSON.parse(await readFile(META_PATH, 'utf8')); - return meta.url ?? null; + const logo = await getLogoRecord(); + return logo ? LOGO_PUBLIC_URL : null; } catch { return null; } @@ -59,8 +54,7 @@ export async function getLogoUrl(): Promise { export async function removeLogo(actor: string): Promise { try { - const { unlink } = await import('fs/promises'); - if (existsSync(META_PATH)) await unlink(META_PATH); + await deleteLogoRecord(); const user = await getCurrentUserOptional(); await createAuditLog({ user_id: user?.id, diff --git a/src/actions/s3.ts b/src/actions/s3.ts index 71dce90..c4be9d3 100644 --- a/src/actions/s3.ts +++ b/src/actions/s3.ts @@ -8,6 +8,9 @@ import type { Bucket } from "@/context/BucketContext"; import JSZip from "jszip"; import { getCurrentUserOptional } from "@/lib/session"; import { createAuditLog } from "@/lib/audit"; +import { effectiveMaxUploadSize } from "@/lib/upload-limits"; +import { scanBuffer } from "@/lib/malware-scan"; +import { markObjectUnscanned, clearUnscannedFlag, getUnscannedKeys, markObjectClean, clearCleanFlag, getCleanKeys } from "@/lib/scan-status"; const S3ConfigSchema = z.object({ accessKeyId: z.string().optional(), @@ -40,13 +43,13 @@ export async function listObjects( options?: { limit?: number; continuationToken?: string } ): Promise<{ folders: { Prefix: string }[]; - files: { Key?: string; LastModified?: Date; Size?: number }[]; + files: { Key?: string; LastModified?: Date; Size?: number; scanStatus?: 'unscanned' | 'clean' }[]; nextContinuationToken?: string; isComplete: boolean; }> { const s3Client = getS3Client(config); const folders: { Prefix: string }[] = []; - const files: { Key?: string; LastModified?: Date; Size?: number }[] = []; + const files: { Key?: string; LastModified?: Date; Size?: number; scanStatus?: 'unscanned' | 'clean' }[] = []; let continuationToken: string | undefined = options?.continuationToken; do { @@ -74,6 +77,28 @@ export async function listObjects( if (options?.limit !== undefined) break; } while (continuationToken); + // Annotate files with their malware-scan status: 'unscanned' (fail-open) or + // 'clean' (scanned OK). Files in neither set are left undefined (unknown). + const bucketId = Number(config.id); + if (!Number.isNaN(bucketId) && files.length > 0) { + try { + const keys = files.map((f) => f.Key).filter((k): k is string => !!k); + const [unscanned, clean] = await Promise.all([ + getUnscannedKeys(bucketId, keys), + getCleanKeys(bucketId, keys), + ]); + if (unscanned.size > 0 || clean.size > 0) { + for (const f of files) { + if (!f.Key) continue; + if (unscanned.has(f.Key)) f.scanStatus = 'unscanned'; + else if (clean.has(f.Key)) f.scanStatus = 'clean'; + } + } + } catch (e) { + console.error('[listObjects] Failed to load scan status:', e); + } + } + return { folders, files, nextContinuationToken: continuationToken, isComplete: !continuationToken }; } @@ -289,12 +314,12 @@ export async function uploadObject( onProgress?: (progress: number) => void ): Promise<{ success: boolean; message: string }> { try { - // Validate file size (100MB limit) - const maxSize = 100 * 1024 * 1024; // 100MB in bytes + // Validate file size against the bucket's configured limit (default 10MB). + const maxSize = effectiveMaxUploadSize(config.maxUploadSize); if (file.size > maxSize) { return { success: false, - message: `File size (${(file.size / (1024 * 1024)).toFixed(2)}MB) exceeds the 100MB limit.` + message: `File size (${(file.size / (1024 * 1024)).toFixed(2)}MB) exceeds the ${(maxSize / (1024 * 1024)).toFixed(0)}MB limit for this bucket.` }; } @@ -304,6 +329,35 @@ export async function uploadObject( const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + const user = await getCurrentUserOptional(); + const bucketId = Number(config.id); + + // Malware scan BEFORE upload. Infected files are rejected outright; + // if the scanner is unavailable we fail open (see scanBuffer). + const scan = await scanBuffer(buffer); + if (scan.status === 'infected') { + await createAuditLog({ + user_id: user?.id, + username: user?.username, + action: 'file.upload.blocked', + resource_type: 's3_object', + resource_id: key, + details: { + bucket: config.bucket, + key, + size_bytes: file.size, + reason: 'malware_detected', + viruses: scan.viruses ?? [], + }, + status: 'failure', + }); + const names = scan.viruses?.length ? ` (${scan.viruses.join(', ')})` : ''; + return { + success: false, + message: `File "${file.name}" failed the malware scan${names} and was not uploaded.`, + }; + } + const command = new PutObjectCommand({ Bucket: config.bucket, Key: key, @@ -314,7 +368,26 @@ export async function uploadObject( await s3Client.send(command); - const user = await getCurrentUserOptional(); + // Track scan status so the browser can flag files. The unscanned and + // clean records are mutually exclusive — a re-upload flips one to the + // other, so we always clear the opposite flag. + if (!Number.isNaN(bucketId)) { + try { + if (scan.status === 'unscanned') { + await markObjectUnscanned(bucketId, key, scan.error); + await clearCleanFlag(bucketId, key); + } else { + await markObjectClean(bucketId, key); + await clearUnscannedFlag(bucketId, key); + } + } catch (e) { + console.error('[Upload] Failed to record scan status:', e); + } + } + if (scan.status === 'unscanned') { + console.warn(`[Upload] Object uploaded WITHOUT malware scan (fail-open): ${config.bucket}/${key} — ${scan.error ?? 'scanner unavailable'}`); + } + await createAuditLog({ user_id: user?.id, username: user?.username, @@ -326,13 +399,18 @@ export async function uploadObject( key, size_bytes: file.size, content_type: file.type || 'application/octet-stream', + scan_status: scan.status, + ...(scan.status === 'unscanned' ? { scan_error: scan.error } : {}), }, status: 'success', }); return { success: true, - message: `File "${file.name}" uploaded successfully.` + message: + scan.status === 'unscanned' + ? `File "${file.name}" uploaded, but could not be malware-scanned.` + : `File "${file.name}" uploaded successfully.`, }; } catch (error: any) { console.error("Upload error:", error); diff --git a/src/actions/settings.ts b/src/actions/settings.ts new file mode 100644 index 0000000..179d6e4 --- /dev/null +++ b/src/actions/settings.ts @@ -0,0 +1,44 @@ +'use server'; + +import { getCurrentUserOptional } from '@/lib/session'; +import { createAuditLog } from '@/lib/audit'; +import { getBranding, setBranding, DEFAULT_BRANDING, type BrandingSettings } from '@/lib/settings'; + +/** + * Public read — used by the login page and headers. Never throws. + */ +export async function getBrandingSettings(): Promise { + try { + return await getBranding(); + } catch { + return DEFAULT_BRANDING; + } +} + +/** + * Admin-only write. Re-checks authorization server-side (client gating is UX-only). + */ +export async function updateBrandingSettings( + branding: BrandingSettings +): Promise<{ success: boolean; branding?: BrandingSettings; error?: string }> { + const user = await getCurrentUserOptional(); + if (!user || user.role !== 'admin') { + return { success: false, error: 'Not authorized' }; + } + + try { + const saved = await setBranding(branding, user.id); + await createAuditLog({ + user_id: user.id, + username: user.username, + action: 'settings.branding_update', + resource_type: 'settings', + details: saved, + status: 'success', + }); + return { success: true, branding: saved }; + } catch (err) { + console.error('[Settings] Branding update failed:', err); + return { success: false, error: 'Failed to save branding settings' }; + } +} diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx index e5ab825..0972c66 100644 --- a/src/app/admin/audit/page.tsx +++ b/src/app/admin/audit/page.tsx @@ -244,7 +244,7 @@ export default function AuditLogPage() { } return ( -
+
} />
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fc6491a..e3545c8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; import { Loader2, Settings } from 'lucide-react'; import { LogoUpload } from '@/components/logo-upload'; +import { BrandingSettings } from '@/components/branding-settings'; import { AppSidebar } from '@/components/app-sidebar'; export default function AdminSettingsPage() { @@ -24,11 +25,12 @@ export default function AdminSettingsPage() { } return ( -
+
} />
+
); diff --git a/src/app/api/bucket-assignments/route.ts b/src/app/api/bucket-assignments/route.ts new file mode 100644 index 0000000..6c3d278 --- /dev/null +++ b/src/app/api/bucket-assignments/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateSession } from '@/lib/auth'; +import { + getAllBucketAssignments, + assignBucketToUser, + removeBucketAssignment, +} from '@/lib/buckets'; +import { getUserByUsername } from '@/lib/users'; +import { cookies } from 'next/headers'; + +async function requireAdmin(request: NextRequest) { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get('session_token')?.value; + if (!sessionToken) return null; + const user = await validateSession(sessionToken); + if (!user || user.role !== 'admin') return null; + return user; +} + +// GET /api/bucket-assignments — list all assignments (admin only) +export async function GET(request: NextRequest) { + try { + const user = await requireAdmin(request); + if (!user) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + const assignments = await getAllBucketAssignments(); + return NextResponse.json({ assignments }); + } catch (error) { + console.error('GET /api/bucket-assignments error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// POST /api/bucket-assignments — assign a user to a bucket (admin only) +// Body: { bucket_id: number, username: string, permission: string } +export async function POST(request: NextRequest) { + try { + const user = await requireAdmin(request); + if (!user) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const body = await request.json(); + const { bucket_id, username, permission } = body; + + if (!bucket_id || !username || !permission) { + return NextResponse.json( + { error: 'bucket_id, username, and permission are required' }, + { status: 400 } + ); + } + + const targetUser = await getUserByUsername(username); + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const ok = await assignBucketToUser(Number(bucket_id), targetUser.id, user.id, permission); + if (!ok) { + return NextResponse.json({ error: 'Failed to assign user to bucket' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('POST /api/bucket-assignments error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// DELETE /api/bucket-assignments — remove a user from a bucket (admin only) +// Body: { bucket_id: number, username: string } +export async function DELETE(request: NextRequest) { + try { + const user = await requireAdmin(request); + if (!user) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const body = await request.json(); + const { bucket_id, username } = body; + + if (!bucket_id || !username) { + return NextResponse.json( + { error: 'bucket_id and username are required' }, + { status: 400 } + ); + } + + const targetUser = await getUserByUsername(username); + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const ok = await removeBucketAssignment(Number(bucket_id), targetUser.id); + if (!ok) { + return NextResponse.json({ error: 'Failed to remove assignment' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('DELETE /api/bucket-assignments error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/buckets/[id]/route.ts b/src/app/api/buckets/[id]/route.ts index bf5d53b..2ce235c 100644 --- a/src/app/api/buckets/[id]/route.ts +++ b/src/app/api/buckets/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { validateSession } from '@/lib/auth'; import { getBucketById, updateBucket, deleteBucket } from '@/lib/buckets'; +import { clampUploadSize } from '@/lib/upload-limits'; import { cookies } from 'next/headers'; // GET /api/buckets/:id - Get a single bucket @@ -70,7 +71,14 @@ export async function PATCH( } const body = await request.json(); - const { alias, bucket_name, region, root_folder, access_key_id, secret_access_key, session_token, is_active } = body; + const { alias, bucket_name, region, root_folder, access_key_id, secret_access_key, session_token, is_active, max_upload_size } = body; + + // Only admins may change a bucket's upload limit; ignore the field otherwise. + const isAdmin = user.role === 'admin'; + const maxUploadSize = + isAdmin && max_upload_size !== undefined + ? (clampUploadSize(max_upload_size) ?? null) + : undefined; const bucket = await updateBucket(bucketId, user.id, { alias, @@ -81,8 +89,9 @@ export async function PATCH( secret_access_key, session_token, is_active, + max_upload_size: maxUploadSize, username: user.username, - }, user.role === 'admin'); + }, isAdmin); if (!bucket) { return NextResponse.json( diff --git a/src/app/api/buckets/route.ts b/src/app/api/buckets/route.ts index ee0734b..7302200 100644 --- a/src/app/api/buckets/route.ts +++ b/src/app/api/buckets/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { validateSession } from '@/lib/auth'; -import { getAllBuckets, getBucketsByUserId, createBucket, getBucketCount } from '@/lib/buckets'; +import { getAllBuckets, getBucketsByUserId, getBucketsAssignedToUser, createBucket, getBucketCount } from '@/lib/buckets'; +import { clampUploadSize } from '@/lib/upload-limits'; import { cookies } from 'next/headers'; // GET /api/buckets - Get all buckets for current user @@ -19,13 +20,27 @@ export async function GET(request: NextRequest) { } const isAdmin = user.role === 'admin'; - const buckets = isAdmin ? await getAllBuckets() : await getBucketsByUserId(user.id); - const count = isAdmin ? buckets.length : await getBucketCount(user.id); + let buckets; - return NextResponse.json({ - buckets, - count, - }); + if (isAdmin) { + const all = await getAllBuckets(); // includes owner_username via JOIN + buckets = all.map(b => ({ ...b, is_owned: b.user_id === user.id, permission: null })); + } else { + const owned = await getBucketsByUserId(user.id); + let assigned: Awaited> = []; + try { + assigned = await getBucketsAssignedToUser(user.id); + } catch (assignErr) { + // Table may not exist yet — owned buckets still render; run `npm run db:migrate` to fix + console.error('getBucketsAssignedToUser failed:', assignErr); + } + buckets = [ + ...owned.map(b => ({ ...b, is_owned: true, owner_username: user.username, permission: null })), + ...assigned, // already carries is_owned:false, owner_username, permission + ]; + } + + return NextResponse.json({ buckets, count: buckets.length }); } catch (error) { console.error('Get buckets error:', error); return NextResponse.json( @@ -59,7 +74,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { alias, bucket_name, region, root_folder, access_key_id, secret_access_key, session_token } = body; + const { alias, bucket_name, region, root_folder, access_key_id, secret_access_key, session_token, max_upload_size } = body; if (!alias || !bucket_name || !region) { return NextResponse.json( @@ -68,6 +83,9 @@ export async function POST(request: NextRequest) { ); } + // Only admins may set a per-bucket upload limit; others get the app default (null). + const isAdmin = user.role === 'admin'; + const bucket = await createBucket({ alias, bucket_name, @@ -76,6 +94,7 @@ export async function POST(request: NextRequest) { access_key_id, secret_access_key, session_token, + max_upload_size: isAdmin ? (clampUploadSize(max_upload_size) ?? null) : null, user_id: user.id, username: user.username, }); diff --git a/src/app/api/logo/route.ts b/src/app/api/logo/route.ts new file mode 100644 index 0000000..d98a58a --- /dev/null +++ b/src/app/api/logo/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { getLogoRecord } from '@/lib/settings'; + +// Always read fresh from the DB — the logo can change at runtime. +export const dynamic = 'force-dynamic'; + +export async function GET() { + const logo = await getLogoRecord(); + if (!logo) { + return new NextResponse('Logo not found', { status: 404 }); + } + + return new NextResponse(new Uint8Array(logo.buffer), { + status: 200, + headers: { + 'Content-Type': logo.contentType, + 'Content-Length': String(logo.buffer.length), + // Clients cache-bust with a query param; keep the DB the source of truth. + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/src/app/bucket-assignments/page.tsx b/src/app/bucket-assignments/page.tsx index 67e6776..ce4add3 100644 --- a/src/app/bucket-assignments/page.tsx +++ b/src/app/bucket-assignments/page.tsx @@ -97,7 +97,7 @@ export default function BucketAssignmentsPage() { }; return ( -
+
} />
@@ -111,9 +111,9 @@ export default function BucketAssignmentsPage() {
- + - + diff --git a/src/app/buckets/[id]/page.tsx b/src/app/buckets/[id]/page.tsx index 4a6a369..e6d572b 100644 --- a/src/app/buckets/[id]/page.tsx +++ b/src/app/buckets/[id]/page.tsx @@ -4,8 +4,9 @@ import { useBucket } from "@/context/BucketContext"; import { useAuth } from "@/context/AuthContext"; import { useRouter, useParams } from "next/navigation"; import S3Browser from "@/components/s3-browser"; +import { AppSidebar } from "@/components/app-sidebar"; import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { Loader2, HardDrive } from "lucide-react"; export default function BucketBrowserPage() { const router = useRouter(); @@ -46,8 +47,11 @@ export default function BucketBrowserPage() { } return ( -
- -
+
+ } /> +
+ +
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index 38a748f..894bfd6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,50 +6,95 @@ body { font-family: 'Inter', sans-serif; } +/* + * S3 Navigator — corporate theme. + * Brand palette sourced from avanse.com: + * Primary teal #198694 hsl(187 71% 34%) + * Accent orange #ec7426 hsl(24 84% 54%) + * Highlight gold #ffc20e hsl(45 100% 53%) + * Flat, clean surfaces — no skeuomorphism. + */ @layer base { :root { - --background: 220 20% 92%; - --foreground: 210 25% 18%; + --background: 210 20% 98%; + --foreground: 200 25% 16%; --card: 0 0% 100%; - --card-foreground: 210 25% 18%; + --card-foreground: 200 25% 16%; --popover: 0 0% 100%; - --popover-foreground: 210 25% 18%; - --primary: 204 60% 45%; + --popover-foreground: 200 25% 16%; + --primary: 187 71% 34%; --primary-foreground: 0 0% 100%; - --secondary: 220 14% 85%; - --secondary-foreground: 210 25% 18%; - --muted: 220 14% 88%; - --muted-foreground: 210 9% 45%; - --accent: 204 60% 38%; + --secondary: 200 20% 94%; + --secondary-foreground: 200 25% 20%; + --muted: 200 20% 95%; + --muted-foreground: 200 12% 42%; + --accent: 24 84% 54%; --accent-foreground: 0 0% 100%; --destructive: 0 72% 51%; --destructive-foreground: 0 0% 98%; - --border: 220 15% 78%; - --input: 220 15% 88%; - --ring: 204 60% 45%; - --radius: 0.6rem; + --success: 145 63% 32%; + --success-foreground: 0 0% 100%; + --warning: 38 92% 45%; + --warning-foreground: 0 0% 100%; + --border: 200 18% 88%; + --input: 200 18% 88%; + --ring: 187 71% 34%; + --radius: 0.5rem; + + /* Brand accents for direct use */ + --brand-teal: 187 71% 34%; + --brand-teal-dark: 185 84% 27%; + --brand-orange: 24 84% 54%; + --brand-gold: 45 100% 53%; + + --sidebar-background: 0 0% 100%; + --sidebar-foreground: 200 25% 20%; + --sidebar-primary: 187 71% 34%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 200 20% 95%; + --sidebar-accent-foreground: 200 25% 16%; + --sidebar-border: 200 18% 88%; + --sidebar-ring: 187 71% 34%; } .dark { - --background: 210 18% 12%; + --background: 200 30% 9%; --foreground: 0 0% 95%; - --card: 210 18% 16%; + --card: 200 26% 13%; --card-foreground: 0 0% 95%; - --popover: 210 18% 16%; + --popover: 200 26% 13%; --popover-foreground: 0 0% 95%; - --primary: 204 60% 55%; + --primary: 187 60% 45%; --primary-foreground: 0 0% 100%; - --secondary: 220 13% 22%; + --secondary: 200 18% 20%; --secondary-foreground: 0 0% 95%; - --muted: 220 13% 20%; - --muted-foreground: 210 9% 60%; - --accent: 195 53% 65%; - --accent-foreground: 210 18% 12%; - --destructive: 0 55% 40%; + --muted: 200 18% 18%; + --muted-foreground: 200 10% 62%; + --accent: 24 80% 56%; + --accent-foreground: 0 0% 100%; + --destructive: 0 62% 45%; --destructive-foreground: 0 0% 98%; - --border: 220 13% 28%; - --input: 220 13% 24%; - --ring: 204 60% 55%; + --success: 145 55% 45%; + --success-foreground: 0 0% 100%; + --warning: 38 90% 55%; + --warning-foreground: 0 0% 12%; + --border: 200 16% 26%; + --input: 200 16% 24%; + --ring: 187 60% 45%; + + --brand-teal: 187 60% 45%; + --brand-teal-dark: 187 60% 38%; + --brand-orange: 24 80% 56%; + --brand-gold: 45 90% 55%; + + --sidebar-background: 200 26% 13%; + --sidebar-foreground: 0 0% 92%; + --sidebar-primary: 187 60% 45%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 200 18% 20%; + --sidebar-accent-foreground: 0 0% 95%; + --sidebar-border: 200 16% 26%; + --sidebar-ring: 187 60% 45%; } } @@ -62,115 +107,105 @@ body { } } -/* ── Skeuomorphism Design System ── */ +/* + * Corporate surface system. + * These class names are kept from the previous skeuomorphic theme so existing + * markup transforms automatically — but every rule below is now a flat, + * professional style. + */ @layer components { - /* Page background — brushed linen texture feel */ + /* Page background — clean, flat app canvas */ .skeu-bg { - background-color: #dde3ec; - background-image: - linear-gradient(135deg, #e8edf5 25%, transparent 25%), - linear-gradient(225deg, #dde3ec 25%, transparent 25%), - linear-gradient(45deg, #dde3ec 25%, transparent 25%), - linear-gradient(315deg, #e8edf5 25%, #dde3ec 25%); - background-size: 4px 4px; + background-color: hsl(var(--background)); } - /* Card — raised, beveled look */ + /* Card — flat panel with hairline border + soft elevation */ .skeu-card, .skeu-card.border { - background: linear-gradient(160deg, #ffffff 0%, #f0f4fa 100%); - border: 1px solid #b8c4d8; - border-top-color: #d8e2f0; - border-left-color: #d8e2f0; - border-bottom-color: #8fa0bc; - border-right-color: #8fa0bc; - box-shadow: - 0 1px 0 0 rgba(255,255,255,0.8) inset, - 0 -1px 0 0 rgba(0,0,0,0.08) inset, - 0 4px 12px rgba(0,0,0,0.12), - 0 1px 3px rgba(0,0,0,0.08); - border-radius: 12px; - } - - /* Header bar — brushed metal */ + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + box-shadow: 0 1px 2px rgba(16, 40, 55, 0.04), 0 1px 3px rgba(16, 40, 55, 0.06); + border-radius: var(--radius); + } + + /* Page content offset for the fixed desktop rail. Width follows the + sidebar's collapsed state via the data attribute on . */ + .app-content { + transition: padding-left 0.3s ease; + } + @media (min-width: 768px) { + .app-content { + padding-left: 16rem; + } + html[data-sidebar-collapsed='true'] .app-content { + padding-left: 4rem; + } + } + + /* Header bar — white app chrome with a brand top accent */ .skeu-header { - background: linear-gradient(180deg, #e8eef8 0%, #d0daea 50%, #c8d2e6 100%); - border-bottom: 1px solid #a0aec0; - box-shadow: - 0 1px 0 rgba(255,255,255,0.7) inset, - 0 2px 6px rgba(0,0,0,0.12); + background: hsl(var(--card)); + border-bottom: 1px solid hsl(var(--border)); + box-shadow: inset 0 3px 0 hsl(var(--brand-teal)), 0 1px 2px rgba(16, 40, 55, 0.05); } - /* Button — tactile, pressable */ + /* Button — solid brand fill, flat */ .skeu-btn { - background: linear-gradient(180deg, #5b9bd5 0%, #3b7ec8 50%, #2c6db8 100%); - border: 1px solid #2059a0; - border-bottom: 2px solid #1a4a8a; - color: white; - text-shadow: 0 1px 1px rgba(0,0,0,0.3); - box-shadow: - 0 1px 0 rgba(255,255,255,0.25) inset, - 0 3px 6px rgba(0,0,0,0.2); - border-radius: 8px; - transition: all 0.1s ease; + background: hsl(var(--brand-teal)); + border: 1px solid hsl(var(--brand-teal-dark)); + color: hsl(var(--primary-foreground)); + box-shadow: 0 1px 2px rgba(16, 40, 55, 0.12); + border-radius: var(--radius); + transition: background-color 0.15s ease; } .skeu-btn:hover { - background: linear-gradient(180deg, #6baae0 0%, #4b8ed8 50%, #3c7dc8 100%); + background: hsl(var(--brand-teal-dark)); } .skeu-btn:active { - transform: translateY(1px); - box-shadow: 0 1px 3px rgba(0,0,0,0.2); + background: hsl(var(--brand-teal-dark)); } - /* Input fields — inset/recessed look */ + /* Input fields — flat, bordered */ .skeu-input input, .skeu-input { - background: linear-gradient(180deg, #e8edf5 0%, #f5f7fb 100%); - border: 1px solid #9ab0cc; - border-top-color: #7090b0; - box-shadow: - 0 2px 4px rgba(0,0,0,0.1) inset, - 0 1px 0 rgba(255,255,255,0.6); - border-radius: 6px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--input)); + box-shadow: none; + border-radius: calc(var(--radius) - 2px); } - /* Table — ledger paper feel */ + /* Table — clean data grid */ .skeu-table thead tr { - background: linear-gradient(180deg, #dde5f2 0%, #ccd5e8 100%); - border-bottom: 2px solid #a0b0cc; + background: hsl(var(--muted)); + border-bottom: 1px solid hsl(var(--border)); } - .skeu-table tbody tr:nth-child(even) { - background: rgba(255,255,255,0.5); + .skeu-table thead th { + color: hsl(var(--muted-foreground)); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.72rem; } - .skeu-table tbody tr:nth-child(odd) { - background: rgba(210,220,235,0.3); + .skeu-table tbody tr { + border-bottom: 1px solid hsl(var(--border)); } .skeu-table tbody tr:hover { - background: rgba(180,200,230,0.4); + background: hsl(var(--secondary)); } - /* Login card — thick, embossed */ + /* Login card — flat, moderately elevated */ .skeu-login-card { - background: linear-gradient(160deg, #ffffff 0%, #edf2fa 100%); - border: 1px solid #b0c4dc; - border-top-color: #e0eaf8; - border-left-color: #e0eaf8; - border-bottom-color: #7898bc; - border-right-color: #7898bc; - box-shadow: - 0 2px 0 rgba(255,255,255,0.9) inset, - 0 -1px 0 rgba(0,0,0,0.08) inset, - 0 8px 24px rgba(0,0,0,0.18), - 0 2px 6px rgba(0,0,0,0.1); - border-radius: 16px; - } - - /* Badge pills — embossed */ + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + box-shadow: 0 10px 30px rgba(16, 40, 55, 0.10), 0 2px 6px rgba(16, 40, 55, 0.06); + border-radius: calc(var(--radius) + 4px); + } + + /* Badge pills — flat */ .skeu-badge { - box-shadow: - 0 1px 0 rgba(255,255,255,0.6) inset, - 0 1px 3px rgba(0,0,0,0.15); - border: 1px solid rgba(0,0,0,0.1); + box-shadow: none; + border: 1px solid transparent; + font-weight: 600; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 895944b..542bbff 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,15 +3,18 @@ import { Toaster } from "@/components/ui/toaster" import './globals.css'; import { AuthProvider } from '@/context/AuthContext'; import { BucketProvider } from '@/context/BucketContext'; -import { UserProvider } from '@/context/UserContext'; import { BucketAssignmentProvider } from '@/context/BucketAssignmentContext'; import fs from 'fs'; import path from 'path'; +import { getBranding } from '@/lib/settings'; -export const metadata: Metadata = { - title: 'S3 Navigator', - description: 'A simple AWS S3 bucket browser app.', -}; +export async function generateMetadata(): Promise { + const branding = await getBranding(); + return { + title: branding.title, + description: branding.subtitle, + }; +} export default function RootLayout({ children, @@ -22,20 +25,24 @@ export default function RootLayout({ return ( +