putplace 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of putplace might be problematic. Click here for more details.

putplace/main.py ADDED
@@ -0,0 +1,3048 @@
1
+ """FastAPI application for file metadata storage."""
2
+
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+ from typing import AsyncGenerator
6
+
7
+ from fastapi import Depends, FastAPI, File, HTTPException, UploadFile, status
8
+ from fastapi.responses import HTMLResponse, JSONResponse
9
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
10
+ from pymongo.errors import ConnectionFailure
11
+
12
+ from .config import settings
13
+ from . import database
14
+ from .auth import APIKeyAuth, get_current_api_key
15
+ from .database import MongoDB
16
+ from .models import (
17
+ APIKeyCreate,
18
+ APIKeyInfo,
19
+ APIKeyResponse,
20
+ FileMetadata,
21
+ FileMetadataResponse,
22
+ FileMetadataUploadResponse,
23
+ Token,
24
+ User,
25
+ UserCreate,
26
+ UserLogin,
27
+ )
28
+ from .storage import get_storage_backend, StorageBackend
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Global storage backend instance
33
+ storage_backend: StorageBackend | None = None
34
+
35
+
36
+ async def ensure_admin_exists(db: MongoDB) -> None:
37
+ """Ensure an admin user exists using multiple fallback methods.
38
+
39
+ This function implements a hybrid approach:
40
+ 1. If users exist, do nothing
41
+ 2. If PUTPLACE_ADMIN_USERNAME and PUTPLACE_ADMIN_PASSWORD are set, use them
42
+ 3. Otherwise, generate a random password and display it once
43
+
44
+ Args:
45
+ db: MongoDB database instance
46
+ """
47
+ import os
48
+ from datetime import datetime
49
+
50
+ try:
51
+ # Check if any users exist
52
+ user_count = await db.users_collection.count_documents({})
53
+ if user_count > 0:
54
+ logger.debug("Users already exist, skipping admin creation")
55
+ return # Users exist, nothing to do
56
+
57
+ # Method 1: Try environment variables (best for production/containers)
58
+ admin_user = os.getenv("PUTPLACE_ADMIN_USERNAME")
59
+ admin_pass = os.getenv("PUTPLACE_ADMIN_PASSWORD")
60
+ admin_email = os.getenv("PUTPLACE_ADMIN_EMAIL", "admin@localhost")
61
+
62
+ if admin_user and admin_pass:
63
+ # Validate password strength
64
+ if len(admin_pass) < 8:
65
+ logger.error(
66
+ "PUTPLACE_ADMIN_PASSWORD must be at least 8 characters. "
67
+ "Admin user not created."
68
+ )
69
+ return
70
+
71
+ # Create admin from environment variables
72
+ from .user_auth import get_password_hash
73
+
74
+ hashed_password = get_password_hash(admin_pass)
75
+ user_doc = {
76
+ "username": admin_user,
77
+ "email": admin_email,
78
+ "hashed_password": hashed_password,
79
+ "full_name": "Administrator",
80
+ "is_active": True,
81
+ "created_at": datetime.utcnow(),
82
+ }
83
+
84
+ await db.users_collection.insert_one(user_doc)
85
+ logger.info(f"✅ Created admin user from environment: {admin_user}")
86
+ return
87
+
88
+ # Method 2: Generate random password (fallback for development)
89
+ import secrets
90
+ random_password = secrets.token_urlsafe(16) # 16 bytes = ~21 chars
91
+
92
+ from .user_auth import get_password_hash
93
+
94
+ hashed_password = get_password_hash(random_password)
95
+ user_doc = {
96
+ "username": "admin",
97
+ "email": "admin@localhost",
98
+ "hashed_password": hashed_password,
99
+ "full_name": "Administrator",
100
+ "is_active": True,
101
+ "created_at": datetime.utcnow(),
102
+ }
103
+
104
+ await db.users_collection.insert_one(user_doc)
105
+
106
+ # Display credentials prominently in logs
107
+ logger.warning("=" * 80)
108
+ logger.warning("🔐 INITIAL ADMIN CREDENTIALS GENERATED")
109
+ logger.warning("=" * 80)
110
+ logger.warning(f" Username: admin")
111
+ logger.warning(f" Password: {random_password}")
112
+ logger.warning("")
113
+ logger.warning("⚠️ SAVE THESE CREDENTIALS NOW - They won't be shown again!")
114
+ logger.warning("")
115
+ logger.warning("For production, set environment variables instead:")
116
+ logger.warning(" PUTPLACE_ADMIN_USERNAME=your-admin")
117
+ logger.warning(" PUTPLACE_ADMIN_PASSWORD=your-secure-password")
118
+ logger.warning(" PUTPLACE_ADMIN_EMAIL=admin@example.com")
119
+ logger.warning("=" * 80)
120
+
121
+ # Also write to a temporary file
122
+ from pathlib import Path
123
+ import tempfile
124
+
125
+ creds_dir = Path(tempfile.gettempdir())
126
+ creds_file = creds_dir / "putplace_initial_creds.txt"
127
+
128
+ try:
129
+ creds_file.write_text(
130
+ f"PutPlace Initial Admin Credentials\n"
131
+ f"{'=' * 40}\n"
132
+ f"Username: admin\n"
133
+ f"Password: {random_password}\n"
134
+ f"Created: {datetime.utcnow()}\n\n"
135
+ f"⚠️ DELETE THIS FILE after saving credentials!\n"
136
+ )
137
+ creds_file.chmod(0o600) # Owner read/write only
138
+ logger.warning(f"📄 Credentials also written to: {creds_file}")
139
+ logger.warning("")
140
+ except Exception as e:
141
+ logger.debug(f"Could not write credentials file: {e}")
142
+
143
+ except Exception as e:
144
+ logger.error(f"Failed to ensure admin user exists: {e}")
145
+ # Don't raise - allow app to start even if admin creation fails
146
+
147
+
148
+ def get_db() -> MongoDB:
149
+ """Get database instance - dependency injection."""
150
+ return database.mongodb
151
+
152
+
153
+ def get_storage() -> StorageBackend:
154
+ """Get storage backend instance - dependency injection."""
155
+ if storage_backend is None:
156
+ raise RuntimeError("Storage backend not initialized")
157
+ return storage_backend
158
+
159
+
160
+ # JWT bearer token scheme
161
+ security = HTTPBearer()
162
+
163
+
164
+ async def get_current_user(
165
+ credentials: HTTPAuthorizationCredentials = Depends(security),
166
+ db: MongoDB = Depends(get_db)
167
+ ) -> dict:
168
+ """Get current user from JWT token.
169
+
170
+ Args:
171
+ credentials: HTTP Authorization credentials with JWT token
172
+ db: Database instance
173
+
174
+ Returns:
175
+ User document from database
176
+
177
+ Raises:
178
+ HTTPException: If token is invalid or user not found
179
+ """
180
+ from .user_auth import decode_access_token
181
+
182
+ # Extract token
183
+ token = credentials.credentials
184
+
185
+ # Decode token to get username
186
+ username = decode_access_token(token)
187
+
188
+ if username is None:
189
+ raise HTTPException(
190
+ status_code=status.HTTP_401_UNAUTHORIZED,
191
+ detail="Invalid authentication credentials",
192
+ headers={"WWW-Authenticate": "Bearer"},
193
+ )
194
+
195
+ # Get user from database
196
+ user = await db.get_user_by_username(username)
197
+
198
+ if user is None:
199
+ raise HTTPException(
200
+ status_code=status.HTTP_401_UNAUTHORIZED,
201
+ detail="User not found",
202
+ headers={"WWW-Authenticate": "Bearer"},
203
+ )
204
+
205
+ if not user.get("is_active", True):
206
+ raise HTTPException(
207
+ status_code=status.HTTP_400_BAD_REQUEST,
208
+ detail="Inactive user account"
209
+ )
210
+
211
+ return user
212
+
213
+
214
+ @asynccontextmanager
215
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
216
+ """Manage application lifespan events."""
217
+ global storage_backend
218
+
219
+ # Startup
220
+ try:
221
+ await database.mongodb.connect()
222
+ logger.info("Application startup: Database connected successfully")
223
+ except ConnectionFailure as e:
224
+ logger.error(f"Failed to connect to database during startup: {e}")
225
+ logger.warning("Application starting without database connection - health endpoint will report degraded")
226
+ # Don't raise - allow app to start in degraded mode
227
+ except Exception as e:
228
+ logger.error(f"Unexpected error during startup: {e}")
229
+ raise
230
+
231
+ # Initialize storage backend
232
+ try:
233
+ if settings.storage_backend == "local":
234
+ storage_backend = get_storage_backend(
235
+ "local",
236
+ base_path=settings.storage_path,
237
+ )
238
+ logger.info(f"Initialized local storage backend at {settings.storage_path}")
239
+
240
+ # Test write access to storage directory
241
+ import os
242
+ from pathlib import Path
243
+ storage_path = Path(settings.storage_path).resolve()
244
+
245
+ # Check if directory exists and is writable
246
+ if not storage_path.exists():
247
+ raise RuntimeError(
248
+ f"Storage directory does not exist: {storage_path}\n"
249
+ f"Please create this directory or update STORAGE_PATH in your .env file."
250
+ )
251
+
252
+ if not storage_path.is_dir():
253
+ raise RuntimeError(
254
+ f"Storage path is not a directory: {storage_path}\n"
255
+ f"Please ensure STORAGE_PATH points to a valid directory."
256
+ )
257
+
258
+ # Test write permission by creating and removing a test file
259
+ import uuid
260
+ test_filename = f".write_test_{uuid.uuid4().hex}"
261
+ test_file = storage_path / test_filename
262
+
263
+ # Ensure test file doesn't already exist (extremely unlikely with UUID)
264
+ if test_file.exists():
265
+ raise RuntimeError(
266
+ f"Test file unexpectedly exists: {test_file}\n"
267
+ f"Please remove it and restart the server."
268
+ )
269
+
270
+ try:
271
+ test_file.write_text("test")
272
+ test_file.unlink()
273
+ logger.info(f"Storage directory write test successful: {storage_path}")
274
+ except PermissionError as e:
275
+ raise RuntimeError(
276
+ f"Cannot write to storage directory: {storage_path}\n"
277
+ f"Error: {e}\n"
278
+ f"Please check directory permissions or update STORAGE_PATH in your .env file."
279
+ ) from e
280
+ except Exception as e:
281
+ # Clean up test file if it was created
282
+ if test_file.exists():
283
+ try:
284
+ test_file.unlink()
285
+ except:
286
+ pass
287
+ raise RuntimeError(
288
+ f"Failed to write to storage directory: {storage_path}\n"
289
+ f"Error: {e}"
290
+ ) from e
291
+
292
+ elif settings.storage_backend == "s3":
293
+ if not settings.s3_bucket_name:
294
+ raise ValueError("S3 bucket name not configured")
295
+ storage_backend = get_storage_backend(
296
+ "s3",
297
+ bucket_name=settings.s3_bucket_name,
298
+ region_name=settings.s3_region_name,
299
+ prefix=settings.s3_prefix,
300
+ aws_profile=settings.aws_profile,
301
+ aws_access_key_id=settings.aws_access_key_id,
302
+ aws_secret_access_key=settings.aws_secret_access_key,
303
+ )
304
+ logger.info(
305
+ f"Initialized S3 storage backend: bucket={settings.s3_bucket_name}, "
306
+ f"region={settings.s3_region_name}"
307
+ )
308
+ else:
309
+ raise ValueError(f"Unsupported storage backend: {settings.storage_backend}")
310
+ except Exception as e:
311
+ logger.error(f"Failed to initialize storage backend: {e}")
312
+ raise
313
+
314
+ # Ensure admin user exists (only creates if no users exist)
315
+ if database.mongodb.client is not None:
316
+ await ensure_admin_exists(database.mongodb)
317
+
318
+ yield
319
+
320
+ # Shutdown
321
+ try:
322
+ await database.mongodb.close()
323
+ logger.info("Application shutdown: Database connection closed")
324
+ except Exception as e:
325
+ logger.error(f"Error during shutdown: {e}")
326
+
327
+
328
+ app = FastAPI(
329
+ title=settings.api_title,
330
+ version=settings.api_version,
331
+ description=settings.api_description,
332
+ lifespan=lifespan,
333
+ )
334
+
335
+
336
+ @app.get("/", response_class=HTMLResponse, tags=["health"])
337
+ async def root() -> str:
338
+ """Root endpoint - Home page."""
339
+ html_content = """
340
+ <!DOCTYPE html>
341
+ <html lang="en">
342
+ <head>
343
+ <meta charset="UTF-8">
344
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
345
+ <title>PutPlace - File Metadata Storage</title>
346
+ <style>
347
+ * {
348
+ margin: 0;
349
+ padding: 0;
350
+ box-sizing: border-box;
351
+ }
352
+ body {
353
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
354
+ line-height: 1.6;
355
+ color: #333;
356
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
357
+ min-height: 100vh;
358
+ padding: 20px;
359
+ }
360
+ .container {
361
+ max-width: 900px;
362
+ margin: 0 auto;
363
+ background: white;
364
+ border-radius: 10px;
365
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
366
+ overflow: hidden;
367
+ }
368
+ .header {
369
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
370
+ color: white;
371
+ padding: 40px;
372
+ text-align: center;
373
+ }
374
+ .header h1 {
375
+ font-size: 2.5rem;
376
+ margin-bottom: 10px;
377
+ }
378
+ .header p {
379
+ font-size: 1.2rem;
380
+ opacity: 0.9;
381
+ }
382
+ .content {
383
+ padding: 40px;
384
+ }
385
+ .section {
386
+ margin-bottom: 30px;
387
+ }
388
+ .section h2 {
389
+ color: #667eea;
390
+ margin-bottom: 15px;
391
+ font-size: 1.5rem;
392
+ border-bottom: 2px solid #667eea;
393
+ padding-bottom: 5px;
394
+ }
395
+ .card {
396
+ background: #f8f9fa;
397
+ border-left: 4px solid #667eea;
398
+ padding: 15px;
399
+ margin-bottom: 15px;
400
+ border-radius: 4px;
401
+ }
402
+ .card h3 {
403
+ color: #667eea;
404
+ margin-bottom: 8px;
405
+ }
406
+ .card code {
407
+ background: #e9ecef;
408
+ padding: 2px 6px;
409
+ border-radius: 3px;
410
+ font-family: 'Courier New', monospace;
411
+ font-size: 0.9rem;
412
+ }
413
+ .btn-group {
414
+ display: flex;
415
+ gap: 15px;
416
+ flex-wrap: wrap;
417
+ margin-top: 20px;
418
+ }
419
+ .btn {
420
+ display: inline-block;
421
+ padding: 12px 24px;
422
+ background: #667eea;
423
+ color: white;
424
+ text-decoration: none;
425
+ border-radius: 5px;
426
+ font-weight: 500;
427
+ transition: all 0.3s ease;
428
+ }
429
+ .btn:hover {
430
+ background: #764ba2;
431
+ transform: translateY(-2px);
432
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
433
+ }
434
+ .btn-secondary {
435
+ background: #6c757d;
436
+ }
437
+ .btn-secondary:hover {
438
+ background: #5a6268;
439
+ }
440
+ .endpoint-list {
441
+ list-style: none;
442
+ }
443
+ .endpoint-list li {
444
+ padding: 10px;
445
+ margin-bottom: 8px;
446
+ background: #f8f9fa;
447
+ border-radius: 4px;
448
+ display: flex;
449
+ align-items: center;
450
+ }
451
+ .method {
452
+ display: inline-block;
453
+ padding: 4px 8px;
454
+ border-radius: 3px;
455
+ font-weight: 600;
456
+ font-size: 0.85rem;
457
+ margin-right: 10px;
458
+ min-width: 60px;
459
+ text-align: center;
460
+ }
461
+ .method-get { background: #61affe; color: white; }
462
+ .method-post { background: #49cc90; color: white; }
463
+ .method-put { background: #fca130; color: white; }
464
+ .method-delete { background: #f93e3e; color: white; }
465
+ .status-badge {
466
+ display: inline-block;
467
+ padding: 5px 12px;
468
+ background: #28a745;
469
+ color: white;
470
+ border-radius: 20px;
471
+ font-size: 0.9rem;
472
+ font-weight: 500;
473
+ }
474
+ pre {
475
+ background: #2d2d2d;
476
+ color: #f8f8f2;
477
+ padding: 15px;
478
+ border-radius: 5px;
479
+ overflow-x: auto;
480
+ font-size: 0.9rem;
481
+ }
482
+ .footer {
483
+ background: #f8f9fa;
484
+ padding: 20px 40px;
485
+ text-align: center;
486
+ color: #6c757d;
487
+ border-top: 1px solid #dee2e6;
488
+ }
489
+ .auth-buttons {
490
+ display: flex;
491
+ gap: 10px;
492
+ justify-content: center;
493
+ margin-top: 20px;
494
+ }
495
+ .auth-btn {
496
+ display: inline-block;
497
+ padding: 10px 20px;
498
+ background: rgba(255, 255, 255, 0.2);
499
+ color: white;
500
+ text-decoration: none;
501
+ border-radius: 5px;
502
+ font-weight: 500;
503
+ transition: all 0.3s ease;
504
+ border: 2px solid white;
505
+ }
506
+ .auth-btn:hover {
507
+ background: white;
508
+ color: #667eea;
509
+ transform: translateY(-2px);
510
+ }
511
+ </style>
512
+ </head>
513
+ <body>
514
+ <div class="container">
515
+ <div class="header">
516
+ <h1>🗄️ PutPlace</h1>
517
+ <p>File Metadata Storage Service</p>
518
+ <div style="margin-top: 15px;">
519
+ <span class="status-badge">✓ Running</span>
520
+ </div>
521
+ <div class="auth-buttons" id="authButtons">
522
+ <a href="/login" class="auth-btn">🔐 Login</a>
523
+ <a href="/register" class="auth-btn">📝 Register</a>
524
+ </div>
525
+ </div>
526
+
527
+ <div class="content">
528
+ <div class="section">
529
+ <h2>Welcome</h2>
530
+ <p>PutPlace is a FastAPI-based service for storing and retrieving file metadata with MongoDB backend. Track file locations, SHA256 hashes, and metadata across your infrastructure.</p>
531
+ </div>
532
+
533
+ <div class="section">
534
+ <h2>Quick Start</h2>
535
+ <div class="btn-group">
536
+ <a href="/docs" class="btn">📖 Interactive API Docs (Swagger)</a>
537
+ <a href="/redoc" class="btn btn-secondary">📚 Alternative Docs (ReDoc)</a>
538
+ <a href="/health" class="btn btn-secondary">❤️ Health Check</a>
539
+ </div>
540
+ </div>
541
+
542
+ <div class="section">
543
+ <h2>API Endpoints</h2>
544
+ <ul class="endpoint-list">
545
+ <li>
546
+ <span class="method method-get">GET</span>
547
+ <code>/health</code> - Health check with database status
548
+ </li>
549
+ <li>
550
+ <span class="method method-post">POST</span>
551
+ <code>/put_file</code> - Store file metadata
552
+ </li>
553
+ <li>
554
+ <span class="method method-get">GET</span>
555
+ <code>/get_file/{sha256}</code> - Retrieve file by SHA256 hash
556
+ </li>
557
+ <li>
558
+ <span class="method method-post">POST</span>
559
+ <code>/upload_file/{sha256}</code> - Upload file content
560
+ </li>
561
+ <li>
562
+ <span class="method method-post">POST</span>
563
+ <code>/api_keys</code> - Create new API key
564
+ </li>
565
+ <li>
566
+ <span class="method method-get">GET</span>
567
+ <code>/api_keys</code> - List all API keys
568
+ </li>
569
+ <li>
570
+ <span class="method method-delete">DELETE</span>
571
+ <code>/api_keys/{key_id}</code> - Delete API key
572
+ </li>
573
+ <li>
574
+ <span class="method method-put">PUT</span>
575
+ <code>/api_keys/{key_id}/revoke</code> - Revoke API key
576
+ </li>
577
+ </ul>
578
+ </div>
579
+
580
+ <div class="section">
581
+ <h2>Example Usage</h2>
582
+ <div class="card">
583
+ <h3>Store File Metadata</h3>
584
+ <pre>curl -X POST http://localhost:8000/put_file \\
585
+ -H "Content-Type: application/json" \\
586
+ -H "X-API-Key: your-api-key" \\
587
+ -d '{
588
+ "filepath": "/var/log/app.log",
589
+ "hostname": "server01",
590
+ "ip_address": "192.168.1.100",
591
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
592
+ }'</pre>
593
+ </div>
594
+
595
+ <div class="card">
596
+ <h3>Retrieve File Metadata</h3>
597
+ <pre>curl -H "X-API-Key: your-api-key" \\
598
+ http://localhost:8000/get_file/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855</pre>
599
+ </div>
600
+
601
+ <div class="card">
602
+ <h3>Using the Client Tool</h3>
603
+ <pre># Scan a directory and send metadata to server
604
+ ppclient /var/log --api-key your-api-key
605
+
606
+ # With exclude patterns
607
+ ppclient /home/user --exclude .git --exclude "*.log"
608
+
609
+ # Dry run mode
610
+ ppclient /var/log --dry-run</pre>
611
+ </div>
612
+ </div>
613
+
614
+ <div class="section">
615
+ <h2>Getting Started</h2>
616
+ <div class="card">
617
+ <h3>1. Create Your First API Key</h3>
618
+ <p>Use the bootstrap script to create your first API key:</p>
619
+ <pre>python -m putplace.scripts.create_api_key</pre>
620
+ </div>
621
+
622
+ <div class="card">
623
+ <h3>2. Install the Client</h3>
624
+ <pre>pip install -e .
625
+ source .venv/bin/activate
626
+ ppclient --help</pre>
627
+ </div>
628
+
629
+ <div class="card">
630
+ <h3>3. Start Scanning</h3>
631
+ <p>Use the <code>ppclient</code> command to scan directories and send metadata to the server.</p>
632
+ </div>
633
+ </div>
634
+ </div>
635
+
636
+ <div class="footer">
637
+ <p>PutPlace v""" + settings.api_version + """ | Built with FastAPI & MongoDB</p>
638
+ <p style="margin-top: 5px; font-size: 0.9rem;">
639
+ <a href="/docs" style="color: #667eea; text-decoration: none;">API Documentation</a> |
640
+ <a href="/health" style="color: #667eea; text-decoration: none;">Health Status</a>
641
+ </p>
642
+ </div>
643
+ </div>
644
+ <script>
645
+ // Check if user is logged in and update buttons
646
+ (function() {
647
+ const token = localStorage.getItem('access_token');
648
+ const authButtons = document.getElementById('authButtons');
649
+
650
+ if (token && authButtons) {
651
+ // User is logged in - show My Files, API Keys and Logout buttons
652
+ authButtons.innerHTML = `
653
+ <a href="/my_files" class="auth-btn">📁 My Files</a>
654
+ <a href="/api_keys_page" class="auth-btn">🔑 My API Keys</a>
655
+ <button onclick="logout()" class="auth-btn" style="cursor: pointer;">Logout</button>
656
+ `;
657
+ }
658
+ })();
659
+
660
+ function logout() {
661
+ localStorage.removeItem('access_token');
662
+ window.location.reload();
663
+ }
664
+ </script>
665
+ </body>
666
+ </html>
667
+ """
668
+ return html_content
669
+
670
+
671
+ @app.get("/health", tags=["health"])
672
+ async def health(db: MongoDB = Depends(get_db)) -> dict[str, str | dict]:
673
+ """Health check endpoint with database connectivity check."""
674
+ db_healthy = await db.is_healthy()
675
+
676
+ if db_healthy:
677
+ return {
678
+ "status": "healthy",
679
+ "database": {"status": "connected", "type": "mongodb"}
680
+ }
681
+ else:
682
+ return {
683
+ "status": "degraded",
684
+ "database": {"status": "disconnected", "type": "mongodb"}
685
+ }
686
+
687
+
688
+ @app.post(
689
+ "/put_file",
690
+ response_model=FileMetadataUploadResponse,
691
+ status_code=status.HTTP_201_CREATED,
692
+ tags=["files"],
693
+ )
694
+ async def put_file(
695
+ file_metadata: FileMetadata,
696
+ db: MongoDB = Depends(get_db),
697
+ api_key: dict = Depends(get_current_api_key),
698
+ ) -> FileMetadataUploadResponse:
699
+ """Store file metadata in MongoDB.
700
+
701
+ Requires authentication via X-API-Key header.
702
+
703
+ Args:
704
+ file_metadata: File metadata containing filepath, hostname, ip_address, and sha256
705
+ db: Database instance (injected)
706
+ api_key: API key metadata (injected, for authentication)
707
+
708
+ Returns:
709
+ Stored file metadata with MongoDB ID and upload requirement information
710
+
711
+ Raises:
712
+ HTTPException: If database operation fails or authentication fails
713
+ """
714
+ try:
715
+ # Check if we already have the file content for this SHA256
716
+ has_content = await db.has_file_content(file_metadata.sha256)
717
+
718
+ # Convert to dict for MongoDB insertion
719
+ data = file_metadata.model_dump()
720
+
721
+ # Track which user uploaded this file (from API key)
722
+ data["uploaded_by_user_id"] = api_key.get("user_id")
723
+ data["uploaded_by_api_key_id"] = api_key.get("_id")
724
+
725
+ # Insert into MongoDB
726
+ doc_id = await db.insert_file_metadata(data)
727
+
728
+ # Determine if upload is required
729
+ upload_required = not has_content
730
+ upload_url = None
731
+ if upload_required:
732
+ # Provide the upload URL
733
+ upload_url = f"/upload_file/{file_metadata.sha256}"
734
+
735
+ # Return response with ID and upload information
736
+ return FileMetadataUploadResponse(
737
+ **data, _id=doc_id, upload_required=upload_required, upload_url=upload_url
738
+ )
739
+
740
+ except Exception as e:
741
+ raise HTTPException(
742
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
743
+ detail=f"Failed to store file metadata: {str(e)}",
744
+ ) from e
745
+
746
+
747
+ @app.get(
748
+ "/get_file/{sha256}",
749
+ response_model=FileMetadataResponse,
750
+ tags=["files"],
751
+ )
752
+ async def get_file(
753
+ sha256: str,
754
+ db: MongoDB = Depends(get_db),
755
+ api_key: dict = Depends(get_current_api_key),
756
+ ) -> FileMetadataResponse:
757
+ """Retrieve file metadata by SHA256 hash.
758
+
759
+ Requires authentication via X-API-Key header.
760
+
761
+ Args:
762
+ sha256: SHA256 hash of the file (64 characters)
763
+ db: Database instance (injected)
764
+ api_key: API key metadata (injected, for authentication)
765
+
766
+ Returns:
767
+ File metadata if found
768
+
769
+ Raises:
770
+ HTTPException: If file not found, invalid hash, or authentication fails
771
+ """
772
+ if len(sha256) != 64:
773
+ raise HTTPException(
774
+ status_code=status.HTTP_400_BAD_REQUEST,
775
+ detail="SHA256 hash must be exactly 64 characters",
776
+ )
777
+
778
+ result = await db.find_by_sha256(sha256)
779
+
780
+ if not result:
781
+ raise HTTPException(
782
+ status_code=status.HTTP_404_NOT_FOUND,
783
+ detail=f"File with SHA256 {sha256} not found",
784
+ )
785
+
786
+ # Convert MongoDB _id to string
787
+ result["_id"] = str(result["_id"])
788
+
789
+ return FileMetadataResponse(**result)
790
+
791
+
792
+ @app.post(
793
+ "/upload_file/{sha256}",
794
+ status_code=status.HTTP_200_OK,
795
+ tags=["files"],
796
+ )
797
+ async def upload_file(
798
+ sha256: str,
799
+ hostname: str,
800
+ filepath: str,
801
+ file: UploadFile = File(...),
802
+ db: MongoDB = Depends(get_db),
803
+ storage: StorageBackend = Depends(get_storage),
804
+ api_key: dict = Depends(get_current_api_key),
805
+ ) -> dict[str, str]:
806
+ """Upload actual file content for a previously registered file metadata.
807
+
808
+ Requires authentication via X-API-Key header.
809
+
810
+ This endpoint is called after POST /put_file indicates upload_required=true.
811
+ The file content is stored using the configured storage backend (local or S3).
812
+
813
+ Args:
814
+ sha256: SHA256 hash of the file (must match file content)
815
+ hostname: Hostname where file is located
816
+ filepath: Full path to the file
817
+ file: File upload
818
+ db: Database instance (injected)
819
+ storage: Storage backend instance (injected)
820
+ api_key: API key metadata (injected, for authentication)
821
+
822
+ Returns:
823
+ Success message with details
824
+
825
+ Raises:
826
+ HTTPException: If validation fails, database operation fails, or authentication fails
827
+ """
828
+ import hashlib
829
+
830
+ if len(sha256) != 64:
831
+ raise HTTPException(
832
+ status_code=status.HTTP_400_BAD_REQUEST,
833
+ detail="SHA256 hash must be exactly 64 characters",
834
+ )
835
+
836
+ try:
837
+ # Read and verify file content
838
+ content = await file.read()
839
+
840
+ # Calculate SHA256 of uploaded content
841
+ calculated_hash = hashlib.sha256(content).hexdigest()
842
+
843
+ if calculated_hash != sha256:
844
+ raise HTTPException(
845
+ status_code=status.HTTP_400_BAD_REQUEST,
846
+ detail=f"File content SHA256 ({calculated_hash}) does not match provided hash ({sha256})",
847
+ )
848
+
849
+ logger.info(f"File upload verified for SHA256: {sha256}, size: {len(content)} bytes")
850
+
851
+ # Store file content using storage backend
852
+ stored = await storage.store(sha256, content)
853
+ if not stored:
854
+ raise HTTPException(
855
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
856
+ detail="Failed to store file content",
857
+ )
858
+
859
+ # Get the storage path where file was stored
860
+ storage_path = storage.get_storage_path(sha256)
861
+
862
+ # Mark the file as uploaded in database with storage path
863
+ updated = await db.mark_file_uploaded(sha256, hostname, filepath, storage_path)
864
+
865
+ if not updated:
866
+ raise HTTPException(
867
+ status_code=status.HTTP_404_NOT_FOUND,
868
+ detail=f"No metadata found for sha256={sha256}, hostname={hostname}, filepath={filepath}",
869
+ )
870
+
871
+ return {
872
+ "message": "File uploaded successfully",
873
+ "sha256": sha256,
874
+ "size": str(len(content)),
875
+ "hostname": hostname,
876
+ "filepath": filepath,
877
+ "status": "uploaded",
878
+ }
879
+
880
+ except HTTPException:
881
+ raise
882
+ except Exception as e:
883
+ logger.error(f"Error uploading file: {e}")
884
+ raise HTTPException(
885
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
886
+ detail=f"Failed to upload file: {str(e)}",
887
+ ) from e
888
+
889
+
890
+ # API Key Management Endpoints
891
+
892
+
893
+ @app.post(
894
+ "/api_keys",
895
+ response_model=APIKeyResponse,
896
+ status_code=status.HTTP_201_CREATED,
897
+ tags=["auth"],
898
+ )
899
+ async def create_api_key(
900
+ key_data: APIKeyCreate,
901
+ db: MongoDB = Depends(get_db),
902
+ current_user: dict = Depends(get_current_user),
903
+ ) -> APIKeyResponse:
904
+ """Create a new API key.
905
+
906
+ Requires user authentication via JWT Bearer token.
907
+ Include the token in the Authorization header: `Authorization: Bearer <token>`
908
+
909
+ Args:
910
+ key_data: API key creation data (name, description)
911
+ db: Database instance (injected)
912
+ current_user: Current logged-in user (injected, for authentication)
913
+
914
+ Returns:
915
+ The new API key and its metadata. SAVE THE API KEY - it won't be shown again!
916
+
917
+ Raises:
918
+ HTTPException: If database operation fails or authentication fails
919
+ """
920
+ auth = APIKeyAuth(db)
921
+
922
+ try:
923
+ # Create new API key associated with the current user
924
+ new_api_key, key_metadata = await auth.create_api_key(
925
+ name=key_data.name,
926
+ user_id=str(current_user["_id"]), # Associate with logged-in user
927
+ description=key_data.description,
928
+ )
929
+
930
+ # Return the key (only time it's shown)
931
+ return APIKeyResponse(
932
+ api_key=new_api_key,
933
+ **key_metadata,
934
+ )
935
+
936
+ except Exception as e:
937
+ logger.error(f"Error creating API key: {e}")
938
+ raise HTTPException(
939
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
940
+ detail=f"Failed to create API key: {str(e)}",
941
+ ) from e
942
+
943
+
944
+ @app.get(
945
+ "/api_keys",
946
+ response_model=list[APIKeyInfo],
947
+ tags=["auth"],
948
+ )
949
+ async def list_api_keys(
950
+ db: MongoDB = Depends(get_db),
951
+ current_user: dict = Depends(get_current_user),
952
+ ) -> list[APIKeyInfo]:
953
+ """List all API keys for the current user (without showing the actual keys).
954
+
955
+ Requires user authentication via JWT Bearer token.
956
+
957
+ Args:
958
+ db: Database instance (injected)
959
+ current_user: Current logged-in user (injected, for authentication)
960
+
961
+ Returns:
962
+ List of API key metadata owned by the current user
963
+
964
+ Raises:
965
+ HTTPException: If database operation fails or authentication fails
966
+ """
967
+ auth = APIKeyAuth(db)
968
+
969
+ try:
970
+ # List only the keys owned by the current user
971
+ keys = await auth.list_api_keys(user_id=str(current_user["_id"]))
972
+ return [APIKeyInfo(**key) for key in keys]
973
+
974
+ except Exception as e:
975
+ logger.error(f"Error listing API keys: {e}")
976
+ raise HTTPException(
977
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
978
+ detail=f"Failed to list API keys: {str(e)}",
979
+ ) from e
980
+
981
+
982
+ @app.delete(
983
+ "/api_keys/{key_id}",
984
+ status_code=status.HTTP_200_OK,
985
+ tags=["auth"],
986
+ )
987
+ async def delete_api_key(
988
+ key_id: str,
989
+ db: MongoDB = Depends(get_db),
990
+ current_user: dict = Depends(get_current_user),
991
+ ) -> dict[str, str]:
992
+ """Permanently delete an API key.
993
+
994
+ Requires user authentication via JWT Bearer token.
995
+
996
+ WARNING: This cannot be undone! Consider using PUT /api_keys/{key_id}/revoke instead.
997
+
998
+ Args:
999
+ key_id: API key ID to delete
1000
+ db: Database instance (injected)
1001
+ current_user: Current logged-in user (injected, for authentication)
1002
+
1003
+ Returns:
1004
+ Success message
1005
+
1006
+ Raises:
1007
+ HTTPException: If key not found, database operation fails, or authentication fails
1008
+ """
1009
+ auth = APIKeyAuth(db)
1010
+
1011
+ try:
1012
+ deleted = await auth.delete_api_key(key_id)
1013
+
1014
+ if not deleted:
1015
+ raise HTTPException(
1016
+ status_code=status.HTTP_404_NOT_FOUND,
1017
+ detail=f"API key {key_id} not found",
1018
+ )
1019
+
1020
+ return {"message": f"API key {key_id} deleted successfully"}
1021
+
1022
+ except HTTPException:
1023
+ raise
1024
+ except Exception as e:
1025
+ logger.error(f"Error deleting API key: {e}")
1026
+ raise HTTPException(
1027
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1028
+ detail=f"Failed to delete API key: {str(e)}",
1029
+ ) from e
1030
+
1031
+
1032
+ @app.put(
1033
+ "/api_keys/{key_id}/revoke",
1034
+ status_code=status.HTTP_200_OK,
1035
+ tags=["auth"],
1036
+ )
1037
+ async def revoke_api_key(
1038
+ key_id: str,
1039
+ db: MongoDB = Depends(get_db),
1040
+ current_user: dict = Depends(get_current_user),
1041
+ ) -> dict[str, str]:
1042
+ """Revoke (deactivate) an API key without deleting it.
1043
+
1044
+ Requires user authentication via JWT Bearer token.
1045
+
1046
+ The key will be marked as inactive and can no longer be used for authentication,
1047
+ but its metadata is retained for audit purposes.
1048
+
1049
+ Args:
1050
+ key_id: API key ID to revoke
1051
+ db: Database instance (injected)
1052
+ current_user: Current logged-in user (injected, for authentication)
1053
+
1054
+ Returns:
1055
+ Success message
1056
+
1057
+ Raises:
1058
+ HTTPException: If key not found, database operation fails, or authentication fails
1059
+ """
1060
+ auth = APIKeyAuth(db)
1061
+
1062
+ try:
1063
+ revoked = await auth.revoke_api_key(key_id)
1064
+
1065
+ if not revoked:
1066
+ raise HTTPException(
1067
+ status_code=status.HTTP_404_NOT_FOUND,
1068
+ detail=f"API key {key_id} not found",
1069
+ )
1070
+
1071
+ return {"message": f"API key {key_id} revoked successfully"}
1072
+
1073
+ except HTTPException:
1074
+ raise
1075
+ except Exception as e:
1076
+ logger.error(f"Error revoking API key: {e}")
1077
+ raise HTTPException(
1078
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1079
+ detail=f"Failed to revoke API key: {str(e)}",
1080
+ ) from e
1081
+
1082
+
1083
+ # User Authentication Endpoints
1084
+
1085
+
1086
+ @app.get("/api_keys_page", response_class=HTMLResponse, tags=["users"])
1087
+ async def api_keys_page() -> str:
1088
+ """API Keys management page."""
1089
+ html_content = """
1090
+ <!DOCTYPE html>
1091
+ <html lang="en">
1092
+ <head>
1093
+ <meta charset="UTF-8">
1094
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1095
+ <title>API Keys - PutPlace</title>
1096
+ <style>
1097
+ * {
1098
+ margin: 0;
1099
+ padding: 0;
1100
+ box-sizing: border-box;
1101
+ }
1102
+ body {
1103
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
1104
+ line-height: 1.6;
1105
+ color: #333;
1106
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1107
+ min-height: 100vh;
1108
+ padding: 20px;
1109
+ }
1110
+ .container {
1111
+ max-width: 1000px;
1112
+ margin: 0 auto;
1113
+ background: white;
1114
+ border-radius: 10px;
1115
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1116
+ overflow: hidden;
1117
+ }
1118
+ .header {
1119
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1120
+ color: white;
1121
+ padding: 30px 40px;
1122
+ display: flex;
1123
+ justify-content: space-between;
1124
+ align-items: center;
1125
+ }
1126
+ .header h1 {
1127
+ font-size: 2rem;
1128
+ }
1129
+ .logout-btn {
1130
+ padding: 8px 16px;
1131
+ background: rgba(255, 255, 255, 0.2);
1132
+ color: white;
1133
+ border: 2px solid white;
1134
+ border-radius: 5px;
1135
+ cursor: pointer;
1136
+ font-weight: 500;
1137
+ text-decoration: none;
1138
+ transition: all 0.3s ease;
1139
+ }
1140
+ .logout-btn:hover {
1141
+ background: white;
1142
+ color: #667eea;
1143
+ }
1144
+ .content {
1145
+ padding: 40px;
1146
+ }
1147
+ .message {
1148
+ padding: 12px;
1149
+ border-radius: 5px;
1150
+ margin-bottom: 20px;
1151
+ display: none;
1152
+ }
1153
+ .message.error {
1154
+ background: #fee;
1155
+ color: #c33;
1156
+ border: 1px solid #fcc;
1157
+ }
1158
+ .message.success {
1159
+ background: #efe;
1160
+ color: #3c3;
1161
+ border: 1px solid #cfc;
1162
+ }
1163
+ .message.info {
1164
+ background: #e7f3ff;
1165
+ color: #004085;
1166
+ border: 1px solid #b8daff;
1167
+ }
1168
+ .section {
1169
+ margin-bottom: 30px;
1170
+ }
1171
+ .section h2 {
1172
+ color: #667eea;
1173
+ margin-bottom: 15px;
1174
+ font-size: 1.5rem;
1175
+ border-bottom: 2px solid #667eea;
1176
+ padding-bottom: 5px;
1177
+ }
1178
+ .form-group {
1179
+ margin-bottom: 15px;
1180
+ }
1181
+ .form-group label {
1182
+ display: block;
1183
+ margin-bottom: 5px;
1184
+ font-weight: 500;
1185
+ }
1186
+ .form-group input,
1187
+ .form-group textarea {
1188
+ width: 100%;
1189
+ padding: 10px;
1190
+ border: 2px solid #e0e0e0;
1191
+ border-radius: 5px;
1192
+ font-size: 1rem;
1193
+ }
1194
+ .form-group input:focus,
1195
+ .form-group textarea:focus {
1196
+ outline: none;
1197
+ border-color: #667eea;
1198
+ }
1199
+ .btn {
1200
+ padding: 10px 20px;
1201
+ background: #667eea;
1202
+ color: white;
1203
+ border: none;
1204
+ border-radius: 5px;
1205
+ cursor: pointer;
1206
+ font-size: 1rem;
1207
+ font-weight: 500;
1208
+ transition: all 0.3s ease;
1209
+ }
1210
+ .btn:hover {
1211
+ background: #764ba2;
1212
+ transform: translateY(-2px);
1213
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
1214
+ }
1215
+ .btn:disabled {
1216
+ background: #ccc;
1217
+ cursor: not-allowed;
1218
+ transform: none;
1219
+ }
1220
+ .btn-danger {
1221
+ background: #dc3545;
1222
+ }
1223
+ .btn-danger:hover {
1224
+ background: #c82333;
1225
+ }
1226
+ .btn-warning {
1227
+ background: #ffc107;
1228
+ color: #333;
1229
+ }
1230
+ .btn-warning:hover {
1231
+ background: #e0a800;
1232
+ }
1233
+ .btn-small {
1234
+ padding: 5px 10px;
1235
+ font-size: 0.85rem;
1236
+ }
1237
+ .keys-table {
1238
+ width: 100%;
1239
+ border-collapse: collapse;
1240
+ margin-top: 15px;
1241
+ }
1242
+ .keys-table th,
1243
+ .keys-table td {
1244
+ padding: 12px;
1245
+ text-align: left;
1246
+ border-bottom: 1px solid #e0e0e0;
1247
+ }
1248
+ .keys-table th {
1249
+ background: #f8f9fa;
1250
+ font-weight: 600;
1251
+ color: #667eea;
1252
+ }
1253
+ .keys-table tr:hover {
1254
+ background: #f8f9fa;
1255
+ }
1256
+ .status-active {
1257
+ color: #28a745;
1258
+ font-weight: 500;
1259
+ }
1260
+ .status-inactive {
1261
+ color: #dc3545;
1262
+ font-weight: 500;
1263
+ }
1264
+ .key-actions {
1265
+ display: flex;
1266
+ gap: 5px;
1267
+ }
1268
+ .no-keys {
1269
+ text-align: center;
1270
+ padding: 40px;
1271
+ color: #6c757d;
1272
+ }
1273
+ .key-display {
1274
+ background: #f8f9fa;
1275
+ padding: 15px;
1276
+ border-radius: 5px;
1277
+ border: 2px solid #667eea;
1278
+ margin: 15px 0;
1279
+ font-family: 'Courier New', monospace;
1280
+ word-break: break-all;
1281
+ }
1282
+ .key-warning {
1283
+ background: #fff3cd;
1284
+ border: 1px solid #ffc107;
1285
+ padding: 15px;
1286
+ border-radius: 5px;
1287
+ margin: 15px 0;
1288
+ }
1289
+ .back-link {
1290
+ display: inline-block;
1291
+ margin-top: 20px;
1292
+ color: #667eea;
1293
+ text-decoration: none;
1294
+ }
1295
+ .back-link:hover {
1296
+ color: #764ba2;
1297
+ }
1298
+ </style>
1299
+ </head>
1300
+ <body>
1301
+ <div class="container">
1302
+ <div class="header">
1303
+ <h1>🔑 My API Keys</h1>
1304
+ <div>
1305
+ <a href="/" class="logout-btn">← Home</a>
1306
+ <button onclick="logout()" class="logout-btn" style="margin-left: 10px;">Logout</button>
1307
+ </div>
1308
+ </div>
1309
+
1310
+ <div class="content">
1311
+ <div id="message" class="message"></div>
1312
+
1313
+ <!-- Create New API Key Section -->
1314
+ <div class="section">
1315
+ <h2>Create New API Key</h2>
1316
+ <form id="createKeyForm">
1317
+ <div class="form-group">
1318
+ <label for="keyName">Name *</label>
1319
+ <input type="text" id="keyName" required placeholder="e.g., Production Server">
1320
+ </div>
1321
+ <div class="form-group">
1322
+ <label for="keyDescription">Description</label>
1323
+ <textarea id="keyDescription" rows="3" placeholder="Optional description"></textarea>
1324
+ </div>
1325
+ <button type="submit" class="btn">Create API Key</button>
1326
+ </form>
1327
+
1328
+ <div id="newKeyDisplay" style="display: none;">
1329
+ <div class="key-warning">
1330
+ <strong>⚠️ Save this API key now!</strong> You won't be able to see it again.
1331
+ </div>
1332
+ <div class="key-display" id="newKeyValue"></div>
1333
+ <button onclick="copyKey()" class="btn">Copy to Clipboard</button>
1334
+ <button onclick="closeKeyDisplay()" class="btn btn-warning">Done</button>
1335
+ </div>
1336
+ </div>
1337
+
1338
+ <!-- Existing API Keys Section -->
1339
+ <div class="section">
1340
+ <h2>Your API Keys</h2>
1341
+ <div id="keysContainer">
1342
+ <p class="no-keys">Loading...</p>
1343
+ </div>
1344
+ </div>
1345
+
1346
+ <a href="/" class="back-link">← Back to Home</a>
1347
+ </div>
1348
+ </div>
1349
+
1350
+ <script>
1351
+ let currentToken = null;
1352
+ let newApiKey = null;
1353
+
1354
+ // Check if user is logged in
1355
+ function checkAuth() {
1356
+ currentToken = localStorage.getItem('access_token');
1357
+ if (!currentToken) {
1358
+ window.location.href = '/login';
1359
+ return false;
1360
+ }
1361
+ return true;
1362
+ }
1363
+
1364
+ // Logout function
1365
+ function logout() {
1366
+ localStorage.removeItem('access_token');
1367
+ window.location.href = '/';
1368
+ }
1369
+
1370
+ // Load API keys
1371
+ async function loadApiKeys() {
1372
+ if (!checkAuth()) return;
1373
+
1374
+ try {
1375
+ const response = await fetch('/api_keys', {
1376
+ headers: {
1377
+ 'Authorization': `Bearer ${currentToken}`
1378
+ }
1379
+ });
1380
+
1381
+ if (response.status === 401) {
1382
+ logout();
1383
+ return;
1384
+ }
1385
+
1386
+ if (!response.ok) {
1387
+ throw new Error('Failed to load API keys');
1388
+ }
1389
+
1390
+ const keys = await response.json();
1391
+ displayApiKeys(keys);
1392
+ } catch (error) {
1393
+ showMessage('Error loading API keys: ' + error.message, 'error');
1394
+ }
1395
+ }
1396
+
1397
+ // Display API keys in table
1398
+ function displayApiKeys(keys) {
1399
+ const container = document.getElementById('keysContainer');
1400
+
1401
+ if (keys.length === 0) {
1402
+ container.innerHTML = '<p class="no-keys">No API keys yet. Create one above to get started!</p>';
1403
+ return;
1404
+ }
1405
+
1406
+ let html = '<table class="keys-table"><thead><tr>';
1407
+ html += '<th>Name</th><th>Description</th><th>Created</th><th>Last Used</th><th>Status</th><th>Actions</th>';
1408
+ html += '</tr></thead><tbody>';
1409
+
1410
+ keys.forEach(key => {
1411
+ const createdDate = new Date(key.created_at).toLocaleDateString();
1412
+ const lastUsed = key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never';
1413
+ const statusClass = key.is_active ? 'status-active' : 'status-inactive';
1414
+ const status = key.is_active ? 'Active' : 'Inactive';
1415
+
1416
+ html += '<tr>';
1417
+ html += `<td><strong>${escapeHtml(key.name)}</strong></td>`;
1418
+ html += `<td>${escapeHtml(key.description || '-')}</td>`;
1419
+ html += `<td>${createdDate}</td>`;
1420
+ html += `<td>${lastUsed}</td>`;
1421
+ html += `<td class="${statusClass}">${status}</td>`;
1422
+ html += '<td><div class="key-actions">';
1423
+
1424
+ if (key.is_active) {
1425
+ html += `<button class="btn btn-warning btn-small" onclick="revokeKey('${key._id}')">Revoke</button>`;
1426
+ }
1427
+ html += `<button class="btn btn-danger btn-small" onclick="deleteKey('${key._id}')">Delete</button>`;
1428
+ html += '</div></td>';
1429
+ html += '</tr>';
1430
+ });
1431
+
1432
+ html += '</tbody></table>';
1433
+ container.innerHTML = html;
1434
+ }
1435
+
1436
+ // Escape HTML to prevent XSS
1437
+ function escapeHtml(text) {
1438
+ const div = document.createElement('div');
1439
+ div.textContent = text;
1440
+ return div.innerHTML;
1441
+ }
1442
+
1443
+ // Create new API key
1444
+ document.getElementById('createKeyForm').addEventListener('submit', async (e) => {
1445
+ e.preventDefault();
1446
+
1447
+ const name = document.getElementById('keyName').value;
1448
+ const description = document.getElementById('keyDescription').value;
1449
+ const submitBtn = e.target.querySelector('button[type="submit"]');
1450
+
1451
+ submitBtn.disabled = true;
1452
+ submitBtn.textContent = 'Creating...';
1453
+
1454
+ try {
1455
+ const response = await fetch('/api_keys', {
1456
+ method: 'POST',
1457
+ headers: {
1458
+ 'Content-Type': 'application/json',
1459
+ 'Authorization': `Bearer ${currentToken}`
1460
+ },
1461
+ body: JSON.stringify({ name, description: description || null })
1462
+ });
1463
+
1464
+ if (response.status === 401) {
1465
+ logout();
1466
+ return;
1467
+ }
1468
+
1469
+ const data = await response.json();
1470
+
1471
+ if (response.ok) {
1472
+ newApiKey = data.api_key;
1473
+ document.getElementById('newKeyValue').textContent = newApiKey;
1474
+ document.getElementById('newKeyDisplay').style.display = 'block';
1475
+ document.getElementById('createKeyForm').reset();
1476
+ showMessage('API key created successfully!', 'success');
1477
+ loadApiKeys();
1478
+ } else {
1479
+ showMessage(data.detail || 'Failed to create API key', 'error');
1480
+ }
1481
+ } catch (error) {
1482
+ showMessage('Error: ' + error.message, 'error');
1483
+ } finally {
1484
+ submitBtn.disabled = false;
1485
+ submitBtn.textContent = 'Create API Key';
1486
+ }
1487
+ });
1488
+
1489
+ // Copy key to clipboard
1490
+ function copyKey() {
1491
+ navigator.clipboard.writeText(newApiKey).then(() => {
1492
+ showMessage('API key copied to clipboard!', 'success');
1493
+ });
1494
+ }
1495
+
1496
+ // Close new key display
1497
+ function closeKeyDisplay() {
1498
+ document.getElementById('newKeyDisplay').style.display = 'none';
1499
+ newApiKey = null;
1500
+ }
1501
+
1502
+ // Revoke API key
1503
+ async function revokeKey(keyId) {
1504
+ if (!confirm('Are you sure you want to revoke this API key? It will no longer work.')) {
1505
+ return;
1506
+ }
1507
+
1508
+ try {
1509
+ const response = await fetch(`/api_keys/${keyId}/revoke`, {
1510
+ method: 'PUT',
1511
+ headers: {
1512
+ 'Authorization': `Bearer ${currentToken}`
1513
+ }
1514
+ });
1515
+
1516
+ if (response.status === 401) {
1517
+ logout();
1518
+ return;
1519
+ }
1520
+
1521
+ if (response.ok) {
1522
+ showMessage('API key revoked successfully', 'success');
1523
+ loadApiKeys();
1524
+ } else {
1525
+ const data = await response.json();
1526
+ showMessage(data.detail || 'Failed to revoke API key', 'error');
1527
+ }
1528
+ } catch (error) {
1529
+ showMessage('Error: ' + error.message, 'error');
1530
+ }
1531
+ }
1532
+
1533
+ // Delete API key
1534
+ async function deleteKey(keyId) {
1535
+ if (!confirm('Are you sure you want to permanently delete this API key? This cannot be undone!')) {
1536
+ return;
1537
+ }
1538
+
1539
+ try {
1540
+ const response = await fetch(`/api_keys/${keyId}`, {
1541
+ method: 'DELETE',
1542
+ headers: {
1543
+ 'Authorization': `Bearer ${currentToken}`
1544
+ }
1545
+ });
1546
+
1547
+ if (response.status === 401) {
1548
+ logout();
1549
+ return;
1550
+ }
1551
+
1552
+ if (response.ok) {
1553
+ showMessage('API key deleted successfully', 'success');
1554
+ loadApiKeys();
1555
+ } else {
1556
+ const data = await response.json();
1557
+ showMessage(data.detail || 'Failed to delete API key', 'error');
1558
+ }
1559
+ } catch (error) {
1560
+ showMessage('Error: ' + error.message, 'error');
1561
+ }
1562
+ }
1563
+
1564
+ // Show message
1565
+ function showMessage(text, type) {
1566
+ const messageDiv = document.getElementById('message');
1567
+ messageDiv.textContent = text;
1568
+ messageDiv.className = 'message ' + type;
1569
+ messageDiv.style.display = 'block';
1570
+
1571
+ setTimeout(() => {
1572
+ messageDiv.style.display = 'none';
1573
+ }, 5000);
1574
+ }
1575
+
1576
+ // Initialize
1577
+ if (checkAuth()) {
1578
+ loadApiKeys();
1579
+ }
1580
+ </script>
1581
+ </body>
1582
+ </html>
1583
+ """
1584
+ return html_content
1585
+
1586
+
1587
+ @app.get("/login", response_class=HTMLResponse, tags=["users"])
1588
+ async def login_page() -> str:
1589
+ """Login page."""
1590
+ html_content = """
1591
+ <!DOCTYPE html>
1592
+ <html lang="en">
1593
+ <head>
1594
+ <meta charset="UTF-8">
1595
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1596
+ <title>Login - PutPlace</title>
1597
+ <style>
1598
+ * {
1599
+ margin: 0;
1600
+ padding: 0;
1601
+ box-sizing: border-box;
1602
+ }
1603
+ body {
1604
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
1605
+ line-height: 1.6;
1606
+ color: #333;
1607
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1608
+ min-height: 100vh;
1609
+ padding: 20px;
1610
+ display: flex;
1611
+ align-items: center;
1612
+ justify-content: center;
1613
+ }
1614
+ .container {
1615
+ max-width: 450px;
1616
+ width: 100%;
1617
+ background: white;
1618
+ border-radius: 10px;
1619
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1620
+ overflow: hidden;
1621
+ }
1622
+ .header {
1623
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1624
+ color: white;
1625
+ padding: 30px;
1626
+ text-align: center;
1627
+ }
1628
+ .header h1 {
1629
+ font-size: 2rem;
1630
+ margin-bottom: 5px;
1631
+ }
1632
+ .header p {
1633
+ font-size: 1rem;
1634
+ opacity: 0.9;
1635
+ }
1636
+ .content {
1637
+ padding: 40px;
1638
+ }
1639
+ .form-group {
1640
+ margin-bottom: 20px;
1641
+ }
1642
+ .form-group label {
1643
+ display: block;
1644
+ margin-bottom: 8px;
1645
+ color: #333;
1646
+ font-weight: 500;
1647
+ }
1648
+ .form-group input {
1649
+ width: 100%;
1650
+ padding: 12px;
1651
+ border: 2px solid #e0e0e0;
1652
+ border-radius: 5px;
1653
+ font-size: 1rem;
1654
+ transition: border-color 0.3s;
1655
+ }
1656
+ .form-group input:focus {
1657
+ outline: none;
1658
+ border-color: #667eea;
1659
+ }
1660
+ .btn {
1661
+ width: 100%;
1662
+ padding: 14px;
1663
+ background: #667eea;
1664
+ color: white;
1665
+ border: none;
1666
+ border-radius: 5px;
1667
+ font-size: 1rem;
1668
+ font-weight: 600;
1669
+ cursor: pointer;
1670
+ transition: all 0.3s ease;
1671
+ }
1672
+ .btn:hover {
1673
+ background: #764ba2;
1674
+ transform: translateY(-2px);
1675
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
1676
+ }
1677
+ .btn:disabled {
1678
+ background: #ccc;
1679
+ cursor: not-allowed;
1680
+ transform: none;
1681
+ }
1682
+ .message {
1683
+ padding: 12px;
1684
+ border-radius: 5px;
1685
+ margin-bottom: 20px;
1686
+ display: none;
1687
+ }
1688
+ .message.error {
1689
+ background: #fee;
1690
+ color: #c33;
1691
+ border: 1px solid #fcc;
1692
+ }
1693
+ .message.success {
1694
+ background: #efe;
1695
+ color: #3c3;
1696
+ border: 1px solid #cfc;
1697
+ }
1698
+ .links {
1699
+ margin-top: 20px;
1700
+ text-align: center;
1701
+ padding-top: 20px;
1702
+ border-top: 1px solid #e0e0e0;
1703
+ }
1704
+ .links a {
1705
+ color: #667eea;
1706
+ text-decoration: none;
1707
+ transition: color 0.3s;
1708
+ }
1709
+ .links a:hover {
1710
+ color: #764ba2;
1711
+ }
1712
+ .back-link {
1713
+ margin-top: 10px;
1714
+ }
1715
+ </style>
1716
+ </head>
1717
+ <body>
1718
+ <div class="container">
1719
+ <div class="header">
1720
+ <h1>🔐 Login</h1>
1721
+ <p>Access your PutPlace account</p>
1722
+ </div>
1723
+
1724
+ <div class="content">
1725
+ <div id="message" class="message"></div>
1726
+
1727
+ <form id="loginForm">
1728
+ <div class="form-group">
1729
+ <label for="username">Username</label>
1730
+ <input type="text" id="username" name="username" required autofocus>
1731
+ </div>
1732
+
1733
+ <div class="form-group">
1734
+ <label for="password">Password</label>
1735
+ <input type="password" id="password" name="password" required>
1736
+ </div>
1737
+
1738
+ <button type="submit" class="btn">Login</button>
1739
+ </form>
1740
+
1741
+ <div class="links">
1742
+ <p>Don't have an account? <a href="/register">Register here</a></p>
1743
+ <p class="back-link"><a href="/">← Back to Home</a></p>
1744
+ </div>
1745
+ </div>
1746
+ </div>
1747
+
1748
+ <script>
1749
+ const form = document.getElementById('loginForm');
1750
+ const messageDiv = document.getElementById('message');
1751
+
1752
+ form.addEventListener('submit', async (e) => {
1753
+ e.preventDefault();
1754
+
1755
+ const username = document.getElementById('username').value;
1756
+ const password = document.getElementById('password').value;
1757
+ const submitBtn = form.querySelector('button[type="submit"]');
1758
+
1759
+ // Disable button during submission
1760
+ submitBtn.disabled = true;
1761
+ submitBtn.textContent = 'Logging in...';
1762
+
1763
+ // Hide previous messages
1764
+ messageDiv.style.display = 'none';
1765
+
1766
+ try {
1767
+ const response = await fetch('/api/login', {
1768
+ method: 'POST',
1769
+ headers: {
1770
+ 'Content-Type': 'application/json',
1771
+ },
1772
+ body: JSON.stringify({ username, password })
1773
+ });
1774
+
1775
+ const data = await response.json();
1776
+
1777
+ if (response.ok) {
1778
+ // Store the token
1779
+ localStorage.setItem('access_token', data.access_token);
1780
+
1781
+ // Show success message
1782
+ messageDiv.textContent = 'Login successful! Redirecting...';
1783
+ messageDiv.className = 'message success';
1784
+ messageDiv.style.display = 'block';
1785
+
1786
+ // Redirect to My Files page after 1 second
1787
+ setTimeout(() => {
1788
+ window.location.href = '/my_files';
1789
+ }, 1000);
1790
+ } else {
1791
+ // Show error message
1792
+ messageDiv.textContent = data.detail || 'Login failed. Please try again.';
1793
+ messageDiv.className = 'message error';
1794
+ messageDiv.style.display = 'block';
1795
+
1796
+ submitBtn.disabled = false;
1797
+ submitBtn.textContent = 'Login';
1798
+ }
1799
+ } catch (error) {
1800
+ messageDiv.textContent = 'An error occurred. Please try again.';
1801
+ messageDiv.className = 'message error';
1802
+ messageDiv.style.display = 'block';
1803
+
1804
+ submitBtn.disabled = false;
1805
+ submitBtn.textContent = 'Login';
1806
+ }
1807
+ });
1808
+ </script>
1809
+ </body>
1810
+ </html>
1811
+ """
1812
+ return html_content
1813
+
1814
+
1815
+ @app.get("/register", response_class=HTMLResponse, tags=["users"])
1816
+ async def register_page() -> str:
1817
+ """Registration page."""
1818
+ html_content = """
1819
+ <!DOCTYPE html>
1820
+ <html lang="en">
1821
+ <head>
1822
+ <meta charset="UTF-8">
1823
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1824
+ <title>Register - PutPlace</title>
1825
+ <style>
1826
+ * {
1827
+ margin: 0;
1828
+ padding: 0;
1829
+ box-sizing: border-box;
1830
+ }
1831
+ body {
1832
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
1833
+ line-height: 1.6;
1834
+ color: #333;
1835
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1836
+ min-height: 100vh;
1837
+ padding: 20px;
1838
+ display: flex;
1839
+ align-items: center;
1840
+ justify-content: center;
1841
+ }
1842
+ .container {
1843
+ max-width: 450px;
1844
+ width: 100%;
1845
+ background: white;
1846
+ border-radius: 10px;
1847
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1848
+ overflow: hidden;
1849
+ }
1850
+ .header {
1851
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1852
+ color: white;
1853
+ padding: 30px;
1854
+ text-align: center;
1855
+ }
1856
+ .header h1 {
1857
+ font-size: 2rem;
1858
+ margin-bottom: 5px;
1859
+ }
1860
+ .header p {
1861
+ font-size: 1rem;
1862
+ opacity: 0.9;
1863
+ }
1864
+ .content {
1865
+ padding: 40px;
1866
+ }
1867
+ .form-group {
1868
+ margin-bottom: 20px;
1869
+ }
1870
+ .form-group label {
1871
+ display: block;
1872
+ margin-bottom: 8px;
1873
+ color: #333;
1874
+ font-weight: 500;
1875
+ }
1876
+ .form-group input {
1877
+ width: 100%;
1878
+ padding: 12px;
1879
+ border: 2px solid #e0e0e0;
1880
+ border-radius: 5px;
1881
+ font-size: 1rem;
1882
+ transition: border-color 0.3s;
1883
+ }
1884
+ .form-group input:focus {
1885
+ outline: none;
1886
+ border-color: #667eea;
1887
+ }
1888
+ .form-group small {
1889
+ display: block;
1890
+ margin-top: 5px;
1891
+ color: #666;
1892
+ font-size: 0.85rem;
1893
+ }
1894
+ .btn {
1895
+ width: 100%;
1896
+ padding: 14px;
1897
+ background: #667eea;
1898
+ color: white;
1899
+ border: none;
1900
+ border-radius: 5px;
1901
+ font-size: 1rem;
1902
+ font-weight: 600;
1903
+ cursor: pointer;
1904
+ transition: all 0.3s ease;
1905
+ }
1906
+ .btn:hover {
1907
+ background: #764ba2;
1908
+ transform: translateY(-2px);
1909
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
1910
+ }
1911
+ .btn:disabled {
1912
+ background: #ccc;
1913
+ cursor: not-allowed;
1914
+ transform: none;
1915
+ }
1916
+ .message {
1917
+ padding: 12px;
1918
+ border-radius: 5px;
1919
+ margin-bottom: 20px;
1920
+ display: none;
1921
+ }
1922
+ .message.error {
1923
+ background: #fee;
1924
+ color: #c33;
1925
+ border: 1px solid #fcc;
1926
+ }
1927
+ .message.success {
1928
+ background: #efe;
1929
+ color: #3c3;
1930
+ border: 1px solid #cfc;
1931
+ }
1932
+ .links {
1933
+ margin-top: 20px;
1934
+ text-align: center;
1935
+ padding-top: 20px;
1936
+ border-top: 1px solid #e0e0e0;
1937
+ }
1938
+ .links a {
1939
+ color: #667eea;
1940
+ text-decoration: none;
1941
+ transition: color 0.3s;
1942
+ }
1943
+ .links a:hover {
1944
+ color: #764ba2;
1945
+ }
1946
+ .back-link {
1947
+ margin-top: 10px;
1948
+ }
1949
+ </style>
1950
+ </head>
1951
+ <body>
1952
+ <div class="container">
1953
+ <div class="header">
1954
+ <h1>📝 Register</h1>
1955
+ <p>Create your PutPlace account</p>
1956
+ </div>
1957
+
1958
+ <div class="content">
1959
+ <div id="message" class="message"></div>
1960
+
1961
+ <form id="registerForm">
1962
+ <div class="form-group">
1963
+ <label for="username">Username *</label>
1964
+ <input type="text" id="username" name="username" required autofocus minlength="3" maxlength="50">
1965
+ <small>3-50 characters</small>
1966
+ </div>
1967
+
1968
+ <div class="form-group">
1969
+ <label for="email">Email *</label>
1970
+ <input type="email" id="email" name="email" required>
1971
+ </div>
1972
+
1973
+ <div class="form-group">
1974
+ <label for="full_name">Full Name</label>
1975
+ <input type="text" id="full_name" name="full_name">
1976
+ <small>Optional</small>
1977
+ </div>
1978
+
1979
+ <div class="form-group">
1980
+ <label for="password">Password *</label>
1981
+ <input type="password" id="password" name="password" required minlength="8">
1982
+ <small>Minimum 8 characters</small>
1983
+ </div>
1984
+
1985
+ <div class="form-group">
1986
+ <label for="confirm_password">Confirm Password *</label>
1987
+ <input type="password" id="confirm_password" name="confirm_password" required minlength="8">
1988
+ </div>
1989
+
1990
+ <button type="submit" class="btn">Register</button>
1991
+ </form>
1992
+
1993
+ <div class="links">
1994
+ <p>Already have an account? <a href="/login">Login here</a></p>
1995
+ <p class="back-link"><a href="/">← Back to Home</a></p>
1996
+ </div>
1997
+ </div>
1998
+ </div>
1999
+
2000
+ <script>
2001
+ const form = document.getElementById('registerForm');
2002
+ const messageDiv = document.getElementById('message');
2003
+
2004
+ form.addEventListener('submit', async (e) => {
2005
+ e.preventDefault();
2006
+
2007
+ const username = document.getElementById('username').value;
2008
+ const email = document.getElementById('email').value;
2009
+ const full_name = document.getElementById('full_name').value;
2010
+ const password = document.getElementById('password').value;
2011
+ const confirmPassword = document.getElementById('confirm_password').value;
2012
+ const submitBtn = form.querySelector('button[type="submit"]');
2013
+
2014
+ // Validate passwords match
2015
+ if (password !== confirmPassword) {
2016
+ messageDiv.textContent = 'Passwords do not match';
2017
+ messageDiv.className = 'message error';
2018
+ messageDiv.style.display = 'block';
2019
+ return;
2020
+ }
2021
+
2022
+ // Disable button during submission
2023
+ submitBtn.disabled = true;
2024
+ submitBtn.textContent = 'Registering...';
2025
+
2026
+ // Hide previous messages
2027
+ messageDiv.style.display = 'none';
2028
+
2029
+ try {
2030
+ const requestBody = {
2031
+ username,
2032
+ email,
2033
+ password
2034
+ };
2035
+
2036
+ // Add full_name only if provided
2037
+ if (full_name) {
2038
+ requestBody.full_name = full_name;
2039
+ }
2040
+
2041
+ const response = await fetch('/api/register', {
2042
+ method: 'POST',
2043
+ headers: {
2044
+ 'Content-Type': 'application/json',
2045
+ },
2046
+ body: JSON.stringify(requestBody)
2047
+ });
2048
+
2049
+ const data = await response.json();
2050
+
2051
+ if (response.ok) {
2052
+ // Show success message
2053
+ messageDiv.textContent = 'Registration successful! Redirecting to login...';
2054
+ messageDiv.className = 'message success';
2055
+ messageDiv.style.display = 'block';
2056
+
2057
+ // Redirect to login page after 2 seconds
2058
+ setTimeout(() => {
2059
+ window.location.href = '/login';
2060
+ }, 2000);
2061
+ } else {
2062
+ // Show error message
2063
+ messageDiv.textContent = data.detail || 'Registration failed. Please try again.';
2064
+ messageDiv.className = 'message error';
2065
+ messageDiv.style.display = 'block';
2066
+
2067
+ submitBtn.disabled = false;
2068
+ submitBtn.textContent = 'Register';
2069
+ }
2070
+ } catch (error) {
2071
+ messageDiv.textContent = 'An error occurred. Please try again.';
2072
+ messageDiv.className = 'message error';
2073
+ messageDiv.style.display = 'block';
2074
+
2075
+ submitBtn.disabled = false;
2076
+ submitBtn.textContent = 'Register';
2077
+ }
2078
+ });
2079
+ </script>
2080
+ </body>
2081
+ </html>
2082
+ """
2083
+ return html_content
2084
+
2085
+
2086
+ @app.post("/api/register", tags=["users"])
2087
+ async def register_user(user_data: UserCreate, db: MongoDB = Depends(get_db)) -> dict:
2088
+ """Register a new user."""
2089
+ from pymongo.errors import DuplicateKeyError
2090
+ from .user_auth import get_password_hash
2091
+
2092
+ try:
2093
+ # Hash the password
2094
+ hashed_password = get_password_hash(user_data.password)
2095
+
2096
+ # Create user in database
2097
+ user_id = await db.create_user(
2098
+ username=user_data.username,
2099
+ email=user_data.email,
2100
+ hashed_password=hashed_password,
2101
+ full_name=user_data.full_name
2102
+ )
2103
+
2104
+ return {"message": "User registered successfully", "user_id": user_id}
2105
+
2106
+ except DuplicateKeyError as e:
2107
+ raise HTTPException(
2108
+ status_code=status.HTTP_400_BAD_REQUEST,
2109
+ detail=str(e)
2110
+ )
2111
+
2112
+
2113
+ @app.post("/api/login", response_model=Token, tags=["users"])
2114
+ async def login_user(user_login: UserLogin, db: MongoDB = Depends(get_db)) -> Token:
2115
+ """Login and get access token."""
2116
+ from .user_auth import verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
2117
+ from datetime import timedelta
2118
+
2119
+ # Get user from database
2120
+ user = await db.get_user_by_username(user_login.username)
2121
+
2122
+ if not user or not verify_password(user_login.password, user["hashed_password"]):
2123
+ raise HTTPException(
2124
+ status_code=status.HTTP_401_UNAUTHORIZED,
2125
+ detail="Incorrect username or password",
2126
+ headers={"WWW-Authenticate": "Bearer"},
2127
+ )
2128
+
2129
+ if not user.get("is_active", True):
2130
+ raise HTTPException(
2131
+ status_code=status.HTTP_400_BAD_REQUEST,
2132
+ detail="Inactive user account"
2133
+ )
2134
+
2135
+ # Create access token
2136
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
2137
+ access_token = create_access_token(
2138
+ data={"sub": user["username"]}, expires_delta=access_token_expires
2139
+ )
2140
+
2141
+ return Token(access_token=access_token)
2142
+
2143
+
2144
+ @app.get("/api/my_files", response_model=list[FileMetadataResponse], tags=["files"])
2145
+ async def get_my_files(
2146
+ db: MongoDB = Depends(get_db),
2147
+ current_user: dict = Depends(get_current_user),
2148
+ limit: int = 100,
2149
+ skip: int = 0,
2150
+ ) -> list[FileMetadataResponse]:
2151
+ """Get all files uploaded by the current user.
2152
+
2153
+ Requires user authentication via JWT Bearer token.
2154
+
2155
+ Args:
2156
+ db: Database instance (injected)
2157
+ current_user: Current logged-in user (injected, for authentication)
2158
+ limit: Maximum number of files to return (default 100)
2159
+ skip: Number of files to skip for pagination (default 0)
2160
+
2161
+ Returns:
2162
+ List of file metadata uploaded by the current user
2163
+
2164
+ Raises:
2165
+ HTTPException: If database operation fails or authentication fails
2166
+ """
2167
+ try:
2168
+ # Get files uploaded by this user
2169
+ files = await db.get_files_by_user(
2170
+ user_id=str(current_user["_id"]),
2171
+ limit=limit,
2172
+ skip=skip
2173
+ )
2174
+
2175
+ return [FileMetadataResponse(**file) for file in files]
2176
+
2177
+ except Exception as e:
2178
+ logger.error(f"Error getting user files: {e}")
2179
+ raise HTTPException(
2180
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
2181
+ detail=f"Failed to get user files: {str(e)}",
2182
+ ) from e
2183
+
2184
+
2185
+ @app.get("/api/clones/{sha256}", response_model=list[FileMetadataResponse], tags=["files"])
2186
+ async def get_clones(
2187
+ sha256: str,
2188
+ db: MongoDB = Depends(get_db),
2189
+ current_user: dict = Depends(get_current_user),
2190
+ ) -> list[FileMetadataResponse]:
2191
+ """Get all files with the same SHA256 hash (clones) across all users.
2192
+
2193
+ This endpoint returns ALL files with the same SHA256, including the epoch file
2194
+ (the first one uploaded with content) even if it was uploaded by a different user.
2195
+
2196
+ Requires user authentication via JWT Bearer token.
2197
+
2198
+ Args:
2199
+ sha256: SHA256 hash to search for
2200
+ db: Database instance (injected)
2201
+ current_user: Current logged-in user (injected, for authentication)
2202
+
2203
+ Returns:
2204
+ List of all file metadata with matching SHA256, sorted with epoch file first
2205
+
2206
+ Raises:
2207
+ HTTPException: If validation fails or database operation fails
2208
+ """
2209
+ if len(sha256) != 64:
2210
+ raise HTTPException(
2211
+ status_code=status.HTTP_400_BAD_REQUEST,
2212
+ detail="SHA256 hash must be exactly 64 characters",
2213
+ )
2214
+
2215
+ try:
2216
+ # Get all files with this SHA256 across all users
2217
+ files = await db.get_files_by_sha256(sha256)
2218
+
2219
+ return [FileMetadataResponse(**file) for file in files]
2220
+
2221
+ except Exception as e:
2222
+ logger.error(f"Error getting clones for SHA256 {sha256}: {e}")
2223
+ raise HTTPException(
2224
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
2225
+ detail=f"Failed to get clones: {str(e)}",
2226
+ ) from e
2227
+
2228
+
2229
+ @app.get("/my_files", response_class=HTMLResponse, tags=["users"])
2230
+ async def my_files_page() -> str:
2231
+ """My Files page - shows files uploaded by the current user in a file system tree."""
2232
+ html_content = """
2233
+ <!DOCTYPE html>
2234
+ <html lang="en">
2235
+ <head>
2236
+ <meta charset="UTF-8">
2237
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2238
+ <title>My Files - PutPlace</title>
2239
+ <style>
2240
+ * {
2241
+ margin: 0;
2242
+ padding: 0;
2243
+ box-sizing: border-box;
2244
+ }
2245
+ body {
2246
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
2247
+ line-height: 1.6;
2248
+ color: #333;
2249
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2250
+ min-height: 100vh;
2251
+ padding: 20px;
2252
+ }
2253
+ .container {
2254
+ max-width: 1400px;
2255
+ margin: 0 auto;
2256
+ background: white;
2257
+ border-radius: 10px;
2258
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
2259
+ overflow: hidden;
2260
+ }
2261
+ .header {
2262
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2263
+ color: white;
2264
+ padding: 30px 40px;
2265
+ display: flex;
2266
+ justify-content: space-between;
2267
+ align-items: center;
2268
+ }
2269
+ .header h1 {
2270
+ font-size: 2rem;
2271
+ }
2272
+ .header-buttons {
2273
+ display: flex;
2274
+ gap: 10px;
2275
+ }
2276
+ .logout-btn {
2277
+ padding: 8px 16px;
2278
+ background: rgba(255, 255, 255, 0.2);
2279
+ color: white;
2280
+ border: 2px solid white;
2281
+ border-radius: 5px;
2282
+ cursor: pointer;
2283
+ font-weight: 500;
2284
+ text-decoration: none;
2285
+ transition: all 0.3s ease;
2286
+ }
2287
+ .logout-btn:hover {
2288
+ background: white;
2289
+ color: #667eea;
2290
+ }
2291
+ .content {
2292
+ padding: 40px;
2293
+ }
2294
+ .message {
2295
+ padding: 12px;
2296
+ border-radius: 5px;
2297
+ margin-bottom: 20px;
2298
+ display: none;
2299
+ }
2300
+ .message.error {
2301
+ background: #fee;
2302
+ color: #c33;
2303
+ border: 1px solid #fcc;
2304
+ }
2305
+ .message.success {
2306
+ background: #efe;
2307
+ color: #3c3;
2308
+ border: 1px solid #cfc;
2309
+ }
2310
+ .section {
2311
+ margin-bottom: 30px;
2312
+ }
2313
+ .section h2 {
2314
+ color: #667eea;
2315
+ margin-bottom: 15px;
2316
+ font-size: 1.5rem;
2317
+ border-bottom: 2px solid #667eea;
2318
+ padding-bottom: 5px;
2319
+ }
2320
+ .no-files {
2321
+ text-align: center;
2322
+ padding: 40px;
2323
+ color: #6c757d;
2324
+ }
2325
+ .back-link {
2326
+ display: inline-block;
2327
+ margin-top: 20px;
2328
+ color: #667eea;
2329
+ text-decoration: none;
2330
+ }
2331
+ .back-link:hover {
2332
+ color: #764ba2;
2333
+ }
2334
+
2335
+ /* File tree styles */
2336
+ .file-tree {
2337
+ font-family: 'Courier New', monospace;
2338
+ font-size: 0.9rem;
2339
+ }
2340
+ .tree-host {
2341
+ margin-bottom: 25px;
2342
+ background: #f8f9fa;
2343
+ border-radius: 8px;
2344
+ padding: 15px;
2345
+ border-left: 4px solid #667eea;
2346
+ }
2347
+ .tree-host-header {
2348
+ display: flex;
2349
+ align-items: center;
2350
+ gap: 10px;
2351
+ padding: 8px;
2352
+ background: white;
2353
+ border-radius: 5px;
2354
+ margin-bottom: 10px;
2355
+ cursor: pointer;
2356
+ transition: background 0.2s;
2357
+ }
2358
+ .tree-host-header:hover {
2359
+ background: #e9ecef;
2360
+ }
2361
+ .tree-host-icon {
2362
+ font-size: 1.2rem;
2363
+ transition: transform 0.2s;
2364
+ }
2365
+ .tree-host-icon.collapsed {
2366
+ transform: rotate(-90deg);
2367
+ }
2368
+ .tree-host-name {
2369
+ font-weight: 600;
2370
+ color: #667eea;
2371
+ font-size: 1rem;
2372
+ }
2373
+ .tree-host-count {
2374
+ margin-left: auto;
2375
+ background: #667eea;
2376
+ color: white;
2377
+ padding: 2px 8px;
2378
+ border-radius: 12px;
2379
+ font-size: 0.85rem;
2380
+ }
2381
+ .tree-host-content {
2382
+ padding-left: 20px;
2383
+ }
2384
+ .tree-host-content.collapsed {
2385
+ display: none;
2386
+ }
2387
+ .tree-folder {
2388
+ margin: 8px 0;
2389
+ padding-left: 15px;
2390
+ }
2391
+ .tree-folder-header {
2392
+ display: flex;
2393
+ align-items: center;
2394
+ gap: 8px;
2395
+ padding: 6px 8px;
2396
+ background: white;
2397
+ border-radius: 4px;
2398
+ cursor: pointer;
2399
+ transition: background 0.2s;
2400
+ }
2401
+ .tree-folder-header:hover {
2402
+ background: #fff3cd;
2403
+ }
2404
+ .tree-folder-icon {
2405
+ font-size: 1rem;
2406
+ transition: transform 0.2s;
2407
+ }
2408
+ .tree-folder-icon.collapsed {
2409
+ transform: rotate(-90deg);
2410
+ }
2411
+ .tree-folder-name {
2412
+ font-weight: 500;
2413
+ color: #495057;
2414
+ }
2415
+ .tree-folder-count {
2416
+ margin-left: auto;
2417
+ color: #6c757d;
2418
+ font-size: 0.85rem;
2419
+ }
2420
+ .tree-folder-content {
2421
+ padding-left: 20px;
2422
+ margin-top: 5px;
2423
+ }
2424
+ .tree-folder-content.collapsed {
2425
+ display: none;
2426
+ }
2427
+ .tree-file {
2428
+ display: flex;
2429
+ align-items: center;
2430
+ gap: 10px;
2431
+ padding: 8px;
2432
+ margin: 4px 0;
2433
+ background: white;
2434
+ border-radius: 4px;
2435
+ transition: all 0.2s;
2436
+ }
2437
+ .tree-file:hover {
2438
+ background: #e7f3ff;
2439
+ transform: translateX(5px);
2440
+ }
2441
+ .file-icon {
2442
+ font-size: 1rem;
2443
+ }
2444
+ .file-name {
2445
+ flex: 1;
2446
+ color: #333;
2447
+ }
2448
+ .file-size {
2449
+ color: #6c757d;
2450
+ font-size: 0.85rem;
2451
+ min-width: 80px;
2452
+ text-align: right;
2453
+ }
2454
+ .file-status {
2455
+ font-size: 0.75rem;
2456
+ padding: 2px 8px;
2457
+ border-radius: 10px;
2458
+ font-weight: 500;
2459
+ }
2460
+ .file-status.uploaded {
2461
+ background: #d4edda;
2462
+ color: #155724;
2463
+ }
2464
+ .file-status.metadata {
2465
+ background: #fff3cd;
2466
+ color: #856404;
2467
+ }
2468
+ .action-btn {
2469
+ border: none;
2470
+ padding: 3px 8px;
2471
+ border-radius: 3px;
2472
+ cursor: pointer;
2473
+ font-size: 0.75rem;
2474
+ transition: all 0.2s;
2475
+ font-weight: 500;
2476
+ }
2477
+ .info-btn {
2478
+ background: #667eea;
2479
+ color: white;
2480
+ }
2481
+ .info-btn:hover {
2482
+ background: #764ba2;
2483
+ transform: scale(1.05);
2484
+ }
2485
+ .clone-btn {
2486
+ background: #28a745;
2487
+ color: white;
2488
+ }
2489
+ .clone-btn:hover:not(.disabled) {
2490
+ background: #218838;
2491
+ transform: scale(1.05);
2492
+ }
2493
+ .clone-btn.disabled {
2494
+ background: #ccc;
2495
+ color: #666;
2496
+ cursor: not-allowed;
2497
+ opacity: 0.6;
2498
+ }
2499
+
2500
+ /* Modal styles */
2501
+ .modal {
2502
+ display: none;
2503
+ position: fixed;
2504
+ z-index: 1000;
2505
+ left: 0;
2506
+ top: 0;
2507
+ width: 100%;
2508
+ height: 100%;
2509
+ overflow: auto;
2510
+ background-color: rgba(0, 0, 0, 0.5);
2511
+ animation: fadeIn 0.3s;
2512
+ }
2513
+ @keyframes fadeIn {
2514
+ from { opacity: 0; }
2515
+ to { opacity: 1; }
2516
+ }
2517
+ .modal-content {
2518
+ background-color: white;
2519
+ margin: 5% auto;
2520
+ padding: 0;
2521
+ border-radius: 10px;
2522
+ width: 90%;
2523
+ max-width: 1200px;
2524
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
2525
+ animation: slideIn 0.3s;
2526
+ max-height: 85vh;
2527
+ display: flex;
2528
+ flex-direction: column;
2529
+ }
2530
+ @keyframes slideIn {
2531
+ from {
2532
+ transform: translateY(-50px);
2533
+ opacity: 0;
2534
+ }
2535
+ to {
2536
+ transform: translateY(0);
2537
+ opacity: 1;
2538
+ }
2539
+ }
2540
+ .modal-header {
2541
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2542
+ color: white;
2543
+ padding: 20px 30px;
2544
+ border-radius: 10px 10px 0 0;
2545
+ display: flex;
2546
+ justify-content: space-between;
2547
+ align-items: center;
2548
+ }
2549
+ .modal-header h3 {
2550
+ font-size: 1.3rem;
2551
+ font-weight: 600;
2552
+ }
2553
+ .modal-close {
2554
+ color: white;
2555
+ font-size: 28px;
2556
+ font-weight: bold;
2557
+ cursor: pointer;
2558
+ background: none;
2559
+ border: none;
2560
+ padding: 0;
2561
+ line-height: 1;
2562
+ transition: transform 0.2s;
2563
+ }
2564
+ .modal-close:hover {
2565
+ transform: scale(1.2);
2566
+ }
2567
+ .modal-body {
2568
+ padding: 30px;
2569
+ overflow-y: auto;
2570
+ overflow-x: auto;
2571
+ flex: 1;
2572
+ }
2573
+ .modal-body table {
2574
+ table-layout: fixed;
2575
+ width: 100%;
2576
+ }
2577
+ .modal-body table th:nth-child(1) {
2578
+ width: 15%;
2579
+ }
2580
+ .modal-body table th:nth-child(2) {
2581
+ width: 55%;
2582
+ }
2583
+ .modal-body table th:nth-child(3) {
2584
+ width: 12%;
2585
+ }
2586
+ .modal-body table th:nth-child(4) {
2587
+ width: 18%;
2588
+ }
2589
+ .modal-body table td {
2590
+ word-wrap: break-word;
2591
+ word-break: break-all;
2592
+ overflow-wrap: break-word;
2593
+ }
2594
+ .detail-grid {
2595
+ display: grid;
2596
+ grid-template-columns: 1fr 2fr;
2597
+ gap: 15px;
2598
+ margin-bottom: 15px;
2599
+ }
2600
+ .detail-label {
2601
+ font-weight: 600;
2602
+ color: #667eea;
2603
+ }
2604
+ .detail-value {
2605
+ word-break: break-all;
2606
+ font-family: 'Courier New', monospace;
2607
+ font-size: 0.9rem;
2608
+ background: #f8f9fa;
2609
+ padding: 5px 10px;
2610
+ border-radius: 4px;
2611
+ }
2612
+ .detail-value.normal {
2613
+ font-family: inherit;
2614
+ }
2615
+ </style>
2616
+ </head>
2617
+ <body>
2618
+ <div class="container">
2619
+ <div class="header">
2620
+ <h1>📁 My Files</h1>
2621
+ <div class="header-buttons">
2622
+ <a href="/api_keys_page" class="logout-btn">🔑 API Keys</a>
2623
+ <a href="/" class="logout-btn">← Home</a>
2624
+ <button onclick="logout()" class="logout-btn">Logout</button>
2625
+ </div>
2626
+ </div>
2627
+
2628
+ <div class="content">
2629
+ <div id="message" class="message"></div>
2630
+
2631
+ <div class="section">
2632
+ <h2>File System</h2>
2633
+ <div id="filesContainer">
2634
+ <p class="no-files">Loading...</p>
2635
+ </div>
2636
+ </div>
2637
+
2638
+ <a href="/" class="back-link">← Back to Home</a>
2639
+ </div>
2640
+ </div>
2641
+
2642
+ <!-- Modal for file details -->
2643
+ <div id="fileModal" class="modal">
2644
+ <div class="modal-content">
2645
+ <div class="modal-header">
2646
+ <h3>📄 File Details</h3>
2647
+ <button class="modal-close" onclick="closeModal()">&times;</button>
2648
+ </div>
2649
+ <div class="modal-body" id="modalBody">
2650
+ <!-- File details will be inserted here -->
2651
+ </div>
2652
+ </div>
2653
+ </div>
2654
+
2655
+ <!-- Modal for clones -->
2656
+ <div id="clonesModal" class="modal">
2657
+ <div class="modal-content">
2658
+ <div class="modal-header">
2659
+ <h3>👥 File Clones (Identical SHA256)</h3>
2660
+ <button class="modal-close" onclick="closeClonesModal()">&times;</button>
2661
+ </div>
2662
+ <div class="modal-body" id="clonesModalBody">
2663
+ <!-- Clone list will be inserted here -->
2664
+ </div>
2665
+ </div>
2666
+ </div>
2667
+
2668
+ <script>
2669
+ let currentToken = null;
2670
+ let allFiles = [];
2671
+
2672
+ // Check if user is logged in
2673
+ function checkAuth() {
2674
+ currentToken = localStorage.getItem('access_token');
2675
+ if (!currentToken) {
2676
+ window.location.href = '/login';
2677
+ return false;
2678
+ }
2679
+ return true;
2680
+ }
2681
+
2682
+ // Logout function
2683
+ function logout() {
2684
+ localStorage.removeItem('access_token');
2685
+ window.location.href = '/';
2686
+ }
2687
+
2688
+ // Load user files
2689
+ async function loadFiles() {
2690
+ if (!checkAuth()) return;
2691
+
2692
+ try {
2693
+ const response = await fetch('/api/my_files', {
2694
+ headers: {
2695
+ 'Authorization': `Bearer ${currentToken}`
2696
+ }
2697
+ });
2698
+
2699
+ if (response.status === 401) {
2700
+ logout();
2701
+ return;
2702
+ }
2703
+
2704
+ if (!response.ok) {
2705
+ throw new Error('Failed to load files');
2706
+ }
2707
+
2708
+ allFiles = await response.json();
2709
+ buildFileTree(allFiles);
2710
+ } catch (error) {
2711
+ showMessage('Error loading files: ' + error.message, 'error');
2712
+ }
2713
+ }
2714
+
2715
+ // Build file system tree structure
2716
+ function buildFileTree(files) {
2717
+ const container = document.getElementById('filesContainer');
2718
+
2719
+ if (files.length === 0) {
2720
+ container.innerHTML = '<p class="no-files">No files uploaded yet. Use the ppclient tool to upload file metadata!</p>';
2721
+ return;
2722
+ }
2723
+
2724
+ // Create SHA256 map to count clones (files with same hash)
2725
+ const sha256Map = {};
2726
+ files.forEach(file => {
2727
+ sha256Map[file.sha256] = (sha256Map[file.sha256] || 0) + 1;
2728
+ });
2729
+
2730
+ // Organize files by hostname and path
2731
+ const tree = {};
2732
+ files.forEach(file => {
2733
+ if (!tree[file.hostname]) {
2734
+ tree[file.hostname] = {};
2735
+ }
2736
+
2737
+ // Parse filepath into directory structure
2738
+ const parts = file.filepath.split('/');
2739
+ const filename = parts.pop();
2740
+ const dirPath = parts.join('/') || '/';
2741
+
2742
+ if (!tree[file.hostname][dirPath]) {
2743
+ tree[file.hostname][dirPath] = [];
2744
+ }
2745
+ tree[file.hostname][dirPath].push({ ...file, filename });
2746
+ });
2747
+
2748
+ // Build HTML
2749
+ let html = '<div class="file-tree">';
2750
+
2751
+ Object.keys(tree).sort().forEach(hostname => {
2752
+ const hostFiles = Object.values(tree[hostname]).flat();
2753
+ html += `
2754
+ <div class="tree-host">
2755
+ <div class="tree-host-header" onclick="toggleHost(this)">
2756
+ <span class="tree-host-icon">🔽</span>
2757
+ <span class="tree-host-name">🖥️ ${escapeHtml(hostname)}</span>
2758
+ <span class="tree-host-count">${hostFiles.length} files</span>
2759
+ </div>
2760
+ <div class="tree-host-content">
2761
+ `;
2762
+
2763
+ Object.keys(tree[hostname]).sort().forEach(dirPath => {
2764
+ const files = tree[hostname][dirPath];
2765
+ html += `
2766
+ <div class="tree-folder">
2767
+ <div class="tree-folder-header" onclick="toggleFolder(this)">
2768
+ <span class="tree-folder-icon">🔽</span>
2769
+ <span class="tree-folder-name">📁 ${escapeHtml(dirPath)}</span>
2770
+ <span class="tree-folder-count">${files.length}</span>
2771
+ </div>
2772
+ <div class="tree-folder-content">
2773
+ `;
2774
+
2775
+ files.forEach(file => {
2776
+ const status = file.has_file_content ? 'uploaded' : 'metadata';
2777
+ const statusText = file.has_file_content ? 'Full' : 'Meta';
2778
+ const cloneCount = sha256Map[file.sha256] || 0;
2779
+ const isZeroLength = file.file_size === 0;
2780
+
2781
+ // For zero-length files, show a special icon and non-clickable "0" for clones
2782
+ const fileIcon = isZeroLength ? '📭' : '📄';
2783
+
2784
+ // Clone button logic:
2785
+ // - Zero-length files: always show "0" disabled
2786
+ // - Metadata-only files: always clickable (must have epoch file somewhere)
2787
+ // - Files with content: always clickable (may have clones from other users)
2788
+ const cloneButton = isZeroLength
2789
+ ? '<span class="action-btn clone-btn disabled" style="cursor: default;">0</span>'
2790
+ : `<button class="action-btn clone-btn" onclick="showClones('${file.sha256}')">${cloneCount > 1 ? cloneCount : '👥'}</button>`;
2791
+
2792
+ html += `
2793
+ <div class="tree-file">
2794
+ <span class="file-icon">${fileIcon}</span>
2795
+ <span class="file-name">${escapeHtml(file.filename)}</span>
2796
+ <span class="file-size">${formatFileSize(file.file_size)}</span>
2797
+ <span class="file-status ${status}">${statusText}</span>
2798
+ <button class="action-btn info-btn" onclick='showFileDetails(${JSON.stringify(file)})'>ℹ️</button>
2799
+ ${cloneButton}
2800
+ </div>
2801
+ `;
2802
+ });
2803
+
2804
+ html += `
2805
+ </div>
2806
+ </div>
2807
+ `;
2808
+ });
2809
+
2810
+ html += `
2811
+ </div>
2812
+ </div>
2813
+ `;
2814
+ });
2815
+
2816
+ html += '</div>';
2817
+ container.innerHTML = html;
2818
+ }
2819
+
2820
+ // Toggle host visibility
2821
+ function toggleHost(element) {
2822
+ const content = element.nextElementSibling;
2823
+ const icon = element.querySelector('.tree-host-icon');
2824
+ content.classList.toggle('collapsed');
2825
+ icon.classList.toggle('collapsed');
2826
+ }
2827
+
2828
+ // Toggle folder visibility
2829
+ function toggleFolder(element) {
2830
+ const content = element.nextElementSibling;
2831
+ const icon = element.querySelector('.tree-folder-icon');
2832
+ content.classList.toggle('collapsed');
2833
+ icon.classList.toggle('collapsed');
2834
+ }
2835
+
2836
+ // Show file details in modal
2837
+ function showFileDetails(file) {
2838
+ const modal = document.getElementById('fileModal');
2839
+ const modalBody = document.getElementById('modalBody');
2840
+
2841
+ const uploadedDate = file.created_at ? new Date(file.created_at).toLocaleString() : 'N/A';
2842
+ const fileUploadedDate = file.file_uploaded_at ? new Date(file.file_uploaded_at).toLocaleString() : 'N/A';
2843
+
2844
+ modalBody.innerHTML = `
2845
+ <div class="detail-grid">
2846
+ <div class="detail-label">Filepath:</div>
2847
+ <div class="detail-value">${escapeHtml(file.filepath)}</div>
2848
+
2849
+ <div class="detail-label">Hostname:</div>
2850
+ <div class="detail-value normal">${escapeHtml(file.hostname)}</div>
2851
+
2852
+ <div class="detail-label">IP Address:</div>
2853
+ <div class="detail-value normal">${escapeHtml(file.ip_address)}</div>
2854
+
2855
+ <div class="detail-label">SHA256:</div>
2856
+ <div class="detail-value">${escapeHtml(file.sha256)}</div>
2857
+
2858
+ <div class="detail-label">File Size:</div>
2859
+ <div class="detail-value normal">${formatFileSize(file.file_size)} (${file.file_size.toLocaleString()} bytes)</div>
2860
+
2861
+ <div class="detail-label">Permissions:</div>
2862
+ <div class="detail-value normal">${formatPermissions(file.file_mode)}</div>
2863
+
2864
+ <div class="detail-label">Owner:</div>
2865
+ <div class="detail-value normal">UID: ${file.file_uid} / GID: ${file.file_gid}</div>
2866
+
2867
+ <div class="detail-label">Modified Time:</div>
2868
+ <div class="detail-value normal">${new Date(file.file_mtime * 1000).toLocaleString()}</div>
2869
+
2870
+ <div class="detail-label">Access Time:</div>
2871
+ <div class="detail-value normal">${new Date(file.file_atime * 1000).toLocaleString()}</div>
2872
+
2873
+ <div class="detail-label">Change Time:</div>
2874
+ <div class="detail-value normal">${new Date(file.file_ctime * 1000).toLocaleString()}</div>
2875
+
2876
+ <div class="detail-label">Metadata Created:</div>
2877
+ <div class="detail-value normal">${uploadedDate}</div>
2878
+
2879
+ <div class="detail-label">File Content:</div>
2880
+ <div class="detail-value normal">${file.has_file_content ? `✅ Uploaded at ${fileUploadedDate}` : '❌ Not uploaded'}</div>
2881
+ </div>
2882
+ `;
2883
+
2884
+ modal.style.display = 'block';
2885
+ }
2886
+
2887
+ // Close modal
2888
+ function closeModal() {
2889
+ document.getElementById('fileModal').style.display = 'none';
2890
+ }
2891
+
2892
+ // Close clones modal
2893
+ function closeClonesModal() {
2894
+ document.getElementById('clonesModal').style.display = 'none';
2895
+ }
2896
+
2897
+ // Show clones for a given SHA256
2898
+ async function showClones(sha256) {
2899
+ const modal = document.getElementById('clonesModal');
2900
+ const modalBody = document.getElementById('clonesModalBody');
2901
+
2902
+ // Show loading message
2903
+ modalBody.innerHTML = '<p style="text-align: center; color: #667eea;">Loading clones...</p>';
2904
+ modal.style.display = 'block';
2905
+
2906
+ try {
2907
+ // Fetch all clones across all users from the server
2908
+ const response = await fetch(`/api/clones/${sha256}`, {
2909
+ headers: {
2910
+ 'Authorization': `Bearer ${currentToken}`
2911
+ }
2912
+ });
2913
+
2914
+ if (!response.ok) {
2915
+ throw new Error(`Failed to load clones: ${response.statusText}`);
2916
+ }
2917
+
2918
+ const clones = await response.json();
2919
+
2920
+ // Sort clones: epoch file (first uploaded) first, then others
2921
+ // (Backend already sorts, but we keep this for safety)
2922
+ clones.sort((a, b) => {
2923
+ // Files with content come before files without content
2924
+ if (a.has_file_content && !b.has_file_content) return -1;
2925
+ if (!a.has_file_content && b.has_file_content) return 1;
2926
+
2927
+ // Among files with content, sort by upload time (earliest first - epoch file)
2928
+ if (a.has_file_content && b.has_file_content) {
2929
+ const timeA = a.file_uploaded_at ? new Date(a.file_uploaded_at).getTime() : 0;
2930
+ const timeB = b.file_uploaded_at ? new Date(b.file_uploaded_at).getTime() : 0;
2931
+ return timeA - timeB;
2932
+ }
2933
+
2934
+ // Among files without content, sort by metadata creation time
2935
+ const createdA = a.created_at ? new Date(a.created_at).getTime() : 0;
2936
+ const createdB = b.created_at ? new Date(b.created_at).getTime() : 0;
2937
+ return createdA - createdB;
2938
+ });
2939
+
2940
+ if (clones.length === 0) {
2941
+ modalBody.innerHTML = '<p>No clone files found.</p>';
2942
+ } else {
2943
+ let html = `
2944
+ <p style="margin-bottom: 15px; color: #667eea; font-weight: 500;">
2945
+ Found ${clones.length} file(s) with identical SHA256: <code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">${sha256.substring(0, 16)}...</code>
2946
+ </p>
2947
+ <table style="width: 100%; border-collapse: collapse;">
2948
+ <thead>
2949
+ <tr style="background: #f8f9fa; border-bottom: 2px solid #667eea;">
2950
+ <th style="padding: 10px; text-align: left; font-weight: 600; color: #667eea;">Hostname</th>
2951
+ <th style="padding: 10px; text-align: left; font-weight: 600; color: #667eea;">File Path</th>
2952
+ <th style="padding: 10px; text-align: left; font-weight: 600; color: #667eea;">Size</th>
2953
+ <th style="padding: 10px; text-align: center; font-weight: 600; color: #667eea;">Status</th>
2954
+ </tr>
2955
+ </thead>
2956
+ <tbody>
2957
+ `;
2958
+
2959
+ clones.forEach((file, index) => {
2960
+ const status = file.has_file_content ? 'uploaded' : 'metadata';
2961
+ const statusText = file.has_file_content ? '✅ Full' : '📝 Meta';
2962
+ // Highlight the epoch file (first row with content)
2963
+ const isEpoch = index === 0 && file.has_file_content;
2964
+ const rowBg = isEpoch ? '#d4edda' : (index % 2 === 0 ? '#ffffff' : '#f8f9fa');
2965
+ const rowBorder = isEpoch ? 'border-left: 4px solid #28a745; border-bottom: 2px solid #28a745;' : 'border-bottom: 1px solid #e0e0e0;';
2966
+ const epochBadge = isEpoch ? '<span style="background: #28a745; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.75rem; margin-left: 8px; font-weight: 600;">EPOCH</span>' : '';
2967
+ const fontWeight = isEpoch ? '600' : '500';
2968
+ html += `
2969
+ <tr style="background: ${rowBg}; ${rowBorder}">
2970
+ <td style="padding: 10px; font-weight: ${fontWeight};">${escapeHtml(file.hostname)}${epochBadge}</td>
2971
+ <td style="padding: 10px; font-family: 'Courier New', monospace; font-size: 0.85rem; font-weight: ${isEpoch ? '500' : 'normal'};">${escapeHtml(file.filepath)}</td>
2972
+ <td style="padding: 10px; font-weight: ${isEpoch ? '500' : 'normal'};">${formatFileSize(file.file_size)}</td>
2973
+ <td style="padding: 10px; text-align: center; font-weight: ${isEpoch ? '500' : 'normal'};">${statusText}</td>
2974
+ </tr>
2975
+ `;
2976
+ });
2977
+
2978
+ html += `
2979
+ </tbody>
2980
+ </table>
2981
+ `;
2982
+ modalBody.innerHTML = html;
2983
+ }
2984
+ } catch (error) {
2985
+ console.error('Error loading clones:', error);
2986
+ modalBody.innerHTML = `<p style="color: #dc3545;">Error loading clones: ${error.message}</p>`;
2987
+ }
2988
+ }
2989
+
2990
+ // Close modal when clicking outside
2991
+ window.onclick = function(event) {
2992
+ const fileModal = document.getElementById('fileModal');
2993
+ const clonesModal = document.getElementById('clonesModal');
2994
+ if (event.target == fileModal) {
2995
+ fileModal.style.display = 'none';
2996
+ }
2997
+ if (event.target == clonesModal) {
2998
+ clonesModal.style.display = 'none';
2999
+ }
3000
+ }
3001
+
3002
+ // Format file size
3003
+ function formatFileSize(bytes) {
3004
+ if (bytes === 0) return '0 B';
3005
+ const k = 1024;
3006
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
3007
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
3008
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
3009
+ }
3010
+
3011
+ // Format file permissions
3012
+ function formatPermissions(mode) {
3013
+ const perms = [];
3014
+ const types = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'];
3015
+ perms.push(types[(mode >> 6) & 7]);
3016
+ perms.push(types[(mode >> 3) & 7]);
3017
+ perms.push(types[mode & 7]);
3018
+ return perms.join('') + ` (${mode.toString(8)})`;
3019
+ }
3020
+
3021
+ // Escape HTML to prevent XSS
3022
+ function escapeHtml(text) {
3023
+ const div = document.createElement('div');
3024
+ div.textContent = text;
3025
+ return div.innerHTML;
3026
+ }
3027
+
3028
+ // Show message
3029
+ function showMessage(text, type) {
3030
+ const messageDiv = document.getElementById('message');
3031
+ messageDiv.textContent = text;
3032
+ messageDiv.className = 'message ' + type;
3033
+ messageDiv.style.display = 'block';
3034
+
3035
+ setTimeout(() => {
3036
+ messageDiv.style.display = 'none';
3037
+ }, 5000);
3038
+ }
3039
+
3040
+ // Initialize
3041
+ if (checkAuth()) {
3042
+ loadFiles();
3043
+ }
3044
+ </script>
3045
+ </body>
3046
+ </html>
3047
+ """
3048
+ return html_content