diff --git a/src/components/Componentes/question-photo.tsx b/src/components/Componentes/question-photo.tsx index e0515f8..b8f1198 100644 --- a/src/components/Componentes/question-photo.tsx +++ b/src/components/Componentes/question-photo.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; import type { QuestionField } from "@/lib/schema-adapter"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; -import { getApiRequestUrl } from "@/lib/http"; +import { resolveMediaUrl } from "@/lib/http"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { useI18n } from "@/translations/provider"; import ErrorToast from "./error-toast"; @@ -99,19 +99,16 @@ export function QuestionPhoto({ URL.revokeObjectURL(activeBlobUrlRef.current); activeBlobUrlRef.current = null; } - setLocalPreviewUrl(null); - // Do NOT clear the stored answer — the file was uploaded successfully, - // only the preview failed to load (proxy timing, network glitch, etc.). - // This matches yesterday's behavior where errors never wiped the answer. }, []); const uploadTmpMediaMutation = useUploadTmpMediaMutation({ onSuccess: (response) => { if (response?.path) { - setAnswerValue(question, response.path); + const resolved = resolveMediaUrl(response.path) ?? response.path; + setAnswerValue(question, resolved); setHasImageError(false); if (!activeBlobUrlRef.current) { - setLocalPreviewUrl(response.path); + setLocalPreviewUrl(resolved); } } else { handleUploadFailure(); @@ -144,22 +141,26 @@ export function QuestionPhoto({ setIsUploading(true); setHasImageError(false); setToastMessage(null); + if (event.data?.files?.[0]?.base64) { + setLocalPreviewUrl(event.data.files[0].base64); + } break; case "completed": { const file = event.data?.files?.[0]; - const remoteUrl = - file?.path || + const rawUrl = file?.url || - (file?.data?.path as string | undefined) || - (file?.data?.url as string | undefined); + file?.path || + (file?.data?.url as string | undefined) || + (file?.data?.path as string | undefined); - if (remoteUrl) { + if (rawUrl) { + const resolved = resolveMediaUrl(rawUrl) ?? rawUrl; setIsUploading(false); isInitiatorRef.current = false; - setAnswerValue(question, remoteUrl); - setLocalPreviewUrl((prev) => prev || remoteUrl); + setAnswerValue(question, resolved); + setLocalPreviewUrl(resolved); setHasImageError(false); - pendingDeferredRef.current?.resolve(remoteUrl); + pendingDeferredRef.current?.resolve(resolved); pendingDeferredRef.current = null; } else if (file?.base64) { setIsUploading(true); @@ -178,12 +179,13 @@ export function QuestionPhoto({ .mutateAsync(f) .then((res) => { if (res?.path) { - setAnswerValue(question, res.path); + const resolved = resolveMediaUrl(res.path) ?? res.path; + setAnswerValue(question, resolved); setHasImageError(false); if (!activeBlobUrlRef.current) { - setLocalPreviewUrl(res.path); + setLocalPreviewUrl(resolved); } - pendingDeferredRef.current?.resolve(res.path); + pendingDeferredRef.current?.resolve(resolved); } else { handleUploadFailure(); pendingDeferredRef.current?.resolve(); @@ -331,10 +333,11 @@ export function QuestionPhoto({ .mutateAsync(file) .then((res) => { if (res?.path) { - setAnswerValue(question, res.path); + const resolved = resolveMediaUrl(res.path) ?? res.path; + setAnswerValue(question, resolved); setHasImageError(false); if (!activeBlobUrlRef.current) { - setLocalPreviewUrl(res.path); + setLocalPreviewUrl(resolved); } } else { handleUploadFailure(); @@ -368,22 +371,7 @@ export function QuestionPhoto({ candidateUrl = storedValue.trim(); } - // Display URL: absolute URLs (https://...) used directly; relative paths proxied via getApiRequestUrl. - // This matches yesterday's working logic — do NOT use resolveMediaUrl which forcefully proxies - // public /static/ URLs, breaking display on mobile where the proxy returns 502. - let displayUrl: string | null = null; - if (candidateUrl) { - if ( - candidateUrl.startsWith("http://") || - candidateUrl.startsWith("https://") || - candidateUrl.startsWith("blob:") || - candidateUrl.startsWith("data:") - ) { - displayUrl = candidateUrl; - } else { - displayUrl = getApiRequestUrl(candidateUrl); - } - } + const displayUrl = resolveMediaUrl(candidateUrl); const hasAnswer = Boolean(displayUrl); return ( diff --git a/src/lib/http.test.ts b/src/lib/http.test.ts new file mode 100644 index 0000000..4a13779 --- /dev/null +++ b/src/lib/http.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveMediaUrl } from "./http"; + +describe("resolveMediaUrl", () => { + it("returns null for null, undefined, or empty string", () => { + expect(resolveMediaUrl(null)).toBeNull(); + expect(resolveMediaUrl(undefined)).toBeNull(); + expect(resolveMediaUrl("")).toBeNull(); + expect(resolveMediaUrl(" ")).toBeNull(); + }); + + it("returns blob and data URLs unchanged for instant preview", () => { + expect(resolveMediaUrl("blob:http://localhost:3000/123-abc")).toBe( + "blob:http://localhost:3000/123-abc", + ); + expect(resolveMediaUrl("data:image/jpeg;base64,xyz123==")).toBe( + "data:image/jpeg;base64,xyz123==", + ); + }); + + it("upgrades http to https for habib.nwhco.ir to avoid mixed content blocking", () => { + expect( + resolveMediaUrl("http://habib.nwhco.ir/static/tmp/marriage/photo.jpg"), + ).toBe("https://habib.nwhco.ir/static/tmp/marriage/photo.jpg"); + }); + + it("preserves already https absolute URLs", () => { + expect( + resolveMediaUrl("https://habib.nwhco.ir/static/tmp/marriage/photo.jpg"), + ).toBe("https://habib.nwhco.ir/static/tmp/marriage/photo.jpg"); + expect(resolveMediaUrl("https://images.example.com/avatar.png")).toBe( + "https://images.example.com/avatar.png", + ); + }); + + it("resolves relative paths to full https URLs without using /api/proxy", () => { + const resolved = resolveMediaUrl("/static/tmp/marriage/photo.jpg"); + expect(resolved).not.toContain("/api/proxy"); + expect(resolved).toMatch(/\/static\/tmp\/marriage\/photo\.jpg$/); + }); +}); diff --git a/src/lib/http.ts b/src/lib/http.ts index 0ed7033..c52ef09 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -68,59 +68,36 @@ export function resolveMediaUrl(url: string | null | undefined): string | null { return trimmed; } - if (shouldUseProxy()) { - if (isAbsoluteUrl(trimmed)) { - try { - const parsed = new URL(trimmed); - const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL; - let isBackendHost = false; - - if ( - parsed.hostname === "127.0.0.1" || - parsed.hostname === "localhost" || - parsed.port === "8000" || - parsed.port === "8001" - ) { - isBackendHost = true; - } else if (apiBase) { - try { - const apiParsed = new URL(apiBase); - if (parsed.host === apiParsed.host) { - isBackendHost = true; - } - } catch { - // Ignore URL parsing error - } - } - - if ( - isBackendHost || - parsed.pathname.startsWith("/static/") || - parsed.pathname.startsWith("/media/") - ) { - const proxyPath = `${parsed.pathname}${parsed.search}`; - const searchParams = new URLSearchParams({ - [PROXY_PATH_PARAM]: proxyPath, - }); - return `/api/proxy?${searchParams.toString()}`; - } - } catch { - // Fall through - } - } else { - const normalizedPath = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - const searchParams = new URLSearchParams({ - [PROXY_PATH_PARAM]: normalizedPath, - }); - return `/api/proxy?${searchParams.toString()}`; + // If it's an absolute URL + if (isAbsoluteUrl(trimmed)) { + // If the app is loaded over HTTPS or the URL is pointing to habib.nwhco.ir, + // upgrade http:// to https:// to prevent mixed-content blocking in mobile WebViews. + if ( + trimmed.startsWith("http://") && + ((typeof window !== "undefined" && window.location.protocol === "https:") || + trimmed.includes("habib.nwhco.ir") || + process.env.NEXT_PUBLIC_API_BASE_URL?.startsWith("https://")) + ) { + return trimmed.replace(/^http:\/\//i, "https://"); } + return trimmed; } - if (isAbsoluteUrl(trimmed)) { - return trimmed; + // Static and media files are public assets served directly by Nginx / CDN, + // NOT by the Docker Gunicorn container, so they must NEVER be routed to /api/proxy! + const rawBase = + process.env.NEXT_PUBLIC_API_BASE_URL || + (typeof window !== "undefined" ? window.location.origin : ""); + + let base = rawBase.replace(/\/$/, ""); + if ( + base.startsWith("http://") && + ((typeof window !== "undefined" && window.location.protocol === "https:") || + base.includes("habib.nwhco.ir")) + ) { + base = base.replace(/^http:\/\//i, "https://"); } - const base = process.env.NEXT_PUBLIC_API_BASE_URL || ""; const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; return `${base}${normalized}`; }