diff --git a/app/routers/api_v3_actions_hosted_file.py b/app/routers/api_v3_actions_hosted_file.py index 18aa29d..6ba7225 100644 --- a/app/routers/api_v3_actions_hosted_file.py +++ b/app/routers/api_v3_actions_hosted_file.py @@ -276,6 +276,50 @@ async def download_file_action( return FileResponse(full_file_path, filename=target_filename, media_type=media_type) +@router.get('/hash/{sha256}/download') +async def download_file_by_hash_action( + response: Response, + sha256: str = Path(min_length=64, max_length=64, regex='^[a-f0-9]{64}$'), + filename: Optional[str] = Query(None, min_length=4, max_length=255), + account: AccountContext = Depends(get_account_context_optional), + delay: DelayParams = Depends(), + ): + """ + Direct hash-based download (Content-Addressable). + - Skips DB lookup for path resolution. + - Requires a valid API Key (via header or ?api_key=). + - Ideal for local caching systems like Events Launcher. + """ + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) + + # 1. Mandatory Auth Check + # For now, we strictly require a valid machine API key (auth_method will not be 'guest') + if account.auth_method == 'guest': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Valid API Key required for hash-based downloads." + ) + + # 2. Path Resolution (Deterministic) + hosted_files_path = settings.FILES_PATH['hosted_files_root'] + subdir = sha256[0:2] + hash_filename = f"{sha256}.file" + full_file_path = os.path.join(hosted_files_path, subdir, hash_filename) + + if not os.path.exists(full_file_path): + # Fallback to root (legacy structure) + full_file_path = os.path.join(hosted_files_path, hash_filename) + if not os.path.exists(full_file_path): + log.error(f"Hash-based file not found: {sha256}") + raise HTTPException(status_code=404, detail="File not found on server.") + + # 3. Serve File + target_filename = filename or f"file_{sha256[:8]}.bin" + media_type = mimetypes.guess_type(target_filename)[0] or 'application/octet-stream' + + return FileResponse(full_file_path, filename=target_filename, media_type=media_type) + + @router.delete('/{hosted_file_id}', response_model=Resp_Body_Base) async def delete_file_action( hosted_file_id: str = Path(min_length=11, max_length=22), diff --git a/app/routers/dependencies_v3.py b/app/routers/dependencies_v3.py index 93b5ab6..c3ebb4d 100644 --- a/app/routers/dependencies_v3.py +++ b/app/routers/dependencies_v3.py @@ -21,9 +21,11 @@ def get_account_context_optional( x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100), x_no_account_id_token: Optional[str] = Query(None, alias='jwt', min_length=11), x_aether_api_key: Optional[str] = Header(None, min_length=11, max_length=22), + x_aether_api_key_query: Optional[str] = Query(None, alias='api_key', min_length=11, max_length=22), ) -> AccountContext: """ Resolves the account context and enforces API Key validation. + Supports API Key in Header or Query param 'api_key'. Uses DEFERRED imports to prevent circular dependency at startup. """ from app.db_sql import redis_lookup_id_random, sql_select @@ -38,10 +40,12 @@ def get_account_context_optional( api_key_authorized = False # 1. Mandatory Machine Auth (API Key) - # This identifies the script/app, regardless of the user/account context. - if x_aether_api_key: + # Prefer header, fallback to query param + key_to_check = x_aether_api_key or x_aether_api_key_query + + if key_to_check: sql = "SELECT * FROM api_key WHERE (public_key = :key OR secret_key = :key) LIMIT 1" - if api_key_results := sql_select(sql=sql, data={'key': x_aether_api_key}): + if api_key_results := sql_select(sql=sql, data={'key': key_to_check}): # sql_select returns a list when raw SQL is used api_key_rec = api_key_results[0] if isinstance(api_key_results, list) else api_key_results @@ -52,14 +56,17 @@ def get_account_context_optional( if (not enable_from or enable_from <= now) and (not enable_to or now <= enable_to): api_key_authorized = True else: - log.warning(f"Security: API Key {x_aether_api_key} expired/not yet valid.") + log.error(f"Security: API Key {key_to_check} expired/not yet valid.") else: - log.warning(f"Security: API Key {x_aether_api_key} is disabled.") + log.error(f"Security: API Key {key_to_check} is disabled.") else: - log.warning(f"Security: API Key {x_aether_api_key} not found.") + log.error(f"Security: API Key {key_to_check} not found.") # 2. Context Resolution (Only if API Key is valid) if api_key_authorized: + # Default to machine auth if no account context is provided + auth_method = 'api_key' + # A. Resolve via Account ID Header if x_account_id: resolved_account_id_random = x_account_id @@ -85,7 +92,7 @@ def get_account_context_optional( log.warning("Security: Failed to decode JWT token.") # Legacy Fallback (just a raw random ID string) - if auth_method == 'guest': + if auth_method in ['guest', 'api_key']: resolved_account_id_random = x_no_account_id_token if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token): resolved_account_id = looked_up_id @@ -96,6 +103,8 @@ def get_account_context_optional( resolved_account_id = 1 resolved_account_id_random = '--- NO ACCOUNT ---' auth_method = 'bypass' + + log.info(f"V3 Auth: method={auth_method}, authorized={api_key_authorized}, account={resolved_account_id_random}") is_admin = (auth_method == 'bypass') is_manager = (auth_method == 'bypass') @@ -121,9 +130,10 @@ def get_account_context( x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100), x_no_account_id_token: Optional[str] = Query(None, alias='jwt', min_length=11), x_aether_api_key: Optional[str] = Header(None, min_length=11, max_length=22), + x_aether_api_key_query: Optional[str] = Query(None, alias='api_key', min_length=11, max_length=22), ) -> AccountContext: """Strict version of account context resolution.""" - ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token, x_aether_api_key) + ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token, x_aether_api_key, x_aether_api_key_query) if ctx.auth_method == 'guest': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required.') return ctx diff --git a/documentation/GUIDE__V3_FRONTEND_API.md b/documentation/GUIDE__V3_FRONTEND_API.md index 9ca8548..141e229 100644 --- a/documentation/GUIDE__V3_FRONTEND_API.md +++ b/documentation/GUIDE__V3_FRONTEND_API.md @@ -94,7 +94,15 @@ V3 uses specialized **"Action"** routes for binary operations to separate proces - **Auth Bypass:** Use `?site_key=` to download without an API Key header or JWT (useful for public kiosks). - **Testing:** Supports `delay_ms` query parameter. -### C. Deletion & Cleanup Action +### C. Hash-Based Download (Content-Addressable) +**Path**: `GET /v3/action/hosted_file/hash/{sha256}/download` + +**Features:** +- **Local Caching:** Ideal for systems like the Events Launcher that cache files locally by hash. +- **Flexible Auth:** Supports `api_key` in the query parameter (e.g., `?api_key=`) for simple machine-to-machine requests. +- **Zero DB Lookup:** Resolves the physical path deterministically from the hash, bypassing database latency. + +### D. Deletion & Cleanup Action **Path**: `DELETE /v3/action/hosted_file/{id}` | Parameter | Type | Default | Description |