diff --git a/go.mod b/go.mod index 903c3cd..93def01 100644 --- a/go.mod +++ b/go.mod @@ -3,34 +3,35 @@ module tangled.org/bnewbold.net/scrumble go 1.26 require ( - github.com/bluesky-social/indigo v0.0.0-20260428083920-ce62b8fce9e0 + github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8 github.com/earthboundkid/versioninfo/v2 v2.24.1 github.com/flosch/pongo2/v7 v7.0.0-alpha.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/gorilla/sessions v1.2.1 github.com/gorilla/websocket v1.5.3 + github.com/ipfs/go-cid v0.6.1 github.com/jackc/pgx/v5 v5.9.2 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.15.2 + github.com/multiformats/go-multihash v0.2.3 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 - github.com/urfave/cli/v3 v3.8.0 - tangled.org/bnewbold.net/cobalt v0.0.0-20260408190925-172aa7b4e9d4 + github.com/urfave/cli/v3 v3.9.0 + tangled.org/bnewbold.net/cobalt v0.0.0-20260605205651-64e0104ee855 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/ipfs/go-block-format v0.2.0 // indirect - github.com/ipfs/go-cid v0.6.1 // indirect - github.com/ipfs/go-ipfs-util v0.0.3 // indirect - github.com/ipfs/go-ipld-cbor v0.1.0 // indirect - github.com/ipfs/go-ipld-format v0.6.0 // indirect + github.com/ipfs/boxo v0.40.0 // indirect + github.com/ipfs/go-block-format v0.2.3 // indirect + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect + github.com/ipfs/go-ipld-format v0.6.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -43,15 +44,14 @@ require ( github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multibase v0.3.0 // indirect - github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect + github.com/polydawn/refmt v0.90.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/rivo/uniseg v0.1.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -60,9 +60,9 @@ require ( gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect diff --git a/go.sum b/go.sum index 9750e35..180f4b1 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,17 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bluesky-social/indigo v0.0.0-20260428083920-ce62b8fce9e0 h1:N1c6zWfPBQ4hiCRqSP6cbdlsX38w2i9cLgGuruM1UyE= -github.com/bluesky-social/indigo v0.0.0-20260428083920-ce62b8fce9e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= +github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8 h1:heYuihJZQZMNij7+dKIEfApG10xrGnDV2rtKpvFyYYU= +github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/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= @@ -37,11 +35,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -49,8 +46,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/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= @@ -59,16 +56,16 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= -github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= +github.com/ipfs/boxo v0.40.0 h1:AXbum7U+aVWqSdZ5RSvKFAmrwUSxOCHyDYEIkpmb23g= +github.com/ipfs/boxo v0.40.0/go.mod h1:LBlAkRBsiiADMdfhcY5CbX/gLBHla4K89RfROjj31NI= +github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk= +github.com/ipfs/go-block-format v0.2.3/go.mod h1:WJaQmPAKhD3LspLixqlqNFxiZ3BZ3xgqxxoSR/76pnA= github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= github.com/ipfs/go-cid v0.6.1/go.mod h1:zrY0SwOhjrrIdfPQ/kf+k1sXyJ0QE7cMxfCployLBs0= -github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= -github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= -github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= -github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= -github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= -github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= +github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= +github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= +github.com/ipfs/go-ipld-format v0.6.3 h1:9/lurLDTotJpZSuL++gh3sTdmcFhVkCwsgx2+rAh4j8= +github.com/ipfs/go-ipld-format v0.6.3/go.mod h1:74ilVN12NXVMIV+SrBAyC05UJRk0jVvGqdmrcYZvCBk= 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= @@ -81,8 +78,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -132,8 +129,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= -github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +github.com/polydawn/refmt v0.90.0 h1:58BfEsP+G4uIRD9ApJTFsag+Mw+QQlZuH9uI/lPmjfY= +github.com/polydawn/refmt v0.90.0/go.mod h1:XAlDMOunevTYDsZtOKQd8itHXFMsX/QtDkPHaj6ZLxk= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -142,16 +139,14 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= -github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= -github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= -github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= -github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -159,52 +154,44 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV 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/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= -github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= +github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= -github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +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/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= @@ -213,11 +200,10 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= -tangled.org/bnewbold.net/cobalt v0.0.0-20260408190925-172aa7b4e9d4 h1:B7FCl+aEnfP3fffkWFvsbg/7At0riOOPCjvKGC6oZ/A= -tangled.org/bnewbold.net/cobalt v0.0.0-20260408190925-172aa7b4e9d4/go.mod h1:zfI64T9omBbA6wuege7Ium7efI5GYYOkpi8ozis87Zc= +tangled.org/bnewbold.net/cobalt v0.0.0-20260605205651-64e0104ee855 h1:urH+IYRMXppVluqT/qXgcDp0Brdr3/lQ8kegYrT/uI0= +tangled.org/bnewbold.net/cobalt v0.0.0-20260605205651-64e0104ee855/go.mod h1:UBqiS9AIXpRt0dt4CNyPvs1IEX/lweP0AivS0019yOg= diff --git a/server.go b/server.go index 0e5f6ed..cf043b9 100644 --- a/server.go +++ b/server.go @@ -209,16 +209,16 @@ func (srv *Server) startWithListener(listen net.Listener) error { e.POST("/s/:scene/item", srv.WebItemCreate) e.GET("/s/:scene/members", srv.WebSceneMembersView) e.POST("/s/:scene/join", srv.WebSceneMemberJoin) - e.POST("/s/:scene/leave", srv.WebSceneMemberLeave) - e.GET("/s/:scene/mgmt", srv.WebSceneManage) - e.POST("/s/:scene/mgmt/invite", srv.WebSceneMemberInvite) - e.POST("/s/:scene/mgmt/remove", srv.WebSceneMemberRemove) + e.POST("/s/:scene/leave", srv.WebSceneMemberLeave) // TODO: could be a DELETE on the join? + e.GET("/s/:scene/mgmt", srv.WebSceneManageView) + e.POST("/s/:scene/mgmt/u/:account", srv.WebSceneManageMemberUpdate) e.GET("/u/:username", srv.WebAccountView) e.GET("/u/:username/scenes", srv.WebAccountScenesView) e.GET("/u/:username/blob/:cid", srv.WebBlobProxy) if srv.config.DevMode { e.GET("/dev/index", srv.WebDevIndex) + e.GET("/dev/login", srv.WebDevLogin) } // In order to support booting on random ports in tests, we need to tell diff --git a/store/example_data.go b/store/example_data.go index 9208f63..97b3f29 100644 --- a/store/example_data.go +++ b/store/example_data.go @@ -50,7 +50,7 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory dir := identity.NewMockDirectory() - voltaire := exampleAccount{ + voltaire := &exampleAccount{ DID: syntax.DID("did:example:voltaire"), Handle: syntax.Handle("voltaire.cosmos.social"), Status: "active", @@ -60,7 +60,7 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory Size: 19949, }, } - micromegas := exampleAccount{ + micromegas := &exampleAccount{ DID: syntax.DID("did:example:micromegas"), Handle: syntax.Handle("micromegas.cosmo.social"), Status: "active", @@ -70,18 +70,18 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory Size: 87074, }, } - saturnian := exampleAccount{ + saturnian := &exampleAccount{ DID: syntax.DID("did:example:saturnian"), Handle: syntax.Handle("saturnian.cosmos.social"), Status: "active", } - accounts := []exampleAccount{ + accounts := []*exampleAccount{ voltaire, micromegas, saturnian, } - jupiter := exampleScene{ + jupiter := &exampleScene{ DID: syntax.DID("did:example:jupiter"), Handle: syntax.Handle("jupiter.solar.town"), DisplayName: "Jupiter", @@ -89,7 +89,7 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory CreatedAt: "2025-05-12T04:11:19.412Z", Members: []syntax.DID{micromegas.DID, saturnian.DID}, } - earth := exampleScene{ + earth := &exampleScene{ DID: syntax.DID("did:example:earth"), Handle: syntax.Handle("earth.solar.town"), DisplayName: "Earth", @@ -97,15 +97,15 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory CreatedAt: "2026-05-12T04:11:12.167Z", Members: []syntax.DID{micromegas.DID, saturnian.DID}, } - scenes := []exampleScene{ + scenes := []*exampleScene{ jupiter, earth, } micromegas.Scenes = []syntax.DID{jupiter.DID, earth.DID} saturnian.Scenes = []syntax.DID{jupiter.DID, earth.DID} - items := []exampleItem{ - exampleItem{ + items := []*exampleItem{ + &exampleItem{ SceneDID: earth.DID, AccountDID: micromegas.DID, RKey: "3mlpgpstll522", @@ -113,7 +113,7 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory Text: "Hello, Earth!", CreatedAt: "2026-05-13T04:11:12.167Z", }, - exampleItem{ + &exampleItem{ SceneDID: earth.DID, AccountDID: saturnian.DID, RKey: "3mlpgrt2pmv22", @@ -176,6 +176,9 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory } for _, scene := range scenes { + if err := st.UpsertIdentity(ctx, scene.DID, scene.Handle, "active"); err != nil { + return nil, err + } decl := socialscrumble.BetaSceneDeclaration{ LexiconTypeID: SceneDeclarationNSID.String(), DisplayName: new(scene.DisplayName), diff --git a/store/membership.go b/store/membership.go index 802b093..5be95ce 100644 --- a/store/membership.go +++ b/store/membership.go @@ -20,6 +20,7 @@ var AccountJoinNSID = syntax.NSID("social.scrumble.beta.account.join") var SceneMemberNSID = syntax.NSID("social.scrumble.beta.scene.member") func (st *Store) UpsertAccountJoin(ctx context.Context, accountDID syntax.DID, sceneDID syntax.DID, version syntax.CID, val json.RawMessage) error { + st.logger.Info("upserting account join", "scene", sceneDID, "account", accountDID) // lexicon and data validation obj, err := atdata.UnmarshalJSON(val) @@ -32,6 +33,10 @@ func (st *Store) UpsertAccountJoin(ctx context.Context, accountDID syntax.DID, s if obj["scene"] != sceneDID.String() { return fmt.Errorf("join record scene mismatch") } + cts, err := syntax.ParseDatetime(obj["createdAt"].(string)) + if err != nil { + return err + } tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { @@ -40,7 +45,13 @@ func (st *Store) UpsertAccountJoin(ctx context.Context, accountDID syntax.DID, s defer tx.Rollback(ctx) q := sqlcdb.New(tx) - // XXX: also a membership row? + if err := q.UpsertAccountJoin(ctx, sqlcdb.UpsertAccountJoinParams{ + SceneDid: sceneDID.String(), + AccountDid: accountDID.String(), + JoinedAt: new(cts.Time()), // TODO: limit range of this timestamp? + }); err != nil { + return err + } if err := q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ AccountDid: accountDID.String(), @@ -56,7 +67,20 @@ func (st *Store) UpsertAccountJoin(ctx context.Context, accountDID syntax.DID, s } func (st *Store) DeleteAccountJoin(ctx context.Context, accountDID syntax.DID, sceneDID syntax.DID) error { - q := sqlcdb.New(st.conn) + st.logger.Info("deleting account join", "scene", sceneDID, "account", accountDID) + tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + q := sqlcdb.New(tx) + + if err := q.DeleteAccountJoin(ctx, sqlcdb.DeleteAccountJoinParams{ + SceneDid: sceneDID.String(), + AccountDid: accountDID.String(), + }); err != nil { + return err + } if err := q.DeleteRecord(ctx, sqlcdb.DeleteRecordParams{ AccountDid: accountDID.String(), @@ -66,10 +90,11 @@ func (st *Store) DeleteAccountJoin(ctx context.Context, accountDID syntax.DID, s return err } - return nil + return tx.Commit(ctx) } func (st *Store) UpsertSceneMember(ctx context.Context, sceneDID syntax.DID, memberDID syntax.DID, version syntax.CID, val json.RawMessage) error { + st.logger.Info("upserting scene member", "scene", sceneDID, "account", memberDID) // lexicon and data validation obj, err := atdata.UnmarshalJSON(val) @@ -82,6 +107,11 @@ func (st *Store) UpsertSceneMember(ctx context.Context, sceneDID syntax.DID, mem if obj["did"] != memberDID.String() { return fmt.Errorf("member record scene mismatch") } + cts, err := syntax.ParseDatetime(obj["createdAt"].(string)) + if err != nil { + return err + } + role := obj["role"].(string) tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) if err != nil { @@ -90,7 +120,14 @@ func (st *Store) UpsertSceneMember(ctx context.Context, sceneDID syntax.DID, mem defer tx.Rollback(ctx) q := sqlcdb.New(tx) - // XXX: also a membership row? + if err := q.UpsertSceneMember(ctx, sqlcdb.UpsertSceneMemberParams{ + SceneDid: sceneDID.String(), + AccountDid: memberDID.String(), + InvitedAt: new(cts.Time()), // TODO: limit range of this timestamp? + Role: new(role), + }); err != nil { + return err + } if err := q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ AccountDid: sceneDID.String(), @@ -106,8 +143,16 @@ func (st *Store) UpsertSceneMember(ctx context.Context, sceneDID syntax.DID, mem } func (st *Store) DeleteSceneMember(ctx context.Context, sceneDID syntax.DID, memberDID syntax.DID) error { + st.logger.Info("deleting scene member", "scene", sceneDID, "account", memberDID) q := sqlcdb.New(st.conn) + if err := q.DeleteSceneMember(ctx, sqlcdb.DeleteSceneMemberParams{ + SceneDid: sceneDID.String(), + AccountDid: memberDID.String(), + }); err != nil { + return err + } + if err := q.DeleteRecord(ctx, sqlcdb.DeleteRecordParams{ AccountDid: sceneDID.String(), Collection: SceneMemberNSID.String(), @@ -121,6 +166,7 @@ func (st *Store) DeleteSceneMember(ctx context.Context, sceneDID syntax.DID, mem type SceneMemberInfo struct { AccountDID syntax.DID + SceneDID syntax.DID JoinedAt *time.Time InvitedAt *time.Time Role *string @@ -155,6 +201,7 @@ func (st *Store) ListSceneMembers(ctx context.Context, sceneDID syntax.DID, curs } infos[i] = SceneMemberInfo{ AccountDID: syntax.DID(row.Membership.AccountDid), + SceneDID: syntax.DID(row.Membership.SceneDid), JoinedAt: row.Membership.JoinedAt, InvitedAt: row.Membership.InvitedAt, Role: row.Membership.Role, @@ -191,6 +238,7 @@ func (st *Store) ListSceneOpenInvites(ctx context.Context, sceneDID syntax.DID, } infos[i] = SceneMemberInfo{ AccountDID: syntax.DID(row.Membership.AccountDid), + SceneDID: syntax.DID(row.Membership.SceneDid), JoinedAt: row.Membership.JoinedAt, InvitedAt: row.Membership.InvitedAt, Role: row.Membership.Role, @@ -227,6 +275,7 @@ func (st *Store) ListSceneMemberRequests(ctx context.Context, sceneDID syntax.DI } infos[i] = SceneMemberInfo{ AccountDID: syntax.DID(row.Membership.AccountDid), + SceneDID: syntax.DID(row.Membership.SceneDid), JoinedAt: row.Membership.JoinedAt, InvitedAt: row.Membership.InvitedAt, Role: row.Membership.Role, @@ -235,3 +284,50 @@ func (st *Store) ListSceneMemberRequests(ctx context.Context, sceneDID syntax.DI } return infos, nil } + +type AccountSceneInfo struct { + AccountDID syntax.DID + SceneDID syntax.DID + JoinedAt *time.Time + InvitedAt *time.Time + Role *string + SceneDeclaration *socialscrumble.BetaSceneDeclaration + SceneIdent *identity.Identity // TODO: hydrated in app, not store +} + +// returns basic scene info for each scene an account is a member of +func (st *Store) ListAccountScenes(ctx context.Context, accountDID syntax.DID, cursor string) ([]AccountSceneInfo, error) { + q := sqlcdb.New(st.conn) + + params := sqlcdb.ListAccountScenesParams{ + AccountDid: accountDID.String(), + } + if cursor != "" { + dt, err := syntax.ParseDatetime(cursor) + if err != nil { + return nil, fmt.Errorf("invalid cursor syntax: %s", cursor) + } + params.Cursor = new(dt.Time()) + } + rows, err := q.ListAccountScenes(ctx, params) + if err != nil { + return nil, err + } + + infos := make([]AccountSceneInfo, len(rows)) + for i, row := range rows { + var decl socialscrumble.BetaSceneDeclaration + if err := json.Unmarshal(row.Record.DataJson, &decl); err != nil { + return nil, err + } + infos[i] = AccountSceneInfo{ + AccountDID: accountDID, + SceneDID: syntax.DID(row.Membership.SceneDid), + JoinedAt: row.Membership.JoinedAt, + InvitedAt: row.Membership.InvitedAt, + Role: row.Membership.Role, + SceneDeclaration: &decl, + } + } + return infos, nil +} diff --git a/store/scene.go b/store/scene.go index 71d61f4..02376f7 100644 --- a/store/scene.go +++ b/store/scene.go @@ -51,7 +51,7 @@ func (st *Store) UpsertSceneDeclaration(ctx context.Context, did syntax.DID, ver defer tx.Rollback(ctx) q := sqlcdb.New(tx) - // XXX: also a scene row? + // TODO: upsert account row? if err := q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ AccountDid: did.String(), diff --git a/store/schemas/000001_init.up.sql b/store/schemas/000001_init.up.sql index b4cfb24..70dc55b 100644 --- a/store/schemas/000001_init.up.sql +++ b/store/schemas/000001_init.up.sql @@ -6,13 +6,6 @@ CREATE TABLE account ( status TEXT NOT NULL DEFAULT 'active' ); -CREATE TABLE scene ( - did TEXT PRIMARY KEY NOT NULL, - handle TEXT, - upstream_status TEXT NOT NULL DEFAULT 'active', - status TEXT NOT NULL DEFAULT 'active' -); - CREATE TABLE record ( collection TEXT NOT NULL, account_did TEXT NOT NULL, diff --git a/store/schemas/query.sql b/store/schemas/query.sql index eebeee7..962a3ea 100644 --- a/store/schemas/query.sql +++ b/store/schemas/query.sql @@ -46,8 +46,12 @@ JOIN record ON record.rkey = item.rkey AND record.account_did = item.account_did AND record.collection = 'social.scrumble.beta.collection.item' +JOIN account ON + account.did = item.account_did AND + account.upstream_status = 'active' AND + account.status = 'active' WHERE - -- TODO: filter by account status (etc) + -- TODO: filter by membership status (etc) item.scene_did = $1 ORDER BY item.created_at DESC LIMIT 30; @@ -60,11 +64,37 @@ JOIN record ON record.account_did = item.account_did AND record.collection = 'social.scrumble.beta.collection.item' WHERE - -- TODO: filter by account status (etc) + -- TODO: filter by membership status (etc) item.account_did = $1 ORDER BY item.created_at DESC LIMIT 30; +-- name: UpsertAccountJoin :exec +INSERT INTO membership (account_did, scene_did, joined_at) + VALUES ($1, $2, $3) +ON CONFLICT (account_did, scene_did) DO UPDATE SET + joined_at = EXCLUDED.joined_at; + +-- name: DeleteAccountJoin :exec +INSERT INTO membership (account_did, scene_did, joined_at) + VALUES ($1, $2, NULL) +ON CONFLICT (account_did, scene_did) DO UPDATE SET + joined_at = NULL; + +-- name: UpsertSceneMember :exec +INSERT INTO membership (scene_did, account_did, invited_at, role) + VALUES ($1, $2, $3, $4) +ON CONFLICT (account_did, scene_did) DO UPDATE SET + invited_at = EXCLUDED.invited_at, + role = EXCLUDED.role; + +-- name: DeleteSceneMember :exec +INSERT INTO membership (scene_did, account_did, invited_at, role) + VALUES ($1, $2, NULL, NULL) +ON CONFLICT (account_did, scene_did) DO UPDATE SET + invited_at = NULL, + role = NULL; + -- name: ListSceneMembers :many SELECT sqlc.embed(membership), sqlc.embed(record) FROM membership @@ -75,7 +105,7 @@ JOIN account ON JOIN record ON record.account_did = membership.account_did AND record.collection = 'social.scrumble.beta.account.profile' AND - record.rkey = membership.scene_did + record.rkey = 'self' WHERE membership.scene_did = $1 AND membership.invited_at IS NOT NULL AND @@ -94,7 +124,7 @@ JOIN account ON JOIN record ON record.account_did = membership.account_did AND record.collection = 'social.scrumble.beta.account.profile' AND - record.rkey = membership.scene_did + record.rkey = 'self' WHERE membership.scene_did = $1 AND membership.invited_at IS NOT NULL AND @@ -113,7 +143,7 @@ JOIN account ON JOIN record ON record.account_did = membership.account_did AND record.collection = 'social.scrumble.beta.account.profile' AND - record.rkey = membership.scene_did + record.rkey = 'self' WHERE membership.scene_did = $1 AND membership.invited_at IS NULL AND @@ -122,6 +152,25 @@ WHERE ORDER BY membership.joined_at ASC LIMIT 30; +-- name: ListAccountScenes :many +SELECT sqlc.embed(membership), sqlc.embed(record) +FROM membership +JOIN account ON + account.did = membership.scene_did AND + account.upstream_status = 'active' AND + account.status = 'active' +JOIN record ON + record.account_did = membership.scene_did AND + record.collection = 'social.scrumble.beta.scene.declaration' AND + record.rkey = 'self' +WHERE + membership.account_did = $1 AND + membership.invited_at IS NOT NULL AND + membership.joined_at IS NOT NULL AND + (sqlc.narg('cursor')::timestamptz IS NULL OR membership.joined_at > sqlc.narg('cursor')::timestamptz) +ORDER BY membership.joined_at ASC +LIMIT 30; + -- ### OAuth Queries ### -- name: GetOAuthSession :one diff --git a/store/sqlcdb/models.go b/store/sqlcdb/models.go index bf960d1..512f5fa 100644 --- a/store/sqlcdb/models.go +++ b/store/sqlcdb/models.go @@ -85,10 +85,3 @@ type Record struct { Version string DataJson json.RawMessage } - -type Scene struct { - Did string - Handle *string - UpstreamStatus string - Status string -} diff --git a/store/sqlcdb/query.sql.go b/store/sqlcdb/query.sql.go index 8b39c7e..3b1e24d 100644 --- a/store/sqlcdb/query.sql.go +++ b/store/sqlcdb/query.sql.go @@ -11,6 +11,23 @@ import ( "time" ) +const deleteAccountJoin = `-- name: DeleteAccountJoin :exec +INSERT INTO membership (account_did, scene_did, joined_at) + VALUES ($1, $2, NULL) +ON CONFLICT (account_did, scene_did) DO UPDATE SET + joined_at = NULL +` + +type DeleteAccountJoinParams struct { + AccountDid string + SceneDid string +} + +func (q *Queries) DeleteAccountJoin(ctx context.Context, arg DeleteAccountJoinParams) error { + _, err := q.db.Exec(ctx, deleteAccountJoin, arg.AccountDid, arg.SceneDid) + return err +} + const deleteItem = `-- name: DeleteItem :exec DELETE FROM item WHERE scene_did = $1 AND account_did = $2 AND rkey = $3 ` @@ -70,6 +87,24 @@ func (q *Queries) DeleteRecord(ctx context.Context, arg DeleteRecordParams) erro return err } +const deleteSceneMember = `-- name: DeleteSceneMember :exec +INSERT INTO membership (scene_did, account_did, invited_at, role) + VALUES ($1, $2, NULL, NULL) +ON CONFLICT (account_did, scene_did) DO UPDATE SET + invited_at = NULL, + role = NULL +` + +type DeleteSceneMemberParams struct { + SceneDid string + AccountDid string +} + +func (q *Queries) DeleteSceneMember(ctx context.Context, arg DeleteSceneMemberParams) error { + _, err := q.db.Exec(ctx, deleteSceneMember, arg.SceneDid, arg.AccountDid) + return err +} + const getAccount = `-- name: GetAccount :one SELECT did, handle, upstream_status, status FROM account WHERE did = $1 @@ -178,7 +213,7 @@ JOIN record ON record.account_did = item.account_did AND record.collection = 'social.scrumble.beta.collection.item' WHERE - -- TODO: filter by account status (etc) + -- TODO: filter by membership status (etc) item.account_did = $1 ORDER BY item.created_at DESC LIMIT 30 @@ -220,6 +255,67 @@ func (q *Queries) ListAccountItems(ctx context.Context, accountDid string) ([]Li return items, nil } +const listAccountScenes = `-- name: ListAccountScenes :many +SELECT membership.scene_did, membership.account_did, membership.invited_at, membership.joined_at, membership.role, record.collection, record.account_did, record.rkey, record.version, record.data_json +FROM membership +JOIN account ON + account.did = membership.scene_did AND + account.upstream_status = 'active' AND + account.status = 'active' +JOIN record ON + record.account_did = membership.scene_did AND + record.collection = 'social.scrumble.beta.scene.declaration' AND + record.rkey = 'self' +WHERE + membership.account_did = $1 AND + membership.invited_at IS NOT NULL AND + membership.joined_at IS NOT NULL AND + ($2::timestamptz IS NULL OR membership.joined_at > $2::timestamptz) +ORDER BY membership.joined_at ASC +LIMIT 30 +` + +type ListAccountScenesParams struct { + AccountDid string + Cursor *time.Time +} + +type ListAccountScenesRow struct { + Membership Membership + Record Record +} + +func (q *Queries) ListAccountScenes(ctx context.Context, arg ListAccountScenesParams) ([]ListAccountScenesRow, error) { + rows, err := q.db.Query(ctx, listAccountScenes, arg.AccountDid, arg.Cursor) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountScenesRow + for rows.Next() { + var i ListAccountScenesRow + if err := rows.Scan( + &i.Membership.SceneDid, + &i.Membership.AccountDid, + &i.Membership.InvitedAt, + &i.Membership.JoinedAt, + &i.Membership.Role, + &i.Record.Collection, + &i.Record.AccountDid, + &i.Record.Rkey, + &i.Record.Version, + &i.Record.DataJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listSceneItems = `-- name: ListSceneItems :many SELECT item.scene_did, item.account_did, item.rkey, item.version, item.created_at, record.collection, record.account_did, record.rkey, record.version, record.data_json FROM item @@ -227,8 +323,12 @@ JOIN record ON record.rkey = item.rkey AND record.account_did = item.account_did AND record.collection = 'social.scrumble.beta.collection.item' +JOIN account ON + account.did = item.account_did AND + account.upstream_status = 'active' AND + account.status = 'active' WHERE - -- TODO: filter by account status (etc) + -- TODO: filter by membership status (etc) item.scene_did = $1 ORDER BY item.created_at DESC LIMIT 30 @@ -280,7 +380,7 @@ JOIN account ON JOIN record ON record.account_did = membership.account_did AND record.collection = 'social.scrumble.beta.account.profile' AND - record.rkey = membership.scene_did + record.rkey = 'self' WHERE membership.scene_did = $1 AND membership.invited_at IS NULL AND @@ -341,7 +441,7 @@ JOIN account ON JOIN record ON record.account_did = membership.account_did AND record.collection = 'social.scrumble.beta.account.profile' AND - record.rkey = membership.scene_did + record.rkey = 'self' WHERE membership.scene_did = $1 AND membership.invited_at IS NOT NULL AND @@ -402,7 +502,7 @@ JOIN account ON JOIN record ON record.account_did = membership.account_did AND record.collection = 'social.scrumble.beta.account.profile' AND - record.rkey = membership.scene_did + record.rkey = 'self' WHERE membership.scene_did = $1 AND membership.invited_at IS NOT NULL AND @@ -453,6 +553,24 @@ func (q *Queries) ListSceneOpenInvites(ctx context.Context, arg ListSceneOpenInv return items, nil } +const upsertAccountJoin = `-- name: UpsertAccountJoin :exec +INSERT INTO membership (account_did, scene_did, joined_at) + VALUES ($1, $2, $3) +ON CONFLICT (account_did, scene_did) DO UPDATE SET + joined_at = EXCLUDED.joined_at +` + +type UpsertAccountJoinParams struct { + AccountDid string + SceneDid string + JoinedAt *time.Time +} + +func (q *Queries) UpsertAccountJoin(ctx context.Context, arg UpsertAccountJoinParams) error { + _, err := q.db.Exec(ctx, upsertAccountJoin, arg.AccountDid, arg.SceneDid, arg.JoinedAt) + return err +} + const upsertIdentity = `-- name: UpsertIdentity :exec INSERT INTO account (did, handle, upstream_status) VALUES ($1, $2, $3) @@ -541,3 +659,28 @@ func (q *Queries) UpsertRecord(ctx context.Context, arg UpsertRecordParams) erro ) return err } + +const upsertSceneMember = `-- name: UpsertSceneMember :exec +INSERT INTO membership (scene_did, account_did, invited_at, role) + VALUES ($1, $2, $3, $4) +ON CONFLICT (account_did, scene_did) DO UPDATE SET + invited_at = EXCLUDED.invited_at, + role = EXCLUDED.role +` + +type UpsertSceneMemberParams struct { + SceneDid string + AccountDid string + InvitedAt *time.Time + Role *string +} + +func (q *Queries) UpsertSceneMember(ctx context.Context, arg UpsertSceneMemberParams) error { + _, err := q.db.Exec(ctx, upsertSceneMember, + arg.SceneDid, + arg.AccountDid, + arg.InvitedAt, + arg.Role, + ) + return err +} diff --git a/store/store.go b/store/store.go index 5affb09..5bef9a6 100644 --- a/store/store.go +++ b/store/store.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/json" + "log/slog" "tangled.org/bnewbold.net/scrumble/store/sqlcdb" @@ -15,14 +16,16 @@ import ( ) type Store struct { - conn *pgx.Conn - cat lexicon.Catalog + logger *slog.Logger + conn *pgx.Conn + cat lexicon.Catalog } func NewStore(conn *pgx.Conn, cat lexicon.Catalog) (*Store, error) { return &Store{ - conn: conn, - cat: cat, + logger: slog.Default(), + conn: conn, + cat: cat, }, nil } diff --git a/templates/base_profile.html b/templates/base_profile.html new file mode 100644 index 0000000..fae9dfd --- /dev/null +++ b/templates/base_profile.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block main_content %} +
+ +
+{% block main_column %} +{% endblock %} +
+ +
+{% if profile %} +
+
+ {% if profile.Avatar %} + + {% endif %} + @{{ ident.Handle }} +
+ {% if profile.CreatedAt %} + {{ profile.CreatedAt }} + {% endif %} +
+ + + +
+{% else %} +
+{% endif %} +
+ +
+{% endblock %} diff --git a/templates/base_scene.html b/templates/base_scene.html index 94c8574..2325fbf 100644 --- a/templates/base_scene.html +++ b/templates/base_scene.html @@ -21,9 +21,25 @@ +
+
+ +
+
+
+ +
+
+ + + {% else %} {% endif %} +
+ + + {% else %} diff --git a/templates/dev_index.html b/templates/dev_index.html index 80ec27a..fe1102d 100644 --- a/templates/dev_index.html +++ b/templates/dev_index.html @@ -4,10 +4,12 @@

dev route index

{% endblock %} diff --git a/templates/home.html b/templates/home.html index f68b07c..b771f39 100644 --- a/templates/home.html +++ b/templates/home.html @@ -22,7 +22,7 @@
-

This is a work-in-progress atproto app for communities ("scenes") to curate and discuss arbitrary content. +

This is a work-in-progress atproto app for subcultures ("scenes") to curate and discuss their own weird special interests on their own terms.

diff --git a/templates/macros.html b/templates/macros.html index 03b0f1e..624fd8f 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -11,3 +11,37 @@ {% endmacro %} + +{% macro member_element(member) export %} +
+ @{{ member.AccountIdent.Handle }} + since {{ member.InvitedAt }} +
+{% endmacro %} + +{% macro scene_element(scene) export %} +
+ @{{ scene.SceneIdent.Handle }} + since {{ scene.JoinedAt }} +
+{% endmacro %} + +{% macro manage_member(member) export %} +
+ @{{ member.AccountIdent.Handle }} + {% if member.Role %} + + {% endif %} + {% if member.InvitedAt %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+{% endmacro %} diff --git a/templates/profile.html b/templates/profile.html index 962f171..a34d84d 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -1,30 +1,12 @@ -{% extends "base.html" %} +{% extends "base_profile.html" %} -{% block main_content %} +{% block main_column %} {% import "macros.html" item_view %} -
-
{% for item in items %} {{ item_view(item) }} {% empty %}

Account has not posted! {% endfor %} -

-
-
-
- {% if profile.Avatar %} - - {% endif %} - @{{ ident.Handle }} -
- {% if profile.CreatedAt %} - {{ profile.CreatedAt }} - {% endif %} -
-
- -
{% endblock %} diff --git a/templates/profile_scenes.html b/templates/profile_scenes.html new file mode 100644 index 0000000..bb444e5 --- /dev/null +++ b/templates/profile_scenes.html @@ -0,0 +1,13 @@ +{% extends "base_profile.html" %} + +{% block main_column %} +{% import "macros.html" scene_element %} + +

Scenes

+{% for scene in scenes %} + {{ scene_element(scene) }} +{% empty %} +

Account is not member of any scenes! +{% endfor %} + +{% endblock %} diff --git a/templates/scene_members.html b/templates/scene_members.html new file mode 100644 index 0000000..40e17f5 --- /dev/null +++ b/templates/scene_members.html @@ -0,0 +1,13 @@ +{% extends "base_scene.html" %} + +{% block main_column %} +{% import "macros.html" member_element %} + +

Members

+{% for member in members %} + {{ member_element(member) }} +{% empty %} +

Scene has no members! +{% endfor %} + +{% endblock %} diff --git a/templates/scene_mgmt.html b/templates/scene_mgmt.html new file mode 100644 index 0000000..8388114 --- /dev/null +++ b/templates/scene_mgmt.html @@ -0,0 +1,29 @@ +{% extends "base_scene.html" %} + +{% block main_column %} +{% import "macros.html" manage_member %} + +

Member Management

+ +

Requests to Join

+{% for member in member_requests %} + {{ manage_member(member) }} +{% empty %} +

None! +{% endfor %} + +

Open Invites

+{% for member in member_invites %} + {{ manage_member(member) }} +{% empty %} +

None! +{% endfor %} + +

Current Members

+{% for member in members %} + {{ manage_member(member) }} +{% empty %} +

None! +{% endfor %} + +{% endblock %} diff --git a/web.go b/web.go index 19c6cad..c1013a3 100644 --- a/web.go +++ b/web.go @@ -5,13 +5,11 @@ import ( "errors" "fmt" "net/http" - "strings" "time" "tangled.org/bnewbold.net/scrumble/api/socialscrumble" "tangled.org/bnewbold.net/scrumble/store" - "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/flosch/pongo2/v7" "github.com/jackc/pgx/v5" @@ -79,6 +77,296 @@ func (srv *Server) WebSceneView(c echo.Context) error { return c.Render(http.StatusOK, "scene.html", info) } +func (srv *Server) WebSceneMembersView(c echo.Context) error { + ctx := c.Request().Context() + info := pongo2.Context{} + sess := srv.GetCurrentSession(c.Request()) + if sess != nil { + info["sess"] = sess + } + sceneInfo, err := srv.GetSceneInfo(ctx, c.Param("scene")) + if err != nil { + return err + } + info["scene"] = sceneInfo + + sceneDID, err := syntax.ParseDID(c.Param("scene")) + if err != nil { + return err + } + + members, err := srv.store.ListSceneMembers(ctx, sceneDID, c.Param("cursor")) + if err != nil { + return err + } + for i := range members { + ident, err := srv.dir.LookupDID(ctx, members[i].AccountDID) + if err != nil { + return err + } + members[i].AccountIdent = ident + } + info["members"] = members + + return c.Render(http.StatusOK, "scene_members.html", info) +} + +func (srv *Server) WebSceneMemberJoin(c echo.Context) error { + ctx := c.Request().Context() + info := pongo2.Context{} + sess := srv.GetCurrentSession(c.Request()) + if sess == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Login required") + } + info["sess"] = sess + sceneInfo, err := srv.GetSceneInfo(ctx, c.Param("scene")) + if err != nil { + return err + } + info["scene"] = sceneInfo + + if c.Request().Method != "POST" { + return echo.NewHTTPError(http.StatusMethodNotAllowed) + } + + record := socialscrumble.BetaAccountJoin{ + LexiconTypeID: "social.scrumble.beta.account.join", + Scene: sceneInfo.DID.String(), + CreatedAt: new(syntax.DatetimeNow().String()), + } + + rkey := syntax.RecordKey(sceneInfo.DID.String()) + if !srv.config.DevMode { + // write to PDS + oauthSess, err := srv.oauth.ResumeSession(ctx, sess.DID, sess.SessionID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to resume session: %s", err)) + } + pc := pdsclient.PDSClient{ + APIClient: oauthSess.APIClient(), + AccountDID: sess.DID, + } + _, _, err = pc.CreateRecord(ctx, syntax.NSID("social.scrumble.beta.account.join"), rkey, &record) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to write record to PDS: %s", err)) + } + + // TODO: either have smarter polling here, or do write-aside logic? + time.Sleep(3) + } else { + // insert directly/locally into database + data, err := json.Marshal(record) + if err != nil { + return err + } + version, err := store.VersionForRecord(data) + if err != nil { + return err + } + if err := srv.store.UpsertAccountJoin(ctx, sess.DID, sceneInfo.DID, version, data); err != nil { + return err + } + } + + return c.Redirect(http.StatusFound, fmt.Sprintf("/s/%s", sceneInfo.DID)) +} + +func (srv *Server) WebSceneMemberLeave(c echo.Context) error { + ctx := c.Request().Context() + info := pongo2.Context{} + sess := srv.GetCurrentSession(c.Request()) + if sess == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Login required") + } + info["sess"] = sess + sceneInfo, err := srv.GetSceneInfo(ctx, c.Param("scene")) + if err != nil { + return err + } + info["scene"] = sceneInfo + + if c.Request().Method != "POST" { + return echo.NewHTTPError(http.StatusMethodNotAllowed) + } + + rkey := syntax.RecordKey(sceneInfo.DID.String()) + if !srv.config.DevMode { + // write to PDS + oauthSess, err := srv.oauth.ResumeSession(ctx, sess.DID, sess.SessionID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to resume session: %s", err)) + } + pc := pdsclient.PDSClient{ + APIClient: oauthSess.APIClient(), + AccountDID: sess.DID, + } + err = pc.DeleteRecord(ctx, syntax.NSID("social.scrumble.beta.account.join"), rkey) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to delete record from PDS: %s", err)) + } + + // TODO: either have smarter polling here, or do write-aside logic? + time.Sleep(3) + } else { + // delete directly/locally from database + if err := srv.store.DeleteAccountJoin(ctx, sess.DID, sceneInfo.DID); err != nil { + return err + } + } + + return c.Redirect(http.StatusFound, fmt.Sprintf("/s/%s", sceneInfo.DID)) +} + +func (srv *Server) WebSceneManageView(c echo.Context) error { + ctx := c.Request().Context() + info := pongo2.Context{} + sess := srv.GetCurrentSession(c.Request()) + // XXX: access control + if sess != nil { + info["sess"] = sess + } + sceneInfo, err := srv.GetSceneInfo(ctx, c.Param("scene")) + if err != nil { + return err + } + info["scene"] = sceneInfo + + sceneDID, err := syntax.ParseDID(c.Param("scene")) + if err != nil { + return err + } + + members, err := srv.store.ListSceneMembers(ctx, sceneDID, c.Param("cursor")) + if err != nil { + return err + } + for i := range members { + ident, err := srv.dir.LookupDID(ctx, members[i].AccountDID) + if err != nil { + return err + } + members[i].AccountIdent = ident + } + info["members"] = members + + invites, err := srv.store.ListSceneOpenInvites(ctx, sceneDID, c.Param("cursor")) + if err != nil { + return err + } + for i := range invites { + ident, err := srv.dir.LookupDID(ctx, invites[i].AccountDID) + if err != nil { + return err + } + invites[i].AccountIdent = ident + } + info["member_invites"] = invites + + memberRequests, err := srv.store.ListSceneMemberRequests(ctx, sceneDID, c.Param("cursor")) + if err != nil { + return err + } + for i := range memberRequests { + ident, err := srv.dir.LookupDID(ctx, memberRequests[i].AccountDID) + if err != nil { + return err + } + memberRequests[i].AccountIdent = ident + } + info["member_requests"] = memberRequests + + return c.Render(http.StatusOK, "scene_mgmt.html", info) +} + +func (srv *Server) WebSceneManageMemberUpdate(c echo.Context) error { + ctx := c.Request().Context() + info := pongo2.Context{} + sess := srv.GetCurrentSession(c.Request()) + if sess == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Login required") + } + info["sess"] = sess + sceneInfo, err := srv.GetSceneInfo(ctx, c.Param("scene")) + if err != nil { + return err + } + info["scene"] = sceneInfo + + if c.Request().Method != "POST" { + return echo.NewHTTPError(http.StatusMethodNotAllowed) + } + + // XXX: mgmt auth + + role := c.FormValue("role") + accountDID, err := syntax.ParseDID(c.Param("account")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Valid account DID required: %s", err)) + } + + var record *socialscrumble.BetaSceneMember + rkey := syntax.RecordKey(accountDID) + collection := syntax.NSID("social.scrumble.beta.scene.member") + if role != "" { + // invite / update role + record = &socialscrumble.BetaSceneMember{ + LexiconTypeID: collection.String(), + Did: accountDID.String(), + Role: role, + CreatedAt: syntax.DatetimeNow().String(), + } + } + + if !srv.config.DevMode { + // write to PDS + oauthSess, err := srv.oauth.ResumeSession(ctx, sess.DID, sess.SessionID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to resume session: %s", err)) + } + pc := pdsclient.PDSClient{ + APIClient: oauthSess.APIClient(), + AccountDID: sess.DID, + } + + if record != nil { + _, _, err = pc.CreateRecord(ctx, collection, rkey, &record) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to write record to PDS: %s", err)) + } + } else { + err = pc.DeleteRecord(ctx, collection, rkey) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to delete record from PDS: %s", err)) + } + } + + // TODO: either have smarter polling here, or do write-aside logic? + time.Sleep(3) + } else { + + if record != nil { + // insert directly/locally into database + data, err := json.Marshal(record) + if err != nil { + return err + } + version, err := store.VersionForRecord(data) + if err != nil { + return err + } + if err := srv.store.UpsertSceneMember(ctx, sceneInfo.DID, accountDID, version, data); err != nil { + return err + } + } else { + if err := srv.store.DeleteSceneMember(ctx, sceneInfo.DID, accountDID); err != nil { + return err + } + } + } + + return c.Redirect(http.StatusFound, fmt.Sprintf("/s/%s/mgmt", sceneInfo.DID)) +} + func (srv *Server) WebItemView(c echo.Context) error { ctx := c.Request().Context() info := pongo2.Context{} @@ -200,162 +488,54 @@ func (srv *Server) WebItemCreate(c echo.Context) error { return c.Redirect(http.StatusFound, fmt.Sprintf("/s/%s/item/%s/%s", sceneInfo.DID, sess.DID, rkey)) } -func (srv *Server) WebLogin(c echo.Context) error { +func (srv *Server) WebAccountView(c echo.Context) error { ctx := c.Request().Context() - - if c.Request().Method != "POST" { - sess := srv.GetCurrentSession(c.Request()) - if sess != nil { - return c.Redirect(http.StatusFound, "/") - } - - info := pongo2.Context{} - return c.Render(http.StatusOK, "login.html", info) - } - - if err := c.Request().ParseForm(); err != nil { - info := pongo2.Context{} - info["error"] = err.Error() - return c.Render(http.StatusBadRequest, "login.html", info) - } - - username, _ := strings.CutPrefix(c.Request().PostFormValue("username"), "@") - - // NOTE: privacy sensitive log line - srv.logger.Debug("OAuthLogin", "client_id", srv.oauth.Config.ClientID, "callback_url", srv.oauth.Config.CallbackURL) - - redirectURL, err := srv.oauth.StartAuthFlow(ctx, username) - if err != nil { - srv.logger.Error("oauth flow failed", "username", username, "err", err) - info := pongo2.Context{} - info["error"] = err.Error() - return c.Render(http.StatusBadRequest, "login.html", info) + info := pongo2.Context{} + sess := srv.GetCurrentSession(c.Request()) + if sess != nil { + info["sess"] = sess } - return c.Redirect(http.StatusFound, redirectURL) -} - -func (srv *Server) WebOAuthCallback(c echo.Context) error { - ctx := c.Request().Context() - - params := c.Request().URL.Query() - // logging this has security concerns - //srv.logger.Debug("received callback", "params", params) - - sessData, err := srv.oauth.ProcessCallback(ctx, params) + atid, err := syntax.ParseAtIdentifier(c.Param("username")) if err != nil { - srv.logger.Warn("oauth callback error", "err", err) - return c.String(http.StatusUnauthorized, "Login Failed") + return echo.NewHTTPError(http.StatusBadRequest, "Broken URL: Invalid Account Name") } - // retrieve session metadata - oauthSess, err := srv.oauth.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) + ident, err := srv.dir.Lookup(ctx, atid) if err != nil { - return c.String(http.StatusUnauthorized, "Not Authenticated") - } - atc := oauthSess.APIClient() - var resp struct { - Handle string `json:"handle"` - // more fields? - } - if err := atc.Get(ctx, "com.atproto.server.getSession", nil, &resp); err != nil { - return err - } - - // create signed cookie session, indicating account DID - csess, _ := srv.cookieStore.Get(c.Request(), "scrumble") - csess.Values["account_did"] = sessData.AccountDID.String() - csess.Values["session_id"] = sessData.SessionID - csess.Values["handle"] = resp.Handle - if err := csess.Save(c.Request(), c.Response()); err != nil { - return err - } - - // in dev mode, insert in to mock ident directory - if srv.config.DevMode { - mock, ok := srv.dir.(*identity.MockDirectory) - if ok { - // NOTE: as a hack, srv.oauth.Dir is different from srv.dir in DevMode - ident, err := srv.oauth.Dir.LookupDID(ctx, sessData.AccountDID) - if err != nil { - // pass - } else { - mock.Insert(*ident) - } - } + return echo.NewHTTPError(http.StatusNotFound, "User Not Found (Identity)") } - // check if we need to create profile record - _, err = srv.store.GetProfile(ctx, sessData.AccountDID) + profile, err := srv.store.GetProfile(ctx, ident.DID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - profile := socialscrumble.BetaAccountProfile{ - LexiconTypeID: store.ProfileNSID.String(), - CreatedAt: new(syntax.DatetimeNow().String()), - } - if !srv.config.DevMode { - // write to PDS - pc := pdsclient.PDSClient{ - APIClient: oauthSess.APIClient(), - AccountDID: sessData.AccountDID, - } - _, _, err := pc.CreateRecord(ctx, store.ProfileNSID, "self", &profile) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to write record to PDS: %s", err)) - } - - // TODO: either have smarter polling here, or do write-aside logic? - time.Sleep(3) - } else { - data, err := json.Marshal(profile) - if err != nil { - return err - } - version, err := store.VersionForRecord(data) - if err != nil { - return err - } - if err := srv.store.UpsertProfile(ctx, sessData.AccountDID, version, data); err != nil { - return err - } - } - } else { - return err + return echo.NewHTTPError(http.StatusNotFound, "User Not Found (Profile)") } + return err } + info["ident"] = ident + info["profile"] = profile - srv.logger.Debug("login successful", "did", sessData.AccountDID.String()) - if srv.config.DevMode { - return c.Redirect(http.StatusFound, "/dev/index") + accountItems, err := srv.store.ListAccountItems(ctx, ident.DID) + if err != nil { + return err } - return c.Redirect(http.StatusFound, "/") -} - -func (srv *Server) WebLogout(c echo.Context) error { - // revoke tokens and delete session from auth store - sess := srv.GetCurrentSession(c.Request()) - if sess == nil { - return c.Redirect(http.StatusFound, "/") - } else { - if err := srv.oauth.Logout(c.Request().Context(), sess.DID, sess.SessionID); err != nil { - srv.logger.Error("failed to delete session", "did", sess.DID, "err", err) + items := make([]ItemInfo, len(accountItems)) + for i, si := range accountItems { + items[i] = ItemInfo{ + Ident: *ident, + SceneDID: si.SceneDID, + RKey: si.RKey, + Record: &si.Record, } } + info["items"] = items - // wipe all secure cookie session data - csess, _ := srv.cookieStore.Get(c.Request(), "scrumble") - csess.Values = make(map[any]any) - err := csess.Save(c.Request(), c.Response()) - if err != nil { - return err - } - - srv.logger.Debug("account logged out", "did", sess.DID) - return c.Redirect(http.StatusFound, "/") + return c.Render(http.StatusOK, "profile.html", info) } -func (srv *Server) WebAccountView(c echo.Context) error { +func (srv *Server) WebAccountScenesView(c echo.Context) error { ctx := c.Request().Context() info := pongo2.Context{} sess := srv.GetCurrentSession(c.Request()) @@ -383,21 +563,18 @@ func (srv *Server) WebAccountView(c echo.Context) error { info["ident"] = ident info["profile"] = profile - accountItems, err := srv.store.ListAccountItems(ctx, ident.DID) + scenes, err := srv.store.ListAccountScenes(ctx, ident.DID, c.Param("cursor")) if err != nil { return err } - - items := make([]ItemInfo, len(accountItems)) - for i, si := range accountItems { - items[i] = ItemInfo{ - Ident: *ident, - SceneDID: si.SceneDID, - RKey: si.RKey, - Record: &si.Record, + for i := range scenes { + ident, err := srv.dir.LookupDID(ctx, scenes[i].SceneDID) + if err != nil { + return err } + scenes[i].SceneIdent = ident } - info["items"] = items + info["scenes"] = scenes - return c.Render(http.StatusOK, "profile.html", info) + return c.Render(http.StatusOK, "profile_scenes.html", info) } diff --git a/web_dev.go b/web_dev.go index 12ba2c7..41aefac 100644 --- a/web_dev.go +++ b/web_dev.go @@ -15,3 +15,15 @@ func (srv *Server) WebDevIndex(c echo.Context) error { } return c.Render(http.StatusOK, "dev_index.html", info) } + +func (srv *Server) WebDevLogin(c echo.Context) error { + // create signed cookie session, indicating account DID + csess, _ := srv.cookieStore.Get(c.Request(), "scrumble") + csess.Values["account_did"] = "did:example:voltaire" + csess.Values["session_id"] = "dummy" + csess.Values["handle"] = "voltaire.cosmos.social" + if err := csess.Save(c.Request(), c.Response()); err != nil { + return err + } + return c.Redirect(http.StatusFound, "/dev/index") +} diff --git a/web_oauth.go b/web_oauth.go new file mode 100644 index 0000000..5cadf41 --- /dev/null +++ b/web_oauth.go @@ -0,0 +1,175 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "tangled.org/bnewbold.net/scrumble/api/socialscrumble" + "tangled.org/bnewbold.net/scrumble/store" + + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/flosch/pongo2/v7" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" + "tangled.org/bnewbold.net/cobalt/pdsclient" +) + +func (srv *Server) WebLogin(c echo.Context) error { + ctx := c.Request().Context() + + if c.Request().Method != "POST" { + sess := srv.GetCurrentSession(c.Request()) + if sess != nil { + return c.Redirect(http.StatusFound, "/") + } + + info := pongo2.Context{} + return c.Render(http.StatusOK, "login.html", info) + } + + if err := c.Request().ParseForm(); err != nil { + info := pongo2.Context{} + info["error"] = err.Error() + return c.Render(http.StatusBadRequest, "login.html", info) + } + + username, _ := strings.CutPrefix(c.Request().PostFormValue("username"), "@") + + // NOTE: privacy sensitive log line + srv.logger.Debug("OAuthLogin", "client_id", srv.oauth.Config.ClientID, "callback_url", srv.oauth.Config.CallbackURL) + + redirectURL, err := srv.oauth.StartAuthFlow(ctx, username) + if err != nil { + srv.logger.Error("oauth flow failed", "username", username, "err", err) + info := pongo2.Context{} + info["error"] = err.Error() + return c.Render(http.StatusBadRequest, "login.html", info) + } + + return c.Redirect(http.StatusFound, redirectURL) +} + +func (srv *Server) WebOAuthCallback(c echo.Context) error { + ctx := c.Request().Context() + + params := c.Request().URL.Query() + // logging this has security concerns + //srv.logger.Debug("received callback", "params", params) + + sessData, err := srv.oauth.ProcessCallback(ctx, params) + if err != nil { + srv.logger.Warn("oauth callback error", "err", err) + return c.String(http.StatusUnauthorized, "Login Failed") + } + + // retrieve session metadata + oauthSess, err := srv.oauth.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) + if err != nil { + return c.String(http.StatusUnauthorized, "Not Authenticated") + } + atc := oauthSess.APIClient() + var resp struct { + Handle string `json:"handle"` + // more fields? + } + if err := atc.Get(ctx, "com.atproto.server.getSession", nil, &resp); err != nil { + return err + } + + // create signed cookie session, indicating account DID + csess, _ := srv.cookieStore.Get(c.Request(), "scrumble") + csess.Values["account_did"] = sessData.AccountDID.String() + csess.Values["session_id"] = sessData.SessionID + csess.Values["handle"] = resp.Handle + if err := csess.Save(c.Request(), c.Response()); err != nil { + return err + } + + // in dev mode, insert in to mock ident directory + if srv.config.DevMode { + mock, ok := srv.dir.(*identity.MockDirectory) + if ok { + // NOTE: as a hack, srv.oauth.Dir is different from srv.dir in DevMode + ident, err := srv.oauth.Dir.LookupDID(ctx, sessData.AccountDID) + if err != nil { + // pass + } else { + mock.Insert(*ident) + } + } + } + + // check if we need to create profile record + _, err = srv.store.GetProfile(ctx, sessData.AccountDID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + profile := socialscrumble.BetaAccountProfile{ + LexiconTypeID: store.ProfileNSID.String(), + CreatedAt: new(syntax.DatetimeNow().String()), + } + if !srv.config.DevMode { + // write to PDS + pc := pdsclient.PDSClient{ + APIClient: oauthSess.APIClient(), + AccountDID: sessData.AccountDID, + } + _, _, err := pc.CreateRecord(ctx, store.ProfileNSID, "self", &profile) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to write record to PDS: %s", err)) + } + + // TODO: either have smarter polling here, or do write-aside logic? + time.Sleep(3) + } else { + data, err := json.Marshal(profile) + if err != nil { + return err + } + version, err := store.VersionForRecord(data) + if err != nil { + return err + } + if err := srv.store.UpsertProfile(ctx, sessData.AccountDID, version, data); err != nil { + return err + } + } + } else { + return err + } + } + + srv.logger.Debug("login successful", "did", sessData.AccountDID.String()) + if srv.config.DevMode { + return c.Redirect(http.StatusFound, "/dev/index") + } + return c.Redirect(http.StatusFound, "/") +} + +func (srv *Server) WebLogout(c echo.Context) error { + + // revoke tokens and delete session from auth store + sess := srv.GetCurrentSession(c.Request()) + if sess == nil { + return c.Redirect(http.StatusFound, "/") + } else { + if err := srv.oauth.Logout(c.Request().Context(), sess.DID, sess.SessionID); err != nil { + srv.logger.Error("failed to delete session", "did", sess.DID, "err", err) + } + } + + // wipe all secure cookie session data + csess, _ := srv.cookieStore.Get(c.Request(), "scrumble") + csess.Values = make(map[any]any) + err := csess.Save(c.Request(), c.Response()) + if err != nil { + return err + } + + srv.logger.Debug("account logged out", "did", sess.DID) + return c.Redirect(http.StatusFound, "/") +}