Browse Source

Merge branch 'staging' of https://git.nwhco.ir/sina_sajjadi/marriage into staging

staging
Ali Alavi 4 days ago
parent
commit
38bd0cf2ef
  1. 60
      src/components/Componentes/question-photo.tsx
  2. 41
      src/lib/http.test.ts
  3. 63
      src/lib/http.ts

60
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 ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; 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 { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import ErrorToast from "./error-toast"; import ErrorToast from "./error-toast";
@ -99,19 +99,16 @@ export function QuestionPhoto({
URL.revokeObjectURL(activeBlobUrlRef.current); URL.revokeObjectURL(activeBlobUrlRef.current);
activeBlobUrlRef.current = null; 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({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { onSuccess: (response) => {
if (response?.path) { if (response?.path) {
setAnswerValue(question, response.path);
const resolved = resolveMediaUrl(response.path) ?? response.path;
setAnswerValue(question, resolved);
setHasImageError(false); setHasImageError(false);
if (!activeBlobUrlRef.current) { if (!activeBlobUrlRef.current) {
setLocalPreviewUrl(response.path);
setLocalPreviewUrl(resolved);
} }
} else { } else {
handleUploadFailure(); handleUploadFailure();
@ -144,22 +141,26 @@ export function QuestionPhoto({
setIsUploading(true); setIsUploading(true);
setHasImageError(false); setHasImageError(false);
setToastMessage(null); setToastMessage(null);
if (event.data?.files?.[0]?.base64) {
setLocalPreviewUrl(event.data.files[0].base64);
}
break; break;
case "completed": { case "completed": {
const file = event.data?.files?.[0]; const file = event.data?.files?.[0];
const remoteUrl =
file?.path ||
const rawUrl =
file?.url || 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); setIsUploading(false);
isInitiatorRef.current = false; isInitiatorRef.current = false;
setAnswerValue(question, remoteUrl);
setLocalPreviewUrl((prev) => prev || remoteUrl);
setAnswerValue(question, resolved);
setLocalPreviewUrl(resolved);
setHasImageError(false); setHasImageError(false);
pendingDeferredRef.current?.resolve(remoteUrl);
pendingDeferredRef.current?.resolve(resolved);
pendingDeferredRef.current = null; pendingDeferredRef.current = null;
} else if (file?.base64) { } else if (file?.base64) {
setIsUploading(true); setIsUploading(true);
@ -178,12 +179,13 @@ export function QuestionPhoto({
.mutateAsync(f) .mutateAsync(f)
.then((res) => { .then((res) => {
if (res?.path) { if (res?.path) {
setAnswerValue(question, res.path);
const resolved = resolveMediaUrl(res.path) ?? res.path;
setAnswerValue(question, resolved);
setHasImageError(false); setHasImageError(false);
if (!activeBlobUrlRef.current) { if (!activeBlobUrlRef.current) {
setLocalPreviewUrl(res.path);
setLocalPreviewUrl(resolved);
} }
pendingDeferredRef.current?.resolve(res.path);
pendingDeferredRef.current?.resolve(resolved);
} else { } else {
handleUploadFailure(); handleUploadFailure();
pendingDeferredRef.current?.resolve(); pendingDeferredRef.current?.resolve();
@ -331,10 +333,11 @@ export function QuestionPhoto({
.mutateAsync(file) .mutateAsync(file)
.then((res) => { .then((res) => {
if (res?.path) { if (res?.path) {
setAnswerValue(question, res.path);
const resolved = resolveMediaUrl(res.path) ?? res.path;
setAnswerValue(question, resolved);
setHasImageError(false); setHasImageError(false);
if (!activeBlobUrlRef.current) { if (!activeBlobUrlRef.current) {
setLocalPreviewUrl(res.path);
setLocalPreviewUrl(resolved);
} }
} else { } else {
handleUploadFailure(); handleUploadFailure();
@ -368,22 +371,7 @@ export function QuestionPhoto({
candidateUrl = storedValue.trim(); 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); const hasAnswer = Boolean(displayUrl);
return ( return (

41
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$/);
});
});

63
src/lib/http.ts

@ -68,59 +68,36 @@ export function resolveMediaUrl(url: string | null | undefined): string | null {
return trimmed; return trimmed;
} }
if (shouldUseProxy()) {
// If it's an absolute URL
if (isAbsoluteUrl(trimmed)) { if (isAbsoluteUrl(trimmed)) {
try {
const parsed = new URL(trimmed);
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL;
let isBackendHost = false;
// 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 ( if (
parsed.hostname === "127.0.0.1" ||
parsed.hostname === "localhost" ||
parsed.port === "8000" ||
parsed.port === "8001"
trimmed.startsWith("http://") &&
((typeof window !== "undefined" && window.location.protocol === "https:") ||
trimmed.includes("habib.nwhco.ir") ||
process.env.NEXT_PUBLIC_API_BASE_URL?.startsWith("https://"))
) { ) {
isBackendHost = true;
} else if (apiBase) {
try {
const apiParsed = new URL(apiBase);
if (parsed.host === apiParsed.host) {
isBackendHost = true;
}
} catch {
// Ignore URL parsing error
return trimmed.replace(/^http:\/\//i, "https://");
} }
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 ( if (
isBackendHost ||
parsed.pathname.startsWith("/static/") ||
parsed.pathname.startsWith("/media/")
base.startsWith("http://") &&
((typeof window !== "undefined" && window.location.protocol === "https:") ||
base.includes("habib.nwhco.ir"))
) { ) {
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 (isAbsoluteUrl(trimmed)) {
return trimmed;
base = base.replace(/^http:\/\//i, "https://");
} }
const base = process.env.NEXT_PUBLIC_API_BASE_URL || "";
const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return `${base}${normalized}`; return `${base}${normalized}`;
} }

Loading…
Cancel
Save