diff --git a/go.mod b/go.mod
index 12fd9f0..99afc2c 100644
--- a/go.mod
+++ b/go.mod
@@ -2,33 +2,52 @@ module wolves.top/todo
go 1.25.5
-require github.com/gin-gonic/gin v1.10.0
+require (
+ github.com/gin-gonic/gin v1.10.0
+ github.com/jackc/pgconn v1.14.3
+ github.com/jackc/pgx/v5 v5.8.0
+ github.com/redis/go-redis/v9 v9.17.2
+ github.com/segmentio/kafka-go v0.4.50
+ golang.org/x/crypto v0.36.0
+)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
+ github.com/jackc/chunkreader/v2 v2.0.1 // indirect
+ github.com/jackc/pgio v1.0.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgproto3/v2 v2.3.3 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/compress v1.15.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/pierrec/lz4/v4 v4.1.15 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
- golang.org/x/crypto v0.23.0 // indirect
- golang.org/x/net v0.25.0 // indirect
- golang.org/x/sys v0.20.0 // indirect
- golang.org/x/text v0.15.0 // indirect
+ golang.org/x/net v0.38.0 // indirect
+ golang.org/x/sync v0.17.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 7f08abb..441fac6 100644
--- a/go.sum
+++ b/go.sum
@@ -1,14 +1,23 @@
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+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/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -28,12 +37,37 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
+github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
+github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
+github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
+github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
+github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
+github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
+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/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
+github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
+github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
+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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
+github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -45,43 +79,63 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
+github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
+github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
+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/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
+github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
+github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
+github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
+github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
-golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
-golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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=
diff --git a/main.go b/main.go
index 1b317e7..6562532 100644
--- a/main.go
+++ b/main.go
@@ -1,12 +1,26 @@
package main
import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "log"
"net/http"
+ "os"
"strconv"
- "sync"
+ "strings"
"time"
"github.com/gin-gonic/gin"
+ "github.com/jackc/pgconn"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/redis/go-redis/v9"
+ "github.com/segmentio/kafka-go"
+ "golang.org/x/crypto/bcrypt"
)
type Task struct {
@@ -21,120 +35,224 @@ type Task struct {
UpdatedAt time.Time `json:"updated_at"`
}
-type taskStore struct {
- mu sync.Mutex
- nextID int64
- items map[int64]Task
-}
-
type User struct {
- ID int64 `json:"id"`
- Email string `json:"email"`
- Password string `json:"-"`
+ ID int64 `json:"id"`
+ Email string `json:"email"`
}
-type authStore struct {
- mu sync.Mutex
- nextID int64
- users map[string]User
- sessions map[string]int64
+type tokenManager struct {
+ secret []byte
+ ttl time.Duration
}
-func newTaskStore() *taskStore {
- return &taskStore{
- nextID: 1,
- items: make(map[int64]Task),
+type postgresStore struct {
+ pool *pgxpool.Pool
+}
+
+type tokenCache struct {
+ client *redis.Client
+ prefix string
+}
+
+type taskEmitter struct {
+ writer *kafka.Writer
+ topic string
+}
+
+func newTokenManager(secret string, ttl time.Duration) *tokenManager {
+ return &tokenManager{
+ secret: []byte(secret),
+ ttl: ttl,
}
}
-func newAuthStore() *authStore {
- return &authStore{
- nextID: 1,
- users: make(map[string]User),
- sessions: make(map[string]int64),
+func (t *tokenManager) Generate(user User) (string, error) {
+ payload := struct {
+ UserID int64 `json:"uid"`
+ Exp int64 `json:"exp"`
+ }{
+ UserID: user.ID,
+ Exp: time.Now().Add(t.ttl).Unix(),
}
-}
-
-func (s *authStore) register(email, password string) (User, bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if _, exists := s.users[email]; exists {
- return User{}, false
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return "", err
}
- user := User{
- ID: s.nextID,
- Email: email,
- Password: password,
+ encoded := base64.RawURLEncoding.EncodeToString(raw)
+ sig := t.sign(encoded)
+ return encoded + "." + sig, nil
+}
+
+func (t *tokenManager) Validate(token string) (int64, bool) {
+ parts := strings.Split(token, ".")
+ if len(parts) != 2 {
+ return 0, false
}
- s.nextID++
- s.users[email] = user
- return user, true
-}
-
-func (s *authStore) login(email, password string) (string, bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- user, ok := s.users[email]
- if !ok || user.Password != password {
- return "", false
+ payload, sig := parts[0], parts[1]
+ if !t.verify(payload, sig) {
+ return 0, false
}
- token := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(user.ID, 10)
- s.sessions[token] = user.ID
- return token, true
-}
-
-func (s *authStore) validate(token string) bool {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- _, ok := s.sessions[token]
- return ok
-}
-
-func (s *taskStore) list() []Task {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- result := make([]Task, 0, len(s.items))
- for _, t := range s.items {
- result = append(result, t)
+ raw, err := base64.RawURLEncoding.DecodeString(payload)
+ if err != nil {
+ return 0, false
}
- return result
+ var data struct {
+ UserID int64 `json:"uid"`
+ Exp int64 `json:"exp"`
+ }
+ if err := json.Unmarshal(raw, &data); err != nil {
+ return 0, false
+ }
+ if data.UserID == 0 || time.Now().Unix() > data.Exp {
+ return 0, false
+ }
+ return data.UserID, true
}
-func (s *taskStore) get(id int64) (Task, bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- t, ok := s.items[id]
- return t, ok
+func (t *tokenManager) sign(payload string) string {
+ mac := hmac.New(sha256.New, t.secret)
+ mac.Write([]byte(payload))
+ return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
-func (s *taskStore) create(input Task) Task {
- s.mu.Lock()
- defer s.mu.Unlock()
+func (t *tokenManager) verify(payload, signature string) bool {
+ expected := t.sign(payload)
+ return hmac.Equal([]byte(signature), []byte(expected))
+}
- input.ID = s.nextID
- s.nextID++
- now := time.Now().UTC()
- input.CreatedAt = now
- input.UpdatedAt = now
+func newPostgresStore(ctx context.Context, url string) (*postgresStore, error) {
+ pool, err := pgxpool.New(ctx, url)
+ if err != nil {
+ return nil, err
+ }
+ if err := pool.Ping(ctx); err != nil {
+ pool.Close()
+ return nil, err
+ }
+ store := &postgresStore{pool: pool}
+ if err := store.initSchema(ctx); err != nil {
+ pool.Close()
+ return nil, err
+ }
+ return store, nil
+}
+
+func (s *postgresStore) initSchema(ctx context.Context) error {
+ statements := []string{
+ `CREATE TABLE IF NOT EXISTS users (
+ id BIGSERIAL PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )`,
+ `CREATE TABLE IF NOT EXISTS tasks (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ description TEXT,
+ status TEXT NOT NULL,
+ due_at TEXT,
+ priority INT NOT NULL DEFAULT 0,
+ tags TEXT[] NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL
+ )`,
+ }
+ for _, stmt := range statements {
+ if _, err := s.pool.Exec(ctx, stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *postgresStore) Register(ctx context.Context, email, password string) (User, error) {
+ hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return User{}, err
+ }
+ var id int64
+ err = s.pool.QueryRow(ctx, `INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`, email, string(hashed)).Scan(&id)
+ if err != nil {
+ if isUniqueViolation(err) {
+ return User{}, errAlreadyExists
+ }
+ return User{}, err
+ }
+ return User{ID: id, Email: email}, nil
+}
+
+func (s *postgresStore) Login(ctx context.Context, email, password string) (User, error) {
+ var user User
+ var hash string
+ err := s.pool.QueryRow(ctx, `SELECT id, email, password_hash FROM users WHERE email = $1`, email).Scan(&user.ID, &user.Email, &hash)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return User{}, errInvalidCredentials
+ }
+ return User{}, err
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
+ return User{}, errInvalidCredentials
+ }
+ return user, nil
+}
+
+func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error) {
+ rows, err := s.pool.Query(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 ORDER BY id DESC`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ result := []Task{}
+ for rows.Next() {
+ var task Task
+ var tags []string
+ if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt); err != nil {
+ return nil, err
+ }
+ task.Tags = tags
+ result = append(result, task)
+ }
+ return result, rows.Err()
+}
+
+func (s *postgresStore) Get(ctx context.Context, userID, id int64) (Task, error) {
+ var task Task
+ var tags []string
+ err := s.pool.QueryRow(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 AND id = $2`, userID, id).
+ Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt)
+ if err != nil {
+ return Task{}, err
+ }
+ task.Tags = tags
+ return task, nil
+}
+
+func (s *postgresStore) Create(ctx context.Context, userID int64, input Task) (Task, error) {
if input.Status == "" {
input.Status = "todo"
}
- s.items[input.ID] = input
- return input
+ var tags []string
+ if input.Tags != nil {
+ tags = input.Tags
+ }
+ err := s.pool.QueryRow(ctx, `
+ INSERT INTO tasks (user_id, title, description, status, due_at, priority, tags, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
+ RETURNING id, created_at, updated_at`,
+ userID, input.Title, input.Description, input.Status, input.DueAt, input.Priority, tags,
+ ).Scan(&input.ID, &input.CreatedAt, &input.UpdatedAt)
+ if err != nil {
+ return Task{}, err
+ }
+ return input, nil
}
-func (s *taskStore) update(id int64, input Task) (Task, bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- existing, ok := s.items[id]
- if !ok {
- return Task{}, false
+func (s *postgresStore) Update(ctx context.Context, userID, id int64, input Task) (Task, error) {
+ existing, err := s.Get(ctx, userID, id)
+ if err != nil {
+ return Task{}, err
}
if input.Title != "" {
existing.Title = input.Title
@@ -154,25 +272,139 @@ func (s *taskStore) update(id int64, input Task) (Task, bool) {
if input.Tags != nil {
existing.Tags = input.Tags
}
- existing.UpdatedAt = time.Now().UTC()
- s.items[id] = existing
- return existing, true
+ var updatedAt time.Time
+ err = s.pool.QueryRow(ctx, `
+ UPDATE tasks
+ SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, updated_at = now()
+ WHERE id = $7 AND user_id = $8
+ RETURNING updated_at`,
+ existing.Title, existing.Description, existing.Status, existing.DueAt, existing.Priority, existing.Tags, id, userID,
+ ).Scan(&updatedAt)
+ if err != nil {
+ return Task{}, err
+ }
+ existing.UpdatedAt = updatedAt
+ return existing, nil
}
-func (s *taskStore) delete(id int64) bool {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if _, ok := s.items[id]; !ok {
- return false
+func (s *postgresStore) Delete(ctx context.Context, userID, id int64) error {
+ result, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1 AND user_id = $2`, id, userID)
+ if err != nil {
+ return err
}
- delete(s.items, id)
- return true
+ if result.RowsAffected() == 0 {
+ return pgx.ErrNoRows
+ }
+ return nil
+}
+
+func (s *postgresStore) Close() {
+ if s.pool != nil {
+ s.pool.Close()
+ }
+}
+
+func newTokenCache(addr, password string, db int) (*tokenCache, error) {
+ if strings.TrimSpace(addr) == "" {
+ return nil, nil
+ }
+ client := redis.NewClient(&redis.Options{
+ Addr: addr,
+ Password: password,
+ DB: db,
+ })
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := client.Ping(ctx).Err(); err != nil {
+ return nil, err
+ }
+ return &tokenCache{client: client, prefix: "auth:token:"}, nil
+}
+
+func (c *tokenCache) Save(ctx context.Context, token string, ttl time.Duration) error {
+ return c.client.Set(ctx, c.prefix+token, "1", ttl).Err()
+}
+
+func (c *tokenCache) Delete(ctx context.Context, token string) error {
+ return c.client.Del(ctx, c.prefix+token).Err()
+}
+
+func (c *tokenCache) Exists(ctx context.Context, token string) (bool, error) {
+ count, err := c.client.Exists(ctx, c.prefix+token).Result()
+ if err != nil {
+ return false, err
+ }
+ return count == 1, nil
+}
+
+func newTaskEmitter(brokers []string, topic string) *taskEmitter {
+ if len(brokers) == 0 {
+ return nil
+ }
+ writer := &kafka.Writer{
+ Addr: kafka.TCP(brokers...),
+ Topic: topic,
+ Balancer: &kafka.LeastBytes{},
+ }
+ return &taskEmitter{writer: writer, topic: topic}
+}
+
+func (e *taskEmitter) Emit(ctx context.Context, eventType string, task Task, userID int64) {
+ if e == nil || e.writer == nil {
+ return
+ }
+ payload := map[string]any{
+ "type": eventType,
+ "task_id": task.ID,
+ "user_id": userID,
+ "status": task.Status,
+ "priority": task.Priority,
+ "at": time.Now().UTC().Format(time.RFC3339),
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return
+ }
+ writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ if err := e.writer.WriteMessages(writeCtx, kafka.Message{
+ Key: []byte(strconv.FormatInt(task.ID, 10)),
+ Value: data,
+ }); err != nil {
+ log.Printf("kafka write failed: %v", err)
+ }
+}
+
+func (e *taskEmitter) Close() {
+ if e == nil || e.writer == nil {
+ return
+ }
+ if err := e.writer.Close(); err != nil {
+ log.Printf("kafka close failed: %v", err)
+ }
+}
+
+var (
+ errAlreadyExists = errors.New("already exists")
+ errInvalidCredentials = errors.New("invalid credentials")
+)
+
+func isUniqueViolation(err error) bool {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ return true
+ }
+ return false
}
func main() {
- store := newTaskStore()
- authStore := newAuthStore()
+ store, tokens, cache, emitter := buildDependencies()
+ defer store.Close()
+ if cache != nil {
+ defer cache.client.Close()
+ }
+ defer emitter.Close()
+
gin.SetMode(gin.DebugMode)
router := gin.Default()
router.RedirectTrailingSlash = false
@@ -193,16 +425,6 @@ func main() {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
- router.GET("/", func(c *gin.Context) {
- c.File("test/web/index.html")
- })
- router.GET("/styles.css", func(c *gin.Context) {
- c.File("test/web/styles.css")
- })
- router.GET("/app.js", func(c *gin.Context) {
- c.File("test/web/app.js")
- })
-
auth := router.Group("/api/v1/auth")
{
auth.POST("/register", func(c *gin.Context) {
@@ -214,13 +436,18 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+ input.Email = strings.TrimSpace(input.Email)
if input.Email == "" || input.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
return
}
- user, ok := authStore.register(input.Email, input.Password)
- if !ok {
- c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
+ user, err := store.Register(c.Request.Context(), input.Email, input.Password)
+ if err != nil {
+ if errors.Is(err, errAlreadyExists) {
+ c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": user.ID, "email": user.Email})
@@ -234,41 +461,90 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+ input.Email = strings.TrimSpace(input.Email)
if input.Email == "" || input.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
return
}
- token, ok := authStore.login(input.Email, input.Password)
- if !ok {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
+ user, err := store.Login(c.Request.Context(), input.Email, input.Password)
+ if err != nil {
+ if errors.Is(err, errInvalidCredentials) {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
return
}
+ token, err := tokens.Generate(user)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "token generation failed"})
+ return
+ }
+ if cache != nil {
+ if err := cache.Save(c.Request.Context(), token, tokens.ttl); err != nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
+ return
+ }
+ }
c.JSON(http.StatusOK, gin.H{"token": token})
})
+ auth.POST("/logout", func(c *gin.Context) {
+ token := extractBearerToken(c)
+ if token == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
+ return
+ }
+ if _, ok := tokens.Validate(token); !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
+ return
+ }
+ if cache != nil {
+ if err := cache.Delete(c.Request.Context(), token); err != nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
+ return
+ }
+ }
+ c.Status(http.StatusNoContent)
+ })
}
api := router.Group("/api/v1")
api.Use(func(c *gin.Context) {
- authHeader := c.GetHeader("Authorization")
- if authHeader == "" {
+ token := extractBearerToken(c)
+ if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
- token := authHeader
- if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
- token = authHeader[7:]
- }
- if token == "" || !authStore.validate(token) {
+ userID, ok := tokens.Validate(token)
+ if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
+ if cache != nil {
+ exists, err := cache.Exists(c.Request.Context(), token)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
+ return
+ }
+ if !exists {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
+ return
+ }
+ }
+ c.Set("user_id", userID)
c.Next()
})
tasks := api.Group("/tasks")
{
tasks.GET("", func(c *gin.Context) {
- c.JSON(http.StatusOK, store.list())
+ userID := c.GetInt64("user_id")
+ items, err := store.List(c.Request.Context(), userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tasks"})
+ return
+ }
+ c.JSON(http.StatusOK, items)
})
tasks.POST("", func(c *gin.Context) {
var input Task
@@ -276,7 +552,13 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- created := store.create(input)
+ userID := c.GetInt64("user_id")
+ created, err := store.Create(c.Request.Context(), userID, input)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"})
+ return
+ }
+ emitter.Emit(c.Request.Context(), "task.created", created, userID)
c.JSON(http.StatusCreated, created)
})
tasks.GET(":id", func(c *gin.Context) {
@@ -285,9 +567,14 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
- task, ok := store.get(id)
- if !ok {
- c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ userID := c.GetInt64("user_id")
+ task, err := store.Get(c.Request.Context(), userID, id)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load task"})
return
}
c.JSON(http.StatusOK, task)
@@ -303,11 +590,17 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- updated, ok := store.update(id, input)
- if !ok {
- c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ userID := c.GetInt64("user_id")
+ updated, err := store.Update(c.Request.Context(), userID, id, input)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
return
}
+ emitter.Emit(c.Request.Context(), "task.updated", updated, userID)
c.JSON(http.StatusOK, updated)
})
tasks.DELETE(":id", func(c *gin.Context) {
@@ -316,10 +609,16 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
- if !store.delete(id) {
- c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ userID := c.GetInt64("user_id")
+ if err := store.Delete(c.Request.Context(), userID, id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"})
return
}
+ emitter.Emit(c.Request.Context(), "task.deleted", Task{ID: id}, userID)
c.Status(http.StatusNoContent)
})
}
@@ -329,6 +628,78 @@ func main() {
}
}
+func buildDependencies() (*postgresStore, *tokenManager, *tokenCache, *taskEmitter) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
+ if dbURL == "" {
+ dbURL = "postgres://todo:todo@localhost:5432/todo?sslmode=disable"
+ }
+ store, err := newPostgresStore(ctx, dbURL)
+ if err != nil {
+ log.Fatalf("postgres connection failed: %v", err)
+ }
+
+ secret := strings.TrimSpace(os.Getenv("AUTH_SECRET"))
+ if secret == "" {
+ secret = "dev-secret-change-me"
+ }
+ tokens := newTokenManager(secret, 24*time.Hour)
+
+ redisAddr := strings.TrimSpace(os.Getenv("REDIS_ADDR"))
+ redisPassword := os.Getenv("REDIS_PASSWORD")
+ redisDB := parseEnvInt("REDIS_DB", 0)
+ cache, err := newTokenCache(redisAddr, redisPassword, redisDB)
+ if err != nil {
+ log.Fatalf("redis connection failed: %v", err)
+ }
+
+ brokers := splitCSV(os.Getenv("KAFKA_BROKERS"))
+ topic := strings.TrimSpace(os.Getenv("KAFKA_TOPIC"))
+ if topic == "" {
+ topic = "todo.tasks"
+ }
+ emitter := newTaskEmitter(brokers, topic)
+
+ return store, tokens, cache, emitter
+}
+
func parseID(value string) (int64, error) {
return strconv.ParseInt(value, 10, 64)
}
+
+func extractBearerToken(c *gin.Context) string {
+ authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
+ if strings.HasPrefix(authHeader, "Bearer ") {
+ return strings.TrimSpace(authHeader[7:])
+ }
+ return authHeader
+}
+
+func parseEnvInt(key string, fallback int) int {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ return fallback
+ }
+ parsed, err := strconv.Atoi(value)
+ if err != nil {
+ return fallback
+ }
+ return parsed
+}
+
+func splitCSV(value string) []string {
+ if strings.TrimSpace(value) == "" {
+ return nil
+ }
+ parts := strings.Split(value, ",")
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ item := strings.TrimSpace(part)
+ if item != "" {
+ result = append(result, item)
+ }
+ }
+ return result
+}
diff --git a/vue/index.html b/vue/index.html
index 2fde181..307d935 100644
--- a/vue/index.html
+++ b/vue/index.html
@@ -4,7 +4,7 @@
-
Todo Control Room
+ Todo Room
diff --git a/vue/src/App.vue b/vue/src/App.vue
index cacdccf..c3ce475 100644
--- a/vue/src/App.vue
+++ b/vue/src/App.vue
@@ -1,5 +1,5 @@