fix(upload): reject exact duplicates at upload time and prevent duplicate mobile uploads
- Move duplicate check before task enqueue in ui_upload endpoint - Clean up temp file and return status "duplicate" for exact duplicates - Add URI-level dedup guard in mobile UploadScreen to prevent repeated uploads - Improve ShareContext URI normalization (collapse slashes, decode percent-encoding) - Guard +not-found.tsx effect against re-firing for the same pathname - Update mobile UploadResponse type and handlers for duplicate status - Update web frontend upload.js to show duplicate status - Update API and Configuration docs Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -28,6 +28,28 @@ const ShareContext = createContext<ShareContextValue>({
|
||||
clearPendingFiles: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Aggressively normalise a file URI so that the same physical file is
|
||||
* recognised regardless of how the URI was constructed.
|
||||
*
|
||||
* - Decode percent-encoding (`%20` → ` `)
|
||||
* - Collapse consecutive slashes after the scheme (`file:////` → `file:///`)
|
||||
* - Strip trailing slashes
|
||||
*/
|
||||
function normalizeFileUri(uri: string): string {
|
||||
let norm: string;
|
||||
try {
|
||||
norm = decodeURIComponent(uri);
|
||||
} catch {
|
||||
norm = uri;
|
||||
}
|
||||
// Collapse multiple slashes after the scheme (e.g. file://// → file:///)
|
||||
norm = norm.replace(/^(file:\/\/)\/{2,}/, "$1/");
|
||||
// Strip trailing slash
|
||||
norm = norm.replace(/\/+$/, "");
|
||||
return norm;
|
||||
}
|
||||
|
||||
export function ShareProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pendingFiles, setPendingFiles] = useState<SharedFile[]>([]);
|
||||
|
||||
@@ -35,11 +57,8 @@ export function ShareProvider({ children }: { children: React.ReactNode }) {
|
||||
setPendingFiles((prev) => {
|
||||
// Deduplicate by normalised URI so the same file is not uploaded twice
|
||||
// when both the Linking handler (_layout.tsx) and +not-found.tsx fire.
|
||||
const normalize = (uri: string) => {
|
||||
try { return decodeURIComponent(uri); } catch { return uri; }
|
||||
};
|
||||
const norm = normalize(file.uri);
|
||||
if (prev.some((f) => normalize(f.uri) === norm)) return prev;
|
||||
const norm = normalizeFileUri(file.uri);
|
||||
if (prev.some((f) => normalizeFileUri(f.uri) === norm)) return prev;
|
||||
return [...prev, file];
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -33,6 +33,25 @@ import api from "../services/api";
|
||||
/** Statuses that indicate processing has finished (no further polling needed). */
|
||||
const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]);
|
||||
|
||||
/**
|
||||
* Normalise a file URI for deduplication.
|
||||
*
|
||||
* - Decode percent-encoding (`%20` → ` `)
|
||||
* - Collapse consecutive slashes after the scheme (`file:////` → `file:///`)
|
||||
* - Strip trailing slashes
|
||||
*/
|
||||
function normalizeUri(uri: string): string {
|
||||
let norm: string;
|
||||
try {
|
||||
norm = decodeURIComponent(uri);
|
||||
} catch {
|
||||
norm = uri;
|
||||
}
|
||||
norm = norm.replace(/^(file:\/\/)\/{2,}/, "$1/");
|
||||
norm = norm.replace(/\/+$/, "");
|
||||
return norm;
|
||||
}
|
||||
|
||||
interface UploadItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
@@ -63,6 +82,11 @@ export default function UploadScreen() {
|
||||
uploadsRef.current = uploads;
|
||||
}, [uploads]);
|
||||
|
||||
// Track URIs that have already been uploaded in this session so that
|
||||
// duplicate share-sheet deliveries (iOS can fire both the Linking handler
|
||||
// and +not-found.tsx for the same file) do not trigger repeated uploads.
|
||||
const uploadedUrisRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core helpers (declared before the effects that depend on them)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -103,20 +127,51 @@ export default function UploadScreen() {
|
||||
}, []);
|
||||
|
||||
const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
|
||||
const id = `${Date.now()}-${filename}`;
|
||||
// Deduplicate: skip if this exact URI was already uploaded in this session.
|
||||
// This guards against duplicate share-sheet deliveries from iOS where the
|
||||
// Linking handler and +not-found.tsx fire for the same file.
|
||||
const normUri = normalizeUri(uri);
|
||||
if (uploadedUrisRef.current.has(normUri)) {
|
||||
console.debug("[uploadFile] skipping duplicate URI:", uri);
|
||||
return;
|
||||
}
|
||||
uploadedUrisRef.current.add(normUri);
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${filename}`;
|
||||
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
|
||||
|
||||
try {
|
||||
const localUri = await ensureLocalUri(uri, filename);
|
||||
const resp = await api.uploadFile(localUri, filename, mimeType);
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: item
|
||||
)
|
||||
);
|
||||
if (resp.status === "duplicate" && resp.duplicate_of) {
|
||||
// Server rejected the file as a known duplicate — mark as done and
|
||||
// set the server-side status to "duplicate" so it appears as a
|
||||
// terminal status and is not polled further.
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
status: "done",
|
||||
fileId: resp.duplicate_of!.original_file_id,
|
||||
originalFilename: resp.original_filename,
|
||||
serverStatus: "duplicate",
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Allow retrying this URI on failure.
|
||||
uploadedUrisRef.current.delete(normUri);
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
setUploads((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
|
||||
@@ -139,13 +194,29 @@ export default function UploadScreen() {
|
||||
try {
|
||||
const localUri = await ensureLocalUri(item.uri, item.filename);
|
||||
const resp = await api.uploadFile(localUri, item.filename, item.mimeType);
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: u
|
||||
)
|
||||
);
|
||||
if (resp.status === "duplicate" && resp.duplicate_of) {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? {
|
||||
...u,
|
||||
status: "done",
|
||||
fileId: resp.duplicate_of!.original_file_id,
|
||||
originalFilename: resp.original_filename,
|
||||
serverStatus: "duplicate",
|
||||
}
|
||||
: u
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: u
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
setUploads((prev) =>
|
||||
|
||||
@@ -66,10 +66,16 @@ export interface FileRecord {
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
task_id: string;
|
||||
task_id?: string;
|
||||
status: string;
|
||||
original_filename: string;
|
||||
stored_filename: string;
|
||||
duplicate_of?: {
|
||||
duplicate_type: string;
|
||||
original_file_id: number;
|
||||
original_filename: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user