diff --git a/package-lock.json b/package-lock.json index e0c2a5407..af936c940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14210,9 +14210,9 @@ } }, "node_modules/mysql2": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.3.tgz", - "integrity": "sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==", + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.4.tgz", + "integrity": "sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.2", @@ -18403,7 +18403,7 @@ "lorem-ipsum": "^2.0.8", "mime-types": "^2.1.35", "murmurhash": "^2.0.1", - "mysql2": "^3.21.1", + "mysql2": "^3.22.4", "nodemailer": "^8.0.7", "openai": "^6.34.0", "otpauth": "^9.2.4", diff --git a/src/backend/controllers/drivers/DriverController.test.ts b/src/backend/controllers/drivers/DriverController.test.ts index 118b18d83..b6850c282 100644 --- a/src/backend/controllers/drivers/DriverController.test.ts +++ b/src/backend/controllers/drivers/DriverController.test.ts @@ -33,7 +33,7 @@ import type { DriverController } from './DriverController.js'; // The DriverController under test is the same instance the live request // pipeline uses, so its iface→driver registry is populated from real // drivers (puter-kvstore, puter-apps, puter-subdomains, …). The HTTP -// handlers (`#handleCall`, `#handleListInterfaces`, `#handleXd`) are +// handlers (`#handleCall`, `#handleListInterfaces`) are // private — we exercise the public lookup API (`resolve` / list / get // default) which the handlers themselves delegate to. @@ -116,7 +116,7 @@ describe('DriverController.resolve', () => { }); }); -// ── Route handlers (#handleCall, #handleListInterfaces, #handleXd) ─ +// ── Route handlers (#handleCall, #handleListInterfaces) ───────────── // The handlers are private class fields. We capture references to them by // invoking `registerRoutes` with a fake router whose `post`/`get` save the @@ -189,10 +189,7 @@ const makeRes = (): MockRes => { return res; }; -const makeReq = ( - body: Record = {}, - actor?: Actor, -): Request => +const makeReq = (body: Record = {}, actor?: Actor): Request => ({ body, actor, @@ -338,11 +335,7 @@ describe('DriverController.#handleCall (via captured router)', () => { actor, ); await runWithContext({ actor }, () => - routes['POST /call']( - req, - res as unknown as Response, - () => {}, - ), + routes['POST /call'](req, res as unknown as Response, () => {}), ); const body = res.body as { success: boolean; @@ -374,11 +367,9 @@ describe('DriverController.#handleCall (via captured router)', () => { // Register it through the controller's private map by re-running // its #buildIfaceMap path: easier to just stash it into the // existing iface map directly via TypeScript-defeating cast. - const internalDrivers = ( - controller as unknown as { - ['#drivers']: Map>; - } - ); + const internalDrivers = controller as unknown as { + ['#drivers']: Map>; + }; // Access the actual private slot via the well-known getter // pattern doesn't work for `#`-private fields; instead, re-run // registerRoutes after stashing on the bag — but that's already @@ -413,13 +404,12 @@ describe('DriverController.#handleCall (via captured router)', () => { >; const original = kv.set; kv.set = async function (...args: unknown[]) { - const { Context } = await import( - '../../core/context.js' - ); + const { Context } = await import('../../core/context.js'); Context.set('driverMetadata', { providerUsed: 'kv-direct' }); - return ( - original as (...x: unknown[]) => Promise - ).apply(this, args); + return (original as (...x: unknown[]) => Promise).apply( + this, + args, + ); }; try { const res = makeRes(); @@ -432,11 +422,7 @@ describe('DriverController.#handleCall (via captured router)', () => { actor, ); await runWithContext({ actor }, () => - routes['POST /call']( - req, - res as unknown as Response, - () => {}, - ), + routes['POST /call'](req, res as unknown as Response, () => {}), ); const body = res.body as { metadata?: Record; @@ -497,31 +483,11 @@ describe('DriverController.#handleListInterfaces', () => { }); }); -describe('DriverController.#handleXd', () => { - let routes: Captured; - beforeAll(() => { - routes = captureRoutes(controller); - }); - - it('serves the iframe-bridge HTML with text/html content-type', () => { - const res = makeRes(); - routes['GET /xd']( - makeReq(), - res as unknown as Response, - () => {}, - ); - expect(res.contentType).toBe('text/html'); - expect(res.sentBody).toMatch(//); - expect(res.sentBody).toMatch(/\/drivers\/call/); - }); -}); - describe('DriverController.registerRoutes', () => { - it('registers POST /call, GET /list-interfaces, and GET /xd', () => { + it('registers POST /call and GET /list-interfaces', () => { const routes = captureRoutes(controller); expect(routes['POST /call']).toBeInstanceOf(Function); expect(routes['GET /list-interfaces']).toBeInstanceOf(Function); - expect(routes['GET /xd']).toBeInstanceOf(Function); }); }); diff --git a/src/backend/controllers/drivers/DriverController.ts b/src/backend/controllers/drivers/DriverController.ts index 87aaa7b34..555f08ebb 100644 --- a/src/backend/controllers/drivers/DriverController.ts +++ b/src/backend/controllers/drivers/DriverController.ts @@ -26,8 +26,6 @@ import { checkDriverRateLimit, } from '../../core/http/middleware/rateLimit.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; -import type { PermissionService } from '../../services/permission/PermissionService.js'; -import type { WithLifecycle } from '../../types'; import type { DriverMeta } from '../../drivers/meta.js'; import { isDriverStreamResult, @@ -35,120 +33,12 @@ import { resolveDriverMethodConcurrent, resolveDriverMethodRateLimit, } from '../../drivers/meta.js'; +import type { PermissionService } from '../../services/permission/PermissionService.js'; +import type { WithLifecycle } from '../../types'; import { PuterController } from '../types.js'; type DriverInstance = WithLifecycle & Record; -// -- /drivers/xd payload --------------------------------------------- -// -// Self-contained HTML/JS shipped to the iframe consumer. Listens for -// postMessage events shaped as `{ id, interface, method, params }`, -// forwards to `/drivers/call`, and posts `{ id, result }` back to the -// originating window. -// -// Wire-shape note: the postMessage uses `params` (puter-js's historical -// name) but `/drivers/call` expects `args`; this bridge translates. - -const XD_SCRIPT = /* js */ ` -(function () { - const call = async ({ interface_name, method_name, params }) => { - const response = await fetch('/drivers/call', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - interface: interface_name, - method: method_name, - args: params, - }), - }); - return await response.json(); - }; - - const fcall = async ({ interface_name, method_name, params }) => { - const form = new FormData(); - form.append('interface', interface_name); - form.append('method', method_name); - for (const k in params) { - form.append(k, params[k]); - } - const response = await fetch('/drivers/call', { - method: 'POST', - body: form, - }); - return await response.json(); - }; - - window.addEventListener('message', async (event) => { - const { id, interface: iface, method, params } = event.data || {}; - let has_file = false; - for (const k in params) { - if (params[k] instanceof File) { - has_file = true; - break; - } - } - const result = has_file - ? await fcall({ interface_name: iface, method_name: method, params }) - : await call({ interface_name: iface, method_name: method, params }); - if (event.source) { - event.source.postMessage({ id, result }, event.origin); - } - }); -})(); -`; - -const XD_HTML = ` - - - Puter Driver API - - - -`; - -// -- Controller ------------------------------------------------------ - -/** - * Routes driver RPC calls through a unified HTTP surface. - * - * - `POST /drivers/call` — invoke `.(args)` on the - * registered driver after a per-actor permission + rate-limit check. - * Stream-shaped results are piped directly; everything else is - * returned as JSON. - * - `GET /drivers/list-interfaces` — enumerate registered driver - * interfaces with their default + alternate implementations. - * - `GET /drivers/xd` — legacy iframe bridge; serves an HTML page that - * proxies `postMessage` RPCs to `/drivers/call` on the same origin. - * - * Holds an internal iface → driverName → instance map built at - * construction time from `this.drivers`. Extensions that register - * additional drivers end up in that bag before this controller is - * instantiated, so they show up here automatically. - */ -/** - * Catch-all upstream-error translator for the driver boundary. - * - * Drivers that wrap a third-party SDK (OpenAI, Anthropic, etc.) often - * let the SDK's own error class bubble — those carry an HTTP `.status` - * but are plain `Error` subclasses, not `HttpError`s, so they would - * otherwise hit the global error handler as unexpected 500s and page - * PagerDuty. Repackage them with `upstream_*` legacy codes so the - * alarm gate (server.ts) treats them as upstream failures and only - * pages on the two we actually care about (rate-limit / auth). - * - * Status extraction covers the shapes we've seen in the wild: - * - `.status` / `.statusCode` (OpenAI / Anthropic / Together / Google GenAI) - * - `.response.status` (Replicate's `ApiError`) - * - `.$metadata.httpStatusCode` (AWS SDK v3, e.g. Polly) - * - status sniffed from the message string (last resort, for providers - * that re-wrap their SDK error in `new Error(msg)` and lose the field) - * - * `HttpError`s thrown by drivers pass through untouched. - */ const extractUpstreamStatus = (e: { status?: number; statusCode?: number; @@ -273,11 +163,6 @@ export class DriverController extends PuterController { { subdomain: 'api', requireAuth: true }, this.#handleListInterfaces, ); - router.get( - '/xd', - { subdomain: 'api', requireAuth: true }, - this.#handleXd, - ); } // -- Handlers ---------------------------------------------------- @@ -489,11 +374,6 @@ export class DriverController extends PuterController { res.json(out); }; - #handleXd = (_req: Request, res: Response): void => { - res.type('text/html'); - res.send(XD_HTML); - }; - // -- Internals --------------------------------------------------- #buildIfaceMap(): void { diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index 09aa49e37..8b094be70 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -1089,6 +1089,85 @@ describe('LegacyFSController.openItem', () => { }); }); +// ── openItem: write_url stripping for read-only callers ───────────── +// +// Mirrors `/sign` and `/readdir`, which already strip `write_url` when the +// caller only proved read. Without these gates, /open_item handed out a +// valid write signature to read-only sharees — `/writeFile`'s own ACL +// re-check would still reject the write, but the leak shape (a signed +// write URL escaping the access boundary) is the same one those other +// endpoints already defend against. + +describe('LegacyFSController.openItem (write_url stripping)', () => { + beforeAll(() => { + ( + controller as unknown as { config: { api_base_url?: string } } + ).config.api_base_url = 'http://api.test.local'; + }); + + it('returns write_url for the owner (who has write)', async () => { + const { actor } = await makeUser(); + const target = `/${actor.user!.username}/Documents/owned.txt`; + await withActor(actor, () => + controller.touch( + makeReq({ body: { path: target }, actor }), + makeRes().res, + ), + ); + + const { res, captured } = makeRes(); + await withActor(actor, () => + controller.openItem( + makeReq({ body: { path: target }, actor }), + res, + ), + ); + const body = captured.body as { + signature: { read_url?: string; write_url?: string }; + }; + expect(body.signature.read_url).toBeDefined(); + expect(body.signature.write_url).toBeDefined(); + expect(body.signature.write_url).toContain('writeFile'); + }); + + it('strips write_url for a read-only sharee (the fix)', async () => { + const victim = await makeUser(); + const attacker = await makeUser(); + const target = `/${victim.actor.user!.username}/Documents/shared-ro.txt`; + await withActor(victim.actor, () => + controller.touch( + makeReq({ body: { path: target }, actor: victim.actor }), + makeRes().res, + ), + ); + const entry = await server.stores.fsEntry.getEntryByPath(target); + await server.services.permission.grantUserUserPermission( + victim.actor, + attacker.actor.user!.username!, + `fs:${entry!.uuid}:read`, + {}, + ); + + const { res, captured } = makeRes(); + await withActor(attacker.actor, () => + controller.openItem( + makeReq({ + body: { uid: entry!.uuid }, + actor: attacker.actor, + }), + res, + ), + ); + const body = captured.body as { + signature: { read_url?: string; write_url?: string }; + }; + // Sharee still gets read_url (they have read). + expect(body.signature.read_url).toBeDefined(); + // …but write_url is stripped (they don't have write). + expect(body.signature.write_url).toBeUndefined(); + }); +}); + // ── requestAppRootDir ─────────────────────────────────────────────── describe('LegacyFSController.requestAppRootDir', () => { diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index f4d8ab345..fb8cf5308 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -1391,6 +1391,20 @@ export class LegacyFSController extends PuterController { 'read', ); + // Downgrade the envelope when the caller only proved read. + // `/writeFile`'s ACL re-check would still block the write, but + // returning `write_url` to a read-only caller is the same + // privilege-leak shape that `/sign` and `/readdir` strip. + const writeOk = await this.services.acl.check( + actor, + { + path: entry.path, + resolveAncestors: () => + this.services.fs.getAncestorChain(entry.path), + }, + 'write', + ); + const suggested = (await this.services.suggestedApps?.getSuggestedApps({ name: entry.name, @@ -1417,7 +1431,13 @@ export class LegacyFSController extends PuterController { } const signingCfg = signingConfigFromAppConfig(this.config); - const signature = { ...signEntry(entry, signingCfg), path: entry.path }; + const signed = signEntry(entry, signingCfg); + const signature = writeOk + ? { ...signed, path: entry.path } + : (() => { + const { write_url: _, ...rest } = signed; + return { ...rest, path: entry.path }; + })(); res.json({ signature, token, diff --git a/src/backend/package.json b/src/backend/package.json index adf1697a9..d15c22d64 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -56,7 +56,7 @@ "lorem-ipsum": "^2.0.8", "mime-types": "^2.1.35", "murmurhash": "^2.0.1", - "mysql2": "^3.21.1", + "mysql2": "^3.22.4", "nodemailer": "^8.0.7", "openai": "^6.34.0", "otpauth": "^9.2.4", diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 2f7905c79..09eeb7623 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -19,17 +19,21 @@ import UIWindowSaveAccount from '../UIWindowSaveAccount.js'; -function buildRecentAppsHTML () { +function buildRecentAppsHTML() { let h = ''; - if ( window.launch_apps?.recent?.length > 0 ) { + if (window.launch_apps?.recent?.length > 0) { h += '
'; // Show up to 6 recent apps (2 columns x 3 rows) const recentApps = window.launch_apps.recent.slice(0, 6); - for ( const app_info of recentApps ) { + for (const app_info of recentApps) { // if title, name and uuid are the same and index_url is set, then show the hostname of index_url - if ( app_info.name === app_info.title && app_info.name === app_info.uuid && app_info.index_url ) { + if ( + app_info.name === app_info.title && + app_info.name === app_info.uuid && + app_info.index_url + ) { app_info.title = new URL(app_info.index_url).hostname; app_info.target_link = app_info.index_url; } @@ -44,9 +48,12 @@ function buildRecentAppsHTML () { h += '
'; } else { h += '
'; - h += ''; - h += ''; - h += ''; + h += + ''; + h += + ''; + h += + ''; h += ''; h += '

