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:
Scott Idem
2026-02-03 13:44:07 -05:00
parent faa6de866d
commit 07609bae9a
3 changed files with 71 additions and 9 deletions

View File

@@ -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),

View File

@@ -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

View File

@@ -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 |