@xuda.io/drive_module 1.1.1500 → 1.1.1502
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +551 -35
- package/index_ms.mjs +12 -0
- package/index_msa.mjs +12 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import fse from 'fs-extra';
|
|
|
5
5
|
import unzipper from 'unzipper';
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import os from 'os';
|
|
8
|
+
import child_process from 'child_process';
|
|
8
9
|
import tesseract from 'node-tesseract-ocr';
|
|
9
10
|
import countryLanguage from 'country-language';
|
|
10
11
|
import url from 'url';
|
|
@@ -226,13 +227,15 @@ export const get_drive_files = async (req) => {
|
|
|
226
227
|
}
|
|
227
228
|
try {
|
|
228
229
|
files.sizeInBytes += doc.size;
|
|
230
|
+
const access_link = `https://${domain}/${drive_type}-drive/${datasource_id || app_id_reference || uid}/${doc._id}${ts}`;
|
|
229
231
|
files.children.push({
|
|
230
232
|
name: doc.originalname,
|
|
231
233
|
sizeInBytes: doc.size,
|
|
232
234
|
size: formatBytes(doc.size),
|
|
233
235
|
extension: doc?.file_ext?.substr(1),
|
|
234
236
|
type: doc.type,
|
|
235
|
-
access_link
|
|
237
|
+
access_link,
|
|
238
|
+
...preview_links_for(doc, access_link),
|
|
236
239
|
is_archive: doc.is_archive,
|
|
237
240
|
public: doc.public,
|
|
238
241
|
path: doc.file_path,
|
|
@@ -676,6 +679,9 @@ export const update_drive_file = async (req, job_id, headers, file_obj) => {
|
|
|
676
679
|
|
|
677
680
|
doc = { ...doc, ...(await get_file_info(tempPath)) };
|
|
678
681
|
|
|
682
|
+
// New bytes, so any preview on the doc now shows the old content. Re-queue it.
|
|
683
|
+
if (is_office_preview_ext(doc.file_ext)) doc.preview_stat = 1;
|
|
684
|
+
|
|
679
685
|
let save_ret;
|
|
680
686
|
let ref;
|
|
681
687
|
switch (drive_type) {
|
|
@@ -726,6 +732,10 @@ export const update_drive_file = async (req, job_id, headers, file_obj) => {
|
|
|
726
732
|
|
|
727
733
|
let file_url = get_file_url(drive_type, file_name, ref);
|
|
728
734
|
|
|
735
|
+
if (doc.preview_stat === 1) {
|
|
736
|
+
generate_drive_file_preview({ app_id, drive_type, uid, doc_id: doc._id }).catch(() => {});
|
|
737
|
+
}
|
|
738
|
+
|
|
729
739
|
return {
|
|
730
740
|
code: 760,
|
|
731
741
|
data: {
|
|
@@ -1892,6 +1902,9 @@ export const upload_drive_file = async (req, job_id, headers = {}, file_obj) =>
|
|
|
1892
1902
|
drive_type,
|
|
1893
1903
|
mime: file_obj.mimetype,
|
|
1894
1904
|
ocr_stat: ['image', 'application', 'text'].includes(file_obj.mimetype.split('/')[0]) ? 1 : 0,
|
|
1905
|
+
// studio drive keeps its files on local disk rather than in a bucket, and the
|
|
1906
|
+
// preview reader fetches from the bucket, so it is not a candidate.
|
|
1907
|
+
preview_stat: is_office_preview_ext(ext) && drive_type !== 'studio' ? 1 : 0,
|
|
1895
1908
|
target_file,
|
|
1896
1909
|
file_path: file_path || '/',
|
|
1897
1910
|
size: stat.isFile() ? stat.size : 0,
|
|
@@ -1910,6 +1923,69 @@ export const upload_drive_file = async (req, job_id, headers = {}, file_obj) =>
|
|
|
1910
1923
|
};
|
|
1911
1924
|
const check_existing_file_ret = await check_drive_file(check_req);
|
|
1912
1925
|
|
|
1926
|
+
// OVERWRITE IN PLACE. Callers that mean "update this file" (the office plugins editing a
|
|
1927
|
+
// deck / document the user named) opt in with req.overwrite. Without it the block below
|
|
1928
|
+
// dedupes the name, so "update /Presentations/deck.pptx" quietly produced
|
|
1929
|
+
// "deck (1).pptx" and the file the user was looking at never changed.
|
|
1930
|
+
//
|
|
1931
|
+
// The existing doc is REUSED rather than replaced: _id, server_file_name and the bucket
|
|
1932
|
+
// key all stay, so every link already handed out (a chat file card, a shared URL, an
|
|
1933
|
+
// agent's saved reference) still resolves to the edited bytes. Only the mutable facts
|
|
1934
|
+
// move. preview_stat goes back to 1 so the rendered preview is regenerated from the new
|
|
1935
|
+
// content instead of showing the old slides for ever.
|
|
1936
|
+
//
|
|
1937
|
+
// Bucket-backed drives only. The datacenter / is_deployment branches ship bytes over scp
|
|
1938
|
+
// to a path derived from the same name, so an overwrite there would need its own handling;
|
|
1939
|
+
// until it does, those fall through to the dedupe rather than half-writing.
|
|
1940
|
+
if (check_existing_file_ret.code < 0 && req.overwrite) {
|
|
1941
|
+
try {
|
|
1942
|
+
const existing = await get_drive_doc('file', drive_type, app_id, uid, path.join(doc.file_path, originalname), headers);
|
|
1943
|
+
const existing_key = existing?.server_file_name;
|
|
1944
|
+
const app_obj_ret = drive_type === 'workspace' ? await db_module.get_app_obj(app_id) : null;
|
|
1945
|
+
const scp_backed = !!(app_obj_ret?.data?.app_type === 'datacenter' || app_obj_ret?.data?.is_deployment);
|
|
1946
|
+
|
|
1947
|
+
if (existing_key && drive_type !== 'studio' && !scp_backed) {
|
|
1948
|
+
const ref_ow = drive_type === 'user' ? await get_account_default_project_id(uid) : await _common.get_project_app_id(req.app_id, true);
|
|
1949
|
+
existing.bucket = {
|
|
1950
|
+
[`${process.env.XUDA_HOSTNAME}`]: await upload_file_to_spaces(tempPath, ref_ow, drive_type, existing_key),
|
|
1951
|
+
};
|
|
1952
|
+
existing.size = doc.size;
|
|
1953
|
+
existing.mime = doc.mime;
|
|
1954
|
+
existing.file_ext = doc.file_ext;
|
|
1955
|
+
existing.ts = Date.now();
|
|
1956
|
+
// Back to 1 (queued) so the office->pdf render re-runs against the new bytes;
|
|
1957
|
+
// without it the viewer would keep showing the slides from before the edit.
|
|
1958
|
+
// preview_link is not stored, it is derived per read by preview_links_for.
|
|
1959
|
+
existing.preview_stat = doc.preview_stat;
|
|
1960
|
+
|
|
1961
|
+
const save_ow =
|
|
1962
|
+
drive_type === 'user' ? await save_user_drive_file(uid, existing) : await db_module.save_app_couch_doc(await _common.get_project_app_id(req.app_id), existing);
|
|
1963
|
+
if (save_ow.code < 0) throw new Error(save_ow.data);
|
|
1964
|
+
|
|
1965
|
+
await fs_module.unlink(tempPath).catch(() => {});
|
|
1966
|
+
if (existing.preview_stat === 1) {
|
|
1967
|
+
generate_drive_file_preview({ app_id: req.app_id, drive_type, uid, doc_id: existing._id }).catch(() => {});
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
return {
|
|
1971
|
+
code: 760,
|
|
1972
|
+
data: {
|
|
1973
|
+
server_file_name: existing_key,
|
|
1974
|
+
filename: existing.originalname,
|
|
1975
|
+
file_url: get_file_url(drive_type, existing_key, ref_ow),
|
|
1976
|
+
file_path: existing.file_path,
|
|
1977
|
+
bucket: existing.bucket,
|
|
1978
|
+
},
|
|
1979
|
+
overwritten: true,
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
} catch (err) {
|
|
1983
|
+
// An overwrite that cannot be completed must not lose the upload: fall through to the
|
|
1984
|
+
// dedupe path below so the bytes still land somewhere the user can reach.
|
|
1985
|
+
console.error('[upload_drive_file] overwrite failed, falling back to a new name:', err?.message || err);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1913
1989
|
if (check_existing_file_ret.code < 0) {
|
|
1914
1990
|
const rename_file_name = async (i) => {
|
|
1915
1991
|
if (i > 1000) {
|
|
@@ -2020,6 +2096,13 @@ export const upload_drive_file = async (req, job_id, headers = {}, file_obj) =>
|
|
|
2020
2096
|
|
|
2021
2097
|
await fs_module.unlink(tempPath);
|
|
2022
2098
|
|
|
2099
|
+
// Office files get a rendered preview. Fire and forget: converting takes seconds and
|
|
2100
|
+
// an upload must not wait for it, nor fail because of it. The doc already carries
|
|
2101
|
+
// preview_stat 1, so if this box never gets to it the sweep will.
|
|
2102
|
+
if (doc.preview_stat === 1) {
|
|
2103
|
+
generate_drive_file_preview({ app_id: req.app_id, drive_type, uid, doc_id: doc._id }).catch(() => {});
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2023
2106
|
return {
|
|
2024
2107
|
code: 760,
|
|
2025
2108
|
data: {
|
|
@@ -2351,19 +2434,100 @@ export const check_drive_file = async (req, job_id, headers, file_obj) => {
|
|
|
2351
2434
|
return ret.files[0];
|
|
2352
2435
|
};
|
|
2353
2436
|
|
|
2354
|
-
|
|
2355
|
-
|
|
2437
|
+
// Which languages tesseract on THIS box actually has traineddata for. Asked once and
|
|
2438
|
+
// cached: it shells out, and the answer cannot change while the process lives.
|
|
2439
|
+
let _tesseract_langs_cache;
|
|
2440
|
+
const tesseract_langs = () => {
|
|
2441
|
+
if (_tesseract_langs_cache) return _tesseract_langs_cache;
|
|
2442
|
+
try {
|
|
2443
|
+
const out = child_process.execFileSync('tesseract', ['--list-langs'], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
2444
|
+
// First line is a "List of available languages (N):" header, which has spaces in it.
|
|
2445
|
+
const langs = out.split('\n').map((s) => s.trim()).filter((s) => s && !s.includes(' '));
|
|
2446
|
+
_tesseract_langs_cache = new Set(langs.length ? langs : ['eng']);
|
|
2447
|
+
} catch (err) {
|
|
2448
|
+
console.warn('[drive_module] tesseract --list-langs failed, assuming eng only:', err.message);
|
|
2449
|
+
_tesseract_langs_cache = new Set(['eng']);
|
|
2450
|
+
}
|
|
2451
|
+
return _tesseract_langs_cache;
|
|
2452
|
+
};
|
|
2453
|
+
|
|
2454
|
+
// Pick the OCR languages for a file, from the country its upload came from.
|
|
2455
|
+
//
|
|
2456
|
+
// Two traps, both of which used to fail the whole OCR run:
|
|
2457
|
+
// - country-language's getCountry REQUIRES a callback and throws "cb is not a function"
|
|
2458
|
+
// for a missing or unknown code. doc.country comes from the cf-ipcountry header, which
|
|
2459
|
+
// is absent on EVERY server-side write (AI output, plugin saves, restores), so this
|
|
2460
|
+
// threw for most files and marked them ocr_stat 4.
|
|
2461
|
+
// - a language whose traineddata is not installed makes tesseract exit with "Error during
|
|
2462
|
+
// processing", losing the English pass along with it. So only offer what is on the box.
|
|
2463
|
+
const detectLanguageByCountry = (cfCountry) => {
|
|
2464
|
+
const have = tesseract_langs();
|
|
2465
|
+
const langs = ['eng'];
|
|
2466
|
+
try {
|
|
2467
|
+
const code = String(cfCountry || '').trim().toUpperCase();
|
|
2468
|
+
const countryData = code ? countryLanguage.getCountry(code) : null;
|
|
2469
|
+
for (const language of countryData?.languages || []) {
|
|
2470
|
+
const iso = language?.iso639_2;
|
|
2471
|
+
if (iso && have.has(iso) && !langs.includes(iso)) langs.push(iso);
|
|
2472
|
+
}
|
|
2473
|
+
} catch (err) {
|
|
2474
|
+
// Unknown or missing country: English only, which is the right default and is what
|
|
2475
|
+
// the caller wanted all along.
|
|
2476
|
+
}
|
|
2477
|
+
// Each extra language costs real time on every page, and the tail of a country's list
|
|
2478
|
+
// is rarely what the document is written in.
|
|
2479
|
+
return langs.slice(0, 4).join('+');
|
|
2480
|
+
};
|
|
2481
|
+
|
|
2482
|
+
// Put a drive file's bytes on local disk and hand back the path. EVERY OCR reader below
|
|
2483
|
+
// wants a real file: tesseract streams one, mammoth and node-xlsx take a `path`, pdf2image
|
|
2484
|
+
// rasterises one. They were being handed a URL instead, and a wrong URL at that (built
|
|
2485
|
+
// with the uid where the route wants the project id, and with the folder baked into the
|
|
2486
|
+
// filename), so the fetch 404'd and tesseract failed with "Error during processing" while
|
|
2487
|
+
// the text branch quietly stored an ENOENT string as the document's OCR text.
|
|
2488
|
+
//
|
|
2489
|
+
// The bytes are already in the region bucket, so read them straight from there and skip
|
|
2490
|
+
// HTTP entirely. Only a drive with no bucket entry (a datacenter workspace drive, whose
|
|
2491
|
+
// files are scp'd to the hosting box) still needs the round trip, and that one now uses a
|
|
2492
|
+
// URL built the same way the drive listing builds it.
|
|
2493
|
+
const ocr_file_to_tmp = async (app_id, doc) => {
|
|
2494
|
+
const write_tmp = async (buffer) => {
|
|
2495
|
+
const tmp_path = path.join(os.tmpdir(), `xuda_ocr_${await _common.xuda_get_uuid('tmp')}${doc.file_ext || path.extname(doc.originalname || '') || ''}`);
|
|
2496
|
+
await fs.promises.writeFile(tmp_path, buffer);
|
|
2497
|
+
return tmp_path;
|
|
2498
|
+
};
|
|
2356
2499
|
|
|
2357
|
-
|
|
2500
|
+
const region = _pick_file_region(doc);
|
|
2501
|
+
const { Bucket, Key } = _drive_bucket_for(doc, region);
|
|
2502
|
+
const s3 = region ? _drive_s3_for(region) : null;
|
|
2358
2503
|
|
|
2359
|
-
|
|
2360
|
-
const
|
|
2361
|
-
|
|
2362
|
-
return parsedUrl.toString();
|
|
2504
|
+
if (s3 && Bucket && Key) {
|
|
2505
|
+
const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
|
|
2506
|
+
return await write_tmp(await _stream_to_buffer(obj.Body));
|
|
2363
2507
|
}
|
|
2364
2508
|
|
|
2365
|
-
|
|
2509
|
+
const ref = doc.drive_type === 'user' ? await get_account_default_project_id(doc.uid) : await _common.get_project_app_id(app_id);
|
|
2510
|
+
const target = new url.URL(get_file_url(doc.drive_type, doc._id, ref));
|
|
2511
|
+
target.searchParams.set('xuda_internal_request_code', _conf.xuda_internal_request_code);
|
|
2512
|
+
|
|
2513
|
+
const res = await fetch(target.toString());
|
|
2514
|
+
if (!res.ok) throw new Error(`file unavailable (${res.status})`);
|
|
2515
|
+
return await write_tmp(Buffer.from(await res.arrayBuffer()));
|
|
2516
|
+
};
|
|
2517
|
+
|
|
2518
|
+
// read_file answers { code, data } and puts the ERROR MESSAGE in data when it fails, so
|
|
2519
|
+
// reading it blindly stored strings like "ENOENT: no such file" as the document's own
|
|
2520
|
+
// searchable text. A failed read has no text in it, and saying so is the honest answer.
|
|
2521
|
+
const read_text_for_ocr = async (file_path) => {
|
|
2522
|
+
const ret = await fs_module.read_file(file_path);
|
|
2523
|
+
if (!ret || ret.code < 0) throw new Error(`could not read file for ocr: ${ret?.data || 'unknown error'}`);
|
|
2524
|
+
return String(ret.data || '');
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
const ocr_drive_file = async (app_id, doc) => {
|
|
2528
|
+
const { drive_type, uid, file_path, originalname } = doc;
|
|
2366
2529
|
|
|
2530
|
+
let target_file;
|
|
2367
2531
|
let save_ret;
|
|
2368
2532
|
try {
|
|
2369
2533
|
doc.ocr_stat = 2;
|
|
@@ -2372,24 +2536,19 @@ const ocr_drive_file = async (app_id, doc) => {
|
|
|
2372
2536
|
|
|
2373
2537
|
doc = await get_drive_doc('file', drive_type, app_id, uid, path.join(file_path, originalname));
|
|
2374
2538
|
|
|
2375
|
-
|
|
2539
|
+
target_file = await ocr_file_to_tmp(app_id, doc);
|
|
2376
2540
|
|
|
2377
|
-
|
|
2378
|
-
const countryData = countryLanguage.getCountry(cfCountry);
|
|
2379
|
-
let lang = 'eng';
|
|
2380
|
-
if (countryData && countryData.languages && countryData.languages.length > 0) {
|
|
2381
|
-
for (let language of countryData.languages) {
|
|
2382
|
-
lang += `+${language.iso639_2}`;
|
|
2383
|
-
}
|
|
2384
|
-
}
|
|
2385
|
-
return lang;
|
|
2386
|
-
}
|
|
2541
|
+
let ocr = '';
|
|
2387
2542
|
|
|
2388
2543
|
let lang = detectLanguageByCountry(doc.country);
|
|
2389
2544
|
|
|
2390
2545
|
const generalConfig = { oem: 1, psm: 3, lang };
|
|
2391
2546
|
|
|
2392
|
-
|
|
2547
|
+
// A doc written before mime was recorded would throw on .split here and be marked
|
|
2548
|
+
// failed for ever. Fall back to the extension, which every drive doc carries.
|
|
2549
|
+
const mime = doc.mime || _drive_content_type_for(doc.originalname || '') || '';
|
|
2550
|
+
|
|
2551
|
+
switch (mime.split('/')[0]) {
|
|
2393
2552
|
case 'image': {
|
|
2394
2553
|
ocr = await tesseract.recognize(target_file, generalConfig);
|
|
2395
2554
|
|
|
@@ -2397,7 +2556,7 @@ const ocr_drive_file = async (app_id, doc) => {
|
|
|
2397
2556
|
}
|
|
2398
2557
|
|
|
2399
2558
|
case 'application': {
|
|
2400
|
-
switch (
|
|
2559
|
+
switch (mime.split('/')[1]) {
|
|
2401
2560
|
case 'pdf': {
|
|
2402
2561
|
const images = await convertPDF(target_file);
|
|
2403
2562
|
for await (const imageData of images) {
|
|
@@ -2411,7 +2570,10 @@ const ocr_drive_file = async (app_id, doc) => {
|
|
|
2411
2570
|
break;
|
|
2412
2571
|
}
|
|
2413
2572
|
|
|
2573
|
+
// 'nd.ms-excel' was a typo for the real .xls mime, so no legacy spreadsheet
|
|
2574
|
+
// ever reached this branch. Both spellings kept, the wrong one costs nothing.
|
|
2414
2575
|
case 'nd.ms-excel':
|
|
2576
|
+
case 'vnd.ms-excel':
|
|
2415
2577
|
case 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
|
|
2416
2578
|
const sheets = xlsx.parse(target_file);
|
|
2417
2579
|
sheets.forEach((sheet) => {
|
|
@@ -2429,7 +2591,7 @@ const ocr_drive_file = async (app_id, doc) => {
|
|
|
2429
2591
|
break;
|
|
2430
2592
|
|
|
2431
2593
|
default:
|
|
2432
|
-
ocr =
|
|
2594
|
+
ocr = await read_text_for_ocr(target_file);
|
|
2433
2595
|
break;
|
|
2434
2596
|
}
|
|
2435
2597
|
break;
|
|
@@ -2454,12 +2616,286 @@ const ocr_drive_file = async (app_id, doc) => {
|
|
|
2454
2616
|
doc.ocr_stat = 4;
|
|
2455
2617
|
doc.ocr_error = err.message;
|
|
2456
2618
|
} finally {
|
|
2619
|
+
// The bytes were copied here only so a reader could open them.
|
|
2620
|
+
if (target_file) await fs.promises.unlink(target_file).catch(() => {});
|
|
2457
2621
|
doc.ocr_stat_ts = Date.now();
|
|
2458
2622
|
|
|
2459
2623
|
save_ret = await save_drive_doc(drive_type, uid, app_id, doc, file_path);
|
|
2460
2624
|
}
|
|
2461
2625
|
};
|
|
2462
2626
|
|
|
2627
|
+
// ── Office previews ────────────────────────────────────────────────────────────
|
|
2628
|
+
// A deck, a document or a spreadsheet had no preview anywhere in the Drive: the
|
|
2629
|
+
// thumbnail was a grey category tile and the lightbox said "No preview available".
|
|
2630
|
+
// So render one. Each office file gets two derived assets stored beside it:
|
|
2631
|
+
//
|
|
2632
|
+
// pdf - the whole file, which the lightbox already knows how to show in an iframe
|
|
2633
|
+
// png - page/slide one, which every thumbnail in the app can draw as an image
|
|
2634
|
+
//
|
|
2635
|
+
// The conversion needs LibreOffice on the box. Where it is absent the file simply
|
|
2636
|
+
// keeps its category tile, exactly as before, and preview_stat records that so the
|
|
2637
|
+
// sweep does not retry a box that can never succeed.
|
|
2638
|
+
//
|
|
2639
|
+
// preview_stat: 0 not applicable / unsupported here, 1 queued, 2 running, 3 done, 4 failed.
|
|
2640
|
+
const PREVIEW_OFFICE_EXTS = ['.pptx', '.ppt', '.docx', '.doc', '.xlsx', '.xls', '.odp', '.odt', '.ods', '.rtf'];
|
|
2641
|
+
|
|
2642
|
+
const is_office_preview_ext = (ext) => PREVIEW_OFFICE_EXTS.includes(String(ext || '').toLowerCase());
|
|
2643
|
+
|
|
2644
|
+
// Resolved once per process. `null` means "looked and it is not here", which is a
|
|
2645
|
+
// real answer and must not be retried on every file.
|
|
2646
|
+
let _soffice_bin_cache;
|
|
2647
|
+
const soffice_bin = () => {
|
|
2648
|
+
if (_soffice_bin_cache !== undefined) return _soffice_bin_cache;
|
|
2649
|
+
_soffice_bin_cache = ['/usr/bin/soffice', '/usr/bin/libreoffice', '/usr/lib/libreoffice/program/soffice'].find((p) => {
|
|
2650
|
+
try {
|
|
2651
|
+
return fs.existsSync(p);
|
|
2652
|
+
} catch (_) {
|
|
2653
|
+
return false;
|
|
2654
|
+
}
|
|
2655
|
+
}) || null;
|
|
2656
|
+
if (!_soffice_bin_cache) console.warn('[drive_module] office previews disabled: no LibreOffice on this box');
|
|
2657
|
+
return _soffice_bin_cache;
|
|
2658
|
+
};
|
|
2659
|
+
|
|
2660
|
+
const run_cmd = (bin, args, { timeout_ms = 120000, cwd } = {}) =>
|
|
2661
|
+
new Promise((resolve, reject) => {
|
|
2662
|
+
const child = child_process.execFile(bin, args, { timeout: timeout_ms, cwd, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
2663
|
+
if (err) return reject(new Error(`${path.basename(bin)} failed: ${err.message} ${String(stderr || '').slice(0, 300)}`));
|
|
2664
|
+
resolve({ stdout, stderr });
|
|
2665
|
+
});
|
|
2666
|
+
child.on('error', reject);
|
|
2667
|
+
});
|
|
2668
|
+
|
|
2669
|
+
// Convert one office file into { pdf, png }. Runs entirely in a private temp dir so
|
|
2670
|
+
// two conversions of the same filename cannot collide, and so a partial run leaves
|
|
2671
|
+
// nothing behind.
|
|
2672
|
+
const render_office_preview = async (buffer, originalname) => {
|
|
2673
|
+
const bin = soffice_bin();
|
|
2674
|
+
if (!bin) return null;
|
|
2675
|
+
|
|
2676
|
+
const work_dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'xuda_preview_'));
|
|
2677
|
+
const src_path = path.join(work_dir, `src${path.extname(originalname) || '.bin'}`);
|
|
2678
|
+
|
|
2679
|
+
try {
|
|
2680
|
+
await fs.promises.writeFile(src_path, buffer);
|
|
2681
|
+
|
|
2682
|
+
// -env:UserInstallation gives this run its own profile dir. Without it concurrent
|
|
2683
|
+
// conversions share ~/.config/libreoffice and the second one exits doing nothing.
|
|
2684
|
+
await run_cmd(bin, [
|
|
2685
|
+
'--headless',
|
|
2686
|
+
'--norestore',
|
|
2687
|
+
'--nolockcheck',
|
|
2688
|
+
`-env:UserInstallation=file://${path.join(work_dir, 'lo_profile')}`,
|
|
2689
|
+
'--convert-to',
|
|
2690
|
+
'pdf',
|
|
2691
|
+
'--outdir',
|
|
2692
|
+
work_dir,
|
|
2693
|
+
src_path,
|
|
2694
|
+
]);
|
|
2695
|
+
|
|
2696
|
+
const pdf_path = path.join(work_dir, 'src.pdf');
|
|
2697
|
+
if (!fs.existsSync(pdf_path)) throw new Error('conversion produced no pdf');
|
|
2698
|
+
const pdf = await fs.promises.readFile(pdf_path);
|
|
2699
|
+
|
|
2700
|
+
// Page one only, capped on width so a poster-sized slide does not become a
|
|
2701
|
+
// multi-megabyte thumbnail. -singlefile keeps the output name predictable.
|
|
2702
|
+
await run_cmd('/usr/bin/pdftoppm', ['-png', '-f', '1', '-l', '1', '-singlefile', '-scale-to-x', '960', '-scale-to-y', '-1', pdf_path, path.join(work_dir, 'page1')]);
|
|
2703
|
+
|
|
2704
|
+
const png_path = path.join(work_dir, 'page1.png');
|
|
2705
|
+
if (!fs.existsSync(png_path)) throw new Error('conversion produced no page image');
|
|
2706
|
+
const png = await fs.promises.readFile(png_path);
|
|
2707
|
+
|
|
2708
|
+
return { pdf, png };
|
|
2709
|
+
} finally {
|
|
2710
|
+
await rimraf(work_dir).catch(() => {});
|
|
2711
|
+
}
|
|
2712
|
+
};
|
|
2713
|
+
|
|
2714
|
+
// Store one derived asset next to the file it came from, in the same bucket and region.
|
|
2715
|
+
const put_preview_asset = async (region, base_key, suffix, body, content_type) => {
|
|
2716
|
+
const Bucket = _conf.storage_bucket?.[region]?.name;
|
|
2717
|
+
const s3 = _drive_s3_for(region);
|
|
2718
|
+
if (!s3 || !Bucket) throw new Error(`no storage bucket for region ${region}`);
|
|
2719
|
+
const Key = `${base_key}.preview.${suffix}`;
|
|
2720
|
+
await s3.send(new PutObjectCommand({ Bucket, Key, Body: body, ContentType: content_type }));
|
|
2721
|
+
return { Bucket, Key, key: Key };
|
|
2722
|
+
};
|
|
2723
|
+
|
|
2724
|
+
// Build (or rebuild) the preview assets for one drive doc and record them on it.
|
|
2725
|
+
// Shaped like ocr_drive_file: takes the doc, mutates preview state, saves it back.
|
|
2726
|
+
const preview_drive_file = async (app_id, doc) => {
|
|
2727
|
+
const { drive_type, uid, file_path } = doc;
|
|
2728
|
+
|
|
2729
|
+
try {
|
|
2730
|
+
if (!soffice_bin()) {
|
|
2731
|
+
doc.preview_stat = 0;
|
|
2732
|
+
doc.preview_error = 'no converter on this box';
|
|
2733
|
+
doc.preview_stat_ts = Date.now();
|
|
2734
|
+
return await save_drive_doc(drive_type, uid, app_id, doc, file_path);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
doc.preview_stat = 2;
|
|
2738
|
+
doc.preview_stat_ts = Date.now();
|
|
2739
|
+
await save_drive_doc(drive_type, uid, app_id, doc, file_path);
|
|
2740
|
+
|
|
2741
|
+
const region = _pick_file_region(doc);
|
|
2742
|
+
const { Bucket, Key } = _drive_bucket_for(doc, region);
|
|
2743
|
+
const s3 = region ? _drive_s3_for(region) : null;
|
|
2744
|
+
if (!s3 || !Bucket || !Key) throw new Error('file bytes unavailable');
|
|
2745
|
+
|
|
2746
|
+
const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
|
|
2747
|
+
const buffer = await _stream_to_buffer(obj.Body);
|
|
2748
|
+
|
|
2749
|
+
const rendered = await render_office_preview(buffer, doc.originalname || 'file');
|
|
2750
|
+
if (!rendered) throw new Error('no converter on this box');
|
|
2751
|
+
|
|
2752
|
+
const [pdf_ref, png_ref] = await Promise.all([
|
|
2753
|
+
put_preview_asset(region, Key, 'pdf', rendered.pdf, 'application/pdf'),
|
|
2754
|
+
put_preview_asset(region, Key, 'png', rendered.png, 'image/png'),
|
|
2755
|
+
]);
|
|
2756
|
+
|
|
2757
|
+
doc.preview = { ...(doc.preview || {}), [region]: { pdf: pdf_ref, png: png_ref } };
|
|
2758
|
+
doc.preview_stat = 3;
|
|
2759
|
+
doc.preview_error = '';
|
|
2760
|
+
} catch (err) {
|
|
2761
|
+
doc.preview_stat = 4;
|
|
2762
|
+
doc.preview_error = err.message;
|
|
2763
|
+
console.error('[drive_module] preview failed for', doc?._id, err.message);
|
|
2764
|
+
} finally {
|
|
2765
|
+
doc.preview_stat_ts = Date.now();
|
|
2766
|
+
await save_drive_doc(drive_type, uid, app_id, doc, file_path).catch(() => {});
|
|
2767
|
+
}
|
|
2768
|
+
};
|
|
2769
|
+
|
|
2770
|
+
// Stat 2 means "a box is working on this right now", and the work is in-process. So a
|
|
2771
|
+
// pm2 restart mid-run (a deploy, a crash, an OOM) leaves the doc claimed by a process that
|
|
2772
|
+
// no longer exists, and nothing ever looks at stat 2 again: it is a dead end, not a state.
|
|
2773
|
+
// Anything that has been "running" for longer than the slowest plausible job is therefore
|
|
2774
|
+
// finished-by-death, and goes back in the queue. STALE_MS is generous on purpose, a big
|
|
2775
|
+
// deck with images is a slow convert and reclaiming a LIVE job would run it twice.
|
|
2776
|
+
const STRANDED_STAT_MS = 15 * 60 * 1000;
|
|
2777
|
+
|
|
2778
|
+
const reclaim_stranded = async (app_id, docType, stat_field, ts_field) => {
|
|
2779
|
+
try {
|
|
2780
|
+
const ret = await db_module.find_app_couch_query(app_id, {
|
|
2781
|
+
selector: { docType, type: 'file', [stat_field]: 2 },
|
|
2782
|
+
limit: 50,
|
|
2783
|
+
});
|
|
2784
|
+
const stale = (ret?.docs || []).filter((d) => !d[ts_field] || Date.now() - d[ts_field] > STRANDED_STAT_MS);
|
|
2785
|
+
for (const doc of stale) {
|
|
2786
|
+
doc[stat_field] = 1;
|
|
2787
|
+
await save_drive_doc(doc.drive_type, doc.uid, docType === 'user_drive' ? null : app_id, doc, doc.file_path).catch(() => {});
|
|
2788
|
+
console.warn(`[drive_module] reclaimed stranded ${stat_field}=2 on ${doc._id}`);
|
|
2789
|
+
}
|
|
2790
|
+
} catch (err) {
|
|
2791
|
+
console.error('[drive_module] reclaim_stranded failed', docType, stat_field, err.message);
|
|
2792
|
+
}
|
|
2793
|
+
};
|
|
2794
|
+
|
|
2795
|
+
// Same sweep shape as run_drive_pending_ocr, and the same region-ownership rule:
|
|
2796
|
+
// only the box that owns the app converts its files, so an active/standby pair does
|
|
2797
|
+
// not run the same conversion twice and race the write.
|
|
2798
|
+
export const run_drive_pending_previews = async () => {
|
|
2799
|
+
try {
|
|
2800
|
+
if (!soffice_bin()) return { code: 1, data: 'no converter on this box' };
|
|
2801
|
+
|
|
2802
|
+
const app_res = await db_module.find_couch_query('xuda_master', {
|
|
2803
|
+
selector: {
|
|
2804
|
+
docType: 'app',
|
|
2805
|
+
app_status_code: { $eq: 3 },
|
|
2806
|
+
$or: [{ app_type: 'master' }, { app_type: 'instance' }, { app_type: 'datacenter' }],
|
|
2807
|
+
},
|
|
2808
|
+
limit: 99999,
|
|
2809
|
+
});
|
|
2810
|
+
|
|
2811
|
+
let done = 0;
|
|
2812
|
+
for (let app of app_res.docs) {
|
|
2813
|
+
if (!_common.is_app_owned_by_local_box(app)) continue;
|
|
2814
|
+
|
|
2815
|
+
for (const docType of ['user_drive', 'workspace_drive']) {
|
|
2816
|
+
const owner = docType === 'user_drive' ? null : app._id;
|
|
2817
|
+
|
|
2818
|
+
await reclaim_stranded(app._id, docType, 'preview_stat', 'preview_stat_ts');
|
|
2819
|
+
|
|
2820
|
+
const queued = await db_module.find_app_couch_query(app._id, {
|
|
2821
|
+
selector: { docType, preview_stat: 1, stat: 3 },
|
|
2822
|
+
limit: 5,
|
|
2823
|
+
});
|
|
2824
|
+
for await (let doc of queued.docs) {
|
|
2825
|
+
await preview_drive_file(owner, doc);
|
|
2826
|
+
done++;
|
|
2827
|
+
}
|
|
2828
|
+
if (queued.docs?.length) continue;
|
|
2829
|
+
|
|
2830
|
+
// Backfill: every file that predates this feature has no preview_stat at all, so
|
|
2831
|
+
// the queued pass above can never see it. Filtered in JS rather than with $in on
|
|
2832
|
+
// file_ext, which no index can serve.
|
|
2833
|
+
const legacy = await db_module.find_app_couch_query(app._id, {
|
|
2834
|
+
selector: { docType, stat: 3, type: 'file', preview_stat: { $exists: false } },
|
|
2835
|
+
limit: 60,
|
|
2836
|
+
});
|
|
2837
|
+
const legacy_docs = legacy.docs || [];
|
|
2838
|
+
|
|
2839
|
+
// Stamp the ones that will never have a preview, so the batch above returns
|
|
2840
|
+
// different docs next tick. Without this a drive whose first 60 files are all
|
|
2841
|
+
// images hands back the same 60 for ever and the backfill never advances.
|
|
2842
|
+
for await (let doc of legacy_docs.filter((d) => !is_office_preview_ext(d.file_ext))) {
|
|
2843
|
+
doc.preview_stat = 0;
|
|
2844
|
+
await save_drive_doc(doc.drive_type, doc.uid, owner, doc, doc.file_path).catch(() => {});
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
for await (let doc of legacy_docs.filter((d) => is_office_preview_ext(d.file_ext)).slice(0, 5)) {
|
|
2848
|
+
await preview_drive_file(owner, doc);
|
|
2849
|
+
done++;
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
return { code: 1, data: `previewed ${done}` };
|
|
2854
|
+
} catch (err) {
|
|
2855
|
+
return { code: -400, data: err.message };
|
|
2856
|
+
}
|
|
2857
|
+
};
|
|
2858
|
+
|
|
2859
|
+
// Called right after an upload so the preview is there in seconds rather than on the
|
|
2860
|
+
// next sweep. Deliberately tolerant: an unconvertible file must never fail its upload.
|
|
2861
|
+
export const generate_drive_file_preview = async (req) => {
|
|
2862
|
+
try {
|
|
2863
|
+
const { app_id, drive_type = 'user', uid, doc_id } = req || {};
|
|
2864
|
+
if (!doc_id) return { code: -1, data: 'doc_id required' };
|
|
2865
|
+
|
|
2866
|
+
const project_id = drive_type === 'user' ? await get_account_default_project_id(uid) : await _common.get_project_app_id(app_id);
|
|
2867
|
+
const ret = await db_module.get_app_couch_doc(project_id, doc_id);
|
|
2868
|
+
const doc = ret?.code >= 0 ? ret.data : null;
|
|
2869
|
+
if (!doc || doc.type !== 'file') return { code: -404, data: 'not_found' };
|
|
2870
|
+
if (!is_office_preview_ext(doc.file_ext)) return { code: 1, data: 'not previewable' };
|
|
2871
|
+
|
|
2872
|
+
await preview_drive_file(drive_type === 'user' ? null : app_id, doc);
|
|
2873
|
+
return { code: 1, data: { preview_stat: doc.preview_stat, preview_error: doc.preview_error || '' } };
|
|
2874
|
+
} catch (err) {
|
|
2875
|
+
return { code: -400, data: err.message };
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2878
|
+
|
|
2879
|
+
// The links the UI needs, or nothing at all when there is no preview yet. Returned on
|
|
2880
|
+
// every drive row so a thumbnail and a lightbox can decide without a second call.
|
|
2881
|
+
//
|
|
2882
|
+
// preview_pending is the third answer, and it matters most right after an AI builds a
|
|
2883
|
+
// deck: the file is saved and its card renders in the chat within a second, while the
|
|
2884
|
+
// conversion behind it takes several. Without this the card can only say "no preview" and
|
|
2885
|
+
// mean "not yet", so it stayed a grey tile until the page was reloaded. Telling the client
|
|
2886
|
+
// a preview is coming lets it look again, and only for files that will actually get one.
|
|
2887
|
+
const preview_links_for = (doc, base_link) => {
|
|
2888
|
+
const region = doc?.preview ? _pick_file_region({ bucket: doc.preview }) || Object.keys(doc.preview)[0] : null;
|
|
2889
|
+
if (!region || !doc.preview[region]?.png) {
|
|
2890
|
+
return [1, 2].includes(doc?.preview_stat) ? { preview_pending: true } : {};
|
|
2891
|
+
}
|
|
2892
|
+
const sep = base_link.includes('?') ? '&' : '?';
|
|
2893
|
+
return {
|
|
2894
|
+
preview_link: `${base_link}${sep}preview=png`,
|
|
2895
|
+
preview_doc_link: `${base_link}${sep}preview=pdf`,
|
|
2896
|
+
};
|
|
2897
|
+
};
|
|
2898
|
+
|
|
2463
2899
|
const validate_drive_type = (drive_type) => {
|
|
2464
2900
|
if (!['studio', 'workspace', 'user'].includes(drive_type)) {
|
|
2465
2901
|
throw new Error('error - drive_type values allowed: studio, workspace or users');
|
|
@@ -2500,6 +2936,11 @@ export const run_drive_pending_ocr = async () => {
|
|
|
2500
2936
|
// active/standby master pair double-run expensive OCR and race the writes.
|
|
2501
2937
|
if (!_common.is_app_owned_by_local_box(app)) continue;
|
|
2502
2938
|
|
|
2939
|
+
// Same dead-end as the preview sweep: ocr_stat 2 survives the restart that killed
|
|
2940
|
+
// the run holding it, and nothing ever selects a 2 again.
|
|
2941
|
+
await reclaim_stranded(app._id, 'user_drive', 'ocr_stat', 'ocr_stat_date');
|
|
2942
|
+
await reclaim_stranded(app._id, 'workspace_drive', 'ocr_stat', 'ocr_stat_date');
|
|
2943
|
+
|
|
2503
2944
|
let opt = {
|
|
2504
2945
|
selector: { docType: 'user_drive', ocr_stat: 1, stat: 3 },
|
|
2505
2946
|
limit: 1,
|
|
@@ -3185,6 +3626,64 @@ export const get_user_drive_file = async function (uid, filename) {
|
|
|
3185
3626
|
return drive_ret;
|
|
3186
3627
|
};
|
|
3187
3628
|
|
|
3629
|
+
// Look a user-drive file up by the id in its access link, for surfaces that hold a
|
|
3630
|
+
// link and nothing else (an AI chat reply that produced a file, a pasted link). The
|
|
3631
|
+
// listing methods all key off a folder path, which a link does not carry.
|
|
3632
|
+
//
|
|
3633
|
+
// Returns the SAME row shape a listing child has, so a caller can hand it straight to
|
|
3634
|
+
// the drive thumbnail and lightbox. file_path is the point of the call: it is what
|
|
3635
|
+
// "Show in drive" needs to reveal the file, and it cannot be derived from the link.
|
|
3636
|
+
//
|
|
3637
|
+
// The lookup runs in the CALLER's own project (get_user_drive_file resolves the project
|
|
3638
|
+
// from the authenticated uid, never from anything the client sends), so a link into
|
|
3639
|
+
// someone else's drive answers not_found here. That is the honest answer: the file may
|
|
3640
|
+
// be readable over its link, but it is not in this user's drive and Show in drive has
|
|
3641
|
+
// nothing to reveal.
|
|
3642
|
+
export const get_user_drive_file_by_id = async (req) => {
|
|
3643
|
+
try {
|
|
3644
|
+
const uid = req.uid || req.token_ret?.data?.uid;
|
|
3645
|
+
if (!uid) return { code: -1, data: 'uid_required' };
|
|
3646
|
+
|
|
3647
|
+
// Links may carry a cache-buster (?ts=) and are often pasted URL-encoded.
|
|
3648
|
+
const raw = String(req.file_id || '').trim();
|
|
3649
|
+
const file_id = decodeURIComponent(raw.split('?')[0].split('/').filter(Boolean).pop() || '');
|
|
3650
|
+
if (!file_id) return { code: -1, data: 'file_id_required' };
|
|
3651
|
+
|
|
3652
|
+
const ret = await get_user_drive_file(uid, file_id);
|
|
3653
|
+
const doc = ret?.code >= 0 ? ret.data : null;
|
|
3654
|
+
if (!doc || doc.type !== 'file' || doc.stat === 4) return { code: -404, data: 'not_found' };
|
|
3655
|
+
|
|
3656
|
+
const app_id = await get_account_default_project_id(uid);
|
|
3657
|
+
const fmt = (b = 0) =>
|
|
3658
|
+
b < 1024 ? b + ' Bytes' : b < 1048576 ? (b / 1024).toFixed(2) + ' KB' : b < 1073741824 ? (b / 1048576).toFixed(2) + ' MB' : (b / 1073741824).toFixed(2) + ' GB';
|
|
3659
|
+
|
|
3660
|
+
const access_link = `https://${process.env.XUDA_HOSTNAME}/user-drive/${app_id}/${doc._id}`;
|
|
3661
|
+
|
|
3662
|
+
return {
|
|
3663
|
+
code: 200,
|
|
3664
|
+
data: {
|
|
3665
|
+
name: doc.originalname,
|
|
3666
|
+
sizeInBytes: doc.size,
|
|
3667
|
+
size: fmt(doc.size),
|
|
3668
|
+
extension: doc?.file_ext?.substr(1),
|
|
3669
|
+
type: doc.type,
|
|
3670
|
+
access_link,
|
|
3671
|
+
...preview_links_for(doc, access_link),
|
|
3672
|
+
is_archive: doc.is_archive,
|
|
3673
|
+
public: doc.public,
|
|
3674
|
+
path: doc.file_path,
|
|
3675
|
+
date_created: doc.date_created,
|
|
3676
|
+
date_modified: doc.date_modified,
|
|
3677
|
+
file_path: path.join(doc.file_path, doc.originalname),
|
|
3678
|
+
tags: doc.tags || [],
|
|
3679
|
+
starred: !!doc.starred,
|
|
3680
|
+
},
|
|
3681
|
+
};
|
|
3682
|
+
} catch (err) {
|
|
3683
|
+
return { code: -404, data: 'not_found' };
|
|
3684
|
+
}
|
|
3685
|
+
};
|
|
3686
|
+
|
|
3188
3687
|
// ── Static-site vibe: see + edit the site's source folder directly through the
|
|
3189
3688
|
// cpi (Spaces + CouchDB) — no temp dir, no server disk growth. The "folder" is
|
|
3190
3689
|
// the app's static_website.source.folder_path; files live in Spaces, hierarchy
|
|
@@ -3284,9 +3783,7 @@ export const read_site_file = async (req) => {
|
|
|
3284
3783
|
const d = ret?.docs?.[0];
|
|
3285
3784
|
if (!d) return { code: -1, data: `file not found: ${rel}` };
|
|
3286
3785
|
const region = _pick_file_region(d);
|
|
3287
|
-
const
|
|
3288
|
-
const Bucket = b.Bucket || b.bucket;
|
|
3289
|
-
const Key = b.Key || b.key;
|
|
3786
|
+
const { Bucket, Key } = _drive_bucket_for(d, region);
|
|
3290
3787
|
const s3 = region ? _drive_s3_for(region) : null;
|
|
3291
3788
|
if (!s3 || !Bucket || !Key) return { code: -1, data: 'file bytes unavailable' };
|
|
3292
3789
|
const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
|
|
@@ -3695,6 +4192,23 @@ const _pick_file_region = (doc) => {
|
|
|
3695
4192
|
return null;
|
|
3696
4193
|
};
|
|
3697
4194
|
|
|
4195
|
+
// Where a drive doc's bytes actually live, for a given region.
|
|
4196
|
+
//
|
|
4197
|
+
// The CONFIG's bucket name wins over the one stored on the doc. Docs written before the
|
|
4198
|
+
// DigitalOcean to R2 migration still carry the old Spaces bucket in doc.bucket[region],
|
|
4199
|
+
// which no longer exists, so trusting the doc fails with "The specified bucket does not
|
|
4200
|
+
// exist" on files that serve perfectly well over https. The router has always resolved the
|
|
4201
|
+
// bucket from config alone (_presign_get), which is exactly why it can still read them;
|
|
4202
|
+
// this keeps every other reader consistent with the one that works. The doc's own name is
|
|
4203
|
+
// kept as a fallback for a region that config does not describe.
|
|
4204
|
+
const _drive_bucket_for = (doc, region) => {
|
|
4205
|
+
const entry = (region && doc?.bucket?.[region]) || {};
|
|
4206
|
+
return {
|
|
4207
|
+
Bucket: (region && _conf.storage_bucket?.[region]?.name) || entry.Bucket || entry.bucket || null,
|
|
4208
|
+
Key: entry.Key || entry.key || null,
|
|
4209
|
+
};
|
|
4210
|
+
};
|
|
4211
|
+
|
|
3698
4212
|
const _stream_to_buffer = async (stream) => {
|
|
3699
4213
|
if (!stream) return Buffer.alloc(0);
|
|
3700
4214
|
// AWS SDK v3 Body is a Node Readable in this runtime.
|
|
@@ -3734,9 +4248,7 @@ export const read_user_drive_folder = async (req) => {
|
|
|
3734
4248
|
for (const d of docs) {
|
|
3735
4249
|
// Resolve the file's own region, then read from THAT region's bucket.
|
|
3736
4250
|
const region = _pick_file_region(d);
|
|
3737
|
-
const
|
|
3738
|
-
const Bucket = b.Bucket || b.bucket || (region && _conf.storage_bucket?.[region]?.name);
|
|
3739
|
-
const Key = b.Key || b.key;
|
|
4251
|
+
const { Bucket, Key } = _drive_bucket_for(d, region);
|
|
3740
4252
|
const s3 = region ? _drive_s3_for(region) : null;
|
|
3741
4253
|
if (!s3 || !Bucket || !Key) {
|
|
3742
4254
|
// No reachable region (e.g. datacenter-scp file with no Spaces
|
|
@@ -3841,9 +4353,7 @@ export const fetch_drive_file_to_tmp = async (req) => {
|
|
|
3841
4353
|
if (!doc) return { code: -1, data: `file not found: ${file_ref}` };
|
|
3842
4354
|
|
|
3843
4355
|
const region = _pick_file_region(doc);
|
|
3844
|
-
const
|
|
3845
|
-
const Bucket = b.Bucket || b.bucket || (region && _conf.storage_bucket?.[region]?.name);
|
|
3846
|
-
const Key = b.Key || b.key;
|
|
4356
|
+
const { Bucket, Key } = _drive_bucket_for(doc, region);
|
|
3847
4357
|
const s3 = region ? _drive_s3_for(region) : null;
|
|
3848
4358
|
if (!s3 || !Bucket || !Key) return { code: -1, data: 'file bytes unavailable' };
|
|
3849
4359
|
|
|
@@ -4609,7 +5119,10 @@ export const drive_set_plan = async function (req) {
|
|
|
4609
5119
|
const account = await _drive_account_doc(uid);
|
|
4610
5120
|
if (!account._id) return { code: -404, data: 'account not found' };
|
|
4611
5121
|
const current = _drive_account_plan(account);
|
|
4612
|
-
|
|
5122
|
+
// UI-192: a switch-off asks for the FREE tier, so an account already sitting on free
|
|
5123
|
+
// matches here and would come back unchanged with the switch still on. Asking to
|
|
5124
|
+
// deactivate is never "no change": clearing the chosen stamp is the change.
|
|
5125
|
+
if (current === plan_id && req?.deactivate !== true) return await get_drive_plans({ uid });
|
|
4613
5126
|
|
|
4614
5127
|
// Downgrade guard. Taking space away from an account that is already using it
|
|
4615
5128
|
// would drop it straight into metered overage on files it cannot see a way to
|
|
@@ -4625,7 +5138,7 @@ export const drive_set_plan = async function (req) {
|
|
|
4625
5138
|
|
|
4626
5139
|
const paid = Number(plan.price) > 0;
|
|
4627
5140
|
if (paid) {
|
|
4628
|
-
const ret = await stripe_ms.add_subscription_item({ uid, plan_id });
|
|
5141
|
+
const ret = await stripe_ms.add_subscription_item({ uid, plan_id, deactivate: req?.deactivate === true });
|
|
4629
5142
|
if (!ret || ret.code < 0) return { code: ret?.code === -402 || ret?.code === -3 ? -402 : -1, data: ret?.data || 'could not change the plan' };
|
|
4630
5143
|
} else if (account.stripe_subscription_items?.[DRIVE_CATEGORY]) {
|
|
4631
5144
|
const ret = await stripe_ms.remove_subscription_item({ uid, category: DRIVE_CATEGORY });
|
|
@@ -4635,7 +5148,10 @@ export const drive_set_plan = async function (req) {
|
|
|
4635
5148
|
// Re-read: the stripe call just wrote stripe_subscription_items on this doc.
|
|
4636
5149
|
const fresh = await _drive_account_doc(uid);
|
|
4637
5150
|
fresh.drive_plan = plan_id;
|
|
4638
|
-
|
|
5151
|
+
// UI-192: see the note in shipping_set_plan. A chosen free tier now reads as switched
|
|
5152
|
+
// on, so switching off has to clear the stamp that recorded the choice.
|
|
5153
|
+
if (req?.deactivate === true) delete fresh.drive_plan_changed;
|
|
5154
|
+
else fresh.drive_plan_changed = Date.now();
|
|
4639
5155
|
await db_module.save_couch_doc('xuda_accounts', fresh);
|
|
4640
5156
|
|
|
4641
5157
|
return await get_drive_plans({ uid });
|
package/index_ms.mjs
CHANGED
|
@@ -121,6 +121,14 @@ export const check_drive_file = async function (...args) {
|
|
|
121
121
|
return await broker.send_to_queue("check_drive_file", ...args);
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
+
export const run_drive_pending_previews = async function (...args) {
|
|
125
|
+
return await broker.send_to_queue("run_drive_pending_previews", ...args);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const generate_drive_file_preview = async function (...args) {
|
|
129
|
+
return await broker.send_to_queue("generate_drive_file_preview", ...args);
|
|
130
|
+
};
|
|
131
|
+
|
|
124
132
|
export const run_drive_pending_ocr = async function (...args) {
|
|
125
133
|
return await broker.send_to_queue("run_drive_pending_ocr", ...args);
|
|
126
134
|
};
|
|
@@ -153,6 +161,10 @@ export const update_drive_addons = async function (...args) {
|
|
|
153
161
|
return await broker.send_to_queue("update_drive_addons", ...args);
|
|
154
162
|
};
|
|
155
163
|
|
|
164
|
+
export const get_user_drive_file_by_id = async function (...args) {
|
|
165
|
+
return await broker.send_to_queue("get_user_drive_file_by_id", ...args);
|
|
166
|
+
};
|
|
167
|
+
|
|
156
168
|
export const list_site_files = async function (...args) {
|
|
157
169
|
return await broker.send_to_queue("list_site_files", ...args);
|
|
158
170
|
};
|
package/index_msa.mjs
CHANGED
|
@@ -121,6 +121,14 @@ export const check_drive_file = function (...args) {
|
|
|
121
121
|
broker.send_to_queue_async("check_drive_file", ...args);
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
+
export const run_drive_pending_previews = function (...args) {
|
|
125
|
+
broker.send_to_queue_async("run_drive_pending_previews", ...args);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const generate_drive_file_preview = function (...args) {
|
|
129
|
+
broker.send_to_queue_async("generate_drive_file_preview", ...args);
|
|
130
|
+
};
|
|
131
|
+
|
|
124
132
|
export const run_drive_pending_ocr = function (...args) {
|
|
125
133
|
broker.send_to_queue_async("run_drive_pending_ocr", ...args);
|
|
126
134
|
};
|
|
@@ -153,6 +161,10 @@ export const update_drive_addons = function (...args) {
|
|
|
153
161
|
broker.send_to_queue_async("update_drive_addons", ...args);
|
|
154
162
|
};
|
|
155
163
|
|
|
164
|
+
export const get_user_drive_file_by_id = function (...args) {
|
|
165
|
+
broker.send_to_queue_async("get_user_drive_file_by_id", ...args);
|
|
166
|
+
};
|
|
167
|
+
|
|
156
168
|
export const list_site_files = function (...args) {
|
|
157
169
|
broker.send_to_queue_async("list_site_files", ...args);
|
|
158
170
|
};
|