feat(media): emit /media proxy paths, carry bearer header on client media fetches
Browse filesupload_file + to_media_url now emit owner-checked proxy paths; legacy absolute HF URLs and local scratch paths are normalised to the same scheme so pre-migration cards still resolve after the repo goes private. ApiClient.mediaHeaders attaches the cached bearer token to proxy URLs only; wired into CardFace.
app/lib/data/services/api_client.dart
CHANGED
|
@@ -109,7 +109,11 @@ class ApiClient {
|
|
| 109 |
this.tokenProvider,
|
| 110 |
}) : baseUrl = (baseUrl ?? _defaultBaseUrl).replaceAll(RegExp(r'/+$'), ''),
|
| 111 |
_client = client ?? http.Client(),
|
| 112 |
-
_store = store
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
/// Supplies the Firebase ID token (uid = backend owner_id). `forceRefresh`
|
| 115 |
/// mints a fresh token after a 401. Null when signed out.
|
|
@@ -153,12 +157,30 @@ class ApiClient {
|
|
| 153 |
return discovered;
|
| 154 |
}
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
Future<Map<String, String>> _authHeader({bool forceRefresh = false}) async {
|
| 157 |
final token = await tokenProvider?.call(forceRefresh: forceRefresh);
|
| 158 |
if (token == null || token.isEmpty) return const {};
|
|
|
|
| 159 |
return {'authorization': 'Bearer $token'};
|
| 160 |
}
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
/// All verbs funnel through here: auth header + one refresh-retry on 401.
|
| 163 |
Future<http.Response> _send(
|
| 164 |
Future<http.Response> Function(Map<String, String> headers) go, {
|
|
|
|
| 109 |
this.tokenProvider,
|
| 110 |
}) : baseUrl = (baseUrl ?? _defaultBaseUrl).replaceAll(RegExp(r'/+$'), ''),
|
| 111 |
_client = client ?? http.Client(),
|
| 112 |
+
_store = store {
|
| 113 |
+
// Warm the token so the first thumbnails (rendered from cache before any
|
| 114 |
+
// API call) can carry the bearer header for the /media proxy.
|
| 115 |
+
if (tokenProvider != null) unawaited(_authHeader());
|
| 116 |
+
}
|
| 117 |
|
| 118 |
/// Supplies the Firebase ID token (uid = backend owner_id). `forceRefresh`
|
| 119 |
/// mints a fresh token after a 401. Null when signed out.
|
|
|
|
| 157 |
return discovered;
|
| 158 |
}
|
| 159 |
|
| 160 |
+
/// Most recent bearer token seen by [_authHeader]. Lets [mediaHeaders] attach
|
| 161 |
+
/// auth synchronously (cached_network_image needs sync headers).
|
| 162 |
+
String? _lastToken;
|
| 163 |
+
|
| 164 |
Future<Map<String, String>> _authHeader({bool forceRefresh = false}) async {
|
| 165 |
final token = await tokenProvider?.call(forceRefresh: forceRefresh);
|
| 166 |
if (token == null || token.isEmpty) return const {};
|
| 167 |
+
_lastToken = token;
|
| 168 |
return {'authorization': 'Bearer $token'};
|
| 169 |
}
|
| 170 |
|
| 171 |
+
/// Auth headers for a resolved media URL. The owner-checked `/media/` proxy
|
| 172 |
+
/// needs the bearer token; external artifact images get none. Uses the most
|
| 173 |
+
/// recent token (refreshed on every API call) — fine because tokens last ~1h
|
| 174 |
+
/// and images are disk-cached, so at worst one thumbnail retries after the
|
| 175 |
+
/// next API call refreshes the token.
|
| 176 |
+
Map<String, String> mediaHeaders(String resolvedUrl) {
|
| 177 |
+
if (_lastToken == null) return const {};
|
| 178 |
+
final path = Uri.tryParse(resolvedUrl)?.path ?? '';
|
| 179 |
+
final isProxy =
|
| 180 |
+
resolvedUrl.startsWith(baseUrl) && path.startsWith('/media/');
|
| 181 |
+
return isProxy ? {'authorization': 'Bearer $_lastToken'} : const {};
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
/// All verbs funnel through here: auth header + one refresh-retry on 401.
|
| 185 |
Future<http.Response> _send(
|
| 186 |
Future<http.Response> Function(Map<String, String> headers) go, {
|
app/lib/ui/core/widgets/card_face.dart
CHANGED
|
@@ -30,8 +30,10 @@ class CardFace extends StatelessWidget {
|
|
| 30 |
if (thumb == null || thumb.isEmpty) {
|
| 31 |
return _AccentFace(accent: accent);
|
| 32 |
}
|
|
|
|
| 33 |
return CachedNetworkImage(
|
| 34 |
-
imageUrl:
|
|
|
|
| 35 |
fit: fit,
|
| 36 |
fadeInDuration: const Duration(milliseconds: 200),
|
| 37 |
placeholder: (_, _) => _AccentFace(accent: accent, dim: true),
|
|
|
|
| 30 |
if (thumb == null || thumb.isEmpty) {
|
| 31 |
return _AccentFace(accent: accent);
|
| 32 |
}
|
| 33 |
+
final url = api.resolveMedia(thumb);
|
| 34 |
return CachedNetworkImage(
|
| 35 |
+
imageUrl: url,
|
| 36 |
+
httpHeaders: api.mediaHeaders(url),
|
| 37 |
fit: fit,
|
| 38 |
fadeInDuration: const Duration(milliseconds: 200),
|
| 39 |
placeholder: (_, _) => _AccentFace(accent: accent, dim: true),
|
app/test/api_client_auth_test.dart
CHANGED
|
@@ -21,6 +21,19 @@ void main() {
|
|
| 21 |
expect(seenAuth, 'Bearer tok-1');
|
| 22 |
});
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
test('one forced-refresh retry on 401', () async {
|
| 25 |
var calls = 0;
|
| 26 |
final mock = MockClient((req) async {
|
|
|
|
| 21 |
expect(seenAuth, 'Bearer tok-1');
|
| 22 |
});
|
| 23 |
|
| 24 |
+
test('mediaHeaders: bearer for /media proxy, none for external', () async {
|
| 25 |
+
final mock = MockClient((req) async => http.Response(jsonEncode([]), 200));
|
| 26 |
+
final api = ApiClient(
|
| 27 |
+
baseUrl: 'http://x',
|
| 28 |
+
client: mock,
|
| 29 |
+
tokenProvider: ({bool forceRefresh = false}) async => 'tok-1',
|
| 30 |
+
);
|
| 31 |
+
await api.listCards(); // warms the cached token
|
| 32 |
+
expect(api.mediaHeaders('http://x/media/c1/thumb.jpg'),
|
| 33 |
+
{'authorization': 'Bearer tok-1'});
|
| 34 |
+
expect(api.mediaHeaders('https://commons.example/pic.png'), isEmpty);
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
test('one forced-refresh retry on 401', () async {
|
| 38 |
var calls = 0;
|
| 39 |
final mock = MockClient((req) async {
|
backend/app/store/media.py
CHANGED
|
@@ -27,28 +27,34 @@ def job_dir_for_card(card_id: str) -> str:
|
|
| 27 |
|
| 28 |
|
| 29 |
def to_media_url(path: str | None) -> str | None:
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
if not path:
|
| 37 |
return None
|
|
|
|
|
|
|
| 38 |
if path.startswith("http://") or path.startswith("https://"):
|
|
|
|
|
|
|
|
|
|
| 39 |
return path
|
| 40 |
-
#
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
card_id = p[len("cachy_"):]
|
| 48 |
-
break
|
| 49 |
-
if card_id:
|
| 50 |
-
filename = Path(path).name
|
| 51 |
-
return f"https://huggingface.co/datasets/{s.hf_media_repo}/resolve/main/media/{card_id}/{filename}"
|
| 52 |
return None
|
| 53 |
|
| 54 |
|
|
@@ -94,7 +100,9 @@ def upload_file(local_path: str, card_id: str) -> str | None:
|
|
| 94 |
repo_type="dataset",
|
| 95 |
commit_message="media upload",
|
| 96 |
)
|
| 97 |
-
|
|
|
|
|
|
|
| 98 |
except Exception as exc:
|
| 99 |
log.warning("HF media upload failed for %s: %s", local_path, exc)
|
| 100 |
return None
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def to_media_url(path: str | None) -> str | None:
|
| 30 |
+
"""Normalise a stored media ref to the owner-checked proxy path.
|
| 31 |
+
|
| 32 |
+
Media now streams through `GET /media/{card_id}/{filename}` (auth-gated) so
|
| 33 |
+
the HF dataset can stay private. This maps every historical storage shape to
|
| 34 |
+
that scheme:
|
| 35 |
+
- `/media/...` proxy paths pass through unchanged (new scheme).
|
| 36 |
+
- Legacy absolute HF dataset URLs are rewritten to the proxy path so they
|
| 37 |
+
still resolve after the repo goes private.
|
| 38 |
+
- Other external image URLs (non-HF) pass through untouched.
|
| 39 |
+
- Legacy local scratch paths like `/tmp/cachy_<card_id>/thumb.jpg` are
|
| 40 |
+
rebuilt into the proxy path from the embedded card_id.
|
| 41 |
+
- Returns None when the path can't be resolved."""
|
| 42 |
if not path:
|
| 43 |
return None
|
| 44 |
+
if path.startswith("/media/"):
|
| 45 |
+
return path
|
| 46 |
if path.startswith("http://") or path.startswith("https://"):
|
| 47 |
+
marker = "/resolve/main/media/"
|
| 48 |
+
if "huggingface.co/datasets/" in path and marker in path:
|
| 49 |
+
return "/media/" + path.split(marker, 1)[1]
|
| 50 |
return path
|
| 51 |
+
# Legacy local path like /tmp/cachy_<card_id>/thumb.jpg
|
| 52 |
+
parts = path.replace("\\", "/").split("/")
|
| 53 |
+
card_id = next(
|
| 54 |
+
(p[len("cachy_"):] for p in parts if p.startswith("cachy_")), None
|
| 55 |
+
)
|
| 56 |
+
if card_id:
|
| 57 |
+
return f"/media/{card_id}/{Path(path).name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
return None
|
| 59 |
|
| 60 |
|
|
|
|
| 100 |
repo_type="dataset",
|
| 101 |
commit_message="media upload",
|
| 102 |
)
|
| 103 |
+
# Store the owner-checked proxy path, not the raw HF URL — the client
|
| 104 |
+
# fetches media through GET /media/{card_id}/{filename} (auth-gated).
|
| 105 |
+
return f"/media/{card_id}/{Path(local_path).name}"
|
| 106 |
except Exception as exc:
|
| 107 |
log.warning("HF media upload failed for %s: %s", local_path, exc)
|
| 108 |
return None
|
backend/tests/test_media.py
CHANGED
|
@@ -17,6 +17,25 @@ async def _make_card(owner_id: str) -> str:
|
|
| 17 |
return row.id
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
async def test_media_anonymous_401(client) -> None:
|
| 21 |
"""No verified identity -> rejected (401 bad token / 503 auth unconfigured)."""
|
| 22 |
from app.auth import get_owner
|
|
|
|
| 17 |
return row.id
|
| 18 |
|
| 19 |
|
| 20 |
+
def test_to_media_url_normalisation() -> None:
|
| 21 |
+
"""Every historical storage shape maps to the /media proxy path."""
|
| 22 |
+
from app.store.media import to_media_url
|
| 23 |
+
|
| 24 |
+
assert to_media_url(None) is None
|
| 25 |
+
# New scheme passes through.
|
| 26 |
+
assert to_media_url("/media/abc/thumb.jpg") == "/media/abc/thumb.jpg"
|
| 27 |
+
# Legacy absolute HF URL -> proxy path (survives the repo going private).
|
| 28 |
+
legacy = (
|
| 29 |
+
"https://huggingface.co/datasets/Vatxzz/cachy-media/resolve/main/"
|
| 30 |
+
"media/abc/thumb.jpg"
|
| 31 |
+
)
|
| 32 |
+
assert to_media_url(legacy) == "/media/abc/thumb.jpg"
|
| 33 |
+
# Non-HF external image is left alone.
|
| 34 |
+
assert to_media_url("https://example.com/pic.png") == "https://example.com/pic.png"
|
| 35 |
+
# Legacy local scratch path -> proxy path.
|
| 36 |
+
assert to_media_url("/tmp/cachy_abc/thumb.jpg") == "/media/abc/thumb.jpg"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
async def test_media_anonymous_401(client) -> None:
|
| 40 |
"""No verified identity -> rejected (401 bad token / 503 auth unconfigured)."""
|
| 41 |
from app.auth import get_owner
|