A free workspace keeps up to 1 GB of files in its docs and artifacts. Images and attachments were capped at 25 MB each with no total, kept in R2 and served from g1tusercontent.com, so a free workspace could use g1t as a file host. Each upload, from the page editor, an artifact or an agent, now adds up the workspace's files (migration 0008 indexes them by workspace) and, past FREE_DOC_FILES_BYTES (1 GiB, a wrangler var; 0 is no cap), asks billing whether the workspace is free; if it is, the file is refused with a message saying how much it holds and how to make room. The artifacts service gains a BILLING binding for that. Billing is asked only past the cap, and when it does not answer the upload goes ahead. Files already kept are not touched. The artifacts guide says so.
7 files+112−40/7 viewed
| 204 | 204 | by title; it lists yours under **Linked from**. | |
| 205 | 205 | - **Add icon** above the title gives the doc an emoji. | |
| 206 | 206 | - Images and files are served from `g1tusercontent.com`, never from `g1t.sh`, | |
| 207 | − | up to 25 MB each. | |
| 207 | + | up to 25 MB each. A workspace on the free plan keeps up to 1 GB of them, | |
| 208 | + | across all its docs and artifacts; past it, a new file is refused and the | |
| 209 | + | upload says so. Files already there stay. On the g1t plan there is no | |
| 210 | + | such cap. | |
| 208 | 211 | ||
| 209 | 212 | Docs are live: everyone on one sees each other's changes and cursors, and the | |
| 210 | 213 | faces at the top are who's there now, agents included. When your connection |
| 1 | + | -- What a workspace's files hold, added up before each upload (src/quota.ts). | |
| 2 | + | CREATE INDEX IF NOT EXISTS files_by_workspace ON files (workspace_id); | |
| 3 | + | CREATE INDEX IF NOT EXISTS folio_files_by_workspace ON folio_files (workspace_id); |
| 11 | 11 | * more than that person can, narrowed to their audience | |
| 12 | 12 | * (src/folios/agents.ts). | |
| 13 | 13 | */ | |
| 14 | + | import { type QuotaEnv, uploadRefused } from "../quota.ts"; | |
| 14 | 15 | import { | |
| 15 | 16 | DOCS_VIEWER_HEADER, | |
| 16 | 17 | DOC_MAX_FILE_BYTES, | |
| ⋯ | |||
| 116 | 117 | import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts"; | |
| 117 | 118 | import { deleteSpaceRows, eraseFolios, landingFor } from "./trash.ts"; | |
| 118 | 119 | ||
| 119 | − | export type FoliosEnv = WhoEnv & { | |
| 120 | + | export type FoliosEnv = WhoEnv & QuotaEnv & { | |
| 120 | 121 | FOLIOS: DurableObjectNamespace<FolioRoom>; | |
| 121 | 122 | NOTIFY?: ServiceBinding; | |
| 122 | 123 | EVENTS?: ServiceBinding; | |
| ⋯ | |||
| 2302 | 2303 | } | |
| 2303 | 2304 | if (!bytes.length) return fail("invalid", "The file is empty."); | |
| 2304 | 2305 | if (bytes.length > DOC_MAX_FILE_BYTES) return fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`); | |
| 2306 | + | const refused = await uploadRefused(this.db, this.env, actx.workspace, bytes.length); | |
| 2307 | + | if (refused) return fail("limit", refused); | |
| 2305 | 2308 | const name = safeName(a.file?.name ?? "file"); | |
| 2306 | 2309 | const contentType = servedType(a.file?.content_type ?? ""); | |
| 2307 | 2310 | const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| ⋯ | |||
| 2580 | 2583 | if (!opened.ok) return Response.json(opened); | |
| 2581 | 2584 | const bytes = Number(request.headers.get("content-length") ?? "0"); | |
| 2582 | 2585 | if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`)); | |
| 2586 | + | const refused = await uploadRefused(this.db, this.env, ctx.workspace, bytes); | |
| 2587 | + | if (refused) return Response.json(fail("limit", refused)); | |
| 2583 | 2588 | const name = safeName(url.searchParams.get("name") ?? "file"); | |
| 2584 | 2589 | const contentType = servedType(request.headers.get("content-type") ?? ""); | |
| 2585 | 2590 | const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 20 | 20 | * tables until Phase 7 retires them. | |
| 21 | 21 | */ | |
| 22 | 22 | ||
| 23 | + | import { type QuotaEnv, uploadRefused } from "./quota.ts"; | |
| 23 | 24 | import { | |
| 24 | 25 | DOCS_VIEWER_HEADER, | |
| 25 | 26 | DOC_MAX_FILE_BYTES, | |
| ⋯ | |||
| 109 | 110 | export { PageRoom } from "./room.ts"; | |
| 110 | 111 | export { FolioRoom } from "./folios/room.ts"; | |
| 111 | 112 | ||
| 112 | − | type Env = FileStoreEnv & { | |
| 113 | + | type Env = FileStoreEnv & QuotaEnv & { | |
| 113 | 114 | DB: D1Database; | |
| 114 | 115 | IDENTITY: ServiceBinding; | |
| 115 | 116 | AGENTS: ServiceBinding; | |
| ⋯ | |||
| 2587 | 2588 | if (!found.ok) return Response.json(found); | |
| 2588 | 2589 | const bytes = Number(request.headers.get("content-length") ?? "0"); | |
| 2589 | 2590 | if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`)); | |
| 2591 | + | const refused = await uploadRefused(this.db, this.env, found.value.workspace, bytes); | |
| 2592 | + | if (refused) return Response.json(fail("limit", refused)); | |
| 2590 | 2593 | const name = safeName(url.searchParams.get("name") ?? "file"); | |
| 2591 | 2594 | const contentType = servedType(request.headers.get("content-type") ?? ""); | |
| 2592 | 2595 | const random = crypto.getRandomValues(new Uint8Array(32)); | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { DEFAULT_FREE_DOC_FILES_BYTES, filesRefusal, freeFilesCap, shownBytes } from "./quota.ts"; | |
| 5 | + | ||
| 6 | + | const GB = 1024 * 1024 * 1024; | |
| 7 | + | ||
| 8 | + | test("the cap comes from its var, 1 GB unless set, and 0 is none", () => { | |
| 9 | + | assert.equal(freeFilesCap(undefined), DEFAULT_FREE_DOC_FILES_BYTES); | |
| 10 | + | assert.equal(freeFilesCap(""), DEFAULT_FREE_DOC_FILES_BYTES); | |
| 11 | + | assert.equal(freeFilesCap("lots"), DEFAULT_FREE_DOC_FILES_BYTES); | |
| 12 | + | assert.equal(freeFilesCap(" 5368709120 "), 5 * GB); | |
| 13 | + | assert.equal(freeFilesCap("0"), 0); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("a free workspace's upload past the cap is refused, and says how to make room", () => { | |
| 17 | + | assert.equal(filesRefusal(0.5 * GB, 0.25 * GB, GB), null); | |
| 18 | + | const refused = filesRefusal(GB - 100, 1000, GB); | |
| 19 | + | assert.ok(refused?.includes("of the 1 GB"), refused ?? ""); | |
| 20 | + | assert.ok(refused?.includes("start the g1t plan"), refused ?? ""); | |
| 21 | + | assert.equal(filesRefusal(100 * GB, GB, 0), null); | |
| 22 | + | assert.equal(shownBytes(300 * 1024 * 1024), "300 MB"); | |
| 23 | + | assert.equal(shownBytes(1.5 * GB), "1.5 GB"); | |
| 24 | + | }); |
| 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 | + | } |
| 67 | 67 | // What a merged pull request changed. | |
| 68 | 68 | { "binding": "WORK", "service": "g1t-work" }, | |
| 69 | 69 | // doc.page.* events on the bus (src/events.ts). | |
| 70 | − | { "binding": "EVENTS", "service": "g1t-events" } | |
| 70 | + | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 71 | + | // Whether a workspace has the plan, once its files pass the free cap (src/quota.ts). | |
| 72 | + | { "binding": "BILLING", "service": "g1t-billing" } | |
| 71 | 73 | ], | |
| 74 | + | // What a workspace without the plan may keep in files put in its pages | |
| 75 | + | // and artifacts, in bytes; past it, new uploads are refused (src/quota.ts). | |
| 76 | + | // 0 is no cap. | |
| 77 | + | "vars": { "FREE_DOC_FILES_BYTES": "1073741824" }, | |
| 72 | 78 | // Merges and pushes to default branches: pages whose cited code | |
| 73 | 79 | // changed, and projects' docs read again (src/staleness.ts). The events | |
| 74 | 80 | // service sends it what g1t_contracts::subscribers routes to |
Comments
Sign in to comment.