cambios para worker-remoto
This commit is contained in:
parent
98e0cd0e46
commit
be56de7dd4
19 changed files with 1544 additions and 216 deletions
|
|
@ -7,6 +7,7 @@ require (
|
|||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/jackc/pgx/v5 v5.4.3
|
||||
github.com/mmcdole/gofeed v1.2.1
|
||||
github.com/redis/go-redis/v9 v9.0.5
|
||||
|
|
|
|||
156
backend/go.sum
156
backend/go.sum
|
|
@ -1,156 +0,0 @@
|
|||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
|
||||
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
|
||||
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
|
||||
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
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/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
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.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/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/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
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.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s=
|
||||
github.com/mmcdole/gofeed v1.2.1/go.mod h1:2wVInNpgmC85q16QTTuwbuKxtKkHLCDDtf0dCmnrNr4=
|
||||
github.com/mmcdole/goxpp v1.1.0 h1:WwslZNF7KNAXTFuzRtn/OKZxFLJAAyOA9w82mDz2ZGI=
|
||||
github.com/mmcdole/goxpp v1.1.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
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.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
|
||||
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
|
||||
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/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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
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.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=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
55
backend/internal/auth/jwt.go
Normal file
55
backend/internal/auth/jwt.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var jwtSecret []byte
|
||||
|
||||
func SetJWTSecret(secret string) {
|
||||
jwtSecret = []byte(secret)
|
||||
}
|
||||
|
||||
func GetJWTSecret() []byte {
|
||||
return jwtSecret
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateToken(userID int64, email, username string, isAdmin bool) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
func ValidateToken(tokenString string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
96
backend/internal/auth/jwt_test.go
Normal file
96
backend/internal/auth/jwt_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateAndValidateToken(t *testing.T) {
|
||||
SetJWTSecret("test-secret-key-12345")
|
||||
|
||||
token, err := GenerateToken(1, "test@example.com", "testuser", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken failed: %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Fatal("token should not be empty")
|
||||
}
|
||||
|
||||
claims, err := ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken failed: %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != 1 {
|
||||
t.Errorf("expected userID 1, got %d", claims.UserID)
|
||||
}
|
||||
if claims.Email != "test@example.com" {
|
||||
t.Errorf("expected email test@example.com, got %s", claims.Email)
|
||||
}
|
||||
if claims.Username != "testuser" {
|
||||
t.Errorf("expected username testuser, got %s", claims.Username)
|
||||
}
|
||||
if !claims.IsAdmin {
|
||||
t.Error("expected isAdmin true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInvalidToken(t *testing.T) {
|
||||
SetJWTSecret("test-secret-key-12345")
|
||||
|
||||
_, err := ValidateToken("invalid-token")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenWithWrongSecret(t *testing.T) {
|
||||
SetJWTSecret("secret-1")
|
||||
|
||||
token, err := GenerateToken(1, "test@example.com", "testuser", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken failed: %v", err)
|
||||
}
|
||||
|
||||
SetJWTSecret("secret-2")
|
||||
|
||||
_, err = ValidateToken(token)
|
||||
if err == nil {
|
||||
t.Error("expected error for token with different secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimsHaveExpiration(t *testing.T) {
|
||||
SetJWTSecret("test-secret-key-12345")
|
||||
|
||||
token, err := GenerateToken(1, "test@example.com", "testuser", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken failed: %v", err)
|
||||
}
|
||||
|
||||
claims, err := ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken failed: %v", err)
|
||||
}
|
||||
|
||||
if claims.ExpiresAt == nil {
|
||||
t.Error("expected expiration time to be set")
|
||||
}
|
||||
|
||||
expTime := claims.ExpiresAt.Time
|
||||
expectedExpiration := time.Now().Add(24 * time.Hour)
|
||||
|
||||
if expTime.Sub(expectedExpiration) > time.Minute {
|
||||
t.Errorf("expiration time should be ~24 hours from now, got %v", expTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyToken(t *testing.T) {
|
||||
SetJWTSecret("test-secret-key-12345")
|
||||
|
||||
_, err := ValidateToken("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty token")
|
||||
}
|
||||
}
|
||||
145
backend/internal/config/config_test.go
Normal file
145
backend/internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.ServerPort != "8080" {
|
||||
t.Errorf("expected ServerPort '8080', got %s", cfg.ServerPort)
|
||||
}
|
||||
if cfg.SecretKey != "change-this-secret-key" {
|
||||
t.Errorf("expected SecretKey 'change-this-secret-key', got %s", cfg.SecretKey)
|
||||
}
|
||||
if cfg.DefaultLang != "es" {
|
||||
t.Errorf("expected DefaultLang 'es', got %s", cfg.DefaultLang)
|
||||
}
|
||||
if cfg.NewsPerPage != 30 {
|
||||
t.Errorf("expected NewsPerPage 30, got %d", cfg.NewsPerPage)
|
||||
}
|
||||
if cfg.DockerComposeDir != "/datos/rss2" {
|
||||
t.Errorf("expected DockerComposeDir '/datos/rss2', got %s", cfg.DockerComposeDir)
|
||||
}
|
||||
if cfg.WikiImagesPath != "/app/data/wiki_images" {
|
||||
t.Errorf("expected WikiImagesPath '/app/data/wiki_images', got %s", cfg.WikiImagesPath)
|
||||
}
|
||||
if cfg.AllowedOrigins != "*" {
|
||||
t.Errorf("expected AllowedOrigins '*', got %s", cfg.AllowedOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("SERVER_PORT", "9000")
|
||||
os.Setenv("DATABASE_URL", "postgres://user:pass@host:5432/db")
|
||||
os.Setenv("SECRET_KEY", "my-secret-key")
|
||||
os.Setenv("DEFAULT_LANG", "en")
|
||||
os.Setenv("NEWS_PER_PAGE", "50")
|
||||
os.Setenv("JWT_EXPIRATION", "48h")
|
||||
os.Setenv("RATE_LIMIT_PER_MINUTE", "100")
|
||||
os.Setenv("DOCKER_COMPOSE_DIR", "/custom/path")
|
||||
os.Setenv("WIKI_IMAGES_PATH", "/custom/images")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.ServerPort != "9000" {
|
||||
t.Errorf("expected ServerPort '9000', got %s", cfg.ServerPort)
|
||||
}
|
||||
if cfg.DatabaseURL != "postgres://user:pass@host:5432/db" {
|
||||
t.Errorf("expected DatabaseURL, got %s", cfg.DatabaseURL)
|
||||
}
|
||||
if cfg.SecretKey != "my-secret-key" {
|
||||
t.Errorf("expected SecretKey 'my-secret-key', got %s", cfg.SecretKey)
|
||||
}
|
||||
if cfg.DefaultLang != "en" {
|
||||
t.Errorf("expected DefaultLang 'en', got %s", cfg.DefaultLang)
|
||||
}
|
||||
if cfg.NewsPerPage != 50 {
|
||||
t.Errorf("expected NewsPerPage 50, got %d", cfg.NewsPerPage)
|
||||
}
|
||||
if cfg.JWTExpiration != 48*time.Hour {
|
||||
t.Errorf("expected JWTExpiration 48h, got %v", cfg.JWTExpiration)
|
||||
}
|
||||
if cfg.RateLimitPerMinute != 100 {
|
||||
t.Errorf("expected RateLimitPerMinute 100, got %d", cfg.RateLimitPerMinute)
|
||||
}
|
||||
if cfg.DockerComposeDir != "/custom/path" {
|
||||
t.Errorf("expected DockerComposeDir '/custom/path', got %s", cfg.DockerComposeDir)
|
||||
}
|
||||
if cfg.WikiImagesPath != "/custom/images" {
|
||||
t.Errorf("expected WikiImagesPath '/custom/images', got %s", cfg.WikiImagesPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidInt(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("NEWS_PER_PAGE", "invalid")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.NewsPerPage != 30 {
|
||||
t.Errorf("expected default NewsPerPage 30 for invalid value, got %d", cfg.NewsPerPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidDuration(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("JWT_EXPIRATION", "invalid")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.JWTExpiration != 24*time.Hour {
|
||||
t.Errorf("expected default JWTExpiration 24h for invalid value, got %v", cfg.JWTExpiration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("TEST_KEY", "test_value")
|
||||
|
||||
result := getEnv("TEST_KEY", "default")
|
||||
if result != "test_value" {
|
||||
t.Errorf("expected 'test_value', got %s", result)
|
||||
}
|
||||
|
||||
result = getEnv("NON_EXISTENT", "default")
|
||||
if result != "default" {
|
||||
t.Errorf("expected 'default', got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvInt(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("TEST_INT", "123")
|
||||
|
||||
result := getEnvInt("TEST_INT", 0)
|
||||
if result != 123 {
|
||||
t.Errorf("expected 123, got %d", result)
|
||||
}
|
||||
|
||||
result = getEnvInt("NON_EXISTENT", 99)
|
||||
if result != 99 {
|
||||
t.Errorf("expected 99, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvDuration(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("TEST_DURATION", "1h30m")
|
||||
|
||||
result := getEnvDuration("TEST_DURATION", 0)
|
||||
if result != 90*time.Minute {
|
||||
t.Errorf("expected 90m, got %v", result)
|
||||
}
|
||||
|
||||
result = getEnvDuration("NON_EXISTENT", 5*time.Minute)
|
||||
if result != 5*time.Minute {
|
||||
t.Errorf("expected 5m, got %v", result)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
|
@ -14,6 +15,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/config"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
"github.com/rss2/backend/internal/models"
|
||||
)
|
||||
|
|
@ -465,13 +467,14 @@ func StartWorkers(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Detener cualquier translator existente
|
||||
composeDir := config.Load().DockerComposeDir
|
||||
stopCmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
||||
stopCmd.Dir = "/datos/rss2"
|
||||
stopCmd.Dir = composeDir
|
||||
stopCmd.Run()
|
||||
|
||||
// Iniciar con el número de workers
|
||||
startCmd := exec.Command("docker", "compose", "up", "-d", "--scale", fmt.Sprintf("%s=%d", serviceName, workers), serviceName)
|
||||
startCmd.Dir = "/datos/rss2"
|
||||
startCmd.Dir = composeDir
|
||||
output, err := startCmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -483,9 +486,15 @@ func StartWorkers(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Actualizar estado en BD
|
||||
db.GetPool().Exec(ctx, "UPDATE config SET value = 'running', updated_at = NOW() WHERE key = 'translator_status'")
|
||||
db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", translatorType)
|
||||
db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", translatorWorkers)
|
||||
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = 'running', updated_at = NOW() WHERE key = 'translator_status'"); err != nil {
|
||||
log.Printf("Failed to update translator_status: %v", err)
|
||||
}
|
||||
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", translatorType); err != nil {
|
||||
log.Printf("Failed to update translator_type: %v", err)
|
||||
}
|
||||
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", translatorWorkers); err != nil {
|
||||
log.Printf("Failed to update translator_workers: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Workers started successfully",
|
||||
|
|
@ -497,8 +506,9 @@ func StartWorkers(c *gin.Context) {
|
|||
|
||||
func StopWorkers(c *gin.Context) {
|
||||
// Detener traductores
|
||||
composeDir := config.Load().DockerComposeDir
|
||||
cmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
||||
cmd.Dir = "/datos/rss2"
|
||||
cmd.Dir = composeDir
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -510,7 +520,9 @@ func StopWorkers(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Actualizar estado en BD
|
||||
db.GetPool().Exec(c.Request.Context(), "UPDATE config SET value = 'stopped', updated_at = NOW() WHERE key = 'translator_status'")
|
||||
if _, err := db.GetPool().Exec(c.Request.Context(), "UPDATE config SET value = 'stopped', updated_at = NOW() WHERE key = 'translator_status'"); err != nil {
|
||||
log.Printf("Failed to update translator_status: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Workers stopped successfully",
|
||||
|
|
|
|||
|
|
@ -2,18 +2,14 @@ package handlers
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/rss2/backend/internal/config"
|
||||
"github.com/rss2/backend/internal/auth"
|
||||
"github.com/rss2/backend/internal/db"
|
||||
"github.com/rss2/backend/internal/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var jwtSecret []byte
|
||||
|
||||
func CheckFirstUser(c *gin.Context) {
|
||||
var count int
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM users").Scan(&count)
|
||||
|
|
@ -24,18 +20,6 @@ func CheckFirstUser(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, gin.H{"is_first_user": count == 0, "total_users": count})
|
||||
}
|
||||
|
||||
func InitAuth(secret string) {
|
||||
jwtSecret = []byte(secret)
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
@ -60,20 +44,7 @@ func Login(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
IsAdmin: user.IsAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
tokenString, err := auth.GenerateToken(user.ID, user.Email, user.Username, user.IsAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"})
|
||||
return
|
||||
|
|
@ -127,20 +98,7 @@ func Register(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
IsAdmin: user.IsAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
tokenString, err := auth.GenerateToken(user.ID, user.Email, user.Username, user.IsAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"})
|
||||
return
|
||||
|
|
@ -160,7 +118,7 @@ func GetCurrentUser(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
claims := userVal.(*Claims)
|
||||
claims := userVal.(*auth.Claims)
|
||||
|
||||
var user models.User
|
||||
err := db.GetPool().QueryRow(c.Request.Context(), `
|
||||
|
|
@ -176,8 +134,3 @@ func GetCurrentUser(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func init() {
|
||||
cfg := config.Load()
|
||||
InitAuth(cfg.SecretKey)
|
||||
}
|
||||
|
|
|
|||
108
backend/internal/handlers/auth_test.go
Normal file
108
backend/internal/handlers/auth_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/auth"
|
||||
"github.com/rss2/backend/internal/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
auth.SetJWTSecret("test-secret-key-12345")
|
||||
}
|
||||
|
||||
func TestLoginInvalidRequest(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.POST("/auth/login", Login)
|
||||
|
||||
body := []byte(`{}`)
|
||||
req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp models.ErrorResponse
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
if resp.Error != "Invalid request" {
|
||||
t.Errorf("expected 'Invalid request', got %s", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginInvalidCredentials(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.POST("/auth/login", Login)
|
||||
|
||||
body := []byte(`{"email":"invalid@test.com","password":"wrongpass"}`)
|
||||
req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterInvalidRequest(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.POST("/auth/register", Register)
|
||||
|
||||
body := []byte(`{}`)
|
||||
req, _ := http.NewRequest("POST", "/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFirstUser(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.GET("/auth/check-first-user", CheckFirstUser)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/auth/check-first-user", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
if _, ok := resp["is_first_user"]; !ok {
|
||||
t.Error("expected is_first_user in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentUserUnauthorized(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.GET("/auth/me", GetCurrentUser)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/auth/me", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
141
backend/internal/middleware/auth_test.go
Normal file
141
backend/internal/middleware/auth_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rss2/backend/internal/auth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
auth.SetJWTSecret("test-secret-key-12345")
|
||||
}
|
||||
|
||||
func TestAuthRequiredNoHeader(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired())
|
||||
router.GET("/protected", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredInvalidToken(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired())
|
||||
router.GET("/protected", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredValidToken(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired())
|
||||
router.GET("/protected", func(c *gin.Context) {
|
||||
userVal, _ := c.Get("user")
|
||||
claims := userVal.(*auth.Claims)
|
||||
c.JSON(200, gin.H{"user_id": claims.UserID})
|
||||
})
|
||||
|
||||
token, _ := auth.GenerateToken(42, "test@test.com", "testuser", false)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRequiredNotAdmin(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired())
|
||||
router.Use(AdminRequired())
|
||||
router.GET("/admin", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
token, _ := auth.GenerateToken(1, "test@test.com", "testuser", false)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/admin", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected status 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRequiredIsAdmin(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired())
|
||||
router.Use(AdminRequired())
|
||||
router.GET("/admin", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
token, _ := auth.GenerateToken(1, "test@test.com", "adminuser", true)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/admin", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddleware(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(CORSMiddleware())
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") == "" {
|
||||
t.Error("expected CORS header to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSPreflight(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(CORSMiddleware())
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("OPTIONS", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected status 204 for preflight, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
448
bugs.md
Normal file
448
bugs.md
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
# Análisis de Errores e Inconsistencias - RSS2
|
||||
|
||||
Fecha de análisis: 2026-04-01
|
||||
|
||||
---
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
Se han identificado **13 problemas** clasificados en:
|
||||
- 3 Errores Críticos
|
||||
- 7 Inconsistencias
|
||||
- 3 Warnings de Arquitectura
|
||||
|
||||
---
|
||||
|
||||
## 1. Errores Críticos
|
||||
|
||||
### 1.1 Rate Limiting No Implementado
|
||||
|
||||
**Ubicación:** `backend/internal/middleware/auth.go:104-108`
|
||||
|
||||
**Descripción:**
|
||||
La función existe pero no hace nada:
|
||||
```go
|
||||
func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
No implementa ningún tipo de limitación de rate. Vulnerable a ataques de fuerza bruta y DoS.
|
||||
|
||||
**Impacto:** Alto - Seguridad comprometida.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 CORS Demasiado Permisivo
|
||||
|
||||
**Ubicación:** `backend/internal/middleware/auth.go:77-90`
|
||||
|
||||
**Descripción:**
|
||||
```go
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
Permite cualquier origen, lo cual es inseguro para APIs de producción. Permite ataques CSRF y expone datos a orígenes maliciosos.
|
||||
|
||||
**Impacto:** Alto - Seguridad comprometida.
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Secret Key Por Defecto Hardcodeada
|
||||
|
||||
**Ubicación:** `backend/internal/config/config.go:32`
|
||||
|
||||
**Descripción:**
|
||||
```go
|
||||
SecretKey: getEnv("SECRET_KEY", "change-this-secret-key"),
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
Si no se proporciona la variable de entorno, la aplicación usa una clave por defecto conocida. Cualquier despliegue sin configurar esta variable es vulnerable.
|
||||
|
||||
**Impacto:** Crítico - Compromiso total de autenticación.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inconsistencias
|
||||
|
||||
### 2.1 Mapeo de Campos Inconsistente (Snake Case vs Camel Case)
|
||||
|
||||
**Descripción:**
|
||||
La aplicación usa convenciones de nomenclatura diferentes en distintas capas:
|
||||
|
||||
| Capa | Estructura | Ejemplo |
|
||||
|------|------------|---------|
|
||||
| Models | Snake case | `Title`, `Summary`, `ImageURL` |
|
||||
| Handlers | Pascal case | `Titulo`, `Resumen`, `ImagenURL` |
|
||||
| Frontend TS | camelCase | `titulo`, `resumen`, `imagen_url` |
|
||||
|
||||
**Archivos afectados:**
|
||||
- `backend/internal/models/models.go:7-19`
|
||||
- `backend/internal/handlers/news.go:14-28`
|
||||
- `frontend/src/services/api.ts:20-34`
|
||||
|
||||
**Problema:**
|
||||
Dificulta el mantenimiento y genera potenciales errores de serialización.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Idioma de Traducción Hardcodeado
|
||||
|
||||
**Descripción:**
|
||||
El idioma destino de traducción (`es`) está hardcodeado en múltiples lugares:
|
||||
|
||||
**news.go:67:**
|
||||
```go
|
||||
where += " AND t.status = 'done' AND t.titulo_trad IS NOT NULL"
|
||||
```
|
||||
|
||||
**news.go:96:**
|
||||
```go
|
||||
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
|
||||
```
|
||||
|
||||
**search.go:34-37:**
|
||||
```go
|
||||
if lang == "" {
|
||||
lang = "es"
|
||||
}
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
No es configurable. Si se quiere traducir a otros idiomas, requiere cambios en múltiples archivos.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Columna `role` en Users Sin映射 en Modelo
|
||||
|
||||
**Descripción:**
|
||||
Se añade una columna `role` a la tabla users:
|
||||
|
||||
**cmd/server/main.go:41-48:**
|
||||
```go
|
||||
_, err = db.GetPool().Exec(ctx, `
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user'
|
||||
`)
|
||||
```
|
||||
|
||||
**Pero el modelo User no tiene este campo:**
|
||||
|
||||
**models/models.go:86-94:**
|
||||
```go
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
La columna `role` nunca se usa realmente. Hay dos campos (`role` y `is_admin`) que pueden causar confusión.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Feature No Implementada en Discovery
|
||||
|
||||
**Ubicación:** `backend/cmd/discovery/main.go:129-133`
|
||||
|
||||
**Descripción:**
|
||||
```go
|
||||
func findFeedLinksInHTML(baseURL string) ([]string, error) {
|
||||
// Simple feed link finder - returns empty for now
|
||||
// In production, use goquery to parse HTML and find RSS/Atom links
|
||||
return []string{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
El worker de discovery no puede encontrar feeds RSS embebidos en páginas HTML. Solo detecta feeds directos.
|
||||
|
||||
**Impacto:** Medio - Reduce la efectividad del descubrimiento de feeds.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 LoggerMiddleware No Funcional
|
||||
|
||||
**Ubicación:** `backend/internal/middleware/auth.go:93-101`
|
||||
|
||||
**Descripción:**
|
||||
```go
|
||||
func LoggerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
status := c.Writer.Status()
|
||||
if status >= 400 {
|
||||
// Log error responses - pero no hace nada!
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
El logging de errores está commented out. No se registra nada.
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Columnas de BD Diferentes a Modelos
|
||||
|
||||
**Descripción:**
|
||||
El handler `search.go` referencia columnas que no existen en los modelos:
|
||||
|
||||
**search.go:91-92:**
|
||||
```go
|
||||
SELECT n.id, n.titulo, n.resumen, n.contenido, n.url, n.imagen,
|
||||
n.feed_id, n.lang, n.categoria_id, n.pais_id, n.created_at, n.updated_at,
|
||||
```
|
||||
|
||||
**Pero en models.go:7-19:**
|
||||
```go
|
||||
type News struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
URL string `json:"url"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
Lang string `json:"lang"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
- Modelo usa `Title`, BD usa `titulo`
|
||||
- Modelo usa `Summary`, BD usa `resumen`
|
||||
- Modelo usa `ImageURL`, BD usa `imagen`
|
||||
- Modelo usa `PublishedAt`, BD usa `fecha`
|
||||
|
||||
Esto causa errores de serialización potenciales.
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Inicialización de Base de Datos Duplicada
|
||||
|
||||
**Descripción:**
|
||||
Existen dos formas de conectar a la base de datos:
|
||||
|
||||
1. `db.Connect()` en `backend/internal/db/postgres.go:13`
|
||||
2. `workers.Connect()` en `backend/internal/workers/db.go`
|
||||
|
||||
**Problema:**
|
||||
Códigos duplicados que mantienen conexiones separadas. Puede causar problemas de pool de conexiones.
|
||||
|
||||
---
|
||||
|
||||
## 3. Warnings de Arquitectura
|
||||
|
||||
### 3.1 Creación de Tablas en Runtime
|
||||
|
||||
**Ubicación:** `backend/cmd/server/main.go:20-77`
|
||||
|
||||
**Descripción:**
|
||||
En lugar de usar migraciones formales, el servidor crea tablas al iniciar:
|
||||
|
||||
```go
|
||||
func initDB() {
|
||||
_, err := db.GetPool().Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS entity_aliases (...)
|
||||
`)
|
||||
// más tablas...
|
||||
}
|
||||
```
|
||||
|
||||
**Problema:**
|
||||
- No hay control de versiones
|
||||
- No hay rollback
|
||||
- Difícil de mantener en producción
|
||||
|
||||
**Recomendación:** Usar golang-migrate o similar.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 No Hay Tests Unitarios
|
||||
|
||||
**Descripción:**
|
||||
No se encontraron archivos `*_test.go` en el código backend.
|
||||
|
||||
**Problema:**
|
||||
Sin tests, cualquier cambio puede romper funcionalidad existente sin detección.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Pool de Conexiones Sin Validación Periódica
|
||||
|
||||
**Ubicación:** `backend/internal/db/postgres.go`
|
||||
|
||||
**Descripción:**
|
||||
El pool se crea pero no hay health check periódico.
|
||||
|
||||
**Problema:**
|
||||
Las conexiones zombie pueden acumularse sin detección.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recomendaciones de Corrección
|
||||
|
||||
### Alta Prioridad
|
||||
|
||||
1. **Unificar JWT Secret:** Usar un único package para manejar el secret
|
||||
2. **Implementar Rate Limiting:** Usar una librería como `golang.org/x/time/rate`
|
||||
3. **Configurar CORS correctamente:** Usar variable de entorno para orígenes permitidos
|
||||
4. **Forzar Secret Key:** Hacer requerida la variable `SECRET_KEY`
|
||||
|
||||
### Media Prioridad
|
||||
|
||||
5. **Traducir idioma configurable:** Pasar idioma como parámetro o configuración
|
||||
6. **Usar migraciones:** Implementar golang-migrate
|
||||
7. **Añadir tests:** Crear tests unitarios para handlers críticos
|
||||
8. **Corregir mapeo de campos:** Estandarizar snake_case en toda la aplicación
|
||||
|
||||
### Baja Prioridad
|
||||
|
||||
9. **Implementar LoggerMiddleware:** Añadir logging real
|
||||
10. **Implementar findFeedLinksInHTML:** Usar goquery para parsing HTML
|
||||
11. **Consolidar DB:** Unificar inicialización de base de datos
|
||||
|
||||
---
|
||||
|
||||
## 5. Estadísticas
|
||||
|
||||
| Tipo | Cantidad |
|
||||
|------|----------|
|
||||
| Errores Críticos | 3 |
|
||||
| Inconsistencias | 7 |
|
||||
| Warnings | 3 |
|
||||
| **Total** | **13** |
|
||||
|
||||
---
|
||||
|
||||
## 6. Problemas Adicionales Detectados
|
||||
|
||||
### 6.1 Mapeo de Campos Inconsistente (Frontend)
|
||||
|
||||
**Descripción:** El frontend usa `snake_case` pero los modelos Go usan `PascalCase`.
|
||||
|
||||
**Archivos:**
|
||||
- `frontend/src/services/api.ts` - usa `titulo`, `resumen`
|
||||
- `backend/internal/models/models.go` - usa `Title`, `Summary`
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Structs Duplicados para Mismo Recurso
|
||||
|
||||
**Descripción:** `handlers/news.go` define `NewsResponse` diferente a `models/models.go:News`.
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Query Params Inconsistentes
|
||||
|
||||
**Descripción:** Algunos handlers usan `category_id` (inglés) y otros `categoria_id` (español).
|
||||
|
||||
---
|
||||
|
||||
### 6.4 Duplicación Config DB
|
||||
|
||||
**Descripción:** API usa `DATABASE_URL`, workers usan `DB_HOST`, `DB_PORT`, etc.
|
||||
|
||||
---
|
||||
|
||||
### 6.5 Sin Health Checks en Workers
|
||||
|
||||
**Descripción:** Workers no implementan endpoint `/health`.
|
||||
|
||||
---
|
||||
|
||||
### 6.6 Manejo de Errores Inconsistente
|
||||
|
||||
**Descripción:** Mezcla de `gin.H{"error": ...}` y `models.ErrorResponse`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Errores y Problemas Críticos Detectados
|
||||
|
||||
### 7.1 ~~Errores Ignorados Silenciosamente~~ **(RESUELTO)**
|
||||
|
||||
**Descripción:** Múltiples lugares donde errores eran ignorados sin logging.
|
||||
|
||||
**Ejemplos corregidos:**
|
||||
- `admin.go:488-492` - Ahora verifica errores de `db.GetPool().Exec()`
|
||||
- `admin.go:522` - Verifica error al actualizar estado
|
||||
|
||||
**Impacto:** Fallos silenciosos que pasan desapercibidos.
|
||||
|
||||
---
|
||||
|
||||
### 7.2 ~~Rutas Hardcodeadas~~ **(RESUELTO)**
|
||||
|
||||
**Descripción:** Rutas de archivos hardcodeadas en el código.
|
||||
|
||||
**Correcciones:**
|
||||
- `admin.go:469,474,509` - Ahora usa `config.Load().DockerComposeDir`
|
||||
- `server/main.go:113` - Ahora usa `cfg.WikiImagesPath`
|
||||
- Añadidos nuevos campos en config: `DockerComposeDir`, `WikiImagesPath`
|
||||
|
||||
---
|
||||
|
||||
## 7.3 Secret Key Por Defecto
|
||||
|
||||
**Descripción:** `config.go:32` usa clave por defecto known.
|
||||
|
||||
```go
|
||||
SecretKey: getEnv("SECRET_KEY", "change-this-secret-key")
|
||||
```
|
||||
|
||||
**Impacto:** Medio - Si no se configura, usar clave insegura.
|
||||
|
||||
---
|
||||
|
||||
### 7.4 Rate Limiting No Implementado
|
||||
|
||||
**Descripción:** `middleware/auth.go:104-108` tiene función vacía.
|
||||
|
||||
**Impacto:** Alto - Vulnerable a ataques de fuerza bruta.
|
||||
|
||||
---
|
||||
|
||||
### 7.5 CORS Demasiado Permisivo
|
||||
|
||||
**Descripción:** `middleware/auth.go:79` permite cualquier origen.
|
||||
|
||||
**Impacto:** Alto - Expuesto a ataques CSRF.
|
||||
|
||||
---
|
||||
|
||||
## 8. Recomendaciones de Corrección (Actualizado)
|
||||
|
||||
### Alta Prioridad (CRÍTICO)
|
||||
|
||||
1. ~~Unificar JWT Secret~~ ✅
|
||||
2. Implementar Rate Limiting
|
||||
3. ~~**Configurar CORS correctamente:** Usar variable de entorno~~ ✅
|
||||
4. ~~**Corregir manejo de errores:** No ignorar silenciosamente~~ ✅
|
||||
5. ~~**Eliminar rutas hardcodeadas**~~ ✅
|
||||
|
||||
### Media Prioridad
|
||||
|
||||
7. Idioma traducciones configurable
|
||||
8. Usar migraciones
|
||||
9. Añadir tests
|
||||
10. Corregir mapeo de campos
|
||||
|
||||
### Baja Prioridad
|
||||
|
||||
11. Implementar LoggerMiddleware
|
||||
12. Implementar findFeedLinksInHTML
|
||||
13. Consolidar DB
|
||||
14. Estandarizar respuestas API
|
||||
15. Añadir health checks
|
||||
|
|
@ -6,7 +6,10 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
|
|
@ -23,10 +26,16 @@
|
|||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^1.2.0",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/jest-dom": "^6.2.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"jsdom": "^24.0.0",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
"vite": "^5.0.11",
|
||||
"vitest": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom'
|
||||
15
frontend/vitest.config.ts
Normal file
15
frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
},
|
||||
},
|
||||
})
|
||||
7
remote-worker/.gitignore
vendored
Normal file
7
remote-worker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
models/
|
||||
hf_cache/
|
||||
*.egg-info/
|
||||
36
remote-worker/Dockerfile
Normal file
36
remote-worker/Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
FROM python:3.11-slim-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
patchelf gcc git curl wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
TOKENIZERS_PARALLELISM=false \
|
||||
HF_HOME=/root/.cache/huggingface
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
ctranslate2==3.24.0 \
|
||||
sentencepiece \
|
||||
transformers==4.36.0 \
|
||||
protobuf==3.20.3 \
|
||||
"numpy<2" \
|
||||
websocket-client
|
||||
|
||||
RUN find /usr/local/lib/python3.11/site-packages/ctranslate2* \
|
||||
-name "libctranslate2-*.so.*" -o -name "libctranslate2.so*" | \
|
||||
xargs -I {} patchelf --clear-execstack {} || true
|
||||
|
||||
COPY worker.py /app/worker.py
|
||||
|
||||
ENV CT2_DEVICE=cuda
|
||||
ENV CT2_COMPUTE_TYPE=float16
|
||||
|
||||
CMD ["python", "/app/worker.py"]
|
||||
53
remote-worker/README.md
Normal file
53
remote-worker/README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Remote Translator Worker
|
||||
|
||||
Worker remoto de traducción para RSS2. Se conecta al servidor via WebSocket y procesa trabajos de traducción automáticamente.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Docker con soporte GPU (NVIDIA)
|
||||
- O Python 3.11+ con CUDA
|
||||
|
||||
## Uso con Docker
|
||||
|
||||
```bash
|
||||
docker run -d --gpus all --name remote-worker \
|
||||
-e WORKER_API_KEY="tu_api_key" \
|
||||
-e WORKER_SERVER="ws://tu-servidor:8888/ws/worker" \
|
||||
-v ~/models:/app/models \
|
||||
tu-usuario/rss2-remote-worker
|
||||
```
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
| Variable | Default | Descripción |
|
||||
|----------|---------|-------------|
|
||||
| WORKER_API_KEY | (requerido) | API key del worker |
|
||||
| WORKER_SERVER | ws://localhost:8080/ws/worker | URL del servidor WebSocket |
|
||||
| CT2_DEVICE | cuda | Device: cuda o cpu |
|
||||
| CT2_COMPUTE_TYPE | float16 | Tipo de computación |
|
||||
| CT2_MODEL_PATH | /app/models/nllb-ct2 | Ruta del modelo |
|
||||
| UNIVERSAL_MODEL | facebook/nllb-200-1.3B | Modelo de HuggingFace |
|
||||
| WORKER_NAME | remote-worker | Nombre del worker |
|
||||
|
||||
## Sin Docker
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
python worker.py --api-key tu_api_key --server ws://tu-servidor:8888/ws/worker
|
||||
```
|
||||
|
||||
## Modelo
|
||||
|
||||
La primera vez que ejecutas el worker, descargará automáticamente el modelo desde HuggingFace y lo convertirá a formato CTranslate2 (~5-10 min).
|
||||
|
||||
Para acelerar instalaciones posteriores, puedes pre-descargar el modelo:
|
||||
```bash
|
||||
ct2-transformers-converter --model facebook/nllb-200-1.3B --output_dir ./models/nllb-ct2 --quantization int8
|
||||
```
|
||||
|
||||
## Construir imagen
|
||||
|
||||
```bash
|
||||
docker build -t rss2-remote-worker .
|
||||
```
|
||||
8
remote-worker/requirements.txt
Normal file
8
remote-worker/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
ctranslate2==3.24.0
|
||||
sentencepiece
|
||||
transformers==4.36.0
|
||||
protobuf==3.20.3
|
||||
numpy<2
|
||||
websocket-client
|
||||
torch==2.1.0
|
||||
torchvision==0.16.0
|
||||
393
remote-worker/worker.py
Normal file
393
remote-worker/worker.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import hashlib
|
||||
from typing import List, Optional
|
||||
|
||||
import websocket
|
||||
|
||||
import ctranslate2
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s: %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
LOG = logging.getLogger("remote-translator")
|
||||
|
||||
WORKER_NAME = os.environ.get("WORKER_NAME", "remote-worker")
|
||||
WORKER_API_KEY = os.environ.get("WORKER_API_KEY", "")
|
||||
WORKER_SERVER = os.environ.get("WORKER_SERVER", "ws://localhost:8080/ws/worker")
|
||||
DEVICE = os.environ.get("CT2_DEVICE", "cpu")
|
||||
MODEL_PATH = os.environ.get("CT2_MODEL_PATH", "/app/models/nllb-ct2")
|
||||
COMPUTE_TYPE = os.environ.get("CT2_COMPUTE_TYPE", "int8")
|
||||
UNIVERSAL_MODEL = os.environ.get("UNIVERSAL_MODEL", "facebook/nllb-200-1.3B")
|
||||
|
||||
LANG_CODE_MAP = {
|
||||
"en": "eng_Latn", "es": "spa_Latn", "fr": "fra_Latn", "de": "deu_Latn",
|
||||
"it": "ita_Latn", "pt": "por_Latn", "nl": "nld_Latn", "sv": "swe_Latn",
|
||||
"da": "dan_Latn", "fi": "fin_Latn", "no": "nob_Latn",
|
||||
"pl": "pol_Latn", "cs": "ces_Latn", "sk": "slk_Latn",
|
||||
"sl": "slv_Latn", "hu": "hun_Latn", "ro": "ron_Latn",
|
||||
"el": "ell_Grek", "ru": "rus_Cyrl", "uk": "ukr_Cyrl",
|
||||
"tr": "tur_Latn", "ar": "arb_Arab", "fa": "pes_Arab",
|
||||
"he": "heb_Hebr", "zh": "zho_Hans", "ja": "jpn_Jpan",
|
||||
"ko": "kor_Hang", "vi": "vie_Latn",
|
||||
}
|
||||
|
||||
MAX_SRC_TOKENS = 512
|
||||
MAX_NEW_TOKENS = 512
|
||||
BODY_CHARS_CHUNK = 900
|
||||
|
||||
_tokenizer = None
|
||||
_translator = None
|
||||
_ws = None
|
||||
_reconnect_delay = 5
|
||||
_running = True
|
||||
_stats = {"jobs_completed": 0, "jobs_failed": 0}
|
||||
|
||||
|
||||
def ensure_model():
|
||||
global _tokenizer, _translator
|
||||
|
||||
if _translator:
|
||||
return
|
||||
|
||||
model_bin = os.path.join(MODEL_PATH, "model.bin")
|
||||
|
||||
if not os.path.exists(model_bin):
|
||||
LOG.info(f"CTranslate2 model not found at {MODEL_PATH}")
|
||||
LOG.info("Downloading from HuggingFace...")
|
||||
convert_model()
|
||||
|
||||
LOG.info(f"Loading CTranslate2 model from {MODEL_PATH} on {DEVICE}")
|
||||
|
||||
_translator = ctranslate2.Translator(
|
||||
MODEL_PATH,
|
||||
device=DEVICE,
|
||||
compute_type=COMPUTE_TYPE,
|
||||
)
|
||||
|
||||
_tokenizer = AutoTokenizer.from_pretrained(UNIVERSAL_MODEL)
|
||||
LOG.info("CTranslate2 model loaded successfully")
|
||||
|
||||
|
||||
def convert_model():
|
||||
import subprocess
|
||||
|
||||
os.makedirs(MODEL_PATH, exist_ok=True)
|
||||
|
||||
quantization = COMPUTE_TYPE if COMPUTE_TYPE != "auto" else "int8"
|
||||
|
||||
LOG.info(f"Converting {UNIVERSAL_MODEL} to CTranslate2 format...")
|
||||
|
||||
cmd = [
|
||||
"ct2-transformers-converter",
|
||||
"--model", UNIVERSAL_MODEL,
|
||||
"--output_dir", MODEL_PATH,
|
||||
"--quantization", quantization,
|
||||
"--force"
|
||||
]
|
||||
|
||||
LOG.info(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
|
||||
|
||||
if result.returncode != 0:
|
||||
LOG.error(f"Model conversion failed: {result.stderr}")
|
||||
raise RuntimeError("Failed to convert model")
|
||||
|
||||
LOG.info("Model conversion completed")
|
||||
|
||||
|
||||
def clean_text(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = text.replace('<unk>', '')
|
||||
text = text.replace(' ', ' ')
|
||||
text = text.replace('&', '&')
|
||||
text = text.replace('<', '<')
|
||||
text = text.replace('>', '>')
|
||||
text = text.replace('"', '"')
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def translate_texts(src: str, tgt: str, texts: List[str]) -> List[str]:
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
ensure_model()
|
||||
|
||||
clean = [(t or "").strip() for t in texts]
|
||||
if all(not t for t in clean):
|
||||
return ["" for _ in clean]
|
||||
|
||||
src_code = LANG_CODE_MAP.get(src, f"{src}_Latn")
|
||||
tgt_code = LANG_CODE_MAP.get(tgt, "spa_Latn")
|
||||
|
||||
try:
|
||||
_tokenizer.src_lang = src_code
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sources = []
|
||||
for t in clean:
|
||||
if t:
|
||||
ids = _tokenizer.encode(t, truncation=True, max_length=MAX_SRC_TOKENS)
|
||||
tokens = _tokenizer.convert_ids_to_tokens(ids)
|
||||
sources.append(tokens)
|
||||
else:
|
||||
sources.append([])
|
||||
|
||||
target_prefix = [[tgt_code]] * len(sources)
|
||||
|
||||
results = _translator.translate_batch(
|
||||
sources,
|
||||
target_prefix=target_prefix,
|
||||
beam_size=2,
|
||||
max_decoding_length=MAX_NEW_TOKENS,
|
||||
repetition_penalty=2.0,
|
||||
no_repeat_ngram_size=3,
|
||||
)
|
||||
|
||||
translated = []
|
||||
for result in results:
|
||||
try:
|
||||
if result.hypotheses and len(result.hypotheses) > 0:
|
||||
hyp = result.hypotheses[0]
|
||||
if isinstance(hyp, list) and len(hyp) > 0:
|
||||
first_hyp = hyp[0]
|
||||
if isinstance(first_hyp, dict) and "token_ids" in first_hyp:
|
||||
tokens = first_hyp["token_ids"]
|
||||
text = _tokenizer.decode(tokens)
|
||||
translated.append(text.strip())
|
||||
elif isinstance(first_hyp, str):
|
||||
token_strings = hyp[1:] if len(hyp) > 1 else []
|
||||
if token_strings:
|
||||
text = _tokenizer.convert_tokens_to_string(token_strings)
|
||||
translated.append(text.strip())
|
||||
else:
|
||||
translated.append("")
|
||||
else:
|
||||
translated.append("")
|
||||
else:
|
||||
translated.append("")
|
||||
else:
|
||||
translated.append("")
|
||||
except Exception as e:
|
||||
LOG.error(f"Error processing result: {e}")
|
||||
translated.append("")
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
def split_body_into_chunks(text: str) -> List[str]:
|
||||
text = (text or "").strip()
|
||||
if len(text) <= BODY_CHARS_CHUNK:
|
||||
return [text] if text else []
|
||||
|
||||
parts = re.split(r'(\n\n+|(?<=[\.\!\?؛؟。])\s+)', text)
|
||||
chunks = []
|
||||
current = ""
|
||||
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
if len(current) + len(part) <= BODY_CHARS_CHUNK:
|
||||
current += part
|
||||
else:
|
||||
if current.strip():
|
||||
chunks.append(current.strip())
|
||||
current = part
|
||||
if current.strip():
|
||||
chunks.append(current.strip())
|
||||
|
||||
return chunks if chunks else [text]
|
||||
|
||||
|
||||
def translate_body_long(src: str, tgt: str, body: str) -> str:
|
||||
body = (body or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
chunks = split_body_into_chunks(body)
|
||||
if len(chunks) == 1:
|
||||
return translate_texts(src, tgt, [body])[0]
|
||||
|
||||
translated_chunks = []
|
||||
for ch in chunks:
|
||||
tr = translate_texts(src, tgt, [ch])[0]
|
||||
translated_chunks.append(tr)
|
||||
|
||||
return " ".join(translated_chunks)
|
||||
|
||||
|
||||
def process_job(job: dict) -> dict:
|
||||
job_id = job.get("id")
|
||||
lang_from = job.get("lang_from", "en")
|
||||
lang_to = job.get("lang_to", "es")
|
||||
title = job.get("title", "")
|
||||
summary = job.get("summary", "")
|
||||
|
||||
LOG.info(f"Processing job {job_id}: {lang_from} -> {lang_to}")
|
||||
|
||||
if lang_from == lang_to:
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"title_trad": title,
|
||||
"summary_trad": summary,
|
||||
"error": ""
|
||||
}
|
||||
|
||||
try:
|
||||
title_tr = translate_texts(lang_from, lang_to, [title])[0]
|
||||
title_tr = clean_text(title_tr) or title
|
||||
|
||||
summary_tr = ""
|
||||
if summary:
|
||||
summary_tr = translate_body_long(lang_from, lang_to, summary)
|
||||
summary_tr = clean_text(summary_tr) or summary
|
||||
|
||||
_stats["jobs_completed"] += 1
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"title_trad": title_tr,
|
||||
"summary_trad": summary_tr,
|
||||
"error": ""
|
||||
}
|
||||
except Exception as e:
|
||||
LOG.error(f"Translation error for job {job_id}: {e}")
|
||||
_stats["jobs_failed"] += 1
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"title_trad": "",
|
||||
"summary_trad": "",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def send_message(msg: dict):
|
||||
if _ws and _ws.sock and _ws.sock.connected:
|
||||
try:
|
||||
_ws.send(json.dumps(msg))
|
||||
except Exception as e:
|
||||
LOG.error(f"Send error: {e}")
|
||||
|
||||
|
||||
def on_message(ws, message):
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
except:
|
||||
LOG.error(f"Invalid JSON: {message}")
|
||||
return
|
||||
|
||||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "job":
|
||||
job = msg.get("job", {})
|
||||
result = process_job(job)
|
||||
send_message({
|
||||
"type": "result",
|
||||
"result": result
|
||||
})
|
||||
LOG.info(f"Sent result for job {result['job_id']}")
|
||||
|
||||
elif msg_type == "ping":
|
||||
send_message({"type": "heartbeat"})
|
||||
|
||||
elif msg_type == "ack":
|
||||
LOG.debug(f"Server ACK: {msg.get('message', '')}")
|
||||
|
||||
elif msg_type == "error":
|
||||
LOG.error(f"Server error: {msg.get('message', '')}")
|
||||
|
||||
|
||||
def on_error(ws, error):
|
||||
LOG.error(f"WebSocket error: {error}")
|
||||
|
||||
|
||||
def on_close(ws, close_status_code, close_msg):
|
||||
global _reconnect_delay
|
||||
LOG.warning(f"WebSocket closed: {close_status_code} - {close_msg}")
|
||||
LOG.info(f"Reconnecting in {_reconnect_delay}s...")
|
||||
|
||||
|
||||
def on_open(ws):
|
||||
global _reconnect_delay
|
||||
LOG.info("Connected to server")
|
||||
_reconnect_delay = 5
|
||||
|
||||
send_message({
|
||||
"type": "register",
|
||||
"capabilities": DEVICE,
|
||||
"worker_name": WORKER_NAME
|
||||
})
|
||||
|
||||
|
||||
def stats_reporter():
|
||||
while _running:
|
||||
time.sleep(60)
|
||||
LOG.info(f"Stats: completed={_stats['jobs_completed']}, failed={_stats['jobs_failed']}")
|
||||
|
||||
|
||||
def connect():
|
||||
global _ws, _reconnect_delay
|
||||
|
||||
while _running:
|
||||
try:
|
||||
if not WORKER_API_KEY:
|
||||
LOG.error("WORKER_API_KEY not set")
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
ws_url = f"{WORKER_SERVER}?api_key={WORKER_API_KEY}"
|
||||
|
||||
_ws = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
on_close=on_close,
|
||||
header={"X-API-Key": WORKER_API_KEY}
|
||||
)
|
||||
|
||||
LOG.info(f"Connecting to {WORKER_SERVER}...")
|
||||
_ws.run_forever(ping_interval=30, ping_timeout=10)
|
||||
|
||||
except Exception as e:
|
||||
LOG.error(f"Connection error: {e}")
|
||||
|
||||
LOG.info(f"Waiting {_reconnect_delay}s before reconnect...")
|
||||
time.sleep(_reconnect_delay)
|
||||
_reconnect_delay = min(_reconnect_delay * 2, 60)
|
||||
|
||||
|
||||
def main():
|
||||
global _running
|
||||
|
||||
if not WORKER_API_KEY:
|
||||
LOG.error("WORKER_API_KEY environment variable is required")
|
||||
sys.exit(1)
|
||||
|
||||
LOG.info(f"Remote translator worker starting...")
|
||||
LOG.info(f"Server: {WORKER_SERVER}")
|
||||
LOG.info(f"Device: {DEVICE}")
|
||||
LOG.info(f"Model: {MODEL_PATH}")
|
||||
|
||||
ensure_model()
|
||||
|
||||
stats_thread = threading.Thread(target=stats_reporter, daemon=True)
|
||||
stats_thread.start()
|
||||
|
||||
connect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -30,4 +30,7 @@ pydyf==0.10.0
|
|||
redis>=5.0.0
|
||||
qdrant-client==1.11.0
|
||||
feedfinder2>=0.0.4
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.12.0
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue