upload.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import { Platform } from 'react-native'
  2. import i18n from 'i18next';
  3. import { fetcher } from 'app/lib/fetcher';
  4. import { xhrRequest } from 'app/lib/xhr-request';
  5. import { ImageManipulator, SaveFormat } from 'app/lib/image/manipulator';
  6. import { appSetting } from 'app/config';
  7. import { isWeb } from './layout';
  8. import { genRnd } from './misc';
  9. const UPLOAD_TIMEOUT_MS: number = (appSetting as (...args: any[]) => any)('config', 'upload_timeout_ms') || 300000;
  10. const DIRECT_UPLOAD_OVER_MB: number | undefined = (appSetting as (...args: any[]) => any)('config', 'direct_upload_over_mb');
  11. /** Picker metadata passed along with an upload (mime/name hints). */
  12. export type UploadExtra = {
  13. mimeType?: string;
  14. fileType?: string;
  15. fileName?: string;
  16. name?: string;
  17. [key: string]: unknown;
  18. } | null | undefined;
  19. function guessMimeFromExt(ext: string | undefined): string {
  20. const e = String(ext || '').toLowerCase();
  21. if (!e) return 'application/octet-stream';
  22. if (e === 'jpg' || e === 'jpeg' || e === 'jpe') return 'image/jpeg';
  23. if (e === 'png' || e === 'gif' || e === 'webp' || e === 'heic' || e === 'heif' || e === 'bmp') return `image/${e}`;
  24. if (e === 'svg') return 'image/svg+xml';
  25. if (e === 'mov') return 'video/quicktime';
  26. if (e === 'mp4' || e === 'm4v' || e === 'webm' || e === 'avi' || e === '3gp') return `video/${e}`;
  27. if (e === 'pdf') return 'application/pdf';
  28. return `application/${e}`;
  29. }
  30. function extFromMime(mime: string | undefined): string {
  31. const type = String(mime || '').toLowerCase();
  32. if (type === 'image/jpeg' || type === 'image/jpg') return 'jpg';
  33. if (type === 'image/svg+xml') return 'svg';
  34. if (type === 'video/quicktime') return 'mov';
  35. const sub = type.split('/')[1];
  36. return sub && sub !== 'octet-stream' ? sub : '';
  37. }
  38. function resolveNativeUploadMeta(uri: string, extraVar: UploadExtra): { name: string; type: string } {
  39. const uriPath = String(uri || '').split('?')[0]!;
  40. const uriName = decodeURIComponent(uriPath.split('/').pop() || '');
  41. const uriExt = uriName.match(/\.([a-z0-9]+)$/i)?.[1];
  42. const type = extraVar?.mimeType || extraVar?.fileType || guessMimeFromExt(uriExt);
  43. const mimeExt = extFromMime(type) || uriExt || '';
  44. const rawName = extraVar?.fileName || extraVar?.name || uriName || 'upload';
  45. const name = rawName.includes('.')
  46. ? rawName
  47. : (mimeExt ? `${rawName}.${mimeExt}` : rawName);
  48. return { name: name || 'upload', type: type || 'application/octet-stream' };
  49. }
  50. /** Original picked name, with the extension swapped when an image was re-encoded (crop/webp). */
  51. function pickedFileName(name: string | undefined, blobType: string): string {
  52. const clean = String(name || '').split(/[\\/]/).pop() || '';
  53. if (!clean.includes('.')) return '';
  54. if (!blobType.startsWith('image/')) return clean;
  55. const mimeExt = extFromMime(blobType);
  56. const ext = clean.split('.').pop()!.toLowerCase();
  57. const same = ext === mimeExt || (mimeExt === 'jpg' && ext === 'jpeg');
  58. return same || !mimeExt ? clean : clean.replace(/\.[^.]+$/, '.' + mimeExt);
  59. }
  60. function urltoFile(url: string, defaultFilename = 'file', defaultMimeType = 'application/octet-stream'): Promise<File> {
  61. return fetch(url)
  62. .then(function (response) {
  63. return response.blob().then(function (blob) {
  64. const mimeType = blob.type || defaultMimeType;
  65. const contentDisposition = response.headers.get('Content-Disposition');
  66. let filename = defaultFilename;
  67. if (contentDisposition && contentDisposition.includes('filename')) {
  68. const matches = contentDisposition.match(/filename="?(.+?)"?$/);
  69. if (matches && matches[1]) {
  70. filename = matches[1];
  71. }
  72. } else {
  73. const urlParts = url.split('/');
  74. const rawFilename = urlParts[urlParts.length - 1]!;
  75. if (rawFilename.includes('.') && rawFilename.split('.').length > 1) {
  76. filename = rawFilename;
  77. } else {
  78. if (mimeType != 'image/svg+xml') {
  79. const ext = mimeType.split('/')[1] || 'bin';
  80. filename = `${rawFilename || defaultFilename}.${ext}`;
  81. }
  82. else {
  83. filename = `${rawFilename || defaultFilename}.svg`;
  84. }
  85. }
  86. }
  87. return new File([blob], filename, { type: mimeType });
  88. });
  89. })
  90. .catch(function (error) {
  91. console.error('Error converting URL to File:', error);
  92. throw error;
  93. });
  94. }
  95. export const uploadImageFile = async (file: Blob, fetchUrl: string, calback: (result: any, extraVar: UploadExtra) => void, extraVar?: UploadExtra) => {
  96. const formData = new FormData();
  97. formData.append("file", file);
  98. const result = await fetcher([fetchUrl, null, formData], false, { timeoutMs: UPLOAD_TIMEOUT_MS });
  99. if (result?.data?.link) {
  100. calback(result?.data?.link, extraVar);
  101. }
  102. else {
  103. calback(result, extraVar)
  104. }
  105. }
  106. const PICKER_IMAGE_EXTS = new Set(['jpg', 'jpeg', 'jpe', 'jfif', 'gif', 'png', 'svg', 'webp', 'heic', 'heif', 'bmp', 'tif', 'tiff', 'ico', 'avif']);
  107. const PICKER_VIDEO_EXTS = new Set(['mp4', 'mov', 'm4v', 'avi', 'webm', '3gp', 'mkv', 'mpg', 'mpeg', 'wmv', 'flv', 'ogv']);
  108. /** Storage ext list ("jpg,png" / ".jpg, .png") → normalized extensions. */
  109. export const splitExtList = (list: string | null | undefined): string[] =>
  110. String(list || '').toLowerCase().split(',').map((s) => s.trim().replace(/^\./, '')).filter(Boolean);
  111. /** Lowercased extension of a file name, '' when there is none. */
  112. export const fileNameExt = (name: string | null | undefined): string =>
  113. String(name || '').match(/\.([a-z0-9]{1,10})$/i)?.[1]?.toLowerCase() || '';
  114. /** Attachment kind from a file name: 'image' | 'video' | 'file'. */
  115. export const attachmentKindByName = (name: string | null | undefined): 'image' | 'video' | 'file' => {
  116. const ext = fileNameExt(name);
  117. if (PICKER_IMAGE_EXTS.has(ext)) return 'image';
  118. if (PICKER_VIDEO_EXTS.has(ext)) return 'video';
  119. return 'file';
  120. };
  121. /**
  122. * What a storage's `ext_allow` (UNA markers already expanded) lets the user pick; `ext_deny` is checked per file:
  123. * 'images' (photo picker), 'media' (photos + videos) or 'any' (document picker).
  124. */
  125. export const getStoragePickerKind = (extAllow?: string | null): 'images' | 'media' | 'any' => {
  126. const allow = splitExtList(extAllow);
  127. // deny-mode storage (empty allow list) accepts everything except the deny list.
  128. if (!allow.length) return 'any';
  129. if (allow.every((e) => PICKER_IMAGE_EXTS.has(e))) return 'images';
  130. if (allow.every((e) => PICKER_IMAGE_EXTS.has(e) || PICKER_VIDEO_EXTS.has(e))) return 'media';
  131. return 'any';
  132. };
  133. /** Client-side mirror of the storage ext check. Names without an extension are left to the server. */
  134. export const isExtAllowed = (name: string | null | undefined, extAllow?: string | null, extDeny?: string | null): boolean => {
  135. const ext = fileNameExt(name);
  136. if (!ext) return true;
  137. const allow = splitExtList(extAllow);
  138. if (allow.length && !allow.includes(ext)) return false;
  139. return !splitExtList(extDeny).includes(ext);
  140. };
  141. const BYTES_IN_KB = 1024;
  142. const BYTES_IN_MB = BYTES_IN_KB * 1024;
  143. /** Human-readable size for UI: "512 B", "3 KB", "1.4 MB". Empty string for missing/zero. */
  144. export const formatFileSize = (bytes: number | string | null | undefined): string => {
  145. const n = Number(bytes);
  146. if (!n || n < 0) return '';
  147. if (n < BYTES_IN_KB) return `${n} B`;
  148. if (n < BYTES_IN_MB) return `${Math.round(n / BYTES_IN_KB)} KB`;
  149. return `${(n / BYTES_IN_MB).toFixed(1)} MB`;
  150. };
  151. export const getUploadSizeMb = async ({ file, uri, fileSizeBytes }: { file?: { size?: number } | null; uri?: string; fileSizeBytes?: number }): Promise<number> => {
  152. if (typeof fileSizeBytes === 'number' && fileSizeBytes > 0) {
  153. return fileSizeBytes / BYTES_IN_MB;
  154. }
  155. if (file && typeof file.size === 'number') {
  156. return file.size / BYTES_IN_MB;
  157. }
  158. if (uri && Platform.OS === 'web') {
  159. try {
  160. const response = await fetch(uri);
  161. const blob = await response.blob();
  162. return (blob?.size || 0) / BYTES_IN_MB;
  163. } catch {
  164. return 0;
  165. }
  166. }
  167. return 0;
  168. }
  169. export const prepareImageForUpload = async ({
  170. uri,
  171. width,
  172. height,
  173. fileSizeBytes,
  174. maxWidth,
  175. maxHeight,
  176. cropToSquare = false,
  177. squareSize,
  178. webpOverMb = 4,
  179. }: {
  180. uri: string;
  181. width?: number;
  182. height?: number;
  183. fileSizeBytes?: number;
  184. maxWidth?: number;
  185. maxHeight?: number;
  186. cropToSquare?: boolean;
  187. squareSize?: number;
  188. /** Re-encode as WebP when the original exceeds this size (MB). */
  189. webpOverMb?: number;
  190. }): Promise<string> => {
  191. const originalSizeMb = await getUploadSizeMb({ uri, fileSizeBytes });
  192. const shouldConvertToWebp = originalSizeMb > webpOverMb;
  193. // expo-image-manipulator's web .d.ts types the export as the class, but at runtime it is
  194. // the module instance (as on native) — use the native instance typing.
  195. const context = (ImageManipulator as unknown as { manipulate: (uri: string) => any }).manipulate(uri);
  196. let hasActions = false;
  197. if (
  198. typeof width === 'number' &&
  199. typeof height === 'number' &&
  200. typeof maxWidth === 'number' &&
  201. typeof maxHeight === 'number' &&
  202. maxWidth > 0 &&
  203. maxHeight > 0 &&
  204. (width > maxWidth || height > maxHeight)
  205. ) {
  206. const scale = Math.min(maxWidth / width, maxHeight / height, 1);
  207. context.resize({
  208. width: Math.max(1, Math.round(width * scale)),
  209. height: Math.max(1, Math.round(height * scale)),
  210. });
  211. hasActions = true;
  212. }
  213. if (cropToSquare && typeof width === 'number' && typeof height === 'number') {
  214. let squareSide = width;
  215. if (width !== height) {
  216. squareSide = width > height ? height : width;
  217. context.crop({
  218. width: squareSide,
  219. height: squareSide,
  220. originX: width > height ? Math.round((width - squareSide) / 2) : 0,
  221. originY: height > width ? Math.round((height - squareSide) / 2) : 0,
  222. });
  223. hasActions = true;
  224. }
  225. if (typeof squareSize === 'number' && squareSide > squareSize) {
  226. context.resize({ width: squareSize, height: squareSize });
  227. hasActions = true;
  228. }
  229. }
  230. if (!hasActions && !shouldConvertToWebp) {
  231. return uri;
  232. }
  233. const renderedImage = await context.renderAsync();
  234. const processedImage = await renderedImage.saveAsync({
  235. ...(shouldConvertToWebp ? { format: SaveFormat.WEBP } : {}),
  236. });
  237. return processedImage.uri;
  238. }
  239. /** Set once UNA rejects the CORS preflight of a progress-reporting direct upload. */
  240. let directUploadProgressBlocked = false;
  241. const shouldUploadDirect = (size: number): boolean =>
  242. typeof DIRECT_UPLOAD_OVER_MB === 'number' && DIRECT_UPLOAD_OVER_MB >= 0 && size > DIRECT_UPLOAD_OVER_MB * BYTES_IN_MB;
  243. /**
  244. * Web: POST the file straight to UNA with a one-time token (`get_upload_token`, fetched through
  245. * the proxy as the logged in user), so big files skip the Next proxy body limit.
  246. * Resolves `null` when UNA can't issue a token (older server) — the caller falls back to the proxy.
  247. * UNA must allow the app origin (Studio → API → origins) for the cross-origin response to be readable.
  248. */
  249. async function uploadDirectToUna(
  250. fetchUrl: string,
  251. formData: FormData,
  252. signal?: AbortSignal,
  253. onProgress?: (fraction: number) => void,
  254. ): Promise<any | null> {
  255. const query = fetchUrl.split('?')[1] || '';
  256. const params = new URLSearchParams(query);
  257. // UNA honours upload tokens for `upload` and `upload_inline` only.
  258. if (!['upload', 'upload_inline'].includes(params.get('a') || '')) return null;
  259. const tokenPath = '/api.php?r=system/get_upload_token/TemplUploaderServices'
  260. + '&uo=' + encodeURIComponent(params.get('uo') || '')
  261. + '&so=' + encodeURIComponent(params.get('so') || '');
  262. const tokenRes = await fetcher(tokenPath, false, { signal, maxAttempts: 1, silent: true });
  263. const token: string | undefined = tokenRes?.data?.token;
  264. const url: string | undefined = tokenRes?.data?.url;
  265. if (!token || !url) return null;
  266. const controller = new AbortController();
  267. const onAbort = () => controller.abort();
  268. signal?.addEventListener('abort', onAbort);
  269. const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
  270. const directUrl = `${url}?${query}&ut=${encodeURIComponent(token)}&lang=${i18n.language}`;
  271. try {
  272. if (onProgress && !directUploadProgressBlocked) {
  273. let sentAny = false;
  274. try {
  275. const r = await xhrRequest(directUrl, {
  276. method: 'POST',
  277. body: formData,
  278. signal: controller.signal,
  279. onUploadProgress: (fraction) => {
  280. if (fraction > 0) sentAny = true;
  281. onProgress(fraction);
  282. },
  283. });
  284. return await r.json().catch(() => ({}));
  285. } catch (err) {
  286. if (sentAny || !(err instanceof TypeError)) throw err;
  287. // Nothing left the browser: UNA rejected the preflight that progress events require.
  288. directUploadProgressBlocked = true;
  289. }
  290. }
  291. // No custom headers: a "simple" CORS request, so the browser skips the preflight.
  292. const r = await fetch(directUrl, {
  293. method: 'POST',
  294. body: formData,
  295. credentials: 'omit',
  296. signal: controller.signal,
  297. });
  298. return await r.json().catch(() => ({}));
  299. } finally {
  300. clearTimeout(timer);
  301. signal?.removeEventListener('abort', onAbort);
  302. }
  303. }
  304. /** Upload a local/data/blob image URI; `calback` gets `{ result, extraVar }` with the full API payload. */
  305. export const uploadImage = async (
  306. uri: string,
  307. fetchUrl: string,
  308. calback: (payload: { result: any; extraVar: UploadExtra }) => void,
  309. extraVar?: UploadExtra,
  310. options: { signal?: AbortSignal; onProgress?: (fraction: number) => void } = {},
  311. ) => {
  312. const { signal, onProgress } = options;
  313. if (isWeb) {
  314. let fileType = '';
  315. let fileExt = '';
  316. if (uri.startsWith('data:')) {
  317. // For data URI
  318. fileType = uri.split(';')[0]!.split(':')[1] as string; // MIME type
  319. fileExt = fileType.split('/')[1] as string; // extension
  320. if (fileExt == 'svg+xml') {
  321. fileExt = 'svg';
  322. }
  323. } else if (uri.startsWith('blob:')) {
  324. // Enriched web paste: URL.createObjectURL — no filename/extension in the URI
  325. fileType = extraVar?.mimeType || extraVar?.fileType || 'image/png';
  326. fileExt = extFromMime(fileType) || 'png';
  327. if (fileExt === 'svg+xml') fileExt = 'svg';
  328. } else {
  329. const fileName = uri.split('/').pop()!; // file name
  330. fileExt = fileName.split('.').pop()!; // extension
  331. fileType = `image/${fileExt}`; // MIME type
  332. }
  333. const formData = new FormData();
  334. const picked = await urltoFile(uri, genRnd(8) + '.' + fileExt, fileType);
  335. // blob: URLs carry a random UUID, not the picked name — keep the user's original file name.
  336. const originalName = pickedFileName(extraVar?.fileName || extraVar?.name, picked.type);
  337. const file = originalName ? new File([picked], originalName, { type: picked.type }) : picked;
  338. if (signal?.aborted) {
  339. const err: Error & { aborted?: boolean } = new Error('Aborted');
  340. err.name = 'AbortError';
  341. err.aborted = true;
  342. throw err;
  343. }
  344. formData.append("file", file);
  345. let result: any = null;
  346. if (shouldUploadDirect(file.size)) {
  347. try {
  348. result = await uploadDirectToUna(fetchUrl, formData, signal, onProgress);
  349. } catch (err: any) {
  350. if (signal?.aborted || err?.name === 'AbortError') throw err;
  351. console.warn('[upload] direct upload to UNA failed, retrying through the proxy:', err);
  352. }
  353. }
  354. if (!result) {
  355. result = await fetcher([fetchUrl, null, formData], false, {
  356. timeoutMs: UPLOAD_TIMEOUT_MS,
  357. signal,
  358. onUploadProgress: onProgress,
  359. });
  360. }
  361. // Always pass the full API payload so callers can read data.ghost
  362. // (passing only data.link dropped ghosts and left preload spinners).
  363. calback({ result, extraVar });
  364. return;
  365. }
  366. const formData = new FormData();
  367. const { name, type } = resolveNativeUploadMeta(uri, extraVar);
  368. // RN FormData takes a { uri, name, type } descriptor instead of a Blob.
  369. formData.append("file", {
  370. uri,
  371. name,
  372. type,
  373. } as unknown as Blob);
  374. const result = await fetcher([fetchUrl, null, formData], false, {
  375. timeoutMs: UPLOAD_TIMEOUT_MS,
  376. signal,
  377. onUploadProgress: onProgress,
  378. });
  379. calback({ result, extraVar });
  380. };