Skip to content
Source
Sign inSign up

composerAttachmentFiles.ts

101 lines3.3 KB

1export const COMPOSER_ATTACHMENT_DIRECTORY = "t3-composer-attachments";
2​
3const UUID_PATTERN = "[a-f\\d]{8}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{12}";
4const GENERATED_FILE_NAME = new RegExp(`^${UUID_PATTERN}-`, "i");
5const IOS_DOCUMENTS_PATH = new RegExp(
6 `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`,
7 "i",
8);
9const retainedFiles = new Map<string, number>();
10​
11function fileUriPath(uri: string): string | null {
12 try {
13 const url = new URL(uri);
14 if (url.protocol !== "file:" || url.hostname || url.search || url.hash) {
15 return null;
16 }
17 const path = decodeURIComponent(url.pathname);
18 if (path.includes("\\") || path.includes("\0") || path.split("/").includes("..")) {
19 return null;
20 }
21 return path.startsWith("/private/var/") ? path.slice("/private".length) : path;
22 } catch {
23 return null;
24 }
26​
27function ownedFileLocation(uri: string) {
28 const path = fileUriPath(uri);
29 if (path === null) {
30 return null;
31 }
32 const separator = `/${COMPOSER_ATTACHMENT_DIRECTORY}/`;
33 const index = path.lastIndexOf(separator);
34 const name = index < 0 ? "" : path.slice(index + separator.length);
35 if (!name || name === "." || name.includes("/")) {
36 return null;
37 }
38 return { documentPath: path.slice(0, index), name };
40​
41export function composerAttachmentFileReferenceKey(uri: string): string {
42 const location = ownedFileLocation(uri);
43 if (!location) {
44 return uri;
45 }
46 const containerPrefix = GENERATED_FILE_NAME.test(location.name)
47 ? IOS_DOCUMENTS_PATH.exec(location.documentPath)?.[1]
48 : undefined;
49 const documentPath = containerPrefix
50 ? `${containerPrefix}<app>/Documents`
51 : location.documentPath;
52 return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`;
54​
55export function retainComposerAttachmentFile(uri: string, onLastRelease: () => void): () => void {
56 const key = composerAttachmentFileReferenceKey(uri);
57 retainedFiles.set(key, (retainedFiles.get(key) ?? 0) + 1);
58 let released = false;
59 return () => {
60 if (released) {
61 return;
62 }
63 released = true;
64 const remaining = (retainedFiles.get(key) ?? 1) - 1;
65 if (remaining > 0) {
66 retainedFiles.set(key, remaining);
67 return;
68 }
69 retainedFiles.delete(key);
70 onLastRelease();
71 };
73​
74export function isComposerAttachmentFileRetained(uri: string): boolean {
75 return retainedFiles.has(composerAttachmentFileReferenceKey(uri));
77​
78/** iOS updates can change the Documents container UUID. Bypass source URIs from pickers or other apps. */
79export function resolveOwnedComposerAttachmentFileUri(
80 uri: string,
81 documentDirectoryUri: string,
82): string | null {
83 const location = ownedFileLocation(uri);
84 const documentPath = fileUriPath(documentDirectoryUri)?.replace(/\/+$/, "");
85 if (!location || !documentPath) {
86 return null;
87 }
88 if (location.documentPath !== documentPath) {
89 const currentContainerPrefix = IOS_DOCUMENTS_PATH.exec(documentPath)?.[1];
90 if (
91 !currentContainerPrefix ||
92 currentContainerPrefix !== IOS_DOCUMENTS_PATH.exec(location.documentPath)?.[1] ||
93 !GENERATED_FILE_NAME.test(location.name)
94 ) {
95 return null;
96 }
97 }
98 const resolved = new URL(documentDirectoryUri);
99 resolved.pathname = `${resolved.pathname.replace(/\/+$/, "")}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`;
100 return resolved.href;