mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): SQL metadata backend (SQLite + Postgres/Supabase) + conformance (phase 2)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f14601a983
commit
0fa83e4532
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user