No recent apps yet

'; h += 'Apps you use will appear here'; @@ -56,21 +63,25 @@ function buildRecentAppsHTML () { return h; } -function buildUsageHTML () { +function buildUsageHTML() { let h = ''; h += '
'; // Your Plan section - h += '
'; + h += + '
'; h += ''; h += `

${i18n('your_plan')}

`; h += ''; h += '
'; h += '
'; h += '--'; - h += ''; + h += + ''; h += '
'; - h += ''; + h += ''; + h += + ''; h += '
'; // Storage section @@ -83,8 +94,10 @@ function buildUsageHTML () { h += '
'; h += '
'; h += '
'; - h += '-- Used'; - h += '--% of --'; + h += + '-- Used'; + h += + '--% of --'; h += '
'; h += '
'; @@ -98,8 +111,10 @@ function buildUsageHTML () { h += '
'; h += '
'; h += '
'; - h += '-- Used'; - h += '--% of --'; + h += + '-- Used'; + h += + '--% of --'; h += '
'; h += ''; @@ -112,9 +127,10 @@ const TabHome = { label: 'Home', icon: '', - html () { + html() { const username = window.user?.username || 'User'; - const profilePicture = window.user?.profile?.picture || window.icons['profile.svg']; + const profilePicture = + window.user?.profile?.picture || window.icons['profile.svg']; let h = ''; h += '
'; @@ -129,9 +145,10 @@ const TabHome = { h += `

