From 0fa83e4532c374f6e9c5e704c7adf384c566f488 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Fri, 10 Jul 2026 00:07:38 -0700 Subject: [PATCH] feat(web): SQL metadata backend (SQLite + Postgres/Supabase) + conformance (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a database/sql MetaStore (db_sql.go) targeting SQLite locally and Postgres/Supabase in production through one portable schema: real tables (accounts, tokens, auth_policy, projects, orgs, org_members, invites, shares, devices), idempotent CREATE TABLE IF NOT EXISTS migrations at Open, ?→$N placeholder rebinding for Postgres, times as RFC3339 text, and transactional multi-row writes (an org and its members). Pure-Go drivers only (modernc.org/sqlite, jackc/pgx v5) so CGO-free builds keep working. One shared conformance suite (db_conformance_test.go) runs the same service-level operations — accounts+tokens, policy, pending/approve, projects create-or-join/rename/delete, org roles, invite create/redeem/ uses/validity, share create/revoke/expiry, devices — then reopens the store and asserts everything persisted. Runs against file AND sqlite always, and postgres when BDRIVE_TEST_POSTGRES is set. Verified green on all three against a real local Postgres. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs --- go.mod | 15 +- go.sum | 59 +++- internal/webapp/db_conformance_test.go | 241 +++++++++++++ internal/webapp/db_sql.go | 464 +++++++++++++++++++++++++ 4 files changed, 776 insertions(+), 3 deletions(-) create mode 100644 internal/webapp/db_conformance_test.go create mode 100644 internal/webapp/db_sql.go diff --git a/go.mod b/go.mod index d4eec64..2d0fb22 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,18 @@ go 1.25.8 require ( cloud.google.com/go/storage v1.62.3 + github.com/AlecAivazis/survey/v2 v2.3.7 github.com/aws/aws-sdk-go-v2 v1.42.0 github.com/aws/aws-sdk-go-v2/config v1.32.25 github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 github.com/aws/smithy-go v1.27.2 + github.com/jackc/pgx/v5 v5.10.0 + github.com/mattn/go-isatty v0.0.20 github.com/spf13/cobra v1.10.2 github.com/yuin/goldmark v1.8.2 golang.org/x/crypto v0.51.0 google.golang.org/api v0.284.0 + modernc.org/sqlite v1.53.0 ) require ( @@ -22,7 +26,6 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.7.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect - github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect @@ -42,6 +45,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -53,11 +57,15 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-colorable v0.1.2 // indirect - github.com/mattn/go-isatty v0.0.8 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -81,4 +89,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index f130796..704b4b8 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,7 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= @@ -72,11 +73,14 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= @@ -100,6 +104,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -108,22 +114,38 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdC github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -132,7 +154,9 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -164,6 +188,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -181,6 +207,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -198,6 +225,8 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= @@ -217,3 +246,31 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/webapp/db_conformance_test.go b/internal/webapp/db_conformance_test.go new file mode 100644 index 0000000..75942b2 --- /dev/null +++ b/internal/webapp/db_conformance_test.go @@ -0,0 +1,241 @@ +package webapp + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + "time" +) + +// A metaBackend is one MetaStore implementation under test. reset clears any +// shared durable state before the run; open returns a fresh store over the +// SAME underlying storage, so the suite can write, reopen, and prove the data +// persisted. +type metaBackend struct { + name string + reset func(t *testing.T) + open func(t *testing.T) MetaStore +} + +func metaBackends(t *testing.T) []metaBackend { + dir := t.TempDir() + sqlitePath := filepath.Join(t.TempDir(), "meta.db") + backends := []metaBackend{ + { + name: "file", + reset: func(t *testing.T) {}, + open: func(t *testing.T) MetaStore { + s, err := OpenFileStore(dir) + if err != nil { + t.Fatal(err) + } + return s + }, + }, + { + name: "sqlite", + reset: func(t *testing.T) {}, + open: func(t *testing.T) MetaStore { + s, err := OpenSQLStore("sqlite", sqlitePath) + if err != nil { + t.Fatal(err) + } + return s + }, + }, + } + // Postgres/Supabase is exercised only when a DSN is reachable. + if dsn := os.Getenv("BDRIVE_TEST_POSTGRES"); dsn != "" { + backends = append(backends, metaBackend{ + name: "postgres", + reset: func(t *testing.T) { + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("postgres reset: %v", err) + } + defer db.Close() + db.Exec(`DROP TABLE IF EXISTS accounts, tokens, auth_policy, projects, orgs, org_members, invites, shares, devices`) + }, + open: func(t *testing.T) MetaStore { + s, err := OpenSQLStore("pgx", dsn) + if err != nil { + t.Fatal(err) + } + return s + }, + }) + } else { + t.Log("BDRIVE_TEST_POSTGRES not set — postgres backend UNTESTED in this run") + } + return backends +} + +// TestMetaStoreConformance runs the same service-level operations against every +// backend, then reopens the store and asserts the data survived — covering +// accounts+tokens, the signup policy, pending/approve, projects (create-or-join, +// rename, delete), orgs (roles), invites (create/redeem/uses/validity), shares +// (create/revoke/expiry), and devices. +func TestMetaStoreConformance(t *testing.T) { + for _, be := range metaBackends(t) { + t.Run(be.name, func(t *testing.T) { + be.reset(t) + + // ---- write everything through the services ---- + st := be.open(t) + + auth, err := NewBuiltinAuth(st.Accounts(), true, nil) + if err != nil { + t.Fatal(err) + } + // account + token + u, err := auth.signup("dev@x.io", "Dev", "password1") + if err != nil { + t.Fatal(err) + } + tok, err := auth.issueToken(u.ID, "cli") + if err != nil { + t.Fatal(err) + } + if _, ok := auth.userForToken(tok); !ok { + t.Fatal("token should authenticate its account") + } + // pending + policy + if err := auth.SetPolicy(false, true); err != nil { // require approval + t.Fatal(err) + } + pend, err := auth.signup("pending@x.io", "Pend", "password1") + if err != nil { + t.Fatal(err) + } + if pend.Status != statusPending { + t.Fatalf("new account status = %q, want pending", pend.Status) + } + + projects, err := NewProjectDB(st.Projects()) + if err != nil { + t.Fatal(err) + } + p1, created, err := projects.GetOrCreate("wiki", "o-1") + if err != nil || !created { + t.Fatalf("create wiki: created=%v err=%v", created, err) + } + if _, again, _ := projects.GetOrCreate("wiki", "o-1"); again { + t.Fatal("same name+org must join, not create") + } + if _, other, _ := projects.GetOrCreate("wiki", "o-2"); !other { + t.Fatal("same name in a different org must create") + } + if err := projects.Rename(p1.ID, "handbook"); err != nil { + t.Fatal(err) + } + p2, _, _ := projects.GetOrCreate("scratch", "o-1") + if err := projects.Delete(p2.ID); err != nil { + t.Fatal(err) + } + + orgs, err := NewOrgDB(st.Orgs()) + if err != nil { + t.Fatal(err) + } + org, err := orgs.Create("Acme", "boss@x.io") + if err != nil { + t.Fatal(err) + } + if err := orgs.AddMember(org.ID, "worker@x.io", RoleMember); err != nil { + t.Fatal(err) + } + if err := orgs.SetRole(org.ID, "worker@x.io", RoleOwner); err != nil { + t.Fatal(err) + } + inv, err := orgs.CreateInvite(org.ID, "boss@x.io", time.Hour) + if err != nil { + t.Fatal(err) + } + orgs.RecordInviteUse(inv.Token) + if !orgs.ValidInvite(inv.Token) { + t.Fatal("fresh invite should be valid") + } + + shares, err := NewShareDB(st.Shares()) + if err != nil { + t.Fatal(err) + } + live, err := shares.Create(p1.ID, "handbook.md", "boss@x.io", 0) + if err != nil { + t.Fatal(err) + } + gone, _ := shares.Create(p1.ID, "temp.md", "boss@x.io", time.Hour) + if !shares.Revoke(gone.Token) { + t.Fatal("revoke should succeed") + } + + devices, err := NewDeviceRegistry(st.Devices()) + if err != nil { + t.Fatal(err) + } + devices.Observe(DeviceInfo{ID: "d1", Name: "laptop", OS: "mac", User: "dev@x.io", IP: "1.2.3.4"}) + + if err := st.Close(); err != nil { + t.Fatal(err) + } + + // ---- reopen and verify everything persisted ---- + st2 := be.open(t) + defer st2.Close() + + auth2, _ := NewBuiltinAuth(st2.Accounts(), true, nil) + if _, ok := auth2.userForToken(tok); !ok { + t.Fatal("token lost across reload") + } + if !auth2.RequireApproval || auth2.RequireVerification { + t.Fatal("policy lost across reload") + } + if got := auth2.PendingUsers(); len(got) != 1 || got[0].Email != "pending@x.io" { + t.Fatalf("pending users after reload = %+v", got) + } + if err := auth2.Approve(pend.ID); err != nil { + t.Fatal(err) + } + if len(auth2.PendingUsers()) != 0 { + t.Fatal("approve did not clear pending") + } + + projects2, _ := NewProjectDB(st2.Projects()) + list := projects2.List() + if len(list) != 2 { // handbook (o-1), wiki (o-2); scratch was deleted + t.Fatalf("projects after reload = %+v", list) + } + hb, ok := projects2.Get(p1.ID) + if !ok || hb.Name != "handbook" { + t.Fatalf("rename lost across reload: %+v", hb) + } + + orgs2, _ := NewOrgDB(st2.Orgs()) + ro, ok := orgs2.Get(org.ID) + if !ok || ro.Members["boss@x.io"] != RoleOwner || ro.Members["worker@x.io"] != RoleOwner { + t.Fatalf("org roles lost across reload: %+v", ro) + } + if !orgs2.ValidInvite(inv.Token) { + t.Fatal("invite lost across reload") + } + if got := orgs2.ListInvites(org.ID); len(got) != 1 || got[0].Uses != 1 { + t.Fatalf("invite uses after reload = %+v", got) + } + + shares2, _ := NewShareDB(st2.Shares()) + if _, ok := shares2.Get(live.Token); !ok { + t.Fatal("live share lost across reload") + } + if _, ok := shares2.Get(gone.Token); ok { + t.Fatal("revoked share came back after reload") + } + + devices2, _ := NewDeviceRegistry(st2.Devices()) + d, ok := devices2.Get("d1") + if !ok || d.Name != "laptop" || d.IP != "1.2.3.4" { + t.Fatalf("device lost across reload: %+v", d) + } + }) + } +} diff --git a/internal/webapp/db_sql.go b/internal/webapp/db_sql.go new file mode 100644 index 0000000..ed4118a --- /dev/null +++ b/internal/webapp/db_sql.go @@ -0,0 +1,464 @@ +package webapp + +import ( + "database/sql" + "fmt" + "strings" + "time" + + // Pure-Go drivers only, so static (CGO-free) builds keep working. + _ "github.com/jackc/pgx/v5/stdlib" // "pgx" — Postgres / Supabase + _ "modernc.org/sqlite" // "sqlite" — embedded local DB +) + +// The SQL backend: one *sql.DB shared by the repos, targeting SQLite (local) +// or Postgres/Supabase (production) through the same portable schema. Each +// record is a real row; multi-step writes (an org and its members) run in a +// transaction. Timestamps are stored as RFC3339 text and booleans as 0/1 so +// the same statements work on both engines. + +type dialect int + +const ( + dialectSQLite dialect = iota + dialectPostgres +) + +type sqlMetaStore struct { + db *sql.DB + d dialect + + accounts *sqlAccountRepo + projects *sqlProjectRepo + orgs *sqlOrgRepo + shares *sqlShareRepo + devices *sqlDeviceRepo +} + +// OpenSQLStore opens (and migrates) a SQL metadata store. driver is "sqlite" +// or "pgx" (Postgres/Supabase); dsn is the connection string / file path. +func OpenSQLStore(driver, dsn string) (MetaStore, error) { + db, err := sql.Open(driver, dsn) + if err != nil { + return nil, fmt.Errorf("open %s: %w", driver, err) + } + d := dialectSQLite + switch driver { + case "pgx", "postgres", "pgx/v5": + d = dialectPostgres + case "sqlite", "sqlite3": + d = dialectSQLite + default: + db.Close() + return nil, fmt.Errorf("unsupported database driver %q (use sqlite or pgx)", driver) + } + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("connect %s: %w", driver, err) + } + s := &sqlMetaStore{db: db, d: d} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + s.accounts = &sqlAccountRepo{s} + s.projects = &sqlProjectRepo{s} + s.orgs = &sqlOrgRepo{s} + s.shares = &sqlShareRepo{s} + s.devices = &sqlDeviceRepo{s} + return s, nil +} + +func (s *sqlMetaStore) Accounts() AccountRepo { return s.accounts } +func (s *sqlMetaStore) Projects() ProjectRepo { return s.projects } +func (s *sqlMetaStore) Orgs() OrgRepo { return s.orgs } +func (s *sqlMetaStore) Shares() ShareRepo { return s.shares } +func (s *sqlMetaStore) Devices() DeviceRepo { return s.devices } +func (s *sqlMetaStore) Close() error { return s.db.Close() } + +// q rebinds ?-placeholders to $1,$2,… for Postgres; SQLite keeps ?. +func (s *sqlMetaStore) q(query string) string { + if s.d != dialectPostgres { + return query + } + var b strings.Builder + n := 0 + for _, r := range query { + if r == '?' { + n++ + fmt.Fprintf(&b, "$%d", n) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func (s *sqlMetaStore) exec(query string, args ...any) error { + _, err := s.db.Exec(s.q(query), args...) + return err +} + +// tenc / tdec store times as RFC3339 text (empty string for the zero time), +// avoiding per-driver timestamp scanning differences. +func tenc(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339Nano) +} + +func tdec(s string) time.Time { + if s == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return time.Time{} + } + return t.UTC() +} + +func b2i(b bool) int { + if b { + return 1 + } + return 0 +} + +func (s *sqlMetaStore) migrate() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, email TEXT NOT NULL, name TEXT NOT NULL, + pass TEXT NOT NULL, status TEXT NOT NULL DEFAULT '', created TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS tokens ( + hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, device TEXT NOT NULL DEFAULT '', + created TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS auth_policy ( + id INTEGER PRIMARY KEY, require_verification INTEGER NOT NULL DEFAULT 0, + require_approval INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, org TEXT NOT NULL DEFAULT '', + created TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS orgs ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, created TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS org_members ( + org TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, PRIMARY KEY (org, email))`, + `CREATE TABLE IF NOT EXISTS invites ( + token TEXT PRIMARY KEY, org TEXT NOT NULL, creator TEXT NOT NULL DEFAULT '', + created TEXT NOT NULL DEFAULT '', expires TEXT NOT NULL DEFAULT '', uses INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE IF NOT EXISTS shares ( + token TEXT PRIMARY KEY, project TEXT NOT NULL, path TEXT NOT NULL, + creator TEXT NOT NULL DEFAULT '', created TEXT NOT NULL DEFAULT '', expires TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', os TEXT NOT NULL DEFAULT '', + user_email TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', last_seen TEXT NOT NULL DEFAULT '')`, + } + for _, st := range stmts { + if _, err := s.db.Exec(st); err != nil { + return fmt.Errorf("migrate: %w", err) + } + } + return nil +} + +// ---- accounts ---- + +type sqlAccountRepo struct{ s *sqlMetaStore } + +func (r *sqlAccountRepo) Load() ([]*authUser, []authToken, *authPolicy, error) { + var users []*authUser + rows, err := r.s.db.Query(`SELECT id, email, name, pass, status, created FROM accounts`) + if err != nil { + return nil, nil, nil, err + } + for rows.Next() { + var u authUser + var created string + if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Pass, &u.Status, &created); err != nil { + rows.Close() + return nil, nil, nil, err + } + u.Created = tdec(created) + users = append(users, &u) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, nil, err + } + + var tokens []authToken + rows, err = r.s.db.Query(`SELECT hash, user_id, device, created FROM tokens`) + if err != nil { + return nil, nil, nil, err + } + for rows.Next() { + var t authToken + var created string + if err := rows.Scan(&t.Hash, &t.User, &t.Device, &created); err != nil { + rows.Close() + return nil, nil, nil, err + } + t.Created = tdec(created) + tokens = append(tokens, t) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, nil, err + } + + var policy *authPolicy + var rv, ra int + err = r.s.db.QueryRow(`SELECT require_verification, require_approval FROM auth_policy WHERE id = 1`).Scan(&rv, &ra) + if err == nil { + policy = &authPolicy{RequireVerification: rv != 0, RequireApproval: ra != 0} + } else if err != sql.ErrNoRows { + return nil, nil, nil, err + } + return users, tokens, policy, nil +} + +func (r *sqlAccountRepo) PutAccount(u *authUser) error { + return r.s.exec(`INSERT INTO accounts (id,email,name,pass,status,created) VALUES (?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET email=excluded.email, name=excluded.name, pass=excluded.pass, + status=excluded.status, created=excluded.created`, + u.ID, u.Email, u.Name, u.Pass, u.Status, tenc(u.Created)) +} + +func (r *sqlAccountRepo) DeleteAccount(id string) error { + return r.s.exec(`DELETE FROM accounts WHERE id = ?`, id) +} + +func (r *sqlAccountRepo) PutToken(t authToken) error { + return r.s.exec(`INSERT INTO tokens (hash,user_id,device,created) VALUES (?,?,?,?) + ON CONFLICT(hash) DO UPDATE SET user_id=excluded.user_id, device=excluded.device, created=excluded.created`, + t.Hash, t.User, t.Device, tenc(t.Created)) +} + +func (r *sqlAccountRepo) DeleteToken(hash string) error { + return r.s.exec(`DELETE FROM tokens WHERE hash = ?`, hash) +} + +func (r *sqlAccountRepo) PutPolicy(p authPolicy) error { + return r.s.exec(`INSERT INTO auth_policy (id,require_verification,require_approval) VALUES (1,?,?) + ON CONFLICT(id) DO UPDATE SET require_verification=excluded.require_verification, + require_approval=excluded.require_approval`, + b2i(p.RequireVerification), b2i(p.RequireApproval)) +} + +// ---- projects ---- + +type sqlProjectRepo struct{ s *sqlMetaStore } + +func (r *sqlProjectRepo) Load() ([]Project, error) { + rows, err := r.s.db.Query(`SELECT id, name, org, created FROM projects`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Project + for rows.Next() { + var p Project + var created string + if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created); err != nil { + return nil, err + } + p.Created = tdec(created) + out = append(out, p) + } + return out, rows.Err() +} + +func (r *sqlProjectRepo) Put(p Project) error { + return r.s.exec(`INSERT INTO projects (id,name,org,created) VALUES (?,?,?,?) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created`, + p.ID, p.Name, p.Org, tenc(p.Created)) +} + +func (r *sqlProjectRepo) Delete(id string) error { + return r.s.exec(`DELETE FROM projects WHERE id = ?`, id) +} + +// ---- orgs (+ members, + invites) ---- + +type sqlOrgRepo struct{ s *sqlMetaStore } + +func (r *sqlOrgRepo) Load() ([]Org, []OrgInvite, error) { + orgs := map[string]*Org{} + var order []string + rows, err := r.s.db.Query(`SELECT id, name, created FROM orgs`) + if err != nil { + return nil, nil, err + } + for rows.Next() { + var o Org + var created string + if err := rows.Scan(&o.ID, &o.Name, &created); err != nil { + rows.Close() + return nil, nil, err + } + o.Created = tdec(created) + o.Members = map[string]string{} + orgs[o.ID] = &o + order = append(order, o.ID) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, err + } + + rows, err = r.s.db.Query(`SELECT org, email, role FROM org_members`) + if err != nil { + return nil, nil, err + } + for rows.Next() { + var org, email, role string + if err := rows.Scan(&org, &email, &role); err != nil { + rows.Close() + return nil, nil, err + } + if o := orgs[org]; o != nil { + o.Members[email] = role + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, err + } + + outOrgs := make([]Org, 0, len(order)) + for _, id := range order { + outOrgs = append(outOrgs, *orgs[id]) + } + + var invites []OrgInvite + rows, err = r.s.db.Query(`SELECT token, org, creator, created, expires, uses FROM invites`) + if err != nil { + return nil, nil, err + } + for rows.Next() { + var i OrgInvite + var created, expires string + if err := rows.Scan(&i.Token, &i.Org, &i.Creator, &created, &expires, &i.Uses); err != nil { + rows.Close() + return nil, nil, err + } + i.Created, i.Expires = tdec(created), tdec(expires) + invites = append(invites, i) + } + rows.Close() + return outOrgs, invites, rows.Err() +} + +func (r *sqlOrgRepo) PutOrg(o Org) error { + tx, err := r.s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(r.s.q(`INSERT INTO orgs (id,name,created) VALUES (?,?,?) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, created=excluded.created`), + o.ID, o.Name, tenc(o.Created)); err != nil { + return err + } + if _, err := tx.Exec(r.s.q(`DELETE FROM org_members WHERE org = ?`), o.ID); err != nil { + return err + } + for email, role := range o.Members { + if _, err := tx.Exec(r.s.q(`INSERT INTO org_members (org,email,role) VALUES (?,?,?)`), + o.ID, email, role); err != nil { + return err + } + } + return tx.Commit() +} + +func (r *sqlOrgRepo) DeleteOrg(id string) error { + tx, err := r.s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(r.s.q(`DELETE FROM org_members WHERE org = ?`), id); err != nil { + return err + } + if _, err := tx.Exec(r.s.q(`DELETE FROM orgs WHERE id = ?`), id); err != nil { + return err + } + return tx.Commit() +} + +func (r *sqlOrgRepo) PutInvite(i OrgInvite) error { + return r.s.exec(`INSERT INTO invites (token,org,creator,created,expires,uses) VALUES (?,?,?,?,?,?) + ON CONFLICT(token) DO UPDATE SET org=excluded.org, creator=excluded.creator, + created=excluded.created, expires=excluded.expires, uses=excluded.uses`, + i.Token, i.Org, i.Creator, tenc(i.Created), tenc(i.Expires), i.Uses) +} + +func (r *sqlOrgRepo) DeleteInvite(token string) error { + return r.s.exec(`DELETE FROM invites WHERE token = ?`, token) +} + +// ---- shares ---- + +type sqlShareRepo struct{ s *sqlMetaStore } + +func (r *sqlShareRepo) Load() ([]Share, error) { + rows, err := r.s.db.Query(`SELECT token, project, path, creator, created, expires FROM shares`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Share + for rows.Next() { + var s Share + var created, expires string + if err := rows.Scan(&s.Token, &s.Project, &s.Path, &s.Creator, &created, &expires); err != nil { + return nil, err + } + s.Created, s.Expires = tdec(created), tdec(expires) + out = append(out, s) + } + return out, rows.Err() +} + +func (r *sqlShareRepo) Put(s Share) error { + return r.s.exec(`INSERT INTO shares (token,project,path,creator,created,expires) VALUES (?,?,?,?,?,?) + ON CONFLICT(token) DO UPDATE SET project=excluded.project, path=excluded.path, + creator=excluded.creator, created=excluded.created, expires=excluded.expires`, + s.Token, s.Project, s.Path, s.Creator, tenc(s.Created), tenc(s.Expires)) +} + +func (r *sqlShareRepo) Delete(token string) error { + return r.s.exec(`DELETE FROM shares WHERE token = ?`, token) +} + +// ---- devices ---- + +type sqlDeviceRepo struct{ s *sqlMetaStore } + +func (r *sqlDeviceRepo) Load() ([]DeviceInfo, error) { + rows, err := r.s.db.Query(`SELECT id, name, os, user_email, ip, last_seen FROM devices`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []DeviceInfo + for rows.Next() { + var d DeviceInfo + var lastSeen string + if err := rows.Scan(&d.ID, &d.Name, &d.OS, &d.User, &d.IP, &lastSeen); err != nil { + return nil, err + } + d.LastSeen = tdec(lastSeen) + out = append(out, d) + } + return out, rows.Err() +} + +func (r *sqlDeviceRepo) Put(d DeviceInfo) error { + return r.s.exec(`INSERT INTO devices (id,name,os,user_email,ip,last_seen) VALUES (?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, os=excluded.os, user_email=excluded.user_email, + ip=excluded.ip, last_seen=excluded.last_seen`, + d.ID, d.Name, d.OS, d.User, d.IP, tenc(d.LastSeen)) +}