| 1 | /** |
| 2 | * What a workspace without the g1t plan may keep in files put in its pages |
| 3 | * and artifacts (images, attachments): `FREE_DOC_FILES_BYTES` (1 GiB, a |
| 4 | * wrangler var; `0` is no cap). Past it, a new upload is refused with a |
| 5 | * message saying so; nothing already kept is touched. Billing is asked |
| 6 | * only once the workspace is past the cap, and when it does not answer the |
| 7 | * upload goes ahead. |
| 8 | */ |
| 9 | import type { ServiceBinding } from "@g1t/contracts"; |
| 10 | |
| 11 | export const DEFAULT_FREE_DOC_FILES_BYTES = 1024 * 1024 * 1024; |
| 12 | |
| 13 | export type QuotaEnv = { BILLING?: ServiceBinding; FREE_DOC_FILES_BYTES?: string }; |
| 14 | |
| 15 | /** The cap from its var: the default when unset or not a whole number; 0 is none. */ |
| 16 | export function freeFilesCap(raw: string | undefined): number { |
| 17 | const value = Number((raw ?? "").trim()); |
| 18 | return raw !== undefined && raw.trim() !== "" && Number.isInteger(value) && value >= 0 ? value : DEFAULT_FREE_DOC_FILES_BYTES; |
| 19 | } |
| 20 | |
| 21 | /** `1 GB`, `1.5 GB`, `300 MB`. */ |
| 22 | export function shownBytes(bytes: number): string { |
| 23 | const gb = 1024 * 1024 * 1024; |
| 24 | if (bytes >= gb) { |
| 25 | const tenths = Math.ceil((bytes * 10) / gb); |
| 26 | return tenths % 10 === 0 ? `${tenths / 10} GB` : `${(tenths / 10).toFixed(1)} GB`; |
| 27 | } |
| 28 | return `${Math.ceil(bytes / (1024 * 1024))} MB`; |
| 29 | } |
| 30 | |
| 31 | /** What a free workspace is told when `adding` would take it past `cap`; null while it fits. */ |
| 32 | export function filesRefusal(held: number, adding: number, cap: number): string | null { |
| 33 | if (cap <= 0 || held + adding <= cap) return null; |
| 34 | return `This workspace's files hold ${shownBytes(held)} of the ${shownBytes(cap)} a workspace on the free plan keeps, so this file is not kept. Delete files you no longer need, or start the g1t plan, which has no such cap.`; |
| 35 | } |
| 36 | |
| 37 | /** Why an upload of `adding` bytes to `workspace` is refused, or null. */ |
| 38 | export async function uploadRefused( |
| 39 | db: D1Database, |
| 40 | env: QuotaEnv, |
| 41 | workspace: { id: string; slug: string }, |
| 42 | adding: number, |
| 43 | ): Promise<string | null> { |
| 44 | const cap = freeFilesCap(env.FREE_DOC_FILES_BYTES); |
| 45 | if (cap <= 0) return null; |
| 46 | const row = await db |
| 47 | .prepare( |
| 48 | `SELECT (SELECT COALESCE(SUM(bytes), 0) FROM files WHERE workspace_id = ?1) |
| 49 | + (SELECT COALESCE(SUM(bytes), 0) FROM folio_files WHERE workspace_id = ?1) AS bytes`, |
| 50 | ) |
| 51 | .bind(workspace.id) |
| 52 | .first<{ bytes: number }>(); |
| 53 | const why = filesRefusal(row?.bytes ?? 0, adding, cap); |
| 54 | if (!why || !env.BILLING) return null; |
| 55 | try { |
| 56 | // Loaded here, so the rules above test under Node without the contracts' runtime. |
| 57 | const { billingClient } = await import("@g1t/contracts"); |
| 58 | const free = await billingClient(env.BILLING).freeWorkspaces([workspace.slug]); |
| 59 | return free.map((slug) => slug.toLowerCase()).includes(workspace.slug.toLowerCase()) ? why : null; |
| 60 | } catch (error) { |
| 61 | console.error("artifacts: billing did not say whether the workspace is free", workspace.slug, error); |
| 62 | return null; |
| 63 | } |
| 64 | } |