cambios para worker-remoto

This commit is contained in:
lordpietre 2026-04-01 21:29:11 +00:00
parent 98e0cd0e46
commit be56de7dd4
19 changed files with 1544 additions and 216 deletions

View file

@ -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

View file

@ -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=

View 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
}

View 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")
}
}

View 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)
}
}

View file

@ -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",

View file

@ -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)
}

View 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)
}
}

View 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
View 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

View file

@ -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"
}
}

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom'

15
frontend/vitest.config.ts Normal file
View 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
View file

@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
models/
hf_cache/
*.egg-info/

36
remote-worker/Dockerfile Normal file
View 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
View 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 .
```

View 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
View 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('&nbsp;', ' ')
text = text.replace('&amp;', '&')
text = text.replace('&lt;', '<')
text = text.replace('&gt;', '>')
text = text.replace('&quot;', '"')
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()

View file

@ -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