mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(cloud): launch pricing guardrails — egress caps, ignore defaults, storage tiering (#114)
QuotaProvider grows a read half: CheckRead(org, bytes) and RecordEgress(org, bytes). CheckRead is enforced on /s/* only — a public share link is the sole unauthenticated door to stored bytes, so it is the only egress a plan can cap. The sync proxy and viewer merely RecordEgress: refusing a device mid-cycle surfaces as ErrForbidden, which the syncer reads as "access is gone — pause and touch nothing", and sync must never break over a bill. UnlimitedQuota stays the OSS default. countingWriter bills what actually reached the client rather than a size claimed before the write. bdrive init warns past 1 GiB or 20k files and says how to narrow scope; syncer.Measure sizes that through the real Filter and the one walkFolder predicate. starterIgnore gains video/archive/disk-image globs and Library/ — every version is kept forever, so a big binary committed once is paid for forever on every device. deploy: a Nearline-at-30-days lifecycle rule and the arithmetic for why it stops there. Coldline and Archive only pay off below roughly one read per month, and a first sync pulls every historical blob rather than just the current tree, so blob read rate tracks device onboarding. docs/launch-plan.md said Cloud was waitlist-only and framed Product Hunt as an OSS launch whose goal was not signups or revenue; both are stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
77a68542ea
commit
e02bd5330d
@@ -81,7 +81,12 @@ classDiagram
|
||||
+walkFolder(folder, filter, fn)
|
||||
verdict: vSync vSkipFile vDescend vPruneDir vNested
|
||||
}
|
||||
note for walkFolder "walk.go — the ONLY copy of the sync predicate; scan and Explain both go through it, so what --explain reports cannot drift from what leaves"
|
||||
note for walkFolder "walk.go — the ONLY copy of the sync predicate; scan, Explain and Measure all go through it, so what --explain reports and what init warns about cannot drift from what leaves"
|
||||
|
||||
class Measure {
|
||||
+Measure(folder, include) files, bytes
|
||||
}
|
||||
note for Measure "walk.go — sizes what a FIRST sync would upload, for the oversized-folder warning bdrive init prints (1 GiB / 20k files). Filter-aware on purpose: a 40 GB repo whose bulk is already ignored measures as the few MB that really sync, so the warning fires on the folder nobody meant to share and stays quiet on an ordinary checkout. Advice only — an unreadable subtree is skipped, never fatal"
|
||||
|
||||
class Explain {
|
||||
+Explain(folder, include, accepted) two lists
|
||||
@@ -135,6 +140,8 @@ classDiagram
|
||||
Session --> Filter : SkipUp on scan, Skip on materialize
|
||||
Session --> walkFolder : scan
|
||||
Explain --> walkFolder : same predicate
|
||||
Measure --> walkFolder : same predicate
|
||||
Measure --> Filter : own fresh instance
|
||||
Explain --> Filter : own fresh instance
|
||||
Explain ..> Entry : not-synced lines
|
||||
walkFolder --> Filter : SkipUp / PruneDir / addNestedMount
|
||||
|
||||
@@ -278,8 +278,17 @@ classDiagram
|
||||
+CheckWrite(org, bytes)
|
||||
+CheckSeat(org, members)
|
||||
+RecordUsage(org, bytes)
|
||||
+CheckRead(org, bytes)
|
||||
+RecordEgress(org, bytes)
|
||||
}
|
||||
class UnlimitedQuota
|
||||
note for QuotaProvider "The read half is deliberately ASYMMETRIC. CheckRead is called on /s/* and nowhere else: a public share link is the only unauthenticated door to stored bytes, so it is the only egress a plan can cap and the only bandwidth number worth publishing. Refusing a device mid-sync would surface as ErrForbidden, which the syncer reads as 'access is gone — pause and touch nothing', so /store/* and the viewer only RecordEgress. Sync must never break over a bill"
|
||||
class countingWriter {
|
||||
<<quota.go>>
|
||||
+Write(p) n
|
||||
+n int64
|
||||
}
|
||||
note for countingWriter "Bills what actually reached the client. FileInfo.Size and the journal's Size are claims made BEFORE the write; a reader who abandons a download halfway must not be charged for the whole file"
|
||||
|
||||
class grant {
|
||||
+project +org +key
|
||||
@@ -314,6 +323,8 @@ classDiagram
|
||||
Server *-- reservations : holds before it charges
|
||||
reservations *-- grant
|
||||
reservations ..> QuotaProvider : CheckWrite(size + outstanding), RecordUsage on landing
|
||||
ShareDB ..> QuotaProvider : CheckRead before the stream, RecordEgress after
|
||||
Server ..> countingWriter : every bytes-out route that bills
|
||||
reservations ..> Backend : reconcile — did the blob land
|
||||
Server *-- journalDoor : /store/* is the only way a device writes
|
||||
journalDoor ..> DeviceRegistry : OwnerOf gates the journal key
|
||||
|
||||
+78
-3
@@ -22,13 +22,21 @@ import (
|
||||
"github.com/runbear-io/beardrive/internal/config"
|
||||
"github.com/runbear-io/beardrive/internal/remote"
|
||||
"github.com/runbear-io/beardrive/internal/store"
|
||||
"github.com/runbear-io/beardrive/internal/syncer"
|
||||
"github.com/runbear-io/beardrive/internal/templates"
|
||||
)
|
||||
|
||||
// starterIgnore is seeded into new projects so build artifacts and
|
||||
// dependency trees don't flood the sync. Users edit it freely; it syncs to
|
||||
// every device like a normal file.
|
||||
// starterIgnore is seeded into new projects so build artifacts, dependency
|
||||
// trees and large binaries don't flood the sync. Users edit it freely; it
|
||||
// syncs to every device like a normal file.
|
||||
//
|
||||
// Deliberately aggressive: every version of every file is retained forever,
|
||||
// so a big binary checked in once is paid for forever, on every device that
|
||||
// ever syncs the project. Anything genuinely wanted back is one deleted line
|
||||
// away, which is much cheaper than the reverse.
|
||||
const starterIgnore = `# bdrive ignore rules (gitignore-style). This file syncs across devices.
|
||||
|
||||
# Dependency trees and build output
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
@@ -41,12 +49,75 @@ __pycache__/
|
||||
venv/
|
||||
.next/
|
||||
.cache/
|
||||
|
||||
# (.git/ and .bdrive/ are never synced — that is built in, not a rule here,
|
||||
# so removing a line cannot turn it on.)
|
||||
|
||||
# Large binaries. Sync the document, link the video.
|
||||
*.mp4
|
||||
*.mov
|
||||
*.avi
|
||||
*.mkv
|
||||
*.iso
|
||||
*.dmg
|
||||
*.zip
|
||||
*.tar.gz
|
||||
|
||||
# macOS library/junk
|
||||
Library/
|
||||
.DS_Store
|
||||
|
||||
# Local-only
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
`
|
||||
|
||||
// warnBigFolder prints a warning when the folder about to sync is far larger
|
||||
// than anything a shared knowledge project should be, and says how to narrow
|
||||
// it. Advice only — it never blocks init and never fails it.
|
||||
//
|
||||
// The thresholds are guardrails, not policy: they exist to catch the "pointed
|
||||
// bdrive at my home directory" mistake while it is still cheap to undo, since
|
||||
// every version is retained forever and each new device pulls the whole
|
||||
// history.
|
||||
const (
|
||||
bigFolderBytes = 1 << 30 // 1 GiB
|
||||
bigFolderFiles = 20_000
|
||||
)
|
||||
|
||||
func warnBigFolder(folder string, include []string) {
|
||||
files, bytes, err := syncer.Measure(folder, include)
|
||||
if err != nil || (bytes < bigFolderBytes && files < bigFolderFiles) {
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n⚠ this folder would sync %s across %s.\n", humanSize(bytes), plural(files, "file"))
|
||||
fmt.Printf(" Every version is kept forever and every new device pulls all of it.\n")
|
||||
fmt.Printf(" If that's more than you meant to share:\n")
|
||||
fmt.Printf(" bdrive scope add <dir> sync only certain folders\n")
|
||||
fmt.Printf(" bdrive scope --explain show what is and isn't syncing\n")
|
||||
fmt.Printf(" or add patterns to .bdriveignore.\n\n")
|
||||
}
|
||||
|
||||
// humanSize renders a byte count for the warning above.
|
||||
func humanSize(n int64) string {
|
||||
switch {
|
||||
case n >= 1<<30:
|
||||
return fmt.Sprintf("%.1f GB", float64(n)/(1<<30))
|
||||
case n >= 1<<20:
|
||||
return fmt.Sprintf("%.0f MB", float64(n)/(1<<20))
|
||||
default:
|
||||
return fmt.Sprintf("%d KB", n/(1<<10))
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int, unit string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("%d %s", n, unit)
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", n, unit)
|
||||
}
|
||||
|
||||
// initCmd is the front door: sign in if needed, create or connect a project,
|
||||
// choose what syncs, and start syncing — one command, interactive on a TTY,
|
||||
// fully flag-driven for scripts and agents. Re-running it in an initialized
|
||||
@@ -325,6 +396,10 @@ the folder was renamed or moved.`,
|
||||
}
|
||||
fmt.Printf(" syncing: %s only (rules written to .bdriveignore)\n", strings.Join(dirs, ", "))
|
||||
}
|
||||
// After the ignore file and any scope rules are on disk, so the
|
||||
// measurement is of what would REALLY sync — and before the first
|
||||
// cycle, so narrowing is still free.
|
||||
warnBigFolder(folder, proj.Include)
|
||||
if err := startSync(cmd.Context(), folder, proj, foreground, 3*time.Second, 10*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+60
-2
@@ -74,8 +74,66 @@ so the deploy config temporarily allows **domain-gated self-signup**
|
||||
set `--min-instances 0` to save more, at the cost of cold-start journal
|
||||
folding on first hit). ~$5–15/mo warm.
|
||||
- **Cloud SQL** `db-f1-micro`: ~$8–15/mo (smallest shared-core tier).
|
||||
- **GCS**: pay per GB stored + egress. Cheap for text; consider a lifecycle
|
||||
policy later.
|
||||
- **GCS**: pay per GB stored + egress. Cheap for text. `gcp-cloudrun.sh`
|
||||
installs a Nearline-at-30-days lifecycle rule by default (`LIFECYCLE=0`
|
||||
skips it) — see below for why it stops there.
|
||||
|
||||
## Storage tiering
|
||||
|
||||
Every version of every file is retained forever, so stored bytes only ever
|
||||
grow while per-seat revenue stays flat. Aging objects down is the lever.
|
||||
|
||||
Prices below are us-central1 regional, verified 2026-08-03 — re-check before
|
||||
relying on them, GCP moves them.
|
||||
|
||||
| Class | Storage $/GB/mo | Retrieval $/GB | Min duration |
|
||||
|---|---|---|---|
|
||||
| Standard | 0.020 | — | none |
|
||||
| Nearline | 0.010 | 0.01 | 30 days |
|
||||
| Coldline | 0.004 | 0.02 | 90 days |
|
||||
| Archive | 0.0012 | 0.05 | 365 days |
|
||||
|
||||
Archive really is ~6% of Standard, and GCS serves every class at the same
|
||||
millisecond latency — there is no restore job to wait on. The catch is the
|
||||
retrieval fee, so the break-even is entirely about **how often a given object
|
||||
is read**. Writing `r` for reads per GB per month:
|
||||
|
||||
- Nearline beats Standard while `r < 1.0/mo`
|
||||
- Coldline beats Nearline while `r < 0.6/mo`
|
||||
- Archive beats Coldline while `r < 0.09/mo` (about once a year)
|
||||
|
||||
**This is why the script stops at Nearline.** The tempting assumption is that
|
||||
old blobs are cold because old versions are rarely opened. That is not true
|
||||
here: a device syncing a project for the first time downloads a blob for
|
||||
*every put op in every peer journal* — the entire history, not just the
|
||||
current file tree (`internal/syncer` `pull`). Measured on a 10-version file
|
||||
whose working tree is 1 KB, a fresh device pulls 10 KB.
|
||||
|
||||
So the read rate on old blobs tracks **how often anyone adds a device**, not
|
||||
how often anyone opens an old version. A team that adds or replaces roughly
|
||||
one device a month drives `r ≈ 1`, which makes Coldline a wash and Archive a
|
||||
straight bill increase.
|
||||
|
||||
Two consequences worth acting on, in this order:
|
||||
|
||||
1. **The real lever is not the lifecycle policy.** Making a first sync fetch
|
||||
only current-state blobs (history stays available on demand through the
|
||||
existing `/blob?sha=` route) cuts onboarding egress from "all history" to
|
||||
"the working tree" *and* makes old blobs genuinely cold — which is what
|
||||
makes Coldline and Archive safe to turn on afterwards. Until then the
|
||||
ladder is priced against a read pattern the sync engine does not have.
|
||||
2. **Egress scales with devices × total history**, not with change volume,
|
||||
and every byte is relayed: blob reads have no presigned path (only
|
||||
`remote.PutSigner` exists — uploads can go direct to storage, downloads
|
||||
cannot), so they stream GCS → Cloud Run → client. Same-region GCS→Cloud Run
|
||||
transfer is free, so this is one egress charge, not two — but it does
|
||||
occupy the single `max-instances=1` container for the whole transfer.
|
||||
|
||||
The lifecycle rule is applied bucket-wide rather than to `blobs/` alone
|
||||
because journals live under the same per-project prefixes and a GCS
|
||||
lifecycle `matchesPrefix` cannot express `*/blobs/`. That is safe: a peer
|
||||
journal is re-fetched only when the listing shows it grew, and one that grew
|
||||
was just rewritten, so it is Standard again.
|
||||
|
||||
## Notes / limits (single-instance build)
|
||||
|
||||
|
||||
@@ -41,6 +41,43 @@ echo "== GCS bucket for blobs/journals =="
|
||||
gcloud storage buckets describe "gs://$BUCKET" >/dev/null 2>&1 || \
|
||||
gcloud storage buckets create "gs://$BUCKET" --location "$REGION" --uniform-bucket-level-access
|
||||
|
||||
# Age old objects down to Nearline. Every version of every file is retained
|
||||
# forever, so storage grows monotonically against flat per-seat revenue; at
|
||||
# 30 days Nearline halves the per-GB cost ($0.020 → $0.010 in us-central1).
|
||||
#
|
||||
# Nearline and NOT Coldline/Archive, deliberately — see deploy/README.md
|
||||
# "Storage tiering". Colder tiers only pay off when an object is read less
|
||||
# than roughly once a month (Coldline) or once a year (Archive), and today a
|
||||
# BearDrive device that syncs a project for the first time downloads EVERY
|
||||
# historical blob, not just the current file tree. So blob reads scale with
|
||||
# how often anyone adds a device, which is nowhere near cold enough for
|
||||
# Archive to be anything but a bill increase. Revisit when a first sync
|
||||
# fetches only current-state blobs.
|
||||
#
|
||||
# Applied bucket-wide rather than to blobs/ only: journal objects sit under
|
||||
# the same per-project prefixes and a GCS lifecycle prefix cannot express
|
||||
# "*/blobs/". That is safe here because a peer journal is re-fetched only
|
||||
# when the listing shows it GREW (internal/syncer pull), and one that grew
|
||||
# was just rewritten and is Standard again.
|
||||
if [ "${LIFECYCLE:-1}" = "1" ]; then
|
||||
echo "== GCS lifecycle: Nearline at 30 days (LIFECYCLE=0 to skip) =="
|
||||
TMP_LIFECYCLE="$(mktemp)"
|
||||
cat >"$TMP_LIFECYCLE" <<'JSON'
|
||||
{
|
||||
"lifecycle": {
|
||||
"rule": [
|
||||
{
|
||||
"action": { "type": "SetStorageClass", "storageClass": "NEARLINE" },
|
||||
"condition": { "age": 30, "matchesStorageClass": ["STANDARD"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
gcloud storage buckets update "gs://$BUCKET" --lifecycle-file="$TMP_LIFECYCLE"
|
||||
rm -f "$TMP_LIFECYCLE"
|
||||
fi
|
||||
|
||||
echo "== Cloud SQL Postgres (this takes several minutes) =="
|
||||
gcloud sql instances describe "$SQL_INSTANCE" >/dev/null 2>&1 || \
|
||||
gcloud sql instances create "$SQL_INSTANCE" --database-version POSTGRES_16 \
|
||||
|
||||
+7
-6
@@ -61,12 +61,13 @@ Draft copy is ready to paste; adjust voice as you like.
|
||||
title, body, and the first-comment founder note. Post from your
|
||||
account; be present for the first 3 hours to answer everything.
|
||||
11. **Product Hunt** — drafts in launch-plan.md. Schedule after HN, not
|
||||
the same day. Before PH: replace the mailto waitlist on the site
|
||||
with a real form (any hosted waitlist works) — email-composer
|
||||
friction kills launch-day conversion.
|
||||
12. **Claude Code plugin discoverability** — submit/announce the
|
||||
marketplace entry wherever Anthropic surfaces community plugins
|
||||
(Discord, awesome-lists PRs from your account).
|
||||
the same day. This one launches **BearDrive Cloud**, which is live with
|
||||
open signup and a public `/pricing` page; there is no waitlist and no
|
||||
mailto form anywhere, so nothing needs replacing before PH.
|
||||
12. **Agent onboarding discoverability** — announce the paste-prompt
|
||||
onboarding (`INSTALL_FOR_AGENTS.md`) wherever agent users gather. There
|
||||
is no Claude Code plugin or marketplace entry to submit — the
|
||||
integration is `internal/agenthooks` plus that runbook.
|
||||
|
||||
## P3 — measurement plumbing (see metrics.md)
|
||||
|
||||
|
||||
+51
-9
@@ -1,8 +1,13 @@
|
||||
# Launch plan (drafts — nothing here is posted; posting is Snow's call)
|
||||
|
||||
Stage-1 launch: the goal is strangers who care — stars, issues, first
|
||||
outside users — not signups or revenue. Sequence: quiet HN → learnings →
|
||||
Product Hunt → Launch-Week-style feature cadence as Cloud approaches.
|
||||
Two launches with different goals, and the difference matters:
|
||||
|
||||
- **Show HN (stage 1, done first)** — the OSS project. The goal is strangers
|
||||
who care: stars, issues, first outside users. Not signups, not revenue.
|
||||
- **Product Hunt (stage 2)** — **BearDrive Cloud**, the managed service, run
|
||||
as a standalone self-serve business. Cloud is live, signup is open with no
|
||||
credit card, and pricing is public. Revenue signal is the point here, which
|
||||
is exactly why the OSS framing above must not leak into it.
|
||||
|
||||
## Show HN (primary)
|
||||
|
||||
@@ -45,13 +50,39 @@ warm (expect self-host attempts within minutes).
|
||||
|
||||
## Product Hunt (after HN, separate day)
|
||||
|
||||
**This launch is BearDrive Cloud, not the OSS project**, run as a standalone
|
||||
self-serve business rather than top-of-funnel for anything else. Cloud is
|
||||
live, signup is open with no credit card, and pricing is public — see
|
||||
`/pricing`. There is no waitlist and there will not be one.
|
||||
|
||||
- **Tagline:** "The open-source Google Drive for AI agents"
|
||||
- **Description:** One folder your whole team and their agents share —
|
||||
synced in seconds, every change attributed, read analytics included.
|
||||
Self-host in one Go binary, or join the cloud waitlist.
|
||||
Start free, or self-host in one Go binary.
|
||||
- **First comment:** the HN body, warmer tone, plus the 60s demo video.
|
||||
- **Lead with the Insights screenshot** (the reads × staleness quadrant), not
|
||||
the sync. Sync is not defensible on a launch page; nobody else ships
|
||||
read-heat attribution per agent, and that is the image that wins the thread.
|
||||
|
||||
## Launch-Week cadence (when Cloud nears — one artifact/day)
|
||||
**Prepared answer for "$19 for file sync?"** — that question is a positioning
|
||||
failure, not a pricing one, so it gets answered from a script, not composed
|
||||
live: Dropbox syncs files for humans and has no idea which agent read what.
|
||||
BearDrive is the shared folder your team's agents work in, with per-agent
|
||||
attribution, full version history, and read-heat analytics showing which docs
|
||||
your agents actually depend on and which of those are quietly rotting.
|
||||
Different job, different buyer.
|
||||
|
||||
**Day-one paid conversion will look bad, and that is not a signal about
|
||||
price.** Product Hunt sends individuals, not teams; they sign up solo, stay on
|
||||
Free, and never reach the 3-seat wall. The number that matters is second-seat
|
||||
rate — of accounts created in launch week, the fraction that invite at least
|
||||
one person within 14 days. Above ~15% means the team thesis holds. Near zero
|
||||
means people are treating BearDrive as a personal sync tool and the wedge
|
||||
needs rethinking before any tier optimization. Instrumented server-side in the
|
||||
cloud repo (`internal/analytics`): `invite_sent` and `invite_accepted` are
|
||||
tracked as distinct events.
|
||||
|
||||
## Launch-Week cadence (one artifact/day)
|
||||
|
||||
1. **Sync core** — the journal/replay design post ("no locks, no two
|
||||
writers").
|
||||
@@ -60,13 +91,24 @@ warm (expect self-host attempts within minutes).
|
||||
3. **Share links** — files as public pages; sandboxed HTML.
|
||||
4. **Insights** — the read×staleness quadrant; what agents actually
|
||||
read.
|
||||
5. **The Claude Code plugin** — `/beardrive:install`, two-file AGENTS.md
|
||||
orientation, `bdrive url` links in agent replies.
|
||||
5. **Agent onboarding** — one paste that points any agent at
|
||||
`INSTALL_FOR_AGENTS.md`; `bdrive url` links in agent replies. (There is
|
||||
no Claude Code plugin and no bundled skill — the integration is
|
||||
`internal/agenthooks` plus that runbook.)
|
||||
|
||||
Each day: a blog-able writeup + a tweet-length version + a repo artifact
|
||||
(doc or demo). Drafts to be written per-day when scheduled.
|
||||
|
||||
## Not part of launch
|
||||
|
||||
Pricing (Cloud is waitlist-only), enterprise pages, paid promotion, and
|
||||
any claim of traction we don't have.
|
||||
Enterprise self-serve (Enterprise is contact-sales), paid promotion, and any
|
||||
claim of traction we don't have.
|
||||
|
||||
## No "beta" label
|
||||
|
||||
No "beta", "early access", or "preview" badge on the product, the site, or
|
||||
the launch copy. A beta label is a discount that cannot be taken back
|
||||
cleanly, it repels the team-lead buyer who will not put company context into
|
||||
a beta tool, and it suppresses the willingness-to-pay signal this launch
|
||||
exists to produce. "Early but serious" is signalled by the version number,
|
||||
the public CHANGELOG, and the public ROADMAP, which already exist.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Measure exists to size the warning `bdrive init` prints, so the one thing
|
||||
// it must get right is that it counts what would REALLY sync. A folder whose
|
||||
// bulk is already ignored must measure small, or the warning fires on every
|
||||
// ordinary repo and people learn to scroll past it.
|
||||
func TestMeasureCountsOnlyWhatSyncs(t *testing.T) {
|
||||
d := newDevice(t, "deva", nil)
|
||||
write(t, d.Folder, IgnoreFile, "node_modules/\n*.mp4\n")
|
||||
write(t, d.Folder, "notes.md", "hello") // 5 B, syncs
|
||||
write(t, d.Folder, "docs/guide.md", "guide") // 5 B, syncs
|
||||
write(t, d.Folder, "node_modules/dep/index.js", strings.Repeat("x", 900)) // ignored dir
|
||||
write(t, d.Folder, "demo.mp4", strings.Repeat("y", 900)) // ignored glob
|
||||
write(t, d.Folder, ".git/objects/ab/cdef", strings.Repeat("z", 900)) // never syncs, built in
|
||||
|
||||
files, bytes, err := Measure(d.Folder, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// notes.md + docs/guide.md + the .bdriveignore itself, which does sync.
|
||||
if files != 3 {
|
||||
t.Fatalf("files = %d, want 3 (the ignored/reserved paths must not count)", files)
|
||||
}
|
||||
if bytes >= 900 {
|
||||
t.Fatalf("bytes = %d — an ignored 900 B file was counted", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// A scope narrowing is written as .bdriveignore negation rules, so Measure
|
||||
// has to shrink with it: otherwise `bdrive scope add` would never quiet the
|
||||
// warning it tells you to run.
|
||||
func TestMeasureShrinksWithScope(t *testing.T) {
|
||||
d := newDevice(t, "devb", nil)
|
||||
write(t, d.Folder, ".bdrive/config.json", `{"include":["/docs/"]}`)
|
||||
write(t, d.Folder, "docs/guide.md", "in scope")
|
||||
write(t, d.Folder, "private/secrets.txt", strings.Repeat("q", 500))
|
||||
|
||||
all, allBytes, err := Measure(d.Folder, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scoped, scopedBytes, err := Measure(d.Folder, []string{"/docs/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scoped >= all || scopedBytes >= allBytes {
|
||||
t.Fatalf("scoped measure (%d files, %d B) did not shrink vs whole folder (%d files, %d B)",
|
||||
scoped, scopedBytes, all, allBytes)
|
||||
}
|
||||
if scopedBytes >= 500 {
|
||||
t.Fatalf("out-of-scope file counted: %d B", scopedBytes)
|
||||
}
|
||||
}
|
||||
@@ -70,3 +70,36 @@ func walkFolder(folder string, filter *Filter, fn func(abs, rel string, d fs.Dir
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Measure reports what a first sync of this folder would actually upload:
|
||||
// the number of files and their total bytes, after the same filter the cycle
|
||||
// uses. It exists so `bdrive init` can warn about a folder nobody meant to
|
||||
// sync (a home directory, a video library, a checkout whose .bdriveignore
|
||||
// doesn't cover its build output) BEFORE the first push rather than after.
|
||||
//
|
||||
// It is deliberately filter-aware: a 40 GB repo whose bulk is node_modules
|
||||
// measures as the few MB that really sync, so the warning fires on the cases
|
||||
// that are actually expensive and stays quiet on the ones the starter rules
|
||||
// already handle.
|
||||
//
|
||||
// Unreadable entries are skipped rather than failing — this is advice, and a
|
||||
// permission error in one subtree must never block init.
|
||||
func Measure(folder string, include []string) (files int, bytes int64, err error) {
|
||||
filter, err := LoadFilter(folder, include)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
err = walkFolder(folder, filter, func(_, _ string, d fs.DirEntry, v verdict) error {
|
||||
if v != vSync {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
files++
|
||||
bytes += info.Size()
|
||||
return nil
|
||||
})
|
||||
return files, bytes, err
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package webapp
|
||||
|
||||
import "io"
|
||||
|
||||
// QuotaProvider is the seam a managed deployment uses to enforce plan
|
||||
// limits, exactly like AuthProvider is the seam for identity. The
|
||||
// open-source server ships only UnlimitedQuota; billing and plan logic live
|
||||
@@ -14,6 +16,23 @@ type QuotaProvider interface {
|
||||
CheckSeat(org string, members int) error
|
||||
// RecordUsage runs after a write succeeds, for accounting.
|
||||
RecordUsage(org string, addedBytes int64)
|
||||
|
||||
// CheckRead runs before bytes are served to an UNAUTHENTICATED reader —
|
||||
// today that is public share links (/s/*) and nothing else. bytes is the
|
||||
// size about to be streamed. A non-nil error refuses the transfer and its
|
||||
// message is shown to the reader, so write it for a stranger who has no
|
||||
// idea what BearDrive is.
|
||||
//
|
||||
// Deliberately NOT called on the sync proxy or the viewer: a device that
|
||||
// gets refused mid-sync reads it as "access revoked" and stops touching
|
||||
// the folder, which is a far worse outcome than an over-quota bill. Those
|
||||
// paths report through RecordEgress and are governed by fair use.
|
||||
CheckRead(org string, bytes int64) error
|
||||
// RecordEgress runs after bytes have been served, with the number
|
||||
// actually written. Every read path reports here — share links, the sync
|
||||
// proxy, viewer downloads — so egress is measurable even where it is not
|
||||
// enforced.
|
||||
RecordEgress(org string, bytes int64)
|
||||
}
|
||||
|
||||
// UnlimitedQuota is the open-source default: everything is allowed.
|
||||
@@ -22,6 +41,22 @@ type UnlimitedQuota struct{}
|
||||
func (UnlimitedQuota) CheckWrite(string, int64) error { return nil }
|
||||
func (UnlimitedQuota) CheckSeat(string, int) error { return nil }
|
||||
func (UnlimitedQuota) RecordUsage(string, int64) {}
|
||||
func (UnlimitedQuota) CheckRead(string, int64) error { return nil }
|
||||
func (UnlimitedQuota) RecordEgress(string, int64) {}
|
||||
|
||||
// countingWriter counts what actually reached the client. The journal's Size
|
||||
// field and the stat size are both claims made before the write; a connection
|
||||
// that drops halfway must not be billed as a full transfer.
|
||||
type countingWriter struct {
|
||||
w io.Writer
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *countingWriter) Write(p []byte) (int, error) {
|
||||
n, err := c.w.Write(p)
|
||||
c.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// quota returns the configured provider, defaulting to unlimited.
|
||||
func (s *Server) quota() QuotaProvider {
|
||||
|
||||
@@ -22,6 +22,11 @@ func mustJSON(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||
|
||||
// recQuota records every hook call and can be told to say no.
|
||||
type recQuota struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
mu sync.Mutex
|
||||
denyW bool
|
||||
denyS bool
|
||||
|
||||
@@ -46,6 +46,11 @@ func secaudOpLine(seq int, dev, kind, path, blob string) string {
|
||||
// secaudCapQuota is a QuotaProvider with a real ceiling — the only kind that
|
||||
// can show whether an outstanding presigned grant counts against it.
|
||||
type secaudCapQuota struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
mu sync.Mutex
|
||||
cap int64
|
||||
recorded int64
|
||||
|
||||
@@ -544,6 +544,11 @@ func TestSec_Share_RemovedOrgMemberLinkStopsServing(t *testing.T) {
|
||||
// secauthzQuota is a QuotaProvider that enforces a real seat cap, and can be
|
||||
// told to hold callers inside CheckSeat so two redemptions overlap.
|
||||
type secauthzQuota struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
limit int
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -940,6 +940,11 @@ func secdefDropDeviceRows(t *testing.T) {
|
||||
|
||||
// secdefQuota records every CheckWrite / RecordUsage the hub makes.
|
||||
type secdefQuota struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
checked []int64
|
||||
usage []int64
|
||||
}
|
||||
|
||||
@@ -112,6 +112,11 @@ func TestSec_Journal_OwnershipIsNotAHubWideDeviceExistenceOracle(t *testing.T) {
|
||||
|
||||
// secfx5Cap is a QuotaProvider with a real byte cap, so CheckWrite can say no.
|
||||
type secfx5Cap struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
mu sync.Mutex
|
||||
limit int64
|
||||
used int64
|
||||
|
||||
@@ -233,6 +233,11 @@ func TestSec_Store_AJournalCannotNameAPathTheUploadDoorRefuses(t *testing.T) {
|
||||
// managed deployment swaps ("billing and plan logic live in the managed
|
||||
// service"), and round 6 moved the call to it INSIDE the ledger mutex.
|
||||
type secfx6BlockQuota struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
mu sync.Mutex
|
||||
n int
|
||||
blockOn int
|
||||
|
||||
@@ -33,6 +33,11 @@ import (
|
||||
// secaudCapQuota; a separate copy so this file's arithmetic is readable at the
|
||||
// assertion.
|
||||
type sec10CapQuota struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
mu sync.Mutex
|
||||
cap int64
|
||||
recorded int64
|
||||
|
||||
@@ -74,6 +74,11 @@ func secsignHub(t *testing.T) (http.Handler, *Server, Project, *secsignBackend)
|
||||
|
||||
// secsignQuota records every byte the hub charges and every byte it books.
|
||||
type secsignQuota struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
mu sync.Mutex
|
||||
checked, recorded int64
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ func secstoreUnsized(t *testing.T, h http.Handler, method, url string, body []by
|
||||
// secstoreQuota is a plan-limited quota provider: the seam a managed
|
||||
// deployment plugs in. It refuses writes that would exceed limit.
|
||||
type secstoreQuota struct {
|
||||
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
||||
// free: this fake exercises the write path, and a widened interface
|
||||
// should not need a no-op added here every time.
|
||||
UnlimitedQuota
|
||||
|
||||
mu sync.Mutex
|
||||
limit int64
|
||||
used int64
|
||||
|
||||
@@ -448,6 +448,24 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
|
||||
// member reloading is debounced to a visit, distinct visitors still count.
|
||||
s.Reads.Record(sh.Project, sh.Path, ReadKindShare, sh.Token+"/"+s.clientIP(r))
|
||||
|
||||
// Share links are the only unauthenticated door to stored bytes, so they
|
||||
// are the only egress a plan actually caps. The per-IP limiter above
|
||||
// bounds REQUESTS; this bounds BYTES, which is the number a pricing page
|
||||
// can promise and a scraper behind many IPs can otherwise ignore.
|
||||
org := s.orgOf(sh.Project)
|
||||
if err := s.quota().CheckRead(org, fi.Size); err != nil {
|
||||
// Not "forbidden": nothing is wrong with the link or the reader, the
|
||||
// owner is over their transfer allowance. Say so plainly — whoever
|
||||
// opened this has no relationship with us and no way to fix it.
|
||||
http.Error(w, "This link has exceeded its transfer limit for now. "+
|
||||
"Ask whoever shared it to get in touch with us.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
// Count what actually leaves, on every branch below, including a transfer
|
||||
// the reader abandons halfway.
|
||||
cw := &countingWriter{w: w}
|
||||
defer func() { s.quota().RecordEgress(org, cw.n) }()
|
||||
|
||||
rc, err := v.source.Open(r.Context(), sh.Path, fi)
|
||||
if err != nil {
|
||||
http.Error(w, "content temporarily unavailable", http.StatusBadGateway)
|
||||
@@ -458,7 +476,7 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("download") == "1" {
|
||||
w.Header().Set("Content-Type", contentType(sh.Path))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", sanitizeFilename(path.Base(sh.Path))))
|
||||
io.Copy(w, rc)
|
||||
io.Copy(cw, rc)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -475,14 +493,14 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, sharedMarkdownShell, html.EscapeString(path.Base(sh.Path)), updatedStamp(fi.Time), body)
|
||||
fmt.Fprintf(cw, sharedMarkdownShell, html.EscapeString(path.Base(sh.Path)), updatedStamp(fi.Time), body)
|
||||
case ".html", ".htm":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
io.Copy(w, rc)
|
||||
io.Copy(cw, rc)
|
||||
default:
|
||||
w.Header().Set("Content-Type", contentType(sh.Path))
|
||||
setContentLength(w, rc) // measured, never the journal's Size field
|
||||
io.Copy(w, rc)
|
||||
io.Copy(cw, rc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,12 @@ func (s *Server) handleStoreGet(v *volume, w http.ResponseWriter, r *http.Reques
|
||||
// cookie-authenticated GET whose URL one member can hand another, answering
|
||||
// with content the attacker wrote under a Content-Type the hub chose.
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
io.Copy(w, rc)
|
||||
// Recorded, never checked. This is a device syncing: refusing it here
|
||||
// surfaces as ErrForbidden, which the syncer reads as "access is gone —
|
||||
// pause and touch nothing". Sync must not break over a bill.
|
||||
cw := &countingWriter{w: w}
|
||||
io.Copy(cw, rc)
|
||||
s.quota().RecordEgress(s.orgOf(r.PathValue("project")), cw.n)
|
||||
}
|
||||
|
||||
func (s *Server) handleStoreExists(v *volume, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user