fixed thumbnails
Browse files
app/lib/data/services/api_client.dart
CHANGED
|
@@ -5,6 +5,7 @@ library;
|
|
| 5 |
import 'dart:async';
|
| 6 |
import 'dart:convert';
|
| 7 |
|
|
|
|
| 8 |
import 'package:http/http.dart' as http;
|
| 9 |
|
| 10 |
import '../../domain/models/artifact.dart';
|
|
@@ -202,7 +203,14 @@ class ApiClient {
|
|
| 202 |
String resolveMedia(String ref) {
|
| 203 |
if (ref.startsWith('http://') || ref.startsWith('https://')) return ref;
|
| 204 |
final path = ref.startsWith('/') ? ref : '/$ref';
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
// ------------------------------------------------------------------------- //
|
|
|
|
| 5 |
import 'dart:async';
|
| 6 |
import 'dart:convert';
|
| 7 |
|
| 8 |
+
import 'package:flutter/foundation.dart' show kIsWeb;
|
| 9 |
import 'package:http/http.dart' as http;
|
| 10 |
|
| 11 |
import '../../domain/models/artifact.dart';
|
|
|
|
| 203 |
String resolveMedia(String ref) {
|
| 204 |
if (ref.startsWith('http://') || ref.startsWith('https://')) return ref;
|
| 205 |
final path = ref.startsWith('/') ? ref : '/$ref';
|
| 206 |
+
final url = '$baseUrl$path';
|
| 207 |
+
// Web <img> tags can't send an Authorization header, so the auth-gated
|
| 208 |
+
// /media proxy is reached with the token as a query param instead. (Mobile
|
| 209 |
+
// uses mediaHeaders.) The token is URL-safe (base64url JWT); no encoding.
|
| 210 |
+
if (kIsWeb && path.startsWith('/media/') && _lastToken != null) {
|
| 211 |
+
return '$url?token=$_lastToken';
|
| 212 |
+
}
|
| 213 |
+
return url;
|
| 214 |
}
|
| 215 |
|
| 216 |
// ------------------------------------------------------------------------- //
|
app/lib/data/services/auth_service.dart
CHANGED
|
@@ -42,10 +42,12 @@ abstract class AuthService {
|
|
| 42 |
class FirebaseAuthService implements AuthService {
|
| 43 |
FirebaseAuthService({fb.FirebaseAuth? auth, GoogleSignIn? google})
|
| 44 |
: _auth = auth ?? fb.FirebaseAuth.instance,
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
|
| 47 |
final fb.FirebaseAuth _auth;
|
| 48 |
-
final GoogleSignIn _google;
|
| 49 |
|
| 50 |
AuthUser? _map(fb.User? u) => u == null
|
| 51 |
? null
|
|
@@ -77,7 +79,7 @@ class FirebaseAuthService implements AuthService {
|
|
| 77 |
// (throws UnimplementedError); Firebase's popup flow handles Google there.
|
| 78 |
if (kIsWeb) return _signInWithGoogleWeb(mergeGuestData: mergeGuestData);
|
| 79 |
|
| 80 |
-
final account = await _google.signIn();
|
| 81 |
if (account == null) throw fb.FirebaseAuthException(code: 'canceled');
|
| 82 |
final gAuth = await account.authentication;
|
| 83 |
final credential = fb.GoogleAuthProvider.credential(
|
|
@@ -150,7 +152,7 @@ class FirebaseAuthService implements AuthService {
|
|
| 150 |
|
| 151 |
@override
|
| 152 |
Future<void> signOut() async {
|
| 153 |
-
await _google.signOut();
|
| 154 |
await _auth.signOut();
|
| 155 |
}
|
| 156 |
}
|
|
|
|
| 42 |
class FirebaseAuthService implements AuthService {
|
| 43 |
FirebaseAuthService({fb.FirebaseAuth? auth, GoogleSignIn? google})
|
| 44 |
: _auth = auth ?? fb.FirebaseAuth.instance,
|
| 45 |
+
// On web, `google_sign_in` asserts a client-id at construction and web
|
| 46 |
+
// sign-in goes through Firebase's popup instead — so never build it there.
|
| 47 |
+
_google = google ?? (kIsWeb ? null : GoogleSignIn());
|
| 48 |
|
| 49 |
final fb.FirebaseAuth _auth;
|
| 50 |
+
final GoogleSignIn? _google;
|
| 51 |
|
| 52 |
AuthUser? _map(fb.User? u) => u == null
|
| 53 |
? null
|
|
|
|
| 79 |
// (throws UnimplementedError); Firebase's popup flow handles Google there.
|
| 80 |
if (kIsWeb) return _signInWithGoogleWeb(mergeGuestData: mergeGuestData);
|
| 81 |
|
| 82 |
+
final account = await _google!.signIn(); // non-null: web returns above
|
| 83 |
if (account == null) throw fb.FirebaseAuthException(code: 'canceled');
|
| 84 |
final gAuth = await account.authentication;
|
| 85 |
final credential = fb.GoogleAuthProvider.credential(
|
|
|
|
| 152 |
|
| 153 |
@override
|
| 154 |
Future<void> signOut() async {
|
| 155 |
+
await _google?.signOut();
|
| 156 |
await _auth.signOut();
|
| 157 |
}
|
| 158 |
}
|
backend/app/api/media.py
CHANGED
|
@@ -15,7 +15,7 @@ from typing import Any
|
|
| 15 |
from fastapi import APIRouter, HTTPException
|
| 16 |
from fastapi.responses import FileResponse
|
| 17 |
|
| 18 |
-
from app.auth import
|
| 19 |
from app.config import get_settings
|
| 20 |
from app.store import db
|
| 21 |
|
|
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/media", tags=["media"])
|
|
| 25 |
|
| 26 |
|
| 27 |
@router.get("/{card_id}/{filename}")
|
| 28 |
-
async def get_media(card_id: str, filename: str, owner_id:
|
| 29 |
"""Stream one media file for a card the caller owns.
|
| 30 |
|
| 31 |
404 for unknown or non-owned cards (never reveal another owner's media);
|
|
|
|
| 15 |
from fastapi import APIRouter, HTTPException
|
| 16 |
from fastapi.responses import FileResponse
|
| 17 |
|
| 18 |
+
from app.auth import MediaOwnerDep
|
| 19 |
from app.config import get_settings
|
| 20 |
from app.store import db
|
| 21 |
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
@router.get("/{card_id}/{filename}")
|
| 28 |
+
async def get_media(card_id: str, filename: str, owner_id: MediaOwnerDep) -> Any:
|
| 29 |
"""Stream one media file for a card the caller owns.
|
| 30 |
|
| 31 |
404 for unknown or non-owned cards (never reveal another owner's media);
|
backend/app/auth.py
CHANGED
|
@@ -13,7 +13,7 @@ import asyncio
|
|
| 13 |
import logging
|
| 14 |
from typing import Annotated
|
| 15 |
|
| 16 |
-
from fastapi import Depends, Header, HTTPException
|
| 17 |
from google.auth.transport import requests as google_requests
|
| 18 |
from google.oauth2 import id_token as google_id_token
|
| 19 |
|
|
@@ -68,3 +68,34 @@ async def get_owner(authorization: str | None = Header(None)) -> str:
|
|
| 68 |
|
| 69 |
|
| 70 |
OwnerDep = Annotated[str, Depends(get_owner)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
import logging
|
| 14 |
from typing import Annotated
|
| 15 |
|
| 16 |
+
from fastapi import Depends, Header, HTTPException, Query
|
| 17 |
from google.auth.transport import requests as google_requests
|
| 18 |
from google.oauth2 import id_token as google_id_token
|
| 19 |
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
OwnerDep = Annotated[str, Depends(get_owner)]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def get_owner_query_or_header(
|
| 74 |
+
authorization: str | None = Header(None),
|
| 75 |
+
token: str | None = Query(None),
|
| 76 |
+
) -> str:
|
| 77 |
+
"""Like [get_owner] but also accepts the token as a `?token=` query param.
|
| 78 |
+
|
| 79 |
+
Browser `<img>` tags can't send an Authorization header, so web thumbnails
|
| 80 |
+
fetch the auth-gated /media proxy with the token in the query string."""
|
| 81 |
+
if not get_settings().firebase_project_id:
|
| 82 |
+
raise HTTPException(status_code=503, detail="auth not configured")
|
| 83 |
+
raw: str | None = None
|
| 84 |
+
if authorization and authorization.startswith("Bearer "):
|
| 85 |
+
raw = authorization.removeprefix("Bearer ").strip()
|
| 86 |
+
elif token:
|
| 87 |
+
raw = token.strip()
|
| 88 |
+
if not raw:
|
| 89 |
+
raise HTTPException(status_code=401, detail="missing bearer token")
|
| 90 |
+
try:
|
| 91 |
+
decoded = await verify_async(raw)
|
| 92 |
+
except Exception as exc:
|
| 93 |
+
log.info("token verification failed: %s: %s", type(exc).__name__, exc)
|
| 94 |
+
raise HTTPException(status_code=401, detail="invalid or expired token")
|
| 95 |
+
uid = uid_of(decoded)
|
| 96 |
+
if not uid:
|
| 97 |
+
raise HTTPException(status_code=401, detail="token missing subject")
|
| 98 |
+
return uid
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
MediaOwnerDep = Annotated[str, Depends(get_owner_query_or_header)]
|