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
5 changes: 4 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,10 @@ backup_*.sql
# firebase
firebase-debug.log
firestore-debug.loglogs/

# Playwright
test-results/
playwright-report/

# public
/public
75 changes: 75 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
121 changes: 121 additions & 0 deletions e2e/bucket-management.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
64 changes: 64 additions & 0 deletions e2e/db.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 23 additions & 0 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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',
};
43 changes: 43 additions & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading