diff --git a/docs/RTDAS_PRD.md b/docs/RTDAS_PRD.md index f13d1c8..57919e2 100644 --- a/docs/RTDAS_PRD.md +++ b/docs/RTDAS_PRD.md @@ -14,7 +14,7 @@ University students need to demonstrate mastery of advanced Java topics — OOP, ## Solution -A distributed, multi-user **English auction platform** where a headless RMI server manages auctions, persists data in SQLite, and serves one or more JavaFX desktop clients. The system supports two roles (Admin, User) with real-time bid updates via client polling, image galleries with LQIP (Low-Quality Image Placeholder) loading, automatic server discovery via UDP broadcast, and robust concurrency controls including snipe protection. +A distributed, multi-user **English auction platform** where a headless RMI server manages auctions, persists data in SQLite, and serves one or more JavaFX desktop clients. The system supports two roles (Admin, User) with real-time bid updates via client polling, image galleries with LQIP (Low-Quality Image Placeholder) loading, automatic server discovery via UDP broadcast, and robust concurrency controls including snipe protection. Note: "User" is a unified role; any user can be both a seller and a bidder. **Demo environment:** One PC runs the server (you); four teammate PCs connect as clients, all on the same local Wi-Fi. Clients discover the server automatically via UDP broadcast or connect manually by entering the server's IP. No internet required. @@ -33,7 +33,7 @@ A distributed, multi-user **English auction platform** where a headless RMI serv ### Authentication & User Management 6. As an **Admin**, I want to create new user accounts (with a username, password, and role), so that I control who can access the system. -7. As an **Admin**, I want to assign one of two roles (admin, user) when creating a user, so that each person has appropriate permissions. +7. As an **Admin**, I want to assign one of two roles (ADMIN, USER) when creating a user, so that each person has appropriate permissions. 8. As a **User**, I want to log in with my username and password, so that I can access the system with my assigned role. 9. As a **User**, I want my password stored as a SHA-256 hash, so that raw credentials are never persisted. 10. As a **User**, I want to see a clear error message if my login credentials are incorrect, so that I know what went wrong. @@ -43,7 +43,7 @@ A distributed, multi-user **English auction platform** where a headless RMI serv ### Auction Browsing & Discovery 13. As a **User**, I want to see a gallery of all active auctions with thumbnail images, so that I can quickly browse available items. -14. As a **User**, I want to filter auctions by category (Electronics, Furniture, Art, Other), so that I find items I'm interested in. +14. As a **User**, I want to filter auctions by category (ELECTRONICS, FURNITURE, ART, OTHER), so that I find items I'm interested in. 15. As a **User**, I want to sort auctions by end time, current bid, or category, so that I can prioritise what to look at. 16. As a **User**, I want to see a small blurred thumbnail (LQIP) load instantly in the gallery, so that the UI feels fast even with slow connections. 17. As a **User**, I want to click on an auction to see its full detail view, so that I can evaluate the item before bidding. @@ -58,14 +58,14 @@ A distributed, multi-user **English auction platform** where a headless RMI serv 23. As a **User**, I want the system to prevent me from bidding on my own auction, so that self-bidding is impossible. 24. As a **User**, I want the system to prevent me from bidding if I'm already the highest bidder, so that I don't waste bids. 25. As a **User**, I want the "Bid" button to be disabled while my bid is being processed, so that I don't accidentally double-click. -26. As a **User**, I want to see the full bid history for any auction (all bidders, amounts, timestamps), so that I can gauge competition. +26. As a **User**, I want to see the full bid history for any auction (all bidders, amounts in cents, timestamps), so that I can gauge competition. 27. As a **User**, I want the auction detail view to auto-refresh every 2 seconds, so that I see the latest bid without manually refreshing. 28. As a **User**, I want to see a live countdown timer showing time remaining on an auction, so that I know how much time I have. ### Snipe Protection 29. As a **User**, I want the auction timer to extend by 30 seconds if a bid is placed in the last 30 seconds, so that last-second sniping is discouraged. -30. As a **User**, I want the system to cap total extension so auctions don't run forever, so that the demo stays predictable. +30. As a **User**, I want the system to cap total extension so auctions don't run forever, so that the demo stays predictable. Extensions are capped at `capEndTime` (default: `originalEndTime + 10 minutes`). ### User Activity Dashboard @@ -73,14 +73,14 @@ A distributed, multi-user **English auction platform** where a headless RMI serv ### Auction Creation & Management -32. As a **User**, I want to create a new auction with a title, description, category, starting price, end time, and up to 3 images (max 2MB each), so that I can list items for sale. +32. As a **User**, I want to create a new auction with a title, description, category, starting price (in cents), end time, and up to 3 images (max 2MB each), so that I can list items for sale. 33. As a **User**, I want the client to validate image file sizes before upload and reject files over 2MB with a clear message, so that I don't waste time uploading oversized files. 34. As a **User**, I want the system to auto-generate a 40×40 blurred thumbnail from my first image, so that gallery loading is fast. 35. As a **User**, I want to cancel an auction that has zero bids, so that I can remove items I no longer want to sell. 36. As a **User**, I want to be prevented from cancelling an auction that already has bids, so that users are protected. -37. As a **User**, I want to see a dashboard of all my auctions grouped by status (active, sold, expired, cancelled), so that I can track my listings. +37. As a **User**, I want to see a dashboard of all my auctions grouped by status (ACTIVE, SOLD, EXPIRED, CANCELLED), so that I can track my listings. 38. As a **User**, I want to see the full bid history and final sale price for my sold auctions, so that I have a complete sales record. -39. As a **User**, I want to relist an expired auction that received no bids as a new auction, so that I can try selling the item again. +39. As a **User**, I want to relist an expired auction that received no bids as a new auction, so that I can try selling the item again. This creates a new auction row linked via `relisted_from`. 40. As a **User**, I want to bid on other users' auctions, so that I can participate as a buyer too. 41. As a **User**, I want to export my auctions to a CSV file via a save dialog, so that I have an offline record of my sales. @@ -92,7 +92,7 @@ A distributed, multi-user **English auction platform** where a headless RMI serv ### Auction Lifecycle & State Machine -45. As the **System**, I want auctions to follow a strict state machine (ACTIVE → SOLD | EXPIRED → optionally relisted, or ACTIVE → CANCELLED), so that state transitions are predictable. +45. As the **System**, I want auctions to follow a strict state machine (ACTIVE ──(has bids)──→ SOLD; ACTIVE ──(no bids)────────→ EXPIRED; EXPIRED ─/relist/→ ACTIVE (new row); ACTIVE ──(cancel, 0 bids)─→ CANCELLED), so that state transitions are predictable. 46. As the **System**, I want an "Auction Reaper" background thread to scan every 1 second for auctions whose end time has passed and automatically transition them to SOLD (if bids exist) or EXPIRED (if no bids), so that auction closure is automatic and requires no user action. 47. As the **System**, I want to recover from a server crash by expiring all overdue ACTIVE auctions on startup, so that stale auctions don't persist. @@ -135,38 +135,39 @@ All mutating methods require a session token from `login()`. | Method | Signature | Notes | |--------|-----------|-------| -| login | `Session login(String u, String p)` | Returns token | +| login | `String login(String u, String p)` | Returns token | | logout | `void logout(String token)` | Invalidates session | | serverTime | `String serverTime()` | UTC ISO-8601, for clock sync | -| placeBid | `void placeBid(int id, String token, long cents, long expected)` | Stale detection | -| getActiveAuctions | `List getActiveAuctions(String token)` | Gallery feed | -| getAuctionById | `AuctionItem getAuctionById(int id, String token)` | Detail view | -| getBidHistory | `List getBidHistory(int id, String token)` | History table | +| placeBid | `void placeBid(int id, long cents, long expected, String token)` | Stale detection | +| getActiveAuctions | `List getActiveAuctions()` | Gallery feed | +| getActiveAuctionsBySeller | `List getActiveAuctionsBySeller(String seller, String token)` | Filtered | +| getAuctionById | `AuctionItem getAuctionById(int id)` | Detail view | +| getBidHistory | `List getBidHistory(int id)` | History table | | createAuction | `int createAuction(AuctionItem, byte[]1,2,3, String token)` | Returns new ID | | cancelAuction | `void cancelAuction(int id, String token)` | Zero bids only | -| relistAuction | `int relistAuction(int id, String newEnd, String token)` | Creates new row | +| relistAuction | `void relistAuction(int id, String newEnd, String token)` | Creates new row | | getMyBids | `List getMyBids(String token)` | User activity | | getMyWonAuctions | `List getMyWonAuctions(String token)` | User activity | +| getThumbnail | `byte[] getThumbnail(int id, int idx)` | LQIP | +| getFullImage | `byte[] getFullImage(int id, int idx)` | Full-res | | exportAuctionsToCSV | `byte[] exportAuctionsToCSV(String token)` | User dashboard | -| createUser | `void createUser(String adminToken, ...)` | Admin only | -| getAllUsers | `List getAllUsers(String adminToken)` | Admin only | -| backupDatabase | `byte[] backupDatabase(String adminToken)` | Via VACUUM INTO | -| getAuditLogs | `List getAuditLogs(String adminToken, int n)` | Admin only | -| getThumbnail | `byte[] getThumbnail(int id, int idx, String token)` | LQIP | -| getFullImage | `byte[] getFullImage(int id, int idx, String token)` | Full-res | +| createUser | `void createUser(String u, String p, String r, String token)` | Admin only | +| getAllUsers | `List getAllUsers(String token)` | Admin only | +| backupDatabase | `byte[] backupDatabase(String token)` | Via VACUUM INTO | +| getAuditLogs | `List getAuditLogs(int n, String token)` | Admin only | ### 4. Database — SQLite via JDBC - Single file: `data/auction.db.sqlite`. - Three tables: `users`, `auction_items`, `bids`. -- **All monetary values are INTEGER cents** (see rationale below). +- **All monetary values are INTEGER cents.** - **All timestamps are ISO-8601 UTC strings with Z suffix.** - `PRAGMA foreign_keys = ON` enabled per connection. - Schema auto-created on first run via `CREATE TABLE IF NOT EXISTS`. ### 5. Currency Model — Integer Cents -All prices and bids stored as `INTEGER` cents (not `DOUBLE` dollars). +All prices and bids stored as `long` cents (not `double` dollars). **Rationale:** Floating-point equality checks are brittle. In a bidding system where `clientExpectedPrice == currentPrice` must work reliably, integer comparison avoids rounding errors entirely. @@ -187,18 +188,18 @@ All prices and bids stored as `INTEGER` cents (not `DOUBLE` dollars). - Trigger: `endTime - now < 30s` - Effect: `endTime = min(endTime + 30s, capEndTime)` -- `capEndTime = originalEndTime + 10 minutes` +- `capEndTime` is set at auction creation (default = `originalEndTime + 10 minutes`). ### 8. Image Handling — LQIP Pattern -- Server re-encodes all uploads to JPG (no external dependency). +- Server re-encodes all uploads to JPG. - Center-crop 40×40 thumbnail generated from image 1. - Missing images return built-in placeholder bytes. - Client caches images in-memory per session. ### 9. Security -- Passwords: SHA-256 hash (no salt, acceptable for demo). +- Passwords: SHA-256 hash. - Authentication: Session tokens with server-side map. - Admin-only registration. - Rate limiting on `login` and `placeBid` per-IP. diff --git a/docs/architecture.md b/docs/architecture.md index 1c6e654..1ae9c41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,9 +71,15 @@ Enforced by `AuctionManager`: if (amount < currentBid * 1.05) throw new InsufficientBidException(); ``` -### Snipe Protection +### Snipe Protection with Cap -Triggered by `AuctionManager` during bid placement if `now` is within 30s of `endTime`. +Triggered by `AuctionManager` during bid placement if `now` is within 30s of `endTime`. +- Effect: `endTime = min(endTime + 30s, capEndTime)` +- `capEndTime` is set at auction creation to prevent auctions from running indefinitely. + +### Clock Authority (Server-Time) + +To prevent discrepancies between client and server clocks during countdowns, the server provides a `serverTime()` method. Clients compute a drift offset on connection and use it to adjust their local timers to match the server's authoritative clock. --- @@ -88,6 +94,7 @@ Full contract in `shared/interfaces/IAuctionService.java`. | `placeBid(...)` | Adapter | `AuctionManager.placeBid(...)` | | `createAuction(...)` | Adapter | `AuctionManager.createAuction(...)` + `ImageStore` | | `getActiveAuctions()` | Adapter | `AuctionManager.getActiveAuctions()` | +| `serverTime()` | Adapter | Authority for synchronization | --- @@ -95,11 +102,19 @@ Full contract in `shared/interfaces/IAuctionService.java`. ### Server-Side Locking -`AuctionManager` manages per-auction concurrency using fine-grained locks (or database transactions) to ensure atomic bid updates. +`AuctionManager` manages per-auction concurrency using a `ConcurrentHashMap` of `ReentrantLock` objects. Every mutation (bid placement, cancellation, relisting) must acquire the lock for the specific auction ID. + +### Atomic Bid Commit + +Bid placement is executed within a single database transaction: +1. Validate rules (increment, self-bid, etc.) +2. Insert bid record. +3. Update auction record (new price, highest bidder, potentially extended end time). +4. Commit or rollback on failure. ### Background Lifecycle -`AuctionReaper` runs as a daemon thread, ensuring terminal states are reached even if no users are active. +`AuctionReaper` runs as a daemon thread, ensuring terminal states are reached even if no users are active. It also acquires the auction-specific lock before performing transitions. --- @@ -107,4 +122,5 @@ Full contract in `shared/interfaces/IAuctionService.java`. - RMI callbacks (clients must poll). - Salted password hashing (using SHA-256 for demo simplicity). -- Horizontal scaling. \ No newline at end of file +- Horizontal scaling. +- Audit log tamper-resistance (simple append-only text file). \ No newline at end of file diff --git a/docs/database.md b/docs/database.md index 64d933f..3afafc1 100644 --- a/docs/database.md +++ b/docs/database.md @@ -44,7 +44,7 @@ SQLite schema, repository architecture, backup strategy, and security measures. | `start_time` | `TEXT` | `NOT NULL` | ISO-8601 UTC | | `end_time` | `TEXT` | `NOT NULL` | ISO-8601 UTC | | `cap_end_time` | `TEXT` | `NOT NULL` | Snipe limit (end_time + 10 min) | -| `status` | `TEXT` | `NOT NULL CHECK IN (...)` | ACTIVE, SOLD, EXPIRED, CANCELLED | +| `status` | `TEXT` | `NOT NULL CHECK IN ('ACTIVE', 'SOLD', 'EXPIRED', 'CANCELLED')` | | | `img1` | `TEXT` | | Filename or NULL | | `img2` | `TEXT` | | Filename or NULL | | `img3` | `TEXT` | | Filename or NULL | @@ -55,7 +55,7 @@ SQLite schema, repository architecture, backup strategy, and security measures. | Column | Type | Constraints | Notes | |--------|------|-------------|-------| | `id` | `INTEGER` | `PRIMARY KEY AUTOINCREMENT` | | -| `auction_id` | `INTEGER` | `NOT NULL REFERENCES auction_items(id) ON DELETE CASCADE` | | +| `auction_item_id` | `INTEGER` | `NOT NULL REFERENCES auction_items(id) ON DELETE CASCADE` | | | `bidder_username` | `TEXT` | `NOT NULL` | Bid actor username (FK to users.username) | | `amount_cents` | `INTEGER` | `NOT NULL CHECK > 0` | | | `timestamp` | `TEXT` | `NOT NULL` | ISO-8601 UTC | @@ -65,7 +65,7 @@ SQLite schema, repository architecture, backup strategy, and security measures. ## 3. Indexes ```sql -CREATE INDEX idx_bids_auction_id ON bids(auction_id); +CREATE INDEX idx_bids_auction_id ON bids(auction_item_id); CREATE INDEX idx_auction_status_end ON auction_items(status, end_time); CREATE INDEX idx_auction_seller ON auction_items(seller_username); ``` @@ -79,9 +79,9 @@ CREATE INDEX idx_auction_seller ON auction_items(seller_username); ## 4. Data Types: Why Cents? -All monetary values are stored as `INTEGER` cents, not `DOUBLE` dollars. +All monetary values are stored as `long` cents, not `double` dollars. -| Aspect | Double (dollars) | Integer (cents) | +| Aspect | Double (dollars) | Long (cents) | |--------|------------------|-----------------| | Equality checks | Brittle (`0.1 + 0.2 != 0.3`) | Exact (`10 + 20 == 30`) | | SQL constraints | Floating-point imprecision | Exact integer math | @@ -116,7 +116,7 @@ try { ```java // SQLite VACUUM INTO is atomic and non-blocking -String backupPath = "data/backup-" + timestamp + ".db"; +String backupPath = "data/backup-" + UUID.randomUUID().toString() + ".db"; try (Statement stmt = connection.createStatement()) { stmt.execute("VACUUM INTO '" + backupPath + "'"); } @@ -126,7 +126,7 @@ try (Statement stmt = connection.createStatement()) { | Feature | Implementation | |---------|----------------| | Trigger | Admin clicks "Backup" | -| Location | `data/auction_backup_YYYYMMDD.db` | +| Location | `data/backup_.db` | | Format | Full `.db` file (binary) | | Transfer | Returned as `byte[]` over RMI, saved via `FileChooser` | @@ -141,16 +141,16 @@ try (Statement stmt = connection.createStatement()) { | AuctionID | `id` | | | Title | `title` | | | Category | `category` | | -| StartingPrice | `starting_price_cents / 100` | Formatted | -| FinalPrice | `current_bid_cents / 100` | Same as starting if no bids | +| StartingPrice | `starting_price_cents / 100.0` | Formatted as double string | +| FinalPrice | `current_bid_cents / 100.0` | Same as starting if no bids | | Winner | `highest_bidder_username` | Empty string if none | | Status | `status` | ACTIVE, SOLD, EXPIRED, CANCELLED | -| StartTime | `start_time` | ISO-8601 | -| EndTime | `end_time` | ISO-8601 | +| StartTime | `start_time` | ISO-8601 UTC | +| EndTime | `end_time` | ISO-8601 UTC | ### Rules -- Only auctions where `seller_username = ?` +- Only auctions where `seller_username = ?` (or all if Admin) - All statuses included (not just active) - RFC 4180 escaping for commas, quotes, newlines in title/description @@ -161,7 +161,7 @@ try (Statement stmt = connection.createStatement()) { | Aspect | Implementation | |--------|----------------| | Passwords | SHA-256 hash only (no salt) — acceptable for university demo | -| Registration | Admin-only via `createUser()`; no public endpoint | +| Registration | Admin-only via `createUser()` or fixed admin seeding | | SQL Injection | Parameterized queries throughout | --- @@ -176,7 +176,7 @@ connection.createStatement().execute("PRAGMA foreign_keys = ON"); // Create tables if not exist (see schema above) // Insert default admin if users table empty -// Create directories: data/, logs/, resources/images/, resources/thumbs/ +// Create directories: data/, logs/, resources/images/, resources/thumbs/, exports/ ``` --- @@ -188,6 +188,5 @@ connection.createStatement().execute("PRAGMA foreign_keys = ON"); | Path | `logs/audit.log` | | Format | `[ISO-8601] [LEVEL] actor: description` | | Rotation | None (append-only, manual delete) | -| Examples | See architecture.md examples | -**Note:** The term "tamper-resistant" has been removed from documentation; it is a simple append-only text file. \ No newline at end of file +**Note:** The audit log is a simple append-only text file managed by `AsyncLogger`. \ No newline at end of file diff --git a/docs/table-of-contents.md b/docs/table-of-contents.md index 57ccd14..e078430 100644 --- a/docs/table-of-contents.md +++ b/docs/table-of-contents.md @@ -23,7 +23,7 @@ Master index of all documentation. Click any link to jump to that section. - [Authentication & User Management (6-12)](#authentication--user-management) - [Auction Browsing & Discovery (13-19)](#auction-browsing--discovery) - [Bidding (20-28)](#bidding) -- [Snipe Protection with Cap (29-30)](#snipe-protection-with-cap) +- [Snipe Protection with Cap (29-30)](#snipe-protection) - [User Activity Dashboard (31)](#user-activity-dashboard) - [Auction Creation & Management (32-41)](#auction-creation--management) - [Administration (42-44)](#administration) @@ -36,7 +36,7 @@ Master index of all documentation. Click any link to jump to that section. - [2. Networking](#2-networking--java-rmi--udp-discovery) - [3. RMI Interface Contract](#3-rmi-interface-contract) - [4. Database](#4-database--sqlite-via-jdbc) -- [5. Currency Model](#5-concurrency-model) +- [5. Currency Model](#5-currency-model--integer-cents) - [6. Concurrency Model](#6-concurrency-model) - [7. Snipe Protection with Cap](#7-snipe-protection-with-cap) - [8. Image Handling](#8-image-handling--lqip-pattern) @@ -51,7 +51,7 @@ Master index of all documentation. Click any link to jump to that section. - [Modules to test](#modules-to-test) ### 5. Out of Scope -- [12 items explicitly excluded](#out-of-scope) +- [Items explicitly excluded](#out-of-scope) ### 6. Demo & Grading Notes - [Cross-References](#cross-references) @@ -78,7 +78,6 @@ Master index of all documentation. Click any link to jump to that section. - [Auction Gallery](#3-auction-gallery-the-marketplace) - [Auction Detail View](#4-auction-detail-view-high-intensity) - [User Dashboard](#5-user-dashboard) -- [User Dashboard](#5-user-dashboard) - [Create/Edit Auction](#7-createedit-auction-dialog) - [Admin Panel](#8-admin-panel) @@ -95,26 +94,25 @@ Master index of all documentation. Click any link to jump to that section. ### 1. The Core Concept - English auction explanation -### 2. Key Terms -- Players: Server, Client, RMI, Admin, User - -### 3. Auction Rules -- Minimum increment, snipe protection, self-bid prevention +### 2. Server Architecture +- [Deep Modules](#2-server-architecture-deep-modules) -### 4. Auction Lifecycle -- [State machine](#4-auction-lifecycle-state-machine) +### 3. Component Interactions +- [Bidding Flow](#bidding-flow) +- [Expiration Flow](#expiration-flow-the-reaper) -### 5. The Auction Reaper -- Background thread that closes auctions +### 4. Key Mechanics +- [Minimum Increment](#minimum-increment-5-rule) +- [Snipe Protection with Cap](#snipe-protection-with-cap) +- [Clock Authority](#clock-authority-server-time) -### 6. RMI Interface Contract -- Session tokens, method signatures +### 5. RMI Interface Contract +- [IAuctionService](#5-rmi-interface-contract-iauctionservice) -### 7. Concurrency Model -- Per-auction locks, atomic transactions - -### 8. Clock Synchronization -- Server time offset for accurate countdowns +### 6. Concurrency Model +- [Server-Side Locking](#server-side-locking) +- [Atomic Bid Commit](#atomic-bid-commit) +- [Background Lifecycle](#background-lifecycle) --- @@ -125,7 +123,7 @@ Master index of all documentation. Click any link to jump to that section. ### 2. Schema Definition - [users table](#users-table) -- [auction_items table](#auctionitems-table) +- [auction_items table](#auction_items-table) - [bids table](#bids-table) ### 3. Indexes @@ -151,7 +149,7 @@ Master index of all documentation. Click any link to jump to that section. ## docs/networking.md ### 1. UDP Broadcast Discovery -- [Packet format](#packet-format) +- [Packet format (v1)](#packet-format-v1) - Broadcast parameters - Client discovery flow @@ -168,6 +166,9 @@ Master index of all documentation. Click any link to jump to that section. ### 5. Reconnection & Error Handling - Connection lost detection, recovery options +### 6. Multi-NIC Configuration +- RMI host binding + --- ## docs/demo-runbook.md @@ -202,11 +203,11 @@ Master index of all documentation. Click any link to jump to that section. | Topic | File | Section | |-------|------|---------| -| User Stories | RTDAS_PRD.md | Line ~23 | -| State Machine | architecture.md | §4 | +| User Stories | RTDAS_PRD.md | §2 | +| State Machine | RTDAS_PRD.md | §10 | | Database Schema | database.md | §2 | | RMI Contract | RTDAS_PRD.md | §3 | -| Session Tokens | architecture.md | §6 | +| Concurrency Strategy | architecture.md | §6 | | UI Screens | DESIGN.md | §4 | -| Concurrency Rules | architecture.md | §7 | -| Demo Commands | demo-runbook.md | §2 | \ No newline at end of file +| Locking Discipline | architecture.md | §6 | +| Demo Commands | demo-runbook.md | §2 | diff --git a/src/main/java/com/auction/client/controllers/AdminPanelController.java b/src/main/java/com/auction/client/controllers/AdminPanelController.java index 02b975c..977dc61 100644 --- a/src/main/java/com/auction/client/controllers/AdminPanelController.java +++ b/src/main/java/com/auction/client/controllers/AdminPanelController.java @@ -10,9 +10,8 @@ public class AdminPanelController { @FXML private javafx.scene.control.TableView usersTable; @FXML private javafx.scene.control.ListView auditListView; - @FXML private javafx.scene.control.TextField usernameField; - @FXML private javafx.scene.control.PasswordField passwordField; - @FXML private javafx.scene.control.ComboBox roleCombo; + @FXML private javafx.scene.control.TextField searchField; + @FXML private javafx.scene.control.TextField promoteField; @FXML private javafx.scene.control.Label statusLabel; @FXML private Label totalUsersLabel; @FXML private Label adminUsersLabel; @@ -22,12 +21,6 @@ public class AdminPanelController { @FXML public void initialize() { - roleCombo.getItems().addAll( - com.auction.shared.Constants.USER, - com.auction.shared.Constants.ADMIN - ); - roleCombo.getSelectionModel().selectFirst(); - refreshDashboard(); } @@ -61,35 +54,38 @@ private void refreshDashboard() { } @FXML - private void handleCreateUser() { + private void handleSearchUser() { try { - String u = usernameField.getText(); - String p = passwordField.getText(); - String r = roleCombo.getValue(); - + String query = searchField.getText(); com.auction.client.core.ClientContext context = com.auction.client.core.ClientContext.getInstance(); - context.getRmiProvider().getService().createUser(u, p, r, context.getSessionToken()); - statusLabel.setText("User created successfully"); - usernameField.clear(); - passwordField.clear(); - refreshDashboard(); + java.util.List users; + if (query == null || query.trim().isEmpty()) { + users = context.getRmiProvider().getService().getAllUsers(context.getSessionToken()); + } else { + users = context.getRmiProvider().getService().searchUsers(query, context.getSessionToken()); + } + usersTable.getItems().setAll(users); + statusLabel.setText("Search completed."); } catch (java.rmi.RemoteException e) { com.auction.client.core.ClientContext.getInstance().handleConnectionLost(); } catch (Exception e) { - statusLabel.setText("Creation failed: " + e.getMessage()); + statusLabel.setText("Search failed: " + e.getMessage()); } } @FXML - private void handleBackup() { + private void handlePromoteUser() { try { + String username = promoteField.getText(); com.auction.client.core.ClientContext context = com.auction.client.core.ClientContext.getInstance(); - byte[] db = context.getRmiProvider().getService().backupDatabase(context.getSessionToken()); - java.io.File file = new java.io.File("backup_" + System.currentTimeMillis() + ".db"); - java.nio.file.Files.write(file.toPath(), db); - statusLabel.setText("Database backed up to " + file.getAbsolutePath()); + context.getRmiProvider().getService().promoteUserToAdmin(username, context.getSessionToken()); + statusLabel.setText("User promoted to admin successfully"); + promoteField.clear(); + handleSearchUser(); // Refresh the table + } catch (java.rmi.RemoteException e) { + com.auction.client.core.ClientContext.getInstance().handleConnectionLost(); } catch (Exception e) { - statusLabel.setText("Backup failed: " + e.getMessage()); + statusLabel.setText("Promotion failed: " + e.getMessage()); } } diff --git a/src/main/java/com/auction/client/controllers/UserDashboardController.java b/src/main/java/com/auction/client/controllers/UserDashboardController.java index ab32a3d..b7ba506 100644 --- a/src/main/java/com/auction/client/controllers/UserDashboardController.java +++ b/src/main/java/com/auction/client/controllers/UserDashboardController.java @@ -291,6 +291,10 @@ private byte[] pickImage() { ); java.io.File f = fc.showOpenDialog(marketTable.getScene().getWindow()); if (f != null) { + if (f.length() > com.auction.shared.Constants.MAX_IMAGE_SIZE_BYTES) { + statusLabel.setText("Image exceeds 2MB limit."); + return null; + } try { return java.nio.file.Files.readAllBytes(f.toPath()); } catch (Exception e) { diff --git a/src/main/java/com/auction/server/core/AdminManager.java b/src/main/java/com/auction/server/core/AdminManager.java new file mode 100644 index 0000000..2aa374f --- /dev/null +++ b/src/main/java/com/auction/server/core/AdminManager.java @@ -0,0 +1,65 @@ +package com.auction.server.core; + +import com.auction.server.repository.UserRepository; +import com.auction.shared.Constants; +import com.auction.shared.exceptions.AuctionException; +import com.auction.shared.exceptions.UnauthorizedException; +import com.auction.shared.models.AuctionItem; +import com.auction.shared.models.User; +import com.auction.server.core.logging.AsyncLogger; +import com.auction.server.core.logging.LogCategory; +import com.auction.server.core.logging.EventType; + +import java.util.Collections; +import java.util.List; + +public class AdminManager { + private final AuctionManager auctionManager; + private final UserRepository userRepo; + + public AdminManager(AuctionManager auctionManager, UserRepository userRepo) { + this.auctionManager = auctionManager; + this.userRepo = userRepo; + } + + public byte[] exportAuctionsToCSV(SessionContext context) throws AuctionException { + List items; + if (Constants.ADMIN.equals(context.role())) { + items = auctionManager.getActiveAuctions(); + } else if (Constants.USER.equals(context.role())) { + items = auctionManager.findAuctionsBySeller(context.username()); + } else { + throw new UnauthorizedException("Only users or admins can export auctions to CSV"); + } + + byte[] csvData = com.auction.server.util.CsvExportUtil.generateAuctionsCsv(items); + AsyncLogger.log(LogCategory.SYSTEM, EventType.CSV_EXPORT, + "User=" + context.username() + " Count=" + items.size()); + return csvData; + } + + public List getAuditLogs(int lastNLines, SessionContext context) throws AuctionException { + try { + return com.auction.server.util.AdminUtil.readAuditLogs(lastNLines); + } catch (Exception e) { + throw new AuctionException("Failed to read audit logs: " + e.getMessage()); + } + } + + public List getAllUsers(SessionContext context) { + return userRepo.findAllUsers(); + } + + public List searchUsers(String query, SessionContext context) { + return userRepo.searchUsers(query); + } + + public void promoteUserToAdmin(String username, SessionContext context) throws AuctionException { + if (userRepo.findUserByUsername(username) == null) { + throw new AuctionException("User not found"); + } + userRepo.promoteUserToAdmin(username); + AsyncLogger.log(LogCategory.SECURITY, EventType.LOGIN, + "Admin=" + context.username() + " PromotedUser=" + username); + } +} diff --git a/src/main/java/com/auction/server/core/ImageStore.java b/src/main/java/com/auction/server/core/ImageStore.java index 950614f..fd0fbf1 100644 --- a/src/main/java/com/auction/server/core/ImageStore.java +++ b/src/main/java/com/auction/server/core/ImageStore.java @@ -1,6 +1,13 @@ package com.auction.server.core; +import com.auction.shared.Constants; import com.auction.server.repository.AuctionRepository; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -8,15 +15,13 @@ /** * Deep module for media persistence. - * Handles filesystem storage and database path synchronization. - * Provides a high-leverage interface that hides byte-to-file orchestration. + * Handles filesystem storage, JPG re-encoding, and thumbnail generation. */ public class ImageStore { - private static final String IMAGES_DIR = "data/images"; - private static final String THUMBS_DIR = "data/thumbs"; - private final AuctionRepository auctionRepo; + private static final byte[] PLACEHOLDER_BYTES = createPlaceholder(); + public ImageStore(AuctionRepository auctionRepo) { this.auctionRepo = auctionRepo; ensureDirectoriesExist(); @@ -24,39 +29,32 @@ public ImageStore(AuctionRepository auctionRepo) { private void ensureDirectoriesExist() { try { - Files.createDirectories(Paths.get(IMAGES_DIR)); - Files.createDirectories(Paths.get(THUMBS_DIR)); + Files.createDirectories(Paths.get(Constants.IMAGES_DIR)); + Files.createDirectories(Paths.get(Constants.THUMBS_DIR)); } catch (IOException e) { throw new RuntimeException("Could not initialize image storage", e); } } - /** - * Saves provided image payloads to disk and returns the paths. - * Generates random UUIDs for names instead of depending on auction ID. - */ public String[] stageImages(byte[] i1, byte[] i2, byte[] i3) { String baseId = java.util.UUID.randomUUID().toString(); - String p1 = saveToDisk(baseId, 1, i1, true); - String p2 = saveToDisk(baseId, 2, i2, false); - String p3 = saveToDisk(baseId, 3, i3, false); + String p1 = saveProcessedToDisk(baseId, 1, i1, true); + String p2 = saveProcessedToDisk(baseId, 2, i2, false); + String p3 = saveProcessedToDisk(baseId, 3, i3, false); return new String[]{p1, p2, p3}; } - /** - * Deletes staged images if the database transaction fails. - */ public void deleteStagedImages(String[] paths) { if (paths == null) return; for (String pathStr : paths) { if (pathStr != null) { try { - Files.deleteIfExists(Paths.get(pathStr)); - // Also try to delete thumb if it was the first image - if (pathStr.endsWith("_1.jpg")) { - String thumbName = Paths.get(pathStr).getFileName().toString().replace(".jpg", "_thumb.jpg"); - Files.deleteIfExists(Paths.get(THUMBS_DIR, thumbName)); + Path path = Paths.get(pathStr); + Files.deleteIfExists(path); + if (pathStr.contains("_1.jpg")) { + String thumbName = path.getFileName().toString().replace(".jpg", "_thumb.jpg"); + Files.deleteIfExists(Paths.get(Constants.THUMBS_DIR, thumbName)); } } catch (IOException e) { System.err.println("Failed to delete orphaned image: " + pathStr); @@ -65,26 +63,23 @@ public void deleteStagedImages(String[] paths) { } } - /** - * Loads full image bytes from disk. - */ public byte[] loadFullImage(String path) { - return readBytes(path); + return readBytes(path, true); } - /** - * Loads thumbnail bytes. If path points to full image, it should ideally - * point to the generated thumb in THUMBS_DIR. - */ public byte[] loadThumbnail(String img1Path) { - if (img1Path == null) return new byte[0]; - String thumbName = Paths.get(img1Path).getFileName().toString().replace(".jpg", "_thumb.jpg"); - Path path = Paths.get(THUMBS_DIR, thumbName); - return readBytes(path.toString()); + if (img1Path == null) return PLACEHOLDER_BYTES; + try { + String thumbName = Paths.get(img1Path).getFileName().toString().replace(".jpg", "_thumb.jpg"); + Path path = Paths.get(Constants.THUMBS_DIR, thumbName); + return readBytes(path.toString(), false); + } catch (Exception e) { + return PLACEHOLDER_BYTES; + } } - private byte[] readBytes(String pathStr) { - if (pathStr == null || pathStr.isEmpty()) return new byte[0]; + private byte[] readBytes(String pathStr, boolean usePlaceholder) { + if (pathStr == null || pathStr.isEmpty()) return usePlaceholder ? PLACEHOLDER_BYTES : new byte[0]; try { Path path = Paths.get(pathStr); if (Files.exists(path)) { @@ -93,27 +88,71 @@ private byte[] readBytes(String pathStr) { } catch (IOException e) { System.err.println("Failed to read image: " + pathStr); } - return new byte[0]; + return usePlaceholder ? PLACEHOLDER_BYTES : new byte[0]; } - private String saveToDisk(String baseId, int index, byte[] data, boolean generateThumb) { + private String saveProcessedToDisk(String baseId, int index, byte[] data, boolean generateThumb) { if (data == null || data.length == 0) return null; String filename = baseId + "_" + index + ".jpg"; - Path path = Paths.get(IMAGES_DIR, filename); + Path path = Paths.get(Constants.IMAGES_DIR, filename); try { - Files.write(path, data); + byte[] jpgData = reencodeToJpg(data); + Files.write(path, jpgData); if (generateThumb) { - // Simplified: just copy to thumb dir for now, or imagine a resize logic here - Path thumbPath = Paths.get(THUMBS_DIR, baseId + "_" + index + "_thumb.jpg"); - Files.copy(path, thumbPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + byte[] thumbData = generateThumbnail(jpgData); + Path thumbPath = Paths.get(Constants.THUMBS_DIR, baseId + "_" + index + "_thumb.jpg"); + Files.write(thumbPath, thumbData); } return path.toString(); } catch (IOException e) { - System.err.println("Failed to save image " + filename + ": " + e.getMessage()); + System.err.println("Failed to process/save image " + filename + ": " + e.getMessage()); return null; } } + + private byte[] reencodeToJpg(byte[] originalData) throws IOException { + BufferedImage img = ImageIO.read(new ByteArrayInputStream(originalData)); + if (img == null) throw new IOException("Invalid image data"); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "jpg", baos); + return baos.toByteArray(); + } + + private byte[] generateThumbnail(byte[] jpgData) throws IOException { + BufferedImage original = ImageIO.read(new ByteArrayInputStream(jpgData)); + if (original == null) throw new IOException("Invalid image data"); + + int size = Math.min(original.getWidth(), original.getHeight()); + int x = (original.getWidth() - size) / 2; + int y = (original.getHeight() - size) / 2; + + BufferedImage cropped = original.getSubimage(x, y, size, size); + BufferedImage thumb = new BufferedImage(Constants.THUMBNAIL_SIZE, Constants.THUMBNAIL_SIZE, BufferedImage.TYPE_INT_RGB); + Graphics2D g = thumb.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(cropped, 0, 0, Constants.THUMBNAIL_SIZE, Constants.THUMBNAIL_SIZE, null); + g.dispose(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(thumb, "jpg", baos); + return baos.toByteArray(); + } + + private static byte[] createPlaceholder() { + BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.LIGHT_GRAY); + g.fillRect(0, 0, 100, 100); + g.setColor(Color.DARK_GRAY); + g.setFont(new Font("Arial", Font.BOLD, 12)); + g.drawString("NO IMAGE", 20, 55); + g.dispose(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { ImageIO.write(img, "jpg", baos); } catch (IOException ignored) {} + return baos.toByteArray(); + } } diff --git a/src/main/java/com/auction/server/core/ServerBootstrap.java b/src/main/java/com/auction/server/core/ServerBootstrap.java index af42587..e0c45a3 100644 --- a/src/main/java/com/auction/server/core/ServerBootstrap.java +++ b/src/main/java/com/auction/server/core/ServerBootstrap.java @@ -39,8 +39,11 @@ public ServerBootstrap() throws Exception { LifecycleManager lifecycleManager = new LifecycleManager(auctionRepo, bidRepo, lockManager, txManager); ImageStore imageStore = new ImageStore(auctionRepo); + SessionManager sessionManager = new SessionManager(userRepo); + AdminManager adminManager = new AdminManager(auctionManager, userRepo); + // 4. Init Service - this.service = new AuctionServiceImpl(userRepo, auctionManager, lifecycleManager, imageStore); + this.service = new AuctionServiceImpl(auctionManager, sessionManager, adminManager, imageStore); // 5. Setup RMI int port = Constants.DEFAULT_RMI_PORT; diff --git a/src/main/java/com/auction/server/core/SessionManager.java b/src/main/java/com/auction/server/core/SessionManager.java new file mode 100644 index 0000000..99a27d0 --- /dev/null +++ b/src/main/java/com/auction/server/core/SessionManager.java @@ -0,0 +1,99 @@ +package com.auction.server.core; + +import com.auction.server.repository.UserRepository; +import com.auction.shared.Constants; +import com.auction.shared.exceptions.AuctionException; +import com.auction.shared.exceptions.UnauthorizedException; +import com.auction.shared.models.User; +import com.auction.server.core.logging.AsyncLogger; +import com.auction.server.core.logging.LogCategory; +import com.auction.server.core.logging.EventType; +import com.auction.server.util.SecurityUtil; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; + +public class SessionManager { + private final UserRepository userRepo; + + private static class SessionInfo { + final SessionContext context; + Instant expiresAt; + + SessionInfo(SessionContext context, Instant expiresAt) { + this.context = context; + this.expiresAt = expiresAt; + } + } + + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + public SessionManager(UserRepository userRepo) { + this.userRepo = userRepo; + } + + public String login(String username, String password) throws AuctionException { + User u = userRepo.findUserByUsername(username); + if (u != null) { + String hash = SecurityUtil.hashPassword(password); + if (u.getPasswordHash().equals(hash)) { + String token = java.util.UUID.randomUUID().toString(); + SessionContext ctx = new SessionContext(username, u.getRoleType()); + sessions.put(token, new SessionInfo(ctx, Instant.now().plus(Duration.ofMinutes(Constants.SESSION_TTL_MINUTES)))); + AsyncLogger.log(LogCategory.SECURITY, EventType.LOGIN, "User=" + username); + return token; + } + } + AsyncLogger.log(LogCategory.SECURITY, EventType.LOGIN_FAILED, "Username=" + username); + throw new AuctionException("Invalid username or password"); + } + + public void register(String username, String password, String role) throws AuctionException { + if (userRepo.findUserByUsername(username) != null) { + throw new AuctionException("Username already exists"); + } + String hash = SecurityUtil.hashPassword(password); + userRepo.insertUser(username, hash, role); + AsyncLogger.log(LogCategory.SECURITY, EventType.CREATE_AUCTION, "New User Registered: " + username + " Role=" + role); + } + + public SessionContext validateSession(String token) throws AuctionException { + if (token == null || token.isEmpty()) { + throw new UnauthorizedException("Session token is missing"); + } + SessionInfo sessionInfo = sessions.get(token); + if (sessionInfo == null) { + throw new UnauthorizedException("Invalid session token"); + } + if (Instant.now().isAfter(sessionInfo.expiresAt)) { + sessions.remove(token); + throw new UnauthorizedException("Session token has expired"); + } + // Slide token expiration window + sessionInfo.expiresAt = Instant.now().plus(Duration.ofMinutes(Constants.SESSION_TTL_MINUTES)); + return sessionInfo.context; + } + + public SessionContext validateRole(String token, String... allowedRoles) throws AuctionException { + SessionContext context = validateSession(token); + boolean allowed = false; + for (String role : allowedRoles) { + if (role.equals(context.role())) { + allowed = true; + break; + } + } + if (!allowed) { + throw new UnauthorizedException("Access denied: insufficient permissions"); + } + return context; + } + + public void logout(String token) { + SessionInfo sessionInfo = sessions.remove(token); + if (sessionInfo != null) { + AsyncLogger.log(LogCategory.SECURITY, EventType.LOGOUT, "User=" + sessionInfo.context.username()); + } + } +} diff --git a/src/main/java/com/auction/server/repository/UserRepository.java b/src/main/java/com/auction/server/repository/UserRepository.java index 4063b8b..870b2b3 100644 --- a/src/main/java/com/auction/server/repository/UserRepository.java +++ b/src/main/java/com/auction/server/repository/UserRepository.java @@ -84,4 +84,35 @@ public List findAllUsers() { } return users; } + public List searchUsers(String query) { + List users = new ArrayList<>(); + String sql = "SELECT username, password_hash, role, created_at FROM users WHERE username LIKE ?"; + try (var pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, "%" + query + "%"); + try (var rs = pstmt.executeQuery()) { + while (rs.next()) { + String u = rs.getString("username"); + String p = rs.getString("password_hash"); + String r = rs.getString("role"); + String createdAt = rs.getString("created_at"); + if (Constants.ADMIN.equals(r)) users.add(new Admin(u, p, createdAt)); + else if (Constants.USER.equals(r)) users.add(new User(u, p, Constants.USER, createdAt)); + } + } + } catch (SQLException e) { + throw new RuntimeException("Failed to search users", e); + } + return users; + } + + public void promoteUserToAdmin(String username) { + String sql = "UPDATE users SET role = ? WHERE username = ?"; + try (var pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, Constants.ADMIN); + pstmt.setString(2, username); + pstmt.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException("Failed to promote user", e); + } + } } diff --git a/src/main/java/com/auction/server/service/AuctionServiceImpl.java b/src/main/java/com/auction/server/service/AuctionServiceImpl.java index 1b6fe3e..2dd6a0e 100644 --- a/src/main/java/com/auction/server/service/AuctionServiceImpl.java +++ b/src/main/java/com/auction/server/service/AuctionServiceImpl.java @@ -5,159 +5,57 @@ import com.auction.shared.interfaces.IAuctionService; import com.auction.shared.models.*; import com.auction.server.core.AuctionManager; -import com.auction.server.core.LifecycleManager; +import com.auction.server.core.SessionManager; +import com.auction.server.core.AdminManager; import com.auction.server.core.ImageStore; import com.auction.server.core.SessionContext; -import com.auction.server.repository.UserRepository; import com.auction.server.core.logging.AsyncLogger; import com.auction.server.core.logging.LogCategory; import com.auction.server.core.logging.EventType; -import com.auction.server.util.SecurityUtil; import java.rmi.RemoteException; import java.rmi.server.UnicastRemoteObject; -import java.time.Duration; import java.time.Instant; -import java.util.Collections; import java.util.List; -import java.util.ArrayList; -import java.util.concurrent.ConcurrentHashMap; -/** - * RMI service implementation. Extends UnicastRemoteObject for automatic RMI export. - * Implements security, per-IP rate limiting, and delegates all business logic to core managers. - */ public class AuctionServiceImpl extends UnicastRemoteObject implements IAuctionService { private static final long serialVersionUID = 1L; private final AuctionManager auctionManager; - private final LifecycleManager lifecycleManager; + private final SessionManager sessionManager; + private final AdminManager adminManager; private final ImageStore imageStore; - private final UserRepository userRepo; - private static class SessionInfo { - final SessionContext context; - Instant expiresAt; - - SessionInfo(SessionContext context, Instant expiresAt) { - this.context = context; - this.expiresAt = expiresAt; - } - } - - private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> loginAttempts = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> bidAttempts = new ConcurrentHashMap<>(); - - public AuctionServiceImpl(UserRepository userRepo, AuctionManager auctionManager, - LifecycleManager lifecycleManager, ImageStore imageStore) throws RemoteException { + public AuctionServiceImpl(AuctionManager auctionManager, + SessionManager sessionManager, + AdminManager adminManager, + ImageStore imageStore) throws RemoteException { super(); - this.userRepo = userRepo; this.auctionManager = auctionManager; - this.lifecycleManager = lifecycleManager; + this.sessionManager = sessionManager; + this.adminManager = adminManager; this.imageStore = imageStore; } - // --- Rate Limiting & Session Helpers --- - - private String getClientIp() { - try { - return java.rmi.server.RemoteServer.getClientHost(); - } catch (java.rmi.server.ServerNotActiveException e) { - return "local"; - } - } - - private void checkRateLimit(String ip, ConcurrentHashMap> attempts, int maxRequests, int windowSeconds) throws RateLimitedException { - Instant now = Instant.now(); - Instant limitTime = now.minus(Duration.ofSeconds(windowSeconds)); - attempts.compute(ip, (k, list) -> { - if (list == null) list = new ArrayList<>(); - list.removeIf(t -> t.isBefore(limitTime)); - list.add(now); - return list; - }); - List list = attempts.get(ip); - if (list != null && list.size() > maxRequests) { - throw new RateLimitedException("Too many requests. Please wait."); - } - } - - private SessionContext validateSession(String token) throws AuctionException { - if (token == null || token.isEmpty()) { - throw new UnauthorizedException("Session token is missing"); - } - SessionInfo sessionInfo = sessions.get(token); - if (sessionInfo == null) { - throw new UnauthorizedException("Invalid session token"); - } - if (Instant.now().isAfter(sessionInfo.expiresAt)) { - sessions.remove(token); - throw new UnauthorizedException("Session token has expired"); - } - // Slide token expiration window - sessionInfo.expiresAt = Instant.now().plus(Duration.ofMinutes(Constants.SESSION_TTL_MINUTES)); - return sessionInfo.context; - } - - private SessionContext validateRole(String token, String... allowedRoles) throws AuctionException { - SessionContext context = validateSession(token); - boolean allowed = false; - for (String role : allowedRoles) { - if (role.equals(context.role())) { - allowed = true; - break; - } - } - if (!allowed) { - throw new UnauthorizedException("Access denied: insufficient permissions"); - } - return context; - } - // --- Authentication --- - @Override public String login(String username, String password) throws RemoteException, AuctionException { - checkRateLimit(getClientIp(), loginAttempts, 5, 10); - User u = userRepo.findUserByUsername(username); - if (u != null) { - String hash = SecurityUtil.hashPassword(password); - if (u.getPasswordHash().equals(hash)) { - String token = java.util.UUID.randomUUID().toString(); - SessionContext ctx = new SessionContext(username, u.getRoleType()); - sessions.put(token, new SessionInfo(ctx, Instant.now().plus(Duration.ofMinutes(Constants.SESSION_TTL_MINUTES)))); - AsyncLogger.log(LogCategory.SECURITY, EventType.LOGIN, "User=" + username); - return token; - } - } - AsyncLogger.log(LogCategory.SECURITY, EventType.LOGIN_FAILED, "Username=" + username); - throw new AuctionException("Invalid username or password"); + return sessionManager.login(username, password); } @Override public void register(String username, String password, String role) throws RemoteException, AuctionException { - checkRateLimit(getClientIp(), loginAttempts, 5, 10); - if (userRepo.findUserByUsername(username) != null) { - throw new AuctionException("Username already exists"); - } - String hash = SecurityUtil.hashPassword(password); - userRepo.insertUser(username, hash, role); - AsyncLogger.log(LogCategory.SECURITY, EventType.CREATE_AUCTION, "New User Registered: " + username + " Role=" + role); + sessionManager.register(username, password, role); } @Override public String getMyRole(String token) throws RemoteException, AuctionException { - return validateSession(token).role(); + return sessionManager.validateSession(token).role(); } - @Override public void logout(String token) throws RemoteException { - SessionInfo sessionInfo = sessions.remove(token); - if (sessionInfo != null) { - AsyncLogger.log(LogCategory.SECURITY, EventType.LOGOUT, "User=" + sessionInfo.context.username()); - } + sessionManager.logout(token); } @Override @@ -166,7 +64,6 @@ public String serverTime() throws RemoteException { } // --- Auction Browsing --- - @Override public List getActiveAuctions() throws RemoteException { return auctionManager.getActiveAuctions(); @@ -174,7 +71,7 @@ public List getActiveAuctions() throws RemoteException { @Override public List getActiveAuctionsBySeller(String sellerUsername, String token) throws RemoteException, AuctionException { - validateSession(token); + sessionManager.validateSession(token); return auctionManager.findActiveAuctionsBySeller(sellerUsername); } @@ -184,13 +81,10 @@ public AuctionItem getAuctionById(int auctionId) throws RemoteException { } // --- Bidding --- - @Override public void placeBid(int auctionId, long amountCents, long clientExpectedPriceCents, String token) throws RemoteException, AuctionException { - checkRateLimit(getClientIp(), bidAttempts, 10, 10); - SessionContext context = validateRole(token, Constants.USER); - + SessionContext context = sessionManager.validateRole(token, Constants.USER); try { auctionManager.placeBid(auctionId, context, amountCents, clientExpectedPriceCents); } catch (AuctionException e) { @@ -206,12 +100,10 @@ public List getBidHistory(int auctionId) throws RemoteException { } // --- Auction Management (Seller) --- - @Override public int createAuction(AuctionItem item, byte[] image1, byte[] image2, byte[] image3, String token) throws RemoteException, AuctionException { - SessionContext context = validateRole(token, Constants.USER); - + SessionContext context = sessionManager.validateRole(token, Constants.USER); String[] stagedPaths = null; try { stagedPaths = imageStore.stageImages(image1, image2, image3); @@ -227,8 +119,7 @@ public int createAuction(AuctionItem item, byte[] image1, byte[] image2, byte[] @Override public void cancelAuction(int auctionId, String token) throws RemoteException, AuctionException { - SessionContext context = validateRole(token, Constants.USER, Constants.ADMIN); - + SessionContext context = sessionManager.validateRole(token, Constants.USER, Constants.ADMIN); try { auctionManager.cancelAuction(auctionId, context); } catch (AuctionException e) { @@ -241,8 +132,7 @@ public void cancelAuction(int auctionId, String token) throws RemoteException, A @Override public void relistAuction(int auctionId, String newEndTimeIso, String token) throws RemoteException, AuctionException { - SessionContext context = validateRole(token, Constants.USER, Constants.ADMIN); - + SessionContext context = sessionManager.validateRole(token, Constants.USER, Constants.ADMIN); try { auctionManager.relistAuction(auctionId, newEndTimeIso, context); } catch (AuctionException e) { @@ -253,21 +143,19 @@ public void relistAuction(int auctionId, String newEndTimeIso, String token) } // --- Bidder Activity --- - @Override public List getMyBids(String token) throws RemoteException, AuctionException { - SessionContext context = validateRole(token, Constants.USER); + SessionContext context = sessionManager.validateRole(token, Constants.USER); return auctionManager.findBidsByBidder(context.username()); } @Override public List getMyWonAuctions(String token) throws RemoteException, AuctionException { - SessionContext context = validateRole(token, Constants.USER); + SessionContext context = sessionManager.validateRole(token, Constants.USER); return auctionManager.findWonAuctionsByBidder(context.username()); } // --- Image Handling (LQIP) --- - @Override public byte[] getThumbnail(int auctionId, int imageIndex) throws RemoteException { AuctionItem item = auctionManager.getAuctionById(auctionId); @@ -287,102 +175,34 @@ public byte[] getFullImage(int auctionId, int imageIndex) throws RemoteException } // --- Data Export --- - @Override public byte[] exportAuctionsToCSV(String token) throws RemoteException, AuctionException { - SessionContext context = validateSession(token); - List items; - if (Constants.ADMIN.equals(context.role())) { - items = auctionManager.getActiveAuctions(); - } else if (Constants.USER.equals(context.role())) { - items = auctionManager.findAuctionsBySeller(context.username()); - } else { - throw new UnauthorizedException("Only users or admins can export auctions to CSV"); - } - - - StringBuilder sb = new StringBuilder(); - sb.append("AuctionID,Title,Category,StartingPrice,FinalPrice,Winner,Status,StartTime,EndTime\n"); - for (AuctionItem item : items) { - sb.append(item.getId()).append(","); - sb.append(escapeCsvField(item.getTitle())).append(","); - sb.append(escapeCsvField(item.getCategory())).append(","); - sb.append(item.getStartingPriceCents() / 100.0).append(","); - sb.append(item.getCurrentBidCents() / 100.0).append(","); - sb.append(escapeCsvField(item.getHighestBidderUsername() != null ? item.getHighestBidderUsername() : "")).append(","); - sb.append(escapeCsvField(item.getStatus())).append(","); - sb.append(escapeCsvField(item.getStartTime())).append(","); - sb.append(escapeCsvField(item.getEndTime())).append("\n"); - } - AsyncLogger.log(LogCategory.SYSTEM, EventType.CSV_EXPORT, "User=" + context.username() + " Count=" + items.size()); - return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); - } - - private String escapeCsvField(String field) { - if (field == null) return ""; - if (field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r")) { - return "\"" + field.replace("\"", "\"\"") + "\""; - } - return field; + SessionContext context = sessionManager.validateSession(token); + return adminManager.exportAuctionsToCSV(context); } // --- Administration --- - @Override - public void createUser(String newUsername, String password, String role, String token) - throws RemoteException, AuctionException { - SessionContext context = validateRole(token, Constants.ADMIN); - if (userRepo.findUserByUsername(newUsername) != null) { - throw new AuctionException("Username already exists"); - } - String hash = SecurityUtil.hashPassword(password); - userRepo.insertUser(newUsername, hash, role); - AsyncLogger.log(LogCategory.SECURITY, EventType.CREATE_AUCTION, "Admin=" + context.username() + " NewUser=" + newUsername + " Role=" + role); + public List getAllUsers(String token) throws RemoteException, AuctionException { + SessionContext context = sessionManager.validateRole(token, Constants.ADMIN); + return adminManager.getAllUsers(context); } @Override - public List getAllUsers(String token) throws RemoteException, AuctionException { - validateRole(token, Constants.ADMIN); - return userRepo.findAllUsers(); + public List searchUsers(String query, String token) throws RemoteException, AuctionException { + SessionContext context = sessionManager.validateRole(token, Constants.ADMIN); + return adminManager.searchUsers(query, context); } @Override - public byte[] backupDatabase(String token) throws RemoteException, AuctionException { - SessionContext context = validateRole(token, Constants.ADMIN); - java.io.File tempFile = new java.io.File("data/backup_" + java.util.UUID.randomUUID().toString() + ".db"); - try { - String sql = "VACUUM INTO '" + tempFile.getAbsolutePath().replace("\\", "/") + "'"; - try (var stmt = userRepo.getConnection().createStatement()) { - stmt.execute(sql); - } - byte[] bytes = java.nio.file.Files.readAllBytes(tempFile.toPath()); - AsyncLogger.log(LogCategory.SYSTEM, EventType.DB_BACKUP, "User=" + context.username() + " Size=" + bytes.length); - return bytes; - } catch (Exception e) { - throw new AuctionException("Failed to backup database: " + e.getMessage()); - } finally { - if (tempFile.exists()) { - tempFile.delete(); - } - } + public void promoteUserToAdmin(String username, String token) throws RemoteException, AuctionException { + SessionContext context = sessionManager.validateRole(token, Constants.ADMIN); + adminManager.promoteUserToAdmin(username, context); } @Override - public List getAuditLogs(int lastNLines, String token) - throws RemoteException, AuctionException { - validateRole(token, Constants.ADMIN); - java.io.File file = new java.io.File("logs/audit.log"); - if (!file.exists()) { - return Collections.emptyList(); - } - try { - List lines = java.nio.file.Files.readAllLines(file.toPath()); - if (lines.size() <= lastNLines) { - return lines; - } - return lines.subList(lines.size() - lastNLines, lines.size()); - } catch (Exception e) { - throw new AuctionException("Failed to read audit logs: " + e.getMessage()); - } + public List getAuditLogs(int lastNLines, String token) throws RemoteException, AuctionException { + SessionContext context = sessionManager.validateRole(token, Constants.ADMIN); + return adminManager.getAuditLogs(lastNLines, context); } } diff --git a/src/main/java/com/auction/server/util/AdminUtil.java b/src/main/java/com/auction/server/util/AdminUtil.java new file mode 100644 index 0000000..b408f76 --- /dev/null +++ b/src/main/java/com/auction/server/util/AdminUtil.java @@ -0,0 +1,31 @@ +package com.auction.server.util; + +import com.auction.shared.Constants; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Collections; +import java.util.List; + +/** + * Utility for administrative helper functions like log reading. + */ +public final class AdminUtil { + private AdminUtil() {} + + /** + * Reads the last N lines from the audit log file. + */ + public static List readAuditLogs(int lastNLines) throws IOException { + File file = new File(Constants.AUDIT_LOG_PATH); + if (!file.exists()) { + return Collections.emptyList(); + } + + List lines = Files.readAllLines(file.toPath()); + if (lines.size() <= lastNLines) { + return lines; + } + return lines.subList(lines.size() - lastNLines, lines.size()); + } +} diff --git a/src/main/java/com/auction/server/util/CsvExportUtil.java b/src/main/java/com/auction/server/util/CsvExportUtil.java new file mode 100644 index 0000000..b7af482 --- /dev/null +++ b/src/main/java/com/auction/server/util/CsvExportUtil.java @@ -0,0 +1,40 @@ +package com.auction.server.util; + +import com.auction.shared.models.AuctionItem; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Utility for generating CSV exports for auction data. + */ +public final class CsvExportUtil { + private CsvExportUtil() {} + + public static byte[] generateAuctionsCsv(List items) { + StringBuilder sb = new StringBuilder(); + sb.append("AuctionID,Title,Category,StartingPrice,FinalPrice,Winner,Status,StartTime,EndTime\n"); + + for (AuctionItem item : items) { + sb.append(item.getId()).append(","); + sb.append(escapeCsvField(item.getTitle())).append(","); + sb.append(escapeCsvField(item.getCategory())).append(","); + sb.append(item.getStartingPriceCents() / 100.0).append(","); + sb.append(item.getCurrentBidCents() / 100.0).append(","); + sb.append(escapeCsvField(item.getHighestBidderUsername() != null ? item.getHighestBidderUsername() : "")) + .append(","); + sb.append(escapeCsvField(item.getStatus())).append(","); + sb.append(escapeCsvField(item.getStartTime())).append(","); + sb.append(escapeCsvField(item.getEndTime())).append("\n"); + } + + return sb.toString().getBytes(StandardCharsets.UTF_8); + } + + private static String escapeCsvField(String field) { + if (field == null) return ""; + if (field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r")) { + return "\"" + field.replace("\"", "\"\"") + "\""; + } + return field; + } +} diff --git a/src/main/java/com/auction/shared/interfaces/IAuctionService.java b/src/main/java/com/auction/shared/interfaces/IAuctionService.java index 3bd768b..d46d4ed 100644 --- a/src/main/java/com/auction/shared/interfaces/IAuctionService.java +++ b/src/main/java/com/auction/shared/interfaces/IAuctionService.java @@ -15,52 +15,68 @@ */ public interface IAuctionService extends Remote { - // --- Authentication --- - /** Returns a unique session token UUID if login is successful. */ - String login(String username, String password) throws RemoteException, AuctionException; - /** Registers a new user. */ - void register(String username, String password, String role) throws RemoteException, AuctionException; + // --- Authentication --- + /** Returns a unique session token UUID if login is successful. */ + String login(String username, String password) throws RemoteException, AuctionException; + + /** Registers a new user. */ + void register(String username, String password, String role) throws RemoteException, AuctionException; + /** Returns the authenticated user's role for the current session. */ String getMyRole(String token) throws RemoteException, AuctionException; - void logout(String token) throws RemoteException; - String serverTime() throws RemoteException; - - // --- Auction Browsing --- - List getActiveAuctions() throws RemoteException; - List getActiveAuctionsBySeller(String sellerUsername, String token) throws RemoteException, AuctionException; - AuctionItem getAuctionById(int auctionId) throws RemoteException; - - // --- Bidding --- - /** clientExpectedPriceCents enables stale-data detection on the server. */ - void placeBid(int auctionId, long amountCents, long clientExpectedPriceCents, String token) - throws RemoteException, AuctionException; - List getBidHistory(int auctionId) throws RemoteException; - - // --- Auction Management (Seller) --- - /** Returns the new auction's ID. image bytes may be null if no image provided. */ - int createAuction(AuctionItem item, byte[] image1, byte[] image2, byte[] image3, String token) - throws RemoteException, AuctionException; - void cancelAuction(int auctionId, String token) - throws RemoteException, AuctionException; - void relistAuction(int auctionId, String newEndTimeIso, String token) - throws RemoteException, AuctionException; - - // --- Bidder Activity --- - List getMyBids(String token) throws RemoteException, AuctionException; - List getMyWonAuctions(String token) throws RemoteException, AuctionException; - - // --- Image Handling (LQIP) --- - byte[] getThumbnail(int auctionId, int imageIndex) throws RemoteException; - byte[] getFullImage(int auctionId, int imageIndex) throws RemoteException; - - // --- Data Export --- - byte[] exportAuctionsToCSV(String token) throws RemoteException, AuctionException; - - // --- Administration --- - void createUser(String newUsername, String password, String role, String token) - throws RemoteException, AuctionException; - List getAllUsers(String token) throws RemoteException, AuctionException; - byte[] backupDatabase(String token) throws RemoteException, AuctionException; - List getAuditLogs(int lastNLines, String token) - throws RemoteException, AuctionException; + + void logout(String token) throws RemoteException; + + String serverTime() throws RemoteException; + + // --- Auction Browsing --- + List getActiveAuctions() throws RemoteException; + + List getActiveAuctionsBySeller(String sellerUsername, String token) + throws RemoteException, AuctionException; + + AuctionItem getAuctionById(int auctionId) throws RemoteException; + + // --- Bidding --- + /** clientExpectedPriceCents enables stale-data detection on the server. */ + void placeBid(int auctionId, long amountCents, long clientExpectedPriceCents, String token) + throws RemoteException, AuctionException; + + List getBidHistory(int auctionId) throws RemoteException; + + // --- Auction Management (Seller) --- + /** + * Returns the new auction's ID. image bytes may be null if no image provided. + */ + int createAuction(AuctionItem item, byte[] image1, byte[] image2, byte[] image3, String token) + throws RemoteException, AuctionException; + + void cancelAuction(int auctionId, String token) + throws RemoteException, AuctionException; + + void relistAuction(int auctionId, String newEndTimeIso, String token) + throws RemoteException, AuctionException; + + // --- Bidder Activity --- + List getMyBids(String token) throws RemoteException, AuctionException; + + List getMyWonAuctions(String token) throws RemoteException, AuctionException; + + // --- Image Handling (LQIP) --- + byte[] getThumbnail(int auctionId, int imageIndex) throws RemoteException; + + byte[] getFullImage(int auctionId, int imageIndex) throws RemoteException; + + // --- Data Export --- + byte[] exportAuctionsToCSV(String token) throws RemoteException, AuctionException; + + // --- Administration --- + List getAllUsers(String token) throws RemoteException, AuctionException; + + List searchUsers(String query, String token) throws RemoteException, AuctionException; + + void promoteUserToAdmin(String username, String token) throws RemoteException, AuctionException; + + List getAuditLogs(int lastNLines, String token) + throws RemoteException, AuctionException; } diff --git a/src/main/resources/fxml/admin_panel.fxml b/src/main/resources/fxml/admin_panel.fxml index 13962a1..84652dd 100644 --- a/src/main/resources/fxml/admin_panel.fxml +++ b/src/main/resources/fxml/admin_panel.fxml @@ -44,21 +44,23 @@ -