${html_encode(username)}

`; h += `

${i18n('your_personal_internet_computer')}

`; // Show warning if account is temporary/unsaved - if ( window.user?.is_temp ) { + if (window.user?.is_temp) { h += ''; } @@ -141,14 +158,17 @@ const TabHome = { // Recent apps card (rectangle) h += '
'; - h += ''; + h += + ''; h += '
'; - h += ''; + h += + ''; h += '
'; h += '
'; h += '

Apps

'; h += ''; - h += ''; + h += + ''; h += 'Recently used'; h += ''; h += '
'; @@ -168,14 +188,17 @@ const TabHome = { // Usage card (spans full width on second row) h += '
'; - h += ''; + h += + ''; h += '
'; - h += ''; + h += + ''; h += '
'; h += '
'; h += `

${i18n('usage')}

`; h += ''; - h += ''; + h += + ''; h += 'Monthly overview'; h += ''; h += '
'; @@ -189,9 +212,33 @@ const TabHome = { return h; }, - init ($el_window) { + init($el_window) { this.loadRecentApps($el_window); this.loadUsageData($el_window); + // Auto-refresh sources: + // 1. `puter:subscription:changed` — fired by upgrade dialog + // after subscribe / change / cancel / uncancel. + // 2. Visibility / focus — user returning to puter (e.g. from + // Stripe Customer Portal in another tab). Portal mutations + // bypass our dispatch, so we re-pull state + broadcast. + const refresh = () => this.loadUsageData($el_window); + const refreshAndBroadcast = async () => { + refresh(); + try { + await window.refresh_user_data?.(puter.authToken); + } catch {} + try { + window.dispatchEvent( + new CustomEvent('puter:subscription:changed'), + ); + } catch {} + }; + window.addEventListener('puter:subscription:changed', refresh); + const onVisibility = () => { + if (document.visibilityState === 'visible') refreshAndBroadcast(); + }; + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('focus', refreshAndBroadcast); // Handle app clicks $el_window.on('click', '.bento-recent-app', function (e) { @@ -199,23 +246,30 @@ const TabHome = { e.stopPropagation(); const appName = $(this).attr('data-app-name'); const targetLink = $(this).attr('data-target-link'); - if ( targetLink && targetLink !== '' ) { + if (targetLink && targetLink !== '') { window.open(targetLink, '_blank'); - } - else if ( appName ) { + } else if (appName) { window.open(`/app/${appName}`, '_blank'); } }); // Handle "View details" link clicks - $el_window.on('click', '.bento-view-more, .bento-usage-card-header, .bento-card-fancy-header[data-target-tab]', function (e) { - e.preventDefault(); - const targetTab = $(this).attr('data-target-tab'); - if ( targetTab ) { - // Trigger click on the corresponding sidebar item - $el_window.find(`.dashboard-sidebar-item[data-section="${targetTab}"]`).click(); - } - }); + $el_window.on( + 'click', + '.bento-view-more, .bento-usage-card-header, .bento-card-fancy-header[data-target-tab]', + function (e) { + e.preventDefault(); + const targetTab = $(this).attr('data-target-tab'); + if (targetTab) { + // Trigger click on the corresponding sidebar item + $el_window + .find( + `.dashboard-sidebar-item[data-section="${targetTab}"]`, + ) + .click(); + } + }, + ); // Handle desktop switch button $el_window.on('click', '.bento-desktop-switch-btn', function () { @@ -237,15 +291,15 @@ const TabHome = { draggable_body: false, }, }).then(function (is_saved) { - if ( is_saved ) { + if (is_saved) { $el_window.find('.bento-save-account-warning').hide(); } }); }); }, - async loadRecentApps ($el_window) { - if ( ! window.launch_apps?.recent?.length ) { + async loadRecentApps($el_window) { + if (!window.launch_apps?.recent?.length) { try { window.launch_apps = await $.ajax({ url: `${window.api_origin}/get-launch-apps?icon_size=64`, @@ -253,36 +307,89 @@ const TabHome = { async: true, contentType: 'application/json', headers: { - 'Authorization': `Bearer ${window.auth_token}`, + Authorization: `Bearer ${window.auth_token}`, }, }); } catch (e) { console.error('Failed to load launch apps:', e); } } - $el_window.find('.bento-recent-apps-container').html(buildRecentAppsHTML()); + $el_window + .find('.bento-recent-apps-container') + .html(buildRecentAppsHTML()); }, - async loadUsageData ($el_window) { - // Load plan data + async loadUsageData($el_window) { + // Load plan data — fetch live from /marketplace/subscriptions/current + // rather than reading `window.user.subscription` (which is set once + // from whoami at page-load and goes stale after subscribe / portal + // cancel until a hard refresh). try { - const hasSubscription = window.user?.subscription?.active; - const planName = window.user?.subscription?.offering?.name || 'free'; + let subscription = null; + try { + const resp = await fetch( + `${window.api_origin}/marketplace/subscriptions/current`, + { + headers: { Authorization: `Bearer ${puter.authToken}` }, + }, + ); + if (resp.ok) { + const data = await resp.json(); + subscription = data?.subscription ?? null; + } + } catch { + // fall through to free state + } + + const pastDue = + !!subscription && subscription.status === 'past_due'; + // `past_due` keeps benefits during Stripe's dunning window, so + // it still counts as having a plan — we just flag it. + const hasSubscription = + !!subscription && + (subscription.status === 'active' || + subscription.status === 'trialing' || + subscription.status === 'cancel_pending' || + pastDue); + const planName = subscription?.tier || 'free'; $el_window.find('.bento-plan-name').text(i18n(planName)); - if ( hasSubscription ) { - $el_window.find('.bento-plan-badge').text('Current').addClass('active'); + // Reset state-dependent classes / warning each (re)render. + const $badge = $el_window + .find('.bento-plan-badge') + .removeClass('active free past-due'); + const $warning = $el_window + .find('.bento-plan-warning') + .hide() + .text(''); + + if (hasSubscription) { + if (pastDue) { + $badge.text('Payment past due').addClass('past-due'); + $warning + .text( + 'Your last payment failed. Update your payment method to keep your subscription — access will be revoked shortly.', + ) + .show(); + } else { + $badge.text('Current').addClass('active'); + } $el_window.find('.bento-plan-upgrade').text('Manage →').show(); } else { - $el_window.find('.bento-plan-badge').text('Upgrade for more features').addClass('free'); + $badge.text('Upgrade for more features').addClass('free'); $el_window.find('.bento-plan-upgrade').show(); } - $el_window.find('.bento-plan-upgrade').off('click.billing').on('click.billing', (e) => { - e.preventDefault(); - window.puterLegacyBilling?.openSubscriptionsDialog?.(); - }); + $el_window + .find('.bento-plan-upgrade') + .off('click.billing') + .on('click.billing', (e) => { + e.preventDefault(); + if (typeof window.UIUpgradeAccount === 'function') { + new window.UIUpgradeAccount().open_as_window(); + } + }); } catch (e) { console.error('Failed to load plan data:', e); } @@ -290,17 +397,23 @@ const TabHome = { // Load storage data try { const res = await puter.fs.space(); - let usage_percentage = (res.used / res.capacity * 100).toFixed(0); + let usage_percentage = ((res.used / res.capacity) * 100).toFixed(0); usage_percentage = usage_percentage > 100 ? 100 : usage_percentage; let general_used = res.used; - if ( res.host_used ) { + if (res.host_used) { general_used = res.host_used; } - $el_window.find('.bento-storage-used').text(`${window.byte_format(general_used)} Used`); - $el_window.find('.bento-storage-capacity').text(window.byte_format(res.capacity)); - $el_window.find('.bento-storage-percent').text(`${usage_percentage}%`); + $el_window + .find('.bento-storage-used') + .text(`${window.byte_format(general_used)} Used`); + $el_window + .find('.bento-storage-capacity') + .text(window.byte_format(res.capacity)); + $el_window + .find('.bento-storage-percent') + .text(`${usage_percentage}%`); $el_window.find('.bento-storage-bar').css({ width: `${usage_percentage}%`, 'background-color': window.usage_bar_color(usage_percentage), @@ -315,21 +428,38 @@ const TabHome = { let monthlyAllowance = res.allowanceInfo?.monthUsageAllowance; let remaining = res.allowanceInfo?.remaining; let totalUsage = monthlyAllowance - remaining; - let totalUsagePercentage = (totalUsage / monthlyAllowance * 100).toFixed(0); + let totalUsagePercentage = ( + (totalUsage / monthlyAllowance) * + 100 + ).toFixed(0); - $el_window.find('.bento-resources-used').text(`${window.number_format(totalUsage / 100_000_000, { decimals: 2, prefix: '$' })} Used`); - $el_window.find('.bento-resources-capacity').text(window.number_format(monthlyAllowance / 100_000_000, { decimals: 2, prefix: '$' })); - $el_window.find('.bento-resources-percent').text(`${totalUsagePercentage}%`); + $el_window + .find('.bento-resources-used') + .text( + `${window.number_format(totalUsage / 100_000_000, { decimals: 2, prefix: '$' })} Used`, + ); + $el_window + .find('.bento-resources-capacity') + .text( + window.number_format(monthlyAllowance / 100_000_000, { + decimals: 2, + prefix: '$', + }), + ); + $el_window + .find('.bento-resources-percent') + .text(`${totalUsagePercentage}%`); $el_window.find('.bento-resources-bar').css({ width: `${totalUsagePercentage}%`, - 'background-color': window.usage_bar_color(totalUsagePercentage), + 'background-color': + window.usage_bar_color(totalUsagePercentage), }); } catch (e) { console.error('Failed to load monthly usage data:', e); } }, - onActivate ($el_window) { + onActivate($el_window) { this.loadRecentApps($el_window); this.loadUsageData($el_window); }, diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 865a4e208..4dd1f2a2a 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -1473,6 +1473,28 @@ input.myapps-search::placeholder { border: 1px solid var(--dashboard-success-border); } +.dashboard .bento-plan-badge.past-due { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + color: var(--dashboard-danger-text); + background: var(--dashboard-danger-background); + border: 1px solid var(--dashboard-danger-border); +} + +.dashboard .bento-plan-warning { + margin-top: 10px; + padding: 8px 10px; + border-radius: 8px; + color: var(--dashboard-danger-text); + background: var(--dashboard-danger-background); + border: 1px solid var(--dashboard-danger-border); + font-size: 12px; + line-height: 1.4; +} + .dashboard .bento-plan-upgrade { font-size: 14px; font-weight: 500; diff --git a/tools/lib/configMigration.mjs b/tools/lib/configMigration.mjs index 9ecf79a8b..90de5311c 100644 --- a/tools/lib/configMigration.mjs +++ b/tools/lib/configMigration.mjs @@ -258,17 +258,36 @@ export const transformToV2 = (source) => { copyIfSet(h, 'gui_css', out); } - // Legacy billing consolidation (stripe/offerings/__subs-serve → legacyBilling). - const legacyBilling = {}; + // Billing / Stripe consolidation. v1 split these across `stripe`, + // `offerings`, and `__subs-serve`; v2 lands everything under the + // `appStoreAndPurchases` extension (puter-paid subscriptions, the + // marketplace, and webhook handling all share one Stripe client). + const appStoreAndPurchases = (out.appStoreAndPurchases ?? {}); if ( svc.stripe ) { - if ( svc.stripe.api_secret ) legacyBilling.api_secret = svc.stripe.api_secret; - if ( svc.stripe.endpoint_secret ) legacyBilling.endpoint_secret = svc.stripe.endpoint_secret; + if ( svc.stripe.api_secret ) appStoreAndPurchases.api_secret = svc.stripe.api_secret; + if ( svc.stripe.endpoint_secret ) appStoreAndPurchases.endpoint_secret = svc.stripe.endpoint_secret; } if ( svc['__subs-serve']?.stripe_publishable_key ) { - legacyBilling.stripe_publishable_key = svc['__subs-serve'].stripe_publishable_key; + appStoreAndPurchases.stripe_publishable_key = svc['__subs-serve'].stripe_publishable_key; } - if ( svc.offerings?.price_ids ) legacyBilling.price_ids = svc.offerings.price_ids; - if ( Object.keys(legacyBilling).length ) out.legacyBilling = legacyBilling; + if ( svc.offerings?.price_ids ) { + appStoreAndPurchases.puterPaid = appStoreAndPurchases.puterPaid ?? {}; + appStoreAndPurchases.puterPaid.price_ids = svc.offerings.price_ids; + } + // Forward an already-migrated `legacyBilling` block too, in case the + // input config was migrated against an older version of this tool. + if ( source.legacyBilling ) { + if ( source.legacyBilling.api_secret ) appStoreAndPurchases.api_secret ??= source.legacyBilling.api_secret; + if ( source.legacyBilling.endpoint_secret ) appStoreAndPurchases.endpoint_secret ??= source.legacyBilling.endpoint_secret; + if ( source.legacyBilling.stripe_publishable_key ) { + appStoreAndPurchases.stripe_publishable_key ??= source.legacyBilling.stripe_publishable_key; + } + if ( source.legacyBilling.price_ids ) { + appStoreAndPurchases.puterPaid = appStoreAndPurchases.puterPaid ?? {}; + appStoreAndPurchases.puterPaid.price_ids ??= source.legacyBilling.price_ids; + } + } + if ( Object.keys(appStoreAndPurchases).length ) out.appStoreAndPurchases = appStoreAndPurchases; // Abuse / clickhouse / cf_file_cache pass through if already top-level. if ( source.abuse ) out.abuse = source.abuse; @@ -465,7 +484,7 @@ export const transformToV2 = (source) => { 'allow_no_host_header', 'allow_nipio_domains', 'custom_domains_enabled', 'enable_ip_validation', 'default_user_group', 'default_temp_group', - 'abuse', 'clickhouse', 'cf_file_cache', 'legacyBilling', + 'abuse', 'clickhouse', 'cf_file_cache', 'legacyBilling', 'appStoreAndPurchases', 'providers', 'thumbnailStore', 'redis', 'extension', ...droppedTopKeys,