Commit ·
80f0f30
1
Parent(s): 3ed8a18
- api/routers/generate.py +24 -0
- frontend/lib/utils/formatters.ts +20 -4
- services/r2_storage.py +12 -0
api/routers/generate.py
CHANGED
|
@@ -76,6 +76,30 @@ async def get_image(filename: str):
|
|
| 76 |
return FileResponse(filepath)
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
@router.get("/api/download-image")
|
| 80 |
async def download_image_proxy(
|
| 81 |
image_url: Optional[str] = None,
|
|
|
|
| 76 |
return FileResponse(filepath)
|
| 77 |
|
| 78 |
|
| 79 |
+
@router.get("/serve-image")
|
| 80 |
+
async def serve_image(filename: Optional[str] = None):
|
| 81 |
+
"""
|
| 82 |
+
Proxy endpoint for generated images: serve from local disk or fetch from R2.
|
| 83 |
+
Use this when the client cannot reach R2 (e.g. net::ERR_TUNNEL_CONNECTION_FAILED).
|
| 84 |
+
No auth so gallery and public views can load images.
|
| 85 |
+
"""
|
| 86 |
+
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
| 87 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 88 |
+
filepath = os.path.join(settings.output_dir, filename)
|
| 89 |
+
if os.path.exists(filepath):
|
| 90 |
+
return FileResponse(filepath)
|
| 91 |
+
try:
|
| 92 |
+
from services.r2_storage import get_r2_storage
|
| 93 |
+
r2 = get_r2_storage()
|
| 94 |
+
if r2:
|
| 95 |
+
data = r2.get_object_bytes(filename)
|
| 96 |
+
if data:
|
| 97 |
+
return FastAPIResponse(content=data, media_type="image/png")
|
| 98 |
+
except Exception:
|
| 99 |
+
pass
|
| 100 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
@router.get("/api/download-image")
|
| 104 |
async def download_image_proxy(
|
| 105 |
image_url: Optional[str] = None,
|
frontend/lib/utils/formatters.ts
CHANGED
|
@@ -80,7 +80,20 @@ export const truncateText = (text: string, maxLength: number): string => {
|
|
| 80 |
return text.slice(0, maxLength) + "...";
|
| 81 |
};
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
const isAbsoluteUrl = (url: string) =>
|
| 86 |
typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"));
|
|
@@ -103,8 +116,8 @@ export const getImageUrl = (
|
|
| 103 |
};
|
| 104 |
|
| 105 |
/**
|
| 106 |
-
* Primary = R2 URL or absolute image_url (for display). Fallback =
|
| 107 |
-
* Use primary in <img src>, then onError switch to fallback
|
| 108 |
*/
|
| 109 |
export const getImageUrlFallback = (
|
| 110 |
imageUrl: string | null | undefined,
|
|
@@ -116,11 +129,14 @@ export const getImageUrlFallback = (
|
|
| 116 |
(r2Url && isAbsoluteUrl(r2Url)) ? r2Url
|
| 117 |
: (imageUrl && isAbsoluteUrl(imageUrl)) ? imageUrl
|
| 118 |
: null;
|
|
|
|
|
|
|
|
|
|
| 119 |
const fromFilename = filename ? `${apiBase}/images/${filename}` : null;
|
| 120 |
const fromRelative =
|
| 121 |
imageUrl && typeof imageUrl === "string" && imageUrl.startsWith("/")
|
| 122 |
? `${apiBase}${imageUrl}`
|
| 123 |
: null;
|
| 124 |
-
const fallback = fromFilename ?? fromRelative ?? null;
|
| 125 |
return { primary, fallback };
|
| 126 |
};
|
|
|
|
| 80 |
return text.slice(0, maxLength) + "...";
|
| 81 |
};
|
| 82 |
|
| 83 |
+
/**
|
| 84 |
+
* API base for building image/asset URLs. Avoids mixed content on HTTPS:
|
| 85 |
+
* - Empty NEXT_PUBLIC_API_URL → same origin (relative).
|
| 86 |
+
* - In browser on HTTPS with no API URL set → use current origin so we don't request http://localhost.
|
| 87 |
+
*/
|
| 88 |
+
function getApiBase(): string {
|
| 89 |
+
const env = process.env.NEXT_PUBLIC_API_URL;
|
| 90 |
+
if (env === "") return "";
|
| 91 |
+
if (env && (env.startsWith("http://") || env.startsWith("https://"))) return env;
|
| 92 |
+
if (typeof window !== "undefined" && window.location?.protocol === "https:") {
|
| 93 |
+
return window.location.origin;
|
| 94 |
+
}
|
| 95 |
+
return "http://localhost:8000";
|
| 96 |
+
}
|
| 97 |
|
| 98 |
const isAbsoluteUrl = (url: string) =>
|
| 99 |
typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"));
|
|
|
|
| 116 |
};
|
| 117 |
|
| 118 |
/**
|
| 119 |
+
* Primary = R2 URL or absolute image_url (for display). Fallback = backend proxy (serve-image) or local /images.
|
| 120 |
+
* Use primary in <img src>, then onError switch to fallback. Proxy fetches from R2 server-side when the client cannot reach R2 (e.g. ERR_TUNNEL_CONNECTION_FAILED).
|
| 121 |
*/
|
| 122 |
export const getImageUrlFallback = (
|
| 123 |
imageUrl: string | null | undefined,
|
|
|
|
| 129 |
(r2Url && isAbsoluteUrl(r2Url)) ? r2Url
|
| 130 |
: (imageUrl && isAbsoluteUrl(imageUrl)) ? imageUrl
|
| 131 |
: null;
|
| 132 |
+
const fromProxy = filename
|
| 133 |
+
? `${apiBase}/serve-image?filename=${encodeURIComponent(filename)}`
|
| 134 |
+
: null;
|
| 135 |
const fromFilename = filename ? `${apiBase}/images/${filename}` : null;
|
| 136 |
const fromRelative =
|
| 137 |
imageUrl && typeof imageUrl === "string" && imageUrl.startsWith("/")
|
| 138 |
? `${apiBase}${imageUrl}`
|
| 139 |
: null;
|
| 140 |
+
const fallback = fromProxy ?? fromFilename ?? fromRelative ?? null;
|
| 141 |
return { primary, fallback };
|
| 142 |
};
|
services/r2_storage.py
CHANGED
|
@@ -109,6 +109,18 @@ class R2StorageService:
|
|
| 109 |
print(f"Error uploading to R2: {e}")
|
| 110 |
raise Exception(f"Failed to upload image to R2: {str(e)}")
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
def get_public_url(self, filename: str) -> str:
|
| 113 |
"""
|
| 114 |
Get public URL for an image in R2.
|
|
|
|
| 109 |
print(f"Error uploading to R2: {e}")
|
| 110 |
raise Exception(f"Failed to upload image to R2: {str(e)}")
|
| 111 |
|
| 112 |
+
def get_object_bytes(self, filename: str) -> Optional[bytes]:
|
| 113 |
+
"""
|
| 114 |
+
Download image bytes from R2 by filename (server-side).
|
| 115 |
+
Use this to proxy images when the client cannot reach R2 (e.g. ERR_TUNNEL_CONNECTION_FAILED).
|
| 116 |
+
"""
|
| 117 |
+
r2_key = f"{self.folder}/{filename}"
|
| 118 |
+
try:
|
| 119 |
+
response = self.s3_client.get_object(Bucket=self.bucket_name, Key=r2_key)
|
| 120 |
+
return response["Body"].read()
|
| 121 |
+
except (ClientError, BotoCoreError):
|
| 122 |
+
return None
|
| 123 |
+
|
| 124 |
def get_public_url(self, filename: str) -> str:
|
| 125 |
"""
|
| 126 |
Get public URL for an image in R2.
|