@xuda.io/drive_module 1.1.1475 → 1.1.1477

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 CHANGED
@@ -14,7 +14,7 @@ import { imageSize } from 'image-size';
14
14
  import { getPackageManifest } from 'query-registry';
15
15
 
16
16
  // AWS SDK v3 Imports
17
- import { S3Client, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
17
+ import { S3Client, GetObjectCommand, DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
18
18
  import { Upload } from '@aws-sdk/lib-storage';
19
19
 
20
20
  console.log('Drive Module loaded...');
@@ -482,6 +482,25 @@ const get_folder_files = async function (uid, app_id, drive_type, path) {
482
482
  return [...(files_ret.data.children || []), ...(directory_ret.data.children || [])];
483
483
  };
484
484
 
485
+ // Recursively flatten an entire folder subtree into a list of
486
+ // { type, file_path } entries (every file + subfolder at any depth).
487
+ // Each child returned by get_folder_files already carries its FULL
488
+ // file_path (path.join(parent, name)), so we recurse on that directly.
489
+ // Used by delete_drive_files to remove a non-empty folder and all of
490
+ // its contents instead of refusing with "Directory is not empty".
491
+ const collect_folder_descendants = async function (uid, app_id, drive_type, folder_full_path) {
492
+ const out = [];
493
+ const children = await get_folder_files(uid, app_id, drive_type, folder_full_path);
494
+ for (const child of children) {
495
+ out.push({ type: child.type, file_path: child.file_path });
496
+ if (child.type === 'directory') {
497
+ const sub = await collect_folder_descendants(uid, app_id, drive_type, child.file_path);
498
+ out.push(...sub);
499
+ }
500
+ }
501
+ return out;
502
+ };
503
+
485
504
  export const delete_drive_files = async (req, job_id, headers) => {
486
505
  let ret = [];
487
506
  try {
@@ -507,9 +526,21 @@ export const delete_drive_files = async (req, job_id, headers) => {
507
526
  } catch (error) {}
508
527
 
509
528
  if (doc?.type === 'directory') {
510
- const files_in_directory = await get_folder_files(uid, app_id, drive_type, path.join(doc.file_path, doc.folder_name));
511
- if (files_in_directory?.length) {
512
- throw new Error(`Directory is not empty`);
529
+ // Recursively delete the folder's entire subtree. Previously this
530
+ // refused with "Directory is not empty" — folders could never be
531
+ // deleted once they had any content. Now we gather every
532
+ // descendant (files + nested folders), fetch each real doc, mark
533
+ // it deleted (stat=4), and let the same bulk-save + cleanup below
534
+ // handle docs + filesystem/bucket removal for the whole tree.
535
+ const folder_full_path = path.join(doc.file_path, doc.folder_name);
536
+ const descendants = await collect_folder_descendants(uid, app_id, drive_type, folder_full_path);
537
+ for (const entry of descendants) {
538
+ try {
539
+ const child_doc = await get_drive_doc(entry.type, drive_type, app_id, uid, entry.file_path, headers);
540
+ child_doc.ts = Date.now();
541
+ child_doc.stat = 4;
542
+ docs.push(child_doc);
543
+ } catch (error) {}
513
544
  }
514
545
  }
515
546
  }
@@ -538,9 +569,15 @@ export const delete_drive_files = async (req, job_id, headers) => {
538
569
  switch (drive_type) {
539
570
  case 'studio': {
540
571
  const app_id_reference = await _common.get_project_app_id(app_id);
541
- const target_dir = path.join(_conf[`studio_drive_path`], app_id_reference, doc.originalname);
572
+ // Resolve the on-disk path INCLUDING the doc's parent
573
+ // file_path (the old code dropped it, so nested files were
574
+ // never removed). For directory docs we rimraf the folder
575
+ // itself — recursively clearing the whole subtree on disk in
576
+ // one shot (the per-descendant-file rimrafs then no-op).
577
+ const rel = doc.type === 'directory' ? path.join(doc.file_path || '/', doc.folder_name) : path.join(doc.file_path || '/', doc.originalname);
578
+ const target_path = path.join(_conf[`studio_drive_path`], app_id_reference, rel);
542
579
 
543
- rimraf.sync(target_dir);
580
+ rimraf.sync(target_path);
544
581
 
545
582
  misc_msa.rsync_drive_resources_to_remote_servers('studio', app_id_reference);
546
583
  break;
@@ -1094,7 +1131,13 @@ export const delete_file_bulk = async (req) => {
1094
1131
 
1095
1132
  export const upload_drive_file = async (req, job_id, headers = {}, file_obj) => {
1096
1133
  try {
1097
- const { drive_type, app_id, uid, make_public, tags = '', is_system, path: file_path } = req;
1134
+ const { drive_type, app_id, make_public, tags = '', is_system, path: file_path } = req;
1135
+ // uid comes from the authenticated token (token_ret), not the client body —
1136
+ // multipart uploads (raw fetch FormData) can't rely on axios_ajax injecting
1137
+ // it, and trusting a client-sent uid would be spoofable. Fall back to an
1138
+ // explicit req.uid for internal callers (e.g. profile-image upload).
1139
+ const uid = req.uid || req.token_ret?.data?.uid;
1140
+ if (!uid) throw new Error('not authorized (no uid)');
1098
1141
 
1099
1142
  /// validations
1100
1143
  validate_drive_type(drive_type);
@@ -1965,6 +2008,16 @@ const save_drive_doc = async (drive_type, uid, app_id, doc) => {
1965
2008
  return save_ret;
1966
2009
  };
1967
2010
 
2011
+ // Stable hash of a script's source. Lets each server detect when its locally-built
2012
+ // dist is stale vs the current scriptData.value, so a changed script recompiles (and
2013
+ // re-rsyncs to datacenters) instead of being skipped just because vite_build_res exists.
2014
+ export const js_src_hash = (s) => {
2015
+ s = String(s || '');
2016
+ let h = 5381;
2017
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
2018
+ return h.toString(36) + '.' + s.length;
2019
+ };
2020
+
1968
2021
  export const compile_javascript_program_in_studio_drive = async (req, job_id) => {
1969
2022
  const { app_id, doc } = req;
1970
2023
 
@@ -2158,6 +2211,7 @@ export default {
2158
2211
  misc_msa.rsync_drive_resources_to_remote_servers('studio', app_id);
2159
2212
  const vite_build_res = {
2160
2213
  code: 1,
2214
+ src_hash: js_src_hash(inputCode),
2161
2215
  data: {
2162
2216
  build_ret,
2163
2217
  dependencies: devDependencies,
@@ -2255,6 +2309,415 @@ export const get_user_drive_file = async function (uid, filename) {
2255
2309
  return drive_ret;
2256
2310
  };
2257
2311
 
2312
+ // ── Static-site vibe: see + edit the site's source folder directly through the
2313
+ // cpi (Spaces + CouchDB) — no temp dir, no server disk growth. The "folder" is
2314
+ // the app's static_website.source.folder_path; files live in Spaces, hierarchy
2315
+ // in CouchDB. These back the static-site assistant's tools.
2316
+
2317
+ const _site_source_folder = async (app_id) => {
2318
+ const ret = await db_module.get_couch_doc('xuda_master', app_id);
2319
+ const a = ret?.data;
2320
+ if (!a || a.app_type !== 'static_website') return null;
2321
+ return a.static_website?.source?.folder_path || null;
2322
+ };
2323
+
2324
+ // Put bytes to the region bucket + upsert the matching user_drive doc. Reused by
2325
+ // write_site_file (assistant edits) and by version restore (override dev folder).
2326
+ const _write_user_drive_file = async (uid, project_id, dir, name, buffer, mime) => {
2327
+ const region = process.env.XUDA_HOSTNAME;
2328
+ const Bucket = _conf.storage_bucket?.[region]?.name;
2329
+ const s3 = _drive_s3_for(region);
2330
+ if (!s3 || !Bucket) return { code: -1, data: `no storage bucket for region ${region}` };
2331
+ const existing = await db_module.find_app_couch_query(project_id, {
2332
+ selector: { docType: 'user_drive', type: 'file', stat: 3, file_path: dir, originalname: name },
2333
+ limit: 1,
2334
+ });
2335
+ let doc = existing?.docs?.[0] || null;
2336
+ const ext = path.extname(name).toLowerCase();
2337
+ const Key = doc?.bucket?.[region]?.Key || doc?.bucket?.[region]?.key || `drv_${project_id}_${await _common.xuda_get_uuid('user')}${ext}`;
2338
+ await s3.send(new PutObjectCommand({ Bucket, Key, Body: buffer, ContentType: mime || _drive_content_type_for(name) }));
2339
+ if (doc) {
2340
+ doc.size = buffer.length;
2341
+ doc.date_modified = Date.now();
2342
+ doc.mime = mime || doc.mime || _drive_content_type_for(name);
2343
+ doc.bucket = { ...(doc.bucket || {}), [region]: { Bucket, Key } };
2344
+ } else {
2345
+ doc = {
2346
+ _id: Key,
2347
+ docType: 'user_drive',
2348
+ type: 'file',
2349
+ stat: 3,
2350
+ uid,
2351
+ originalname: name,
2352
+ file_path: dir,
2353
+ file_ext: ext,
2354
+ mime: mime || _drive_content_type_for(name),
2355
+ size: buffer.length,
2356
+ date_created: Date.now(),
2357
+ date_modified: Date.now(),
2358
+ server_file_name: Key,
2359
+ bucket: { [region]: { Bucket, Key } },
2360
+ };
2361
+ }
2362
+ await save_user_drive_file(uid, doc);
2363
+ return { code: 1, data: { Key, size: buffer.length } };
2364
+ };
2365
+
2366
+ // List the site's source files (paths only — lightweight, no bytes).
2367
+ export const list_site_files = async (req) => {
2368
+ try {
2369
+ const { uid } = req || {};
2370
+ let folder = req?.site_folder;
2371
+ if (!folder && req?.app_id) folder = await _site_source_folder(req.app_id);
2372
+ if (!uid || !folder) return { code: -1, data: 'uid + (site_folder or app_id) required' };
2373
+ // Plan A: the source lives on the studio fs — list the real files on disk.
2374
+ const { dir } = await _static_site_fs_dir(uid, folder);
2375
+ if (!fs.existsSync(dir)) return { code: 1, data: { folder, files: [] } };
2376
+ const scan = await fs_module.dree_scan(dir, { stat: true, size: true, depth: 100, symbolicLinks: false, hash: false });
2377
+ const files = [];
2378
+ const walk = (node) => {
2379
+ if (!node) return;
2380
+ if (node.type === 'file') {
2381
+ const rel = node.path.replace(dir, '').replace(/^\/+/, '');
2382
+ if (rel) files.push({ path: rel, size: node.size || 0 });
2383
+ }
2384
+ (node.children || []).forEach(walk);
2385
+ };
2386
+ if (scan.code === 1) walk(scan.data);
2387
+ files.sort((a, b) => a.path.localeCompare(b.path));
2388
+ return { code: 1, data: { folder, files } };
2389
+ } catch (err) {
2390
+ return { code: -400, data: err.message };
2391
+ }
2392
+ };
2393
+
2394
+ // Read one source file's text content.
2395
+ export const read_site_file = async (req) => {
2396
+ try {
2397
+ const { uid, app_id } = req;
2398
+ const rel = String(req.path || '').replace(/^\/+/, '');
2399
+ if (!uid || !app_id || !rel) return { code: -1, data: 'uid + app_id + path required' };
2400
+ const folder = await _site_source_folder(app_id);
2401
+ if (!folder) return { code: -1, data: 'no source folder for this site' };
2402
+ const full = path.posix.join(folder, rel);
2403
+ const project_id = await get_account_default_project_id(uid);
2404
+ const ret = await db_module.find_app_couch_query(project_id, {
2405
+ selector: { docType: 'user_drive', type: 'file', stat: 3, file_path: path.posix.dirname(full), originalname: path.posix.basename(full) },
2406
+ limit: 1,
2407
+ });
2408
+ const d = ret?.docs?.[0];
2409
+ if (!d) return { code: -1, data: `file not found: ${rel}` };
2410
+ const region = _pick_file_region(d);
2411
+ const b = (region && d.bucket?.[region]) || {};
2412
+ const Bucket = b.Bucket || b.bucket;
2413
+ const Key = b.Key || b.key;
2414
+ const s3 = region ? _drive_s3_for(region) : null;
2415
+ if (!s3 || !Bucket || !Key) return { code: -1, data: 'file bytes unavailable' };
2416
+ const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
2417
+ const buf = await _stream_to_buffer(obj.Body);
2418
+ return { code: 1, data: { path: rel, content: buf.toString('utf8'), size: buf.length } };
2419
+ } catch (err) {
2420
+ return { code: -400, data: err.message };
2421
+ }
2422
+ };
2423
+
2424
+ // Write/overwrite one source file. Does NOT publish — the user deploys via the
2425
+ // Build modal, so changes are always accepted explicitly.
2426
+ export const write_site_file = async (req) => {
2427
+ try {
2428
+ const { uid, app_id } = req;
2429
+ const rel = String(req.path || '').replace(/^\/+/, '');
2430
+ const content = req.content;
2431
+ if (!uid || !app_id || !rel || typeof content !== 'string') return { code: -1, data: 'uid + app_id + path + content (string) required' };
2432
+ const folder = await _site_source_folder(app_id);
2433
+ if (!folder) return { code: -1, data: 'no source folder for this site' };
2434
+ const full = path.posix.join(folder, rel);
2435
+ const w = await _write_user_drive_file(uid, await get_account_default_project_id(uid), path.posix.dirname(full), path.posix.basename(full), Buffer.from(content, 'utf8'), _drive_content_type_for(path.posix.basename(full)));
2436
+ if (w.code < 0) return w;
2437
+ return { code: 1, data: { path: rel, size: w.data.size } };
2438
+ } catch (err) {
2439
+ return { code: -400, data: err.message };
2440
+ }
2441
+ };
2442
+
2443
+ // ── Plan A: static-site SOURCE on the STUDIO drive (real filesystem, pinned to
2444
+ // the home region, never replicated) so codex can edit it in place — no temp
2445
+ // dir, no Spaces round-trip for editing. Keyed by the account project id (same
2446
+ // key the user drive used), under /static-sites/<site>:
2447
+ // <studio_drive_path>/<account_project_id>/<site_folder>/...
2448
+ // Serving still snapshots to Spaces at deploy time; this is only the editable source.
2449
+ const _static_site_fs_dir = async (uid, site_folder) => {
2450
+ const ref = await get_account_default_project_id(uid);
2451
+ const folder = String(site_folder || '').replace(/^\/+|\/+$/g, '');
2452
+ // Containment: the resolved dir must stay under this account's own root, so a
2453
+ // crafted site_folder/draft_folder ('../../other-account', '../../../etc') can't
2454
+ // escape the tenant or reach the host filesystem.
2455
+ const base = path.join(_conf.studio_drive_path, ref);
2456
+ const dir = path.resolve(base, folder);
2457
+ if (dir !== base && !dir.startsWith(base + path.sep)) throw new Error('invalid site_folder path');
2458
+ return { ref, dir };
2459
+ };
2460
+
2461
+ // Bulk-write source files to the fs (create + upload). Each file is
2462
+ // { path, content (utf8) | content_b64 (binary) }.
2463
+ export const ingest_static_site_source = async (req) => {
2464
+ try {
2465
+ const { uid, site_folder, files } = req || {};
2466
+ if (!uid || !site_folder || !Array.isArray(files)) return { code: -1, data: 'uid + site_folder + files[] required' };
2467
+ const { dir } = await _static_site_fs_dir(uid, site_folder);
2468
+ fs.mkdirSync(dir, { recursive: true });
2469
+ let count = 0;
2470
+ for (const f of files) {
2471
+ const rel = String(f.path || '').replace(/^\/+/, '');
2472
+ if (!rel) continue;
2473
+ const full = path.resolve(dir, rel);
2474
+ if (full !== dir && !full.startsWith(dir + path.sep)) continue; // reject path traversal in per-file paths
2475
+ const parent = path.dirname(full);
2476
+ fs.mkdirSync(parent, { recursive: true });
2477
+ const buf = f.content_b64 != null ? Buffer.from(f.content_b64, 'base64') : Buffer.from(f.content ?? '', 'utf8');
2478
+ const w = await fs_module.save_file(full, buf);
2479
+ if (w.code < 0) return { code: -1, data: `write failed for ${rel}: ${w.data}` };
2480
+ count++;
2481
+ }
2482
+ return { code: 1, data: { dir, file_count: count } };
2483
+ } catch (err) {
2484
+ return { code: -400, data: err.message };
2485
+ }
2486
+ };
2487
+
2488
+ // Read every file under the site's source dir (deploy → snapshot to Spaces).
2489
+ // Returns [{ path, content_b64, content_type }].
2490
+ export const read_static_site_source = async (req) => {
2491
+ try {
2492
+ const { uid, site_folder } = req || {};
2493
+ if (!uid || !site_folder) return { code: -1, data: 'uid + site_folder required' };
2494
+ const { dir } = await _static_site_fs_dir(uid, site_folder);
2495
+ if (!(await file_exists(dir))) return { code: -1, data: `source dir not found: ${dir}` };
2496
+ const scan = await fs_module.dree_scan(dir, { stat: true, size: true, depth: 100, symbolicLinks: false, hash: false });
2497
+ if (scan.code < 0) return { code: -1, data: scan.data };
2498
+ const files = [];
2499
+ const walk = async (node) => {
2500
+ if (!node) return;
2501
+ if (node.type === 'file') {
2502
+ const rd = await fs_module.read_file(node.path, { encoding: null, flag: 'r' });
2503
+ if (rd.code === 1) {
2504
+ const buf = Buffer.isBuffer(rd.data) ? rd.data : Buffer.from(rd.data);
2505
+ const rel = node.path.replace(dir, '').replace(/^\/+/, '');
2506
+ files.push({ path: rel, content_b64: buf.toString('base64'), content_type: _drive_content_type_for(node.name || rel) });
2507
+ }
2508
+ }
2509
+ for (const c of node.children || []) await walk(c);
2510
+ };
2511
+ await walk(scan.data);
2512
+ return { code: 1, data: { dir, file_count: files.length, files } };
2513
+ } catch (err) {
2514
+ return { code: -400, data: err.message };
2515
+ }
2516
+ };
2517
+
2518
+ // Single source of truth for a static site's source dir on the studio fs, so
2519
+ // the vibe flow (codex cwd), deploy (read), and restore all resolve the SAME
2520
+ // path. ai_module calls this so it never has to recompute the account ref.
2521
+ export const get_static_site_dir = async (req) => {
2522
+ try {
2523
+ const { uid, site_folder } = req || {};
2524
+ if (!uid || !site_folder) return { code: -1, data: 'uid + site_folder required' };
2525
+ const { ref, dir } = await _static_site_fs_dir(uid, site_folder);
2526
+ return { code: 1, data: { ref, dir } };
2527
+ } catch (err) {
2528
+ return { code: -400, data: err.message };
2529
+ }
2530
+ };
2531
+
2532
+ // List the account's static-site source folders on the studio fs. The create
2533
+ // wizard's "choose an existing folder" picker uses this — a user's sites/uploads
2534
+ // live here (studio fs), NOT on the Spaces user drive (which is why the old
2535
+ // get_drive_files_user picker showed nothing). Account-scoped (no app yet).
2536
+ export const list_static_site_folders = async (req) => {
2537
+ try {
2538
+ const uid = req?.uid || req?.token_ret?.data?.uid;
2539
+ if (!uid) return { code: -1, data: 'not authorized' };
2540
+ const ref = await get_account_default_project_id(uid);
2541
+ const base = path.join(_conf.studio_drive_path, ref, 'static-sites');
2542
+ if (!fs.existsSync(base)) return { code: 1, data: { folders: [] } };
2543
+ const folders = [];
2544
+ for (const e of fs.readdirSync(base, { withFileTypes: true })) {
2545
+ if (!e.isDirectory()) continue;
2546
+ const dir = path.join(base, e.name);
2547
+ let bytes = 0;
2548
+ let file_count = 0;
2549
+ let has_index = false;
2550
+ let mtime = 0;
2551
+ let birthtime = 0;
2552
+ try {
2553
+ const walk = (d, rel) => {
2554
+ for (const f of fs.readdirSync(d, { withFileTypes: true })) {
2555
+ const r = rel ? rel + '/' + f.name : f.name;
2556
+ const p = path.join(d, f.name);
2557
+ if (f.isDirectory()) walk(p, r);
2558
+ else {
2559
+ file_count++;
2560
+ try {
2561
+ bytes += fs.statSync(p).size;
2562
+ } catch (err) {}
2563
+ if (/^index\.html?$/i.test(r)) has_index = true;
2564
+ }
2565
+ }
2566
+ };
2567
+ walk(dir, '');
2568
+ const st = fs.statSync(dir);
2569
+ mtime = st.mtimeMs;
2570
+ // Creation time (ext4 stores crtime as birthtime); fall back where the
2571
+ // filesystem doesn't report it so the field is always populated.
2572
+ birthtime = st.birthtimeMs || st.ctimeMs || st.mtimeMs;
2573
+ } catch (err) {}
2574
+ folders.push({ folder_path: `/static-sites/${e.name}`, file_path: '/static-sites', folder_name: e.name, file_count, bytes, has_index, mtime, birthtime });
2575
+ }
2576
+ folders.sort((a, b) => b.mtime - a.mtime);
2577
+ return { code: 1, data: { folders } };
2578
+ } catch (err) {
2579
+ return { code: -400, data: err.message };
2580
+ }
2581
+ };
2582
+
2583
+ // Reap abandoned static-site draft folders: any /static-sites/* dir not adopted
2584
+ // by a live static_website app AND older than max_age_hours. Fail-safe — if the
2585
+ // app enumeration fails we abort and delete NOTHING (never risk a live source).
2586
+ // dry_run (default true) reports without deleting. Called by the daily cron.
2587
+ export const cleanup_static_site_drafts = async (req) => {
2588
+ try {
2589
+ const dry_run = !(req && req.dry_run === false); // default true; pass dry_run:false to actually delete
2590
+ const max_age_hours = (req && req.max_age_hours) || 24;
2591
+ const max_age_ms = max_age_hours * 3600000;
2592
+
2593
+ // 1) Referenced set from LIVE apps. Any failure -> abort, delete nothing.
2594
+ let apps;
2595
+ try {
2596
+ const r = await db_module.find_couch_query('xuda_master', { selector: { app_type: 'static_website' }, limit: 100000 });
2597
+ apps = (r && r.docs) || [];
2598
+ } catch (e) {
2599
+ return { code: -1, data: 'aborted: could not enumerate apps (' + e.message + ')' };
2600
+ }
2601
+ if (!Array.isArray(apps)) return { code: -1, data: 'aborted: app enumeration returned no array' };
2602
+ const referenced = new Set();
2603
+ for (const a of apps) {
2604
+ const fp = a && a.static_website && a.static_website.source && a.static_website.source.folder_path;
2605
+ if (typeof fp === 'string' && fp.indexOf('/static-sites/') === 0)
2606
+ referenced.add(
2607
+ fp
2608
+ .replace(/^\/+|\/+$/g, '')
2609
+ .split('/')
2610
+ .pop(),
2611
+ );
2612
+ }
2613
+
2614
+ // 2) Walk the studio fs, classify each /static-sites/* folder. Match by name
2615
+ // (random-suffixed, globally unique in practice) — a false match only keeps
2616
+ // an orphan, it never deletes a live source.
2617
+ const now = Date.now();
2618
+ const base_root = _conf.studio_drive_path;
2619
+ const report = { scanned: 0, kept_referenced: 0, kept_recent: 0, deleted: [], errors: [], dry_run, max_age_hours, referenced_count: referenced.size };
2620
+ let refs = [];
2621
+ try {
2622
+ refs = fs.readdirSync(base_root);
2623
+ } catch (e) {
2624
+ return { code: -1, data: 'aborted: cannot read studio_drive_path (' + e.message + ')' };
2625
+ }
2626
+ for (const ref of refs) {
2627
+ const ss = path.join(base_root, ref, 'static-sites');
2628
+ let names = [];
2629
+ try {
2630
+ if (!fs.existsSync(ss)) continue;
2631
+ names = fs.readdirSync(ss);
2632
+ } catch (e) {
2633
+ continue;
2634
+ }
2635
+ for (const name of names) {
2636
+ const dir = path.join(ss, name);
2637
+ let st;
2638
+ try {
2639
+ st = fs.statSync(dir);
2640
+ if (!st.isDirectory()) continue;
2641
+ } catch (e) {
2642
+ continue;
2643
+ }
2644
+ report.scanned++;
2645
+ if (referenced.has(name)) {
2646
+ report.kept_referenced++;
2647
+ continue;
2648
+ }
2649
+ const age_ms = now - st.mtimeMs;
2650
+ if (age_ms <= max_age_ms) {
2651
+ report.kept_recent++;
2652
+ continue;
2653
+ }
2654
+ const entry = { ref: ref.slice(0, 16), name, age_h: +(age_ms / 3600000).toFixed(1) };
2655
+ if (dry_run) {
2656
+ report.deleted.push({ ...entry, would_delete: true });
2657
+ } else {
2658
+ try {
2659
+ fs.rmSync(dir, { recursive: true, force: true });
2660
+ report.deleted.push(entry);
2661
+ } catch (e) {
2662
+ report.errors.push({ name, error: e.message });
2663
+ }
2664
+ }
2665
+ }
2666
+ }
2667
+ return { code: 1, data: report };
2668
+ } catch (err) {
2669
+ return { code: -400, data: err.message };
2670
+ }
2671
+ };
2672
+
2673
+ // Live DEV preview URL — serves the editable fs source (the uncommitted edits)
2674
+ // over the existing /studio-drive/ route (cross-region proxied), so the vibe
2675
+ // panel shows the dev files, not the deployed version. Relative assets resolve
2676
+ // at the sub-path; cache-bust with a ts query so the iframe always reloads fresh.
2677
+ export const get_static_site_preview_url = async (req) => {
2678
+ try {
2679
+ const { uid, site_folder } = req || {};
2680
+ if (!uid || !site_folder) return { code: -1, data: 'uid + site_folder required' };
2681
+ const { ref } = await _static_site_fs_dir(uid, site_folder);
2682
+ const folder = String(site_folder).replace(/^\/+|\/+$/g, '');
2683
+ return { code: 1, data: { url: `https://${process.env.XUDA_HOSTNAME}/studio-drive/${ref}/${folder}/index.html` } };
2684
+ } catch (err) {
2685
+ return { code: -400, data: err.message };
2686
+ }
2687
+ };
2688
+
2689
+ // Restore: overwrite the editable source on the studio fs with a deployed
2690
+ // version's snapshot (read from Spaces via its manifest). True replace — the dev
2691
+ // folder becomes exactly that version, so the next vibe/build starts clean.
2692
+ export const restore_site_fs_from_manifest = async (req) => {
2693
+ try {
2694
+ const { uid, site_folder, manifest } = req || {};
2695
+ if (!uid || !site_folder || !manifest || typeof manifest !== 'object') return { code: -1, data: 'uid + site_folder + manifest required' };
2696
+ const { dir } = await _static_site_fs_dir(uid, site_folder);
2697
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
2698
+ fs.mkdirSync(dir, { recursive: true });
2699
+ let count = 0;
2700
+ for (const [p, entry] of Object.entries(manifest)) {
2701
+ const rel = String(p || '').replace(/^\/+/, '');
2702
+ if (!rel || !entry) continue;
2703
+ const region = entry.region;
2704
+ const Bucket = entry.bucket || entry.Bucket;
2705
+ const Key = entry.key || entry.Key;
2706
+ const s3 = region ? _drive_s3_for(region) : null;
2707
+ if (!s3 || !Bucket || !Key) continue;
2708
+ const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
2709
+ const buf = await _stream_to_buffer(obj.Body);
2710
+ const full = path.join(dir, rel);
2711
+ fs.mkdirSync(path.dirname(full), { recursive: true });
2712
+ fs.writeFileSync(full, buf);
2713
+ count++;
2714
+ }
2715
+ return { code: 1, data: { dir, file_count: count } };
2716
+ } catch (err) {
2717
+ return { code: -400, data: err.message };
2718
+ }
2719
+ };
2720
+
2258
2721
  export const save_user_drive_file = async function (uid, drive_doc) {
2259
2722
  const app_id = await get_account_default_project_id(uid);
2260
2723
  const save_ret = await db_module.save_app_couch_doc(app_id, drive_doc);
@@ -2273,6 +2736,262 @@ function getExtensionFromString(str) {
2273
2736
  return match ? match[1].toLowerCase() : '';
2274
2737
  }
2275
2738
 
2739
+ // ============================================================================
2740
+ // Static-website source read (user drive -> deploy-ready blobs)
2741
+ // ============================================================================
2742
+ //
2743
+ // read_user_drive_folder is the bridge between a user's drive and the
2744
+ // static-site hosting layer (domains_module.deploy_site). Given a folder
2745
+ // reference in the user's drive, it walks every file at or below that
2746
+ // folder, pulls the bytes from Spaces/R2, and returns them shaped exactly
2747
+ // the way deploy_site wants: [{ path, content_b64, content_type }] where
2748
+ // `path` is RELATIVE to the folder reference (leading slash).
2749
+ // reference "/my-site", file at file_path "/my-site/css" name "app.css"
2750
+ // -> { path: "/css/app.css", ... }
2751
+ // reference "/my-site", file at file_path "/my-site" name "index.html"
2752
+ // -> { path: "/index.html", ... }
2753
+
2754
+ const _drive_content_type_for = (name) => {
2755
+ const ext = (getExtensionFromString(name) || '').toLowerCase();
2756
+ const map = {
2757
+ html: 'text/html',
2758
+ htm: 'text/html',
2759
+ css: 'text/css',
2760
+ js: 'application/javascript',
2761
+ mjs: 'application/javascript',
2762
+ json: 'application/json',
2763
+ svg: 'image/svg+xml',
2764
+ png: 'image/png',
2765
+ jpg: 'image/jpeg',
2766
+ jpeg: 'image/jpeg',
2767
+ gif: 'image/gif',
2768
+ webp: 'image/webp',
2769
+ ico: 'image/x-icon',
2770
+ avif: 'image/avif',
2771
+ txt: 'text/plain',
2772
+ xml: 'application/xml',
2773
+ woff: 'font/woff',
2774
+ woff2: 'font/woff2',
2775
+ ttf: 'font/ttf',
2776
+ otf: 'font/otf',
2777
+ eot: 'application/vnd.ms-fontobject',
2778
+ map: 'application/json',
2779
+ webmanifest: 'application/manifest+json',
2780
+ pdf: 'application/pdf',
2781
+ mp4: 'video/mp4',
2782
+ webm: 'video/webm',
2783
+ wasm: 'application/wasm',
2784
+ };
2785
+ return map[ext] || 'application/octet-stream';
2786
+ };
2787
+
2788
+ // Plan C / regional drives: a user's file lives in a specific region's
2789
+ // Spaces bucket (doc.bucket is keyed by region-host, e.g. "eu.xuda.ai").
2790
+ // Because this read can run on a different server than where the file
2791
+ // lives (create_static_website runs on master; the file may be in eu),
2792
+ // we must address the file's OWN region — not the current server's.
2793
+ // Spaces is S3 over the internet, so any server can reach any region.
2794
+ const _drive_s3_cache = {};
2795
+ const _drive_s3_for = (region_host) => {
2796
+ const conf = _conf.storage_bucket?.[region_host];
2797
+ if (!conf) return null;
2798
+ if (!_drive_s3_cache[region_host]) {
2799
+ _drive_s3_cache[region_host] = new S3Client({
2800
+ endpoint: conf.endpoint,
2801
+ region: conf.region,
2802
+ credentials: {
2803
+ accessKeyId: _conf.digitalocean.spaces_access_key,
2804
+ secretAccessKey: _conf.digitalocean.spaces_secret_key,
2805
+ },
2806
+ forcePathStyle: false,
2807
+ });
2808
+ }
2809
+ return _drive_s3_cache[region_host];
2810
+ };
2811
+
2812
+ // Pick which region to read a file from: prefer the current server's
2813
+ // region if the file is replicated there (lowest latency), else any
2814
+ // region in the file's bucket map that we have config for.
2815
+ const _pick_file_region = (doc) => {
2816
+ const bmap = doc?.bucket || {};
2817
+ const here = process.env.XUDA_HOSTNAME;
2818
+ if (bmap[here] && _conf.storage_bucket?.[here]) return here;
2819
+ for (const region of Object.keys(bmap)) {
2820
+ if (_conf.storage_bucket?.[region]) return region;
2821
+ }
2822
+ return null;
2823
+ };
2824
+
2825
+ const _stream_to_buffer = async (stream) => {
2826
+ if (!stream) return Buffer.alloc(0);
2827
+ // AWS SDK v3 Body is a Node Readable in this runtime.
2828
+ if (typeof stream.transformToByteArray === 'function') {
2829
+ return Buffer.from(await stream.transformToByteArray());
2830
+ }
2831
+ const chunks = [];
2832
+ for await (const chunk of stream) {
2833
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
2834
+ }
2835
+ return Buffer.concat(chunks);
2836
+ };
2837
+
2838
+ export const read_user_drive_folder = async (req) => {
2839
+ try {
2840
+ const { uid } = req || {};
2841
+ if (!uid) return { code: -1, data: 'uid required' };
2842
+ // Normalize the folder reference: strip trailing slashes; '' -> '/'.
2843
+ const root = String(req.folder_path || '/').replace(/\/+$/, '') || '/';
2844
+
2845
+ const app_id = await get_account_default_project_id(uid);
2846
+ const ret = await db_module.find_app_couch_query(app_id, {
2847
+ selector: { docType: 'user_drive', type: 'file', stat: 3 },
2848
+ limit: 100000,
2849
+ });
2850
+
2851
+ // Keep only files at the reference folder or any subfolder of it.
2852
+ const docs = (ret?.docs || []).filter((d) => {
2853
+ const fp = d.file_path || '/';
2854
+ if (root === '/') return true;
2855
+ return fp === root || fp.startsWith(root + '/');
2856
+ });
2857
+ if (!docs.length) return { code: -1, data: `no files found under ${root}` };
2858
+
2859
+ const files = [];
2860
+ const skipped = [];
2861
+ for (const d of docs) {
2862
+ // Resolve the file's own region, then read from THAT region's bucket.
2863
+ const region = _pick_file_region(d);
2864
+ const b = (region && d.bucket?.[region]) || {};
2865
+ const Bucket = b.Bucket || b.bucket || (region && _conf.storage_bucket?.[region]?.name);
2866
+ const Key = b.Key || b.key;
2867
+ const s3 = region ? _drive_s3_for(region) : null;
2868
+ if (!s3 || !Bucket || !Key) {
2869
+ // No reachable region (e.g. datacenter-scp file with no Spaces
2870
+ // entry, or a region we have no config for) — surface it.
2871
+ skipped.push(d.originalname || d._id);
2872
+ continue;
2873
+ }
2874
+ try {
2875
+ const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
2876
+ const buf = await _stream_to_buffer(obj.Body);
2877
+ const fp = d.file_path || '/';
2878
+ const rel_dir = root === '/' ? fp : fp.slice(root.length); // '' | '/css'
2879
+ const rel_path = path.posix.join('/', rel_dir || '/', d.originalname);
2880
+ files.push({
2881
+ path: rel_path,
2882
+ content_b64: buf.toString('base64'),
2883
+ content_type: d.mime || _drive_content_type_for(d.originalname),
2884
+ });
2885
+ } catch (err) {
2886
+ console.error('[read_user_drive_folder] read fail', d._id, err.message);
2887
+ skipped.push(d.originalname || d._id);
2888
+ }
2889
+ }
2890
+
2891
+ if (!files.length) return { code: -1, data: `no readable files under ${root}` };
2892
+ return { code: 1, data: { files, file_count: files.length, skipped } };
2893
+ } catch (err) {
2894
+ return { code: -400, data: err.message };
2895
+ }
2896
+ };
2897
+
2898
+ // put_site_files — snapshot a set of static-site files into ONE region's
2899
+ // Spaces bucket under sites/{site_id}/{version}/{path}, public-read. Each
2900
+ // deploy writes a fresh immutable version directory, so older versions stay
2901
+ // intact for rollback. The router serves a site by fetching the returned
2902
+ // `location` (a public DO Spaces URL, globally reachable — Cloudflare caches
2903
+ // it at the edge), so no per-request cross-region copy is needed.
2904
+ //
2905
+ // Files land in the app's home region (data residency / Plan C). The returned
2906
+ // manifest is what the site doc stores as the version snapshot.
2907
+ //
2908
+ // req: { region_host, site_id, version, files:[{ path, content_b64, content_type? }] }
2909
+ // -> { manifest:{ [path]:{ key, bucket, region, location, content_type, bytes } }, file_count, bytes, region, version }
2910
+ export const put_site_files = async (req) => {
2911
+ try {
2912
+ const { site_id, version } = req || {};
2913
+ const files = Array.isArray(req?.files) ? req.files : [];
2914
+ if (!site_id || !version) return { code: -1, data: 'site_id and version required' };
2915
+ if (!files.length) return { code: -1, data: 'no files' };
2916
+
2917
+ // Resolve the target region: caller's region_host if we have config for it,
2918
+ // else fall back to this server's own region.
2919
+ const region_host = req.region_host && _conf.storage_bucket?.[req.region_host] ? req.region_host : process.env.XUDA_HOSTNAME;
2920
+ const conf = _conf.storage_bucket?.[region_host];
2921
+ if (!conf) return { code: -1, data: `no storage config for region ${region_host}` };
2922
+ const s3 = _drive_s3_for(region_host);
2923
+ const Bucket = conf.name;
2924
+ if (!s3 || !Bucket) return { code: -1, data: `no S3 client for region ${region_host}` };
2925
+
2926
+ const endpoint_host = String(conf.endpoint || '')
2927
+ .replace(/^https?:\/\//, '')
2928
+ .replace(/\/+$/, '');
2929
+
2930
+ const manifest = {};
2931
+ let bytes = 0;
2932
+ for (const f of files) {
2933
+ const raw = String(f.path || '').replace(/\\/g, '/');
2934
+ const norm = path.posix.join('/', raw); // '/index.html', '/css/app.css'
2935
+ if (!norm || norm === '/' || norm.includes('..')) {
2936
+ if (norm.includes('..')) return { code: -1, data: `bad path: ${raw}` };
2937
+ continue;
2938
+ }
2939
+ const body = Buffer.from(String(f.content_b64 || ''), 'base64');
2940
+ const content_type = f.content_type || _drive_content_type_for(norm) || 'application/octet-stream';
2941
+ const Key = `sites/${site_id}/${version}${norm}`;
2942
+ const up = new Upload({
2943
+ client: s3,
2944
+ params: { Bucket, Key, Body: body, ACL: 'public-read', ContentType: content_type },
2945
+ });
2946
+ const res = await up.done();
2947
+ // Prefer the SDK-reported Location; reconstruct a virtual-hosted URL if absent.
2948
+ const location = res?.Location || (endpoint_host ? `https://${Bucket}.${endpoint_host}/${Key}` : null);
2949
+ manifest[norm] = { key: Key, bucket: Bucket, region: region_host, location, content_type, bytes: body.length };
2950
+ bytes += body.length;
2951
+ }
2952
+
2953
+ if (!Object.keys(manifest).length) return { code: -1, data: 'no valid files to upload' };
2954
+ return { code: 1, data: { manifest, file_count: Object.keys(manifest).length, bytes, region: region_host, version } };
2955
+ } catch (err) {
2956
+ console.error('[put_site_files]', err);
2957
+ return { code: -400, data: err.message };
2958
+ }
2959
+ };
2960
+
2961
+ // delete_site_files — best-effort removal of a site's (or one version's)
2962
+ // objects from its region bucket. Used by domains.delete_site so deleting a
2963
+ // site reclaims storage instead of orphaning blobs.
2964
+ // req: { region_host, site_id, version? }
2965
+ export const delete_site_files = async (req) => {
2966
+ try {
2967
+ const { site_id } = req || {};
2968
+ if (!site_id) return { code: -1, data: 'site_id required' };
2969
+ const region_host = req.region_host && _conf.storage_bucket?.[req.region_host] ? req.region_host : process.env.XUDA_HOSTNAME;
2970
+ const conf = _conf.storage_bucket?.[region_host];
2971
+ if (!conf) return { code: 1, data: { deleted: 0 } };
2972
+ const s3 = _drive_s3_for(region_host);
2973
+ const Bucket = conf.name;
2974
+ const prefix = req.version ? `sites/${site_id}/${req.version}/` : `sites/${site_id}/`;
2975
+
2976
+ let deleted = 0;
2977
+ let ContinuationToken;
2978
+ const { ListObjectsV2Command } = await import('@aws-sdk/client-s3');
2979
+ do {
2980
+ const list = await s3.send(new ListObjectsV2Command({ Bucket, Prefix: prefix, ContinuationToken }));
2981
+ for (const obj of list.Contents || []) {
2982
+ await s3.send(new DeleteObjectCommand({ Bucket, Key: obj.Key }));
2983
+ deleted++;
2984
+ }
2985
+ ContinuationToken = list.IsTruncated ? list.NextContinuationToken : undefined;
2986
+ } while (ContinuationToken);
2987
+
2988
+ return { code: 1, data: { deleted, region: region_host } };
2989
+ } catch (err) {
2990
+ console.error('[delete_site_files]', err);
2991
+ return { code: -400, data: err.message };
2992
+ }
2993
+ };
2994
+
2276
2995
  // Explicitly exporting dynamic functions for ESM compatibility
2277
2996
  export const get_drive_files_workspace = async (req, job_id, headers, file_obj) => {
2278
2997
  req.drive_type = 'workspace';
@@ -2284,6 +3003,7 @@ export const get_drive_files_studio = _common.location_sensitive(async (req, job
2284
3003
  });
2285
3004
  export const get_drive_files_user = async (req, job_id, headers, file_obj) => {
2286
3005
  req.drive_type = 'user';
3006
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2287
3007
  return await get_drive_files(req, job_id, headers, file_obj);
2288
3008
  };
2289
3009
 
@@ -2297,6 +3017,7 @@ export const get_drive_file_info_studio = _common.location_sensitive(async (req,
2297
3017
  });
2298
3018
  export const get_drive_file_info_user = async (req, job_id, headers, file_obj) => {
2299
3019
  req.drive_type = 'user';
3020
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2300
3021
  return await get_drive_file_info(req, job_id, headers, file_obj);
2301
3022
  };
2302
3023
 
@@ -2310,6 +3031,7 @@ export const delete_drive_files_studio = _common.location_sensitive(async (req,
2310
3031
  });
2311
3032
  export const delete_drive_files_user = async (req, job_id, headers, file_obj) => {
2312
3033
  req.drive_type = 'user';
3034
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2313
3035
  return await delete_drive_files(req, job_id, headers, file_obj);
2314
3036
  };
2315
3037
 
@@ -2323,6 +3045,7 @@ export const extract_drive_file_studio = _common.location_sensitive(async (req,
2323
3045
  });
2324
3046
  export const extract_drive_file_user = async (req, job_id, headers, file_obj) => {
2325
3047
  req.drive_type = 'user';
3048
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2326
3049
  return await extract_drive_file(req, job_id, headers, file_obj);
2327
3050
  };
2328
3051
 
@@ -2336,6 +3059,7 @@ export const upload_drive_file_studio = _common.location_sensitive(async (req, j
2336
3059
  });
2337
3060
  export const upload_drive_file_user = async (req, job_id, headers, file_obj) => {
2338
3061
  req.drive_type = 'user';
3062
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2339
3063
  return await upload_drive_file(req, job_id, headers, file_obj);
2340
3064
  };
2341
3065
 
@@ -2349,6 +3073,7 @@ export const update_drive_file_studio = _common.location_sensitive(async (req, j
2349
3073
  });
2350
3074
  export const update_drive_file_user = async (req, job_id, headers, file_obj) => {
2351
3075
  req.drive_type = 'user';
3076
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2352
3077
  return await update_drive_file(req, job_id, headers, file_obj);
2353
3078
  };
2354
3079
 
@@ -2362,6 +3087,7 @@ export const create_drive_folder_studio = _common.location_sensitive(async (req,
2362
3087
  });
2363
3088
  export const create_drive_folder_user = async (req, job_id, headers, file_obj) => {
2364
3089
  req.drive_type = 'user';
3090
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2365
3091
  return await create_drive_folder(req, job_id, headers, file_obj);
2366
3092
  };
2367
3093
 
@@ -2375,6 +3101,7 @@ export const rename_drive_file_studio = _common.location_sensitive(async (req, j
2375
3101
  });
2376
3102
  export const rename_drive_file_user = async (req, job_id, headers, file_obj) => {
2377
3103
  req.drive_type = 'user';
3104
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2378
3105
  return await rename_drive_file(req, job_id, headers, file_obj);
2379
3106
  };
2380
3107
 
@@ -2388,6 +3115,7 @@ export const rename_drive_folder_studio = _common.location_sensitive(async (req,
2388
3115
  });
2389
3116
  export const rename_drive_folder_user = async (req, job_id, headers, file_obj) => {
2390
3117
  req.drive_type = 'user';
3118
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2391
3119
  return await rename_drive_folder(req, job_id, headers, file_obj);
2392
3120
  };
2393
3121
 
@@ -2401,6 +3129,7 @@ export const update_drive_file_sharing_mode_studio = _common.location_sensitive(
2401
3129
  });
2402
3130
  export const update_drive_file_sharing_mode_user = async (req, job_id, headers, file_obj) => {
2403
3131
  req.drive_type = 'user';
3132
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2404
3133
  return await update_drive_file_sharing_mode(req, job_id, headers, file_obj);
2405
3134
  };
2406
3135
 
@@ -2414,6 +3143,7 @@ export const delete_file_bulk_studio = _common.location_sensitive(async (req, jo
2414
3143
  });
2415
3144
  export const delete_file_bulk_user = async (req, job_id, headers, file_obj) => {
2416
3145
  req.drive_type = 'user';
3146
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2417
3147
  return await delete_file_bulk(req, job_id, headers, file_obj);
2418
3148
  };
2419
3149
 
@@ -2435,10 +3165,12 @@ export const check_drive_files_studio = _common.location_sensitive(async (req, j
2435
3165
  });
2436
3166
  export const check_drive_file_user = async (req, job_id, headers, file_obj) => {
2437
3167
  req.drive_type = 'user';
3168
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2438
3169
  return await check_drive_file(req, job_id, headers, file_obj);
2439
3170
  };
2440
3171
  export const check_drive_files_user = async (req, job_id, headers, file_obj) => {
2441
3172
  req.drive_type = 'user';
3173
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2442
3174
  return await check_drive_files(req, job_id, headers, file_obj);
2443
3175
  };
2444
3176
 
@@ -2452,6 +3184,7 @@ export const update_drive_file_tags_studio = _common.location_sensitive(async (r
2452
3184
  });
2453
3185
  export const update_drive_file_tags_user = async (req, job_id, headers, file_obj) => {
2454
3186
  req.drive_type = 'user';
3187
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2455
3188
  return await update_drive_file_tags(req, job_id, headers, file_obj);
2456
3189
  };
2457
3190
 
@@ -2465,6 +3198,7 @@ export const update_drive_addons_studio = _common.location_sensitive(async (req,
2465
3198
  });
2466
3199
  export const update_drive_addons_user = async (req, job_id, headers, file_obj) => {
2467
3200
  req.drive_type = 'user';
3201
+ if (!req.uid && req.token_ret?.data?.uid) req.uid = req.token_ret.data.uid; // user-drive ops resolve the project from the authenticated uid
2468
3202
  return await update_drive_addons(req, job_id, headers, file_obj);
2469
3203
  };
2470
3204