feat(hosted-file): implement hash-based download action and flexible auth
- Adds GET /v3/action/hosted_file/hash/{sha256}/download for direct content-addressable storage access.
- Updates V3 authentication dependencies to support 'api_key' in the query parameter (alias 'api_key').
- Implements auth_method: 'api_key' for machine-to-machine requests that provide a valid key but no user/account context.
- Updates GUIDE__V3_FRONTEND_API.md with the new endpoint and auth options.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -94,7 +94,15 @@ V3 uses specialized **"Action"** routes for binary operations to separate proces
|
||||
- **Auth Bypass:** Use `?site_key=<auth_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=<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 |
|
||||
|
||||
Reference in New Issue
Block a user