@xuda.io/drive_module 1.1.1498 → 1.1.1500

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
@@ -4,6 +4,7 @@ import { rimraf } from 'rimraf';
4
4
  import fse from 'fs-extra';
5
5
  import unzipper from 'unzipper';
6
6
  import fs from 'fs';
7
+ import os from 'os';
7
8
  import tesseract from 'node-tesseract-ocr';
8
9
  import countryLanguage from 'country-language';
9
10
  import url from 'url';
@@ -28,6 +29,10 @@ const { get_account_default_project_id } = await import(path.join(process.env.XU
28
29
  const module_path = path.join(process.env.XUDA_HOME, 'cpi') + (!_conf.is_debug ? '/node_modules/@xuda.io' : '');
29
30
  const fs_module = await import(`${module_path}/fs_module/index.mjs`);
30
31
  const db_module = await import(`${module_path}/db_module/index.mjs`);
32
+ // UI-113: the Drive storage ladder is one line item on the consolidated
33
+ // subscription, so drive_set_plan drives Stripe the same way every other module
34
+ // tier does (add_subscription_item to buy or reprice, remove on the free tier).
35
+ const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
31
36
 
32
37
  // const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
33
38
 
@@ -3763,6 +3768,106 @@ export const read_user_drive_folder = async (req) => {
3763
3768
  }
3764
3769
  };
3765
3770
 
3771
+ // fetch_drive_file_to_tmp: copy ONE drive file's bytes onto this box as a temp
3772
+ // file so a server-side document generator can open it, edit it and save the
3773
+ // result back as a new drive file. This is what lets the office plugins (pptx,
3774
+ // docx, xlsx) modify a deck/document the user already has instead of only
3775
+ // creating new ones: user + workspace files live in object storage, so there is
3776
+ // otherwise no path on disk to hand to a parser.
3777
+ //
3778
+ // The file is resolved by drive doc id (drv_...) or by its drive path
3779
+ // (/Folder/name.ext). The caller owns the temp file and must unlink it.
3780
+ //
3781
+ // req: { uid, app_id?, drive_type: 'user'|'workspace'|'studio', file }
3782
+ // -> { tmp_path, originalname, file_path, doc_id, size, mime, drive_type }
3783
+ export const fetch_drive_file_to_tmp = async (req) => {
3784
+ try {
3785
+ const { uid, app_id, drive_type = 'user' } = req || {};
3786
+ const file_ref = String(req?.file || req?.file_id || req?.file_path || '').trim();
3787
+
3788
+ if (!uid) return { code: -1, data: 'uid required' };
3789
+ if (!file_ref) return { code: -1, data: 'file required' };
3790
+ validate_drive_type(drive_type);
3791
+
3792
+ const write_tmp = async (buffer, name) => {
3793
+ const tmp_path = path.join(os.tmpdir(), `xuda_drive_${await _common.xuda_get_uuid('tmp')}${path.extname(name) || ''}`);
3794
+ await fs.promises.writeFile(tmp_path, buffer);
3795
+ return tmp_path;
3796
+ };
3797
+
3798
+ // studio drive is plain files on this box, addressed by their relative path.
3799
+ if (drive_type === 'studio') {
3800
+ const ref = await _common.get_project_app_id(app_id, true);
3801
+ const rel = file_ref.replace(/^\/+/, '');
3802
+ const full = path.join(_conf.studio_drive_path, ref, rel);
3803
+ // Contain the read inside the app's own studio folder.
3804
+ const root = path.join(_conf.studio_drive_path, ref);
3805
+ if (!path.resolve(full).startsWith(path.resolve(root) + path.sep)) {
3806
+ return { code: -1, data: 'file_path unauthorized' };
3807
+ }
3808
+ if (!(await fs_module.file_exists(full))) return { code: -1, data: `file not found: ${file_ref}` };
3809
+ const buffer = await fs.promises.readFile(full);
3810
+ const originalname = path.basename(full);
3811
+ return {
3812
+ code: 1,
3813
+ data: {
3814
+ tmp_path: await write_tmp(buffer, originalname),
3815
+ originalname,
3816
+ file_path: path.posix.join('/', path.dirname(rel) === '.' ? '' : path.dirname(rel)),
3817
+ doc_id: rel,
3818
+ size: buffer.length,
3819
+ mime: _drive_content_type_for(originalname),
3820
+ drive_type,
3821
+ },
3822
+ };
3823
+ }
3824
+
3825
+ const project_id = drive_type === 'user' ? await get_account_default_project_id(uid) : await _common.get_project_app_id(app_id);
3826
+
3827
+ // Either the doc id itself, or a /folder/name.ext path within the drive.
3828
+ const by_id = /^drv_/.test(file_ref);
3829
+ const selector = { docType: `${drive_type}_drive`, type: 'file', stat: 3 };
3830
+ if (by_id) {
3831
+ selector._id = file_ref;
3832
+ } else {
3833
+ const with_slash = file_ref.startsWith('/') ? file_ref : `/${file_ref}`;
3834
+ const dir = path.posix.dirname(with_slash);
3835
+ selector.file_path = dir === '.' ? '/' : dir;
3836
+ selector.originalname = path.posix.basename(with_slash);
3837
+ }
3838
+
3839
+ const find_ret = await db_module.find_app_couch_query(project_id, { selector, limit: 1 });
3840
+ const doc = find_ret?.docs?.[0];
3841
+ if (!doc) return { code: -1, data: `file not found: ${file_ref}` };
3842
+
3843
+ const region = _pick_file_region(doc);
3844
+ const b = (region && doc.bucket?.[region]) || {};
3845
+ const Bucket = b.Bucket || b.bucket || (region && _conf.storage_bucket?.[region]?.name);
3846
+ const Key = b.Key || b.key;
3847
+ const s3 = region ? _drive_s3_for(region) : null;
3848
+ if (!s3 || !Bucket || !Key) return { code: -1, data: 'file bytes unavailable' };
3849
+
3850
+ const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
3851
+ const buffer = await _stream_to_buffer(obj.Body);
3852
+ const originalname = doc.originalname || path.basename(Key);
3853
+
3854
+ return {
3855
+ code: 1,
3856
+ data: {
3857
+ tmp_path: await write_tmp(buffer, originalname),
3858
+ originalname,
3859
+ file_path: doc.file_path || '/',
3860
+ doc_id: doc._id,
3861
+ size: buffer.length,
3862
+ mime: doc.mime || _drive_content_type_for(originalname),
3863
+ drive_type,
3864
+ },
3865
+ };
3866
+ } catch (err) {
3867
+ return { code: -400, data: err.message };
3868
+ }
3869
+ };
3870
+
3766
3871
  // put_site_files — snapshot a set of static-site files into ONE region's
3767
3872
  // Spaces bucket under sites/{site_id}/{version}/{path}, public-read. Each
3768
3873
  // deploy writes a fresh immutable version directory, so older versions stay
@@ -4369,3 +4474,172 @@ export const file_upload_validator = function (file_name) {
4369
4474
  return { valid: false, error: `Unsupported file extension: .${ext}` };
4370
4475
  }
4371
4476
  };
4477
+
4478
+ ///////////////////////////////////////////////////////////////////////////////
4479
+ // DRIVE PLANS (UI-113)
4480
+ //
4481
+ // Drive is sold on its own drive_* ladder and what the ladder sells is SPACE.
4482
+ // A tier's `flags.extra_gb` is added on top of the storage the account already
4483
+ // has from its membership plan and its AI workspace plan, it does not replace
4484
+ // either, so an account that never picks a tier is on drive_free and nothing
4485
+ // about its Drive changes.
4486
+ //
4487
+ // The money is one line item on the consolidated subscription, the same shape
4488
+ // every other module tier uses: a paid tier creates or reprices the line, free
4489
+ // removes it. Billing moves FIRST and the plan is only written once Stripe
4490
+ // agreed, so a failed charge can never leave an account holding space it is
4491
+ // not paying for.
4492
+ ///////////////////////////////////////////////////////////////////////////////
4493
+
4494
+ const DRIVE_CATEGORY = 'drive';
4495
+ const GB = 1073741824;
4496
+
4497
+ const _drive_tiers = () =>
4498
+ Object.values(_conf.PLAN_OBJ || {})
4499
+ .filter((p) => p.category === DRIVE_CATEGORY)
4500
+ .sort((a, b) => (Number(a.rank) || 0) - (Number(b.rank) || 0));
4501
+
4502
+ const _drive_plan = (plan_id) => {
4503
+ const plan = _conf.PLAN_OBJ?.[plan_id];
4504
+ return plan && plan.category === DRIVE_CATEGORY ? plan : null;
4505
+ };
4506
+
4507
+ const _drive_account_doc = async (uid) => {
4508
+ const ret = await db_module.get_couch_doc('xuda_accounts', uid);
4509
+ return ret.code < 0 || !ret.data ? {} : ret.data;
4510
+ };
4511
+
4512
+ // An account that never touched the ladder is on free. Same default the billing
4513
+ // Modules card applies, kept identical on purpose so the two never disagree.
4514
+ const _drive_account_plan = (account) => (_drive_plan(account?.drive_plan) ? account.drive_plan : 'drive_free');
4515
+
4516
+ // What the account is storing, using the SAME sum the overage metering charges
4517
+ // on (account_module increment_account_usage). project_data_size is deliberately
4518
+ // out of it there, so it is out of it here: a guard that counted bytes the
4519
+ // metering ignores would refuse a downgrade the customer is not billed for.
4520
+ const _drive_used_bytes = (account) =>
4521
+ (account?.user_drive_size || 0) +
4522
+ (account?.studio_drive_size || 0) +
4523
+ (account?.workspace_drive_size || 0) +
4524
+ (account?.builds_drive_size || 0) +
4525
+ (account?.plugins_drive_size || 0);
4526
+
4527
+ // The quota the account WOULD have on a given tier, in bytes. Delegates to
4528
+ // account_module's get_effective_entitlements rather than re-deriving the stack,
4529
+ // because that function is what the metering and every storage bar already use.
4530
+ // Imported lazily: this is the only place in the drive module that needs it and
4531
+ // account_module pulls in a large tree of its own.
4532
+ const _drive_quota_bytes = async (account, plan_id) => {
4533
+ const { get_effective_entitlements } = await import(`${module_path}/account_module/index.mjs`);
4534
+ const ent = get_effective_entitlements({ ...account, drive_plan: plan_id ?? account?.drive_plan });
4535
+ return {
4536
+ unlimited: !Number.isFinite(ent.drive_gb),
4537
+ bytes: Number.isFinite(ent.drive_gb) ? Math.round(ent.drive_gb * GB) : 0,
4538
+ breakdown: ent.drive_breakdown || {},
4539
+ };
4540
+ };
4541
+
4542
+ const _drive_pretty = (bytes) => {
4543
+ if (bytes >= 1099511627776) return `${Math.round((bytes / 1099511627776) * 10) / 10} TB`;
4544
+ if (bytes >= GB) return `${Math.round((bytes / GB) * 10) / 10} GB`;
4545
+ return `${Math.round(bytes / 1048576)} MB`;
4546
+ };
4547
+
4548
+ export const get_drive_plans = async function (req) {
4549
+ try {
4550
+ const uid = req?.uid;
4551
+ if (!uid) return { code: -401, data: 'not authenticated' };
4552
+ const account = await _drive_account_doc(uid);
4553
+ if (!account._id) return { code: -404, data: 'account not found' };
4554
+
4555
+ const plan_id = _drive_account_plan(account);
4556
+ const used = _drive_used_bytes(account);
4557
+ const quota = await _drive_quota_bytes(account, plan_id);
4558
+
4559
+ // Every tier carries the quota it WOULD give and whether the account already
4560
+ // stores more than that, so the screen can mark a tier as out of reach
4561
+ // without doing the entitlement arithmetic itself.
4562
+ const plans = [];
4563
+ for (const p of _drive_tiers()) {
4564
+ const q = await _drive_quota_bytes(account, p.id);
4565
+ plans.push({
4566
+ ...p,
4567
+ quota_bytes: q.bytes,
4568
+ quota_unlimited: q.unlimited,
4569
+ fits: q.unlimited || used <= q.bytes,
4570
+ });
4571
+ }
4572
+
4573
+ return {
4574
+ code: 1,
4575
+ data: {
4576
+ plan_id,
4577
+ plan_name: _drive_plan(plan_id)?.name || '',
4578
+ changed_ts: account.drive_plan_changed || null,
4579
+ // The line on the invoice. Free holds no item at all.
4580
+ subscribed: !!account?.stripe_subscription_items?.[DRIVE_CATEGORY],
4581
+ used_bytes: used,
4582
+ quota_bytes: quota.bytes,
4583
+ quota_unlimited: quota.unlimited,
4584
+ // Where the space comes from, so a full bar can point at the plan that
4585
+ // would actually fix it rather than at this one by default.
4586
+ quota_from: {
4587
+ membership_bytes: Math.round((quota.breakdown.membership_gb === Infinity ? 0 : quota.breakdown.membership_gb || 0) * GB),
4588
+ workspace_bytes: Math.round((quota.breakdown.workspace_gb || 0) * GB),
4589
+ drive_plan_bytes: Math.round((quota.breakdown.drive_plan_gb || 0) * GB),
4590
+ },
4591
+ membership_plan: account.membership_plan || 'free',
4592
+ ai_workspace_plan: account.ai_workspace_plan || '',
4593
+ plans,
4594
+ },
4595
+ };
4596
+ } catch (err) {
4597
+ return { code: -1, data: err.message || String(err) };
4598
+ }
4599
+ };
4600
+
4601
+ export const drive_set_plan = async function (req) {
4602
+ try {
4603
+ const uid = req?.uid;
4604
+ if (!uid) return { code: -401, data: 'not authenticated' };
4605
+ const plan_id = req?.plan_id || '';
4606
+ const plan = _drive_plan(plan_id);
4607
+ if (!plan) return { code: -1, data: 'unknown drive plan' };
4608
+
4609
+ const account = await _drive_account_doc(uid);
4610
+ if (!account._id) return { code: -404, data: 'account not found' };
4611
+ const current = _drive_account_plan(account);
4612
+ if (current === plan_id) return await get_drive_plans({ uid });
4613
+
4614
+ // Downgrade guard. Taking space away from an account that is already using it
4615
+ // would drop it straight into metered overage on files it cannot see a way to
4616
+ // remove, so name the gap instead and let the customer clear it first.
4617
+ const target = await _drive_quota_bytes(account, plan_id);
4618
+ const used = _drive_used_bytes(account);
4619
+ if (!target.unlimited && used > target.bytes) {
4620
+ return {
4621
+ code: -1,
4622
+ data: `You are storing ${_drive_pretty(used)} and the ${plan.name} plan would leave you with ${_drive_pretty(target.bytes)}. Free up ${_drive_pretty(used - target.bytes)} in Drive first.`,
4623
+ };
4624
+ }
4625
+
4626
+ const paid = Number(plan.price) > 0;
4627
+ if (paid) {
4628
+ const ret = await stripe_ms.add_subscription_item({ uid, plan_id });
4629
+ if (!ret || ret.code < 0) return { code: ret?.code === -402 || ret?.code === -3 ? -402 : -1, data: ret?.data || 'could not change the plan' };
4630
+ } else if (account.stripe_subscription_items?.[DRIVE_CATEGORY]) {
4631
+ const ret = await stripe_ms.remove_subscription_item({ uid, category: DRIVE_CATEGORY });
4632
+ if (!ret || ret.code < 0) return { code: -1, data: ret?.data || 'could not remove the subscription line' };
4633
+ }
4634
+
4635
+ // Re-read: the stripe call just wrote stripe_subscription_items on this doc.
4636
+ const fresh = await _drive_account_doc(uid);
4637
+ fresh.drive_plan = plan_id;
4638
+ fresh.drive_plan_changed = Date.now();
4639
+ await db_module.save_couch_doc('xuda_accounts', fresh);
4640
+
4641
+ return await get_drive_plans({ uid });
4642
+ } catch (err) {
4643
+ return { code: -1, data: err.message || String(err) };
4644
+ }
4645
+ };
package/index_ms.mjs CHANGED
@@ -197,6 +197,10 @@ export const read_user_drive_folder = async function (...args) {
197
197
  return await broker.send_to_queue("read_user_drive_folder", ...args);
198
198
  };
199
199
 
200
+ export const fetch_drive_file_to_tmp = async function (...args) {
201
+ return await broker.send_to_queue("fetch_drive_file_to_tmp", ...args);
202
+ };
203
+
200
204
  export const put_site_files = async function (...args) {
201
205
  return await broker.send_to_queue("put_site_files", ...args);
202
206
  };
@@ -417,6 +421,14 @@ export const file_upload_validator = async function (...args) {
417
421
  return await broker.send_to_queue("file_upload_validator", ...args);
418
422
  };
419
423
 
424
+ export const get_drive_plans = async function (...args) {
425
+ return await broker.send_to_queue("get_drive_plans", ...args);
426
+ };
427
+
428
+ export const drive_set_plan = async function (...args) {
429
+ return await broker.send_to_queue("drive_set_plan", ...args);
430
+ };
431
+
420
432
  export const get_drive_files_studio = async function (...args) {
421
433
  return await broker.send_to_queue("get_drive_files_studio", ...args);
422
434
  };
package/index_msa.mjs CHANGED
@@ -197,6 +197,10 @@ export const read_user_drive_folder = function (...args) {
197
197
  broker.send_to_queue_async("read_user_drive_folder", ...args);
198
198
  };
199
199
 
200
+ export const fetch_drive_file_to_tmp = function (...args) {
201
+ broker.send_to_queue_async("fetch_drive_file_to_tmp", ...args);
202
+ };
203
+
200
204
  export const put_site_files = function (...args) {
201
205
  broker.send_to_queue_async("put_site_files", ...args);
202
206
  };
@@ -417,6 +421,14 @@ export const file_upload_validator = function (...args) {
417
421
  broker.send_to_queue_async("file_upload_validator", ...args);
418
422
  };
419
423
 
424
+ export const get_drive_plans = function (...args) {
425
+ broker.send_to_queue_async("get_drive_plans", ...args);
426
+ };
427
+
428
+ export const drive_set_plan = function (...args) {
429
+ broker.send_to_queue_async("drive_set_plan", ...args);
430
+ };
431
+
420
432
  export const get_drive_files_studio = function (...args) {
421
433
  broker.send_to_queue_async("get_drive_files_studio", ...args);
422
434
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/drive_module",
3
- "version": "1.1.1498",
3
+ "version": "1.1.1500",
4
4
  "description": "Xuda Drive Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {