Vatxzz commited on
Commit
9981c41
·
1 Parent(s): 847bda9

feat(media): emit /media proxy paths, carry bearer header on client media fetches

Browse files

upload_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: api.resolveMedia(thumb),
 
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
- """Return a publicly accessible URL for a media path.
31
-
32
- - http(s) refs pass through as-is (already an HF URL or external link).
33
- - Local paths reconstruct the HF Dataset URL from the card_id embedded in the
34
- path (handles old rows that still have a local path in the DB).
35
- - Returns None if no HF repo is configured or the path can't be resolved."""
 
 
 
 
 
 
36
  if not path:
37
  return None
 
 
38
  if path.startswith("http://") or path.startswith("https://"):
 
 
 
39
  return path
40
- # Reconstruct HF URL from a legacy local path like /tmp/cachy_<card_id>/thumb.jpg
41
- s = get_settings()
42
- if s.hf_media_repo:
43
- parts = path.replace("\\", "/").split("/")
44
- card_id = None
45
- for p in parts:
46
- if p.startswith("cachy_"):
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
- return f"https://huggingface.co/datasets/{s.hf_media_repo}/resolve/main/{path_in_repo}"
 
 
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