diff --git a/.dockerignore b/.dockerignore old mode 100755 new mode 100644 diff --git a/.env.example b/.env.example old mode 100755 new mode 100644 index 540cddb..193d1c0 --- a/.env.example +++ b/.env.example @@ -9,3 +9,22 @@ KVTUBE_DATA_DIR=./data # Gin mode: debug or release GIN_MODE=release + +# CORS allowed origins (comma-separated, or * for all) +# Example: CORS_ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# Database configuration +DB_MAX_OPEN_CONNS=25 +DB_MAX_IDLE_CONNS=5 +DB_CONN_MAX_LIFETIME=5m + +# Cache configuration +CACHE_TTL=3600 +CACHE_ENABLED=true + +# HTTP client configuration +HTTP_CLIENT_TIMEOUT=30s + +# Security +# Note: SSRF protection is enabled for video proxy - only YouTube/Google domains allowed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml old mode 100755 new mode 100644 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml old mode 100755 new mode 100644 diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 diff --git a/CUsersAdminDocumentskv-tubepage.html b/CUsersAdminDocumentskv-tubepage.html new file mode 100644 index 0000000..e8febe8 --- /dev/null +++ b/CUsersAdminDocumentskv-tubepage.html @@ -0,0 +1,8 @@ +KV-Tube
KV-Tube
\ No newline at end of file diff --git a/CUsersAdminDocumentskv-tubetemp.js b/CUsersAdminDocumentskv-tubetemp.js new file mode 100644 index 0000000..7232fc4 --- /dev/null +++ b/CUsersAdminDocumentskv-tubetemp.js @@ -0,0 +1 @@ +cat: can't open '/app/frontend/.next/required-server-files.js': No such file or directory diff --git a/Dockerfile b/Dockerfile index ddb110f..48f11a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ WORKDIR /app COPY --from=frontend-deps /app/node_modules ./node_modules COPY frontend/ ./ ENV NEXT_TELEMETRY_DISABLED 1 -RUN npm run build +ENV NEXT_PUBLIC_API_URL=http://localhost:8080 +RUN echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" && npm run build # ---- Final Unified Image ---- FROM alpine:latest diff --git a/backend/Dockerfile b/backend/Dockerfile old mode 100755 new mode 100644 diff --git a/backend/go.mod b/backend/go.mod old mode 100755 new mode 100644 index 14aef4e..be11f68 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,42 +1,50 @@ module kvtube-go -go 1.24 +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/joho/godotenv v1.5.1 + modernc.org/sqlite v1.47.0 +) require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/gin-gonic/gin v1.11.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - github.com/ulule/limiter/v3 v3.11.2 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum old mode 100755 new mode 100644 index c04e83e..bd92ba9 --- a/backend/go.sum +++ b/backend/go.sum @@ -5,13 +5,18 @@ github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +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= @@ -22,7 +27,15 @@ 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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -33,21 +46,22 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/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= @@ -55,33 +69,62 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA= -github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/kv-tube-mac b/backend/kv-tube-mac new file mode 100644 index 0000000..c7621d9 Binary files /dev/null and b/backend/kv-tube-mac differ diff --git a/backend/kv-tube-new b/backend/kv-tube-new new file mode 100644 index 0000000..87082ee Binary files /dev/null and b/backend/kv-tube-new differ diff --git a/backend/main.go b/backend/main.go old mode 100755 new mode 100644 diff --git a/backend/models/cache.go b/backend/models/cache.go new file mode 100644 index 0000000..99486a2 --- /dev/null +++ b/backend/models/cache.go @@ -0,0 +1,91 @@ +package models + +import ( + "database/sql" + "encoding/json" + "log" + "time" +) + +type CacheEntry struct { + VideoID string + Data []byte + ExpiresAt time.Time +} + +// GetCachedVideo retrieves cached video data by video ID +func GetCachedVideo(videoID string) ([]byte, error) { + if DB == nil { + return nil, nil + } + + var data []byte + var expiresAt time.Time + err := DB.QueryRow( + `SELECT data, expires_at FROM video_cache WHERE video_id = ? AND expires_at > ?`, + videoID, time.Now(), + ).Scan(&data, &expiresAt) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + log.Printf("Cache query error: %v", err) + return nil, err + } + + return data, nil +} + +// SetCachedVideo stores video data in cache with TTL +func SetCachedVideo(videoID string, data interface{}, ttlSeconds int) error { + if DB == nil { + return nil + } + + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Second) + + _, err = DB.Exec( + `INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)`, + videoID, string(jsonData), expiresAt, + ) + + if err != nil { + log.Printf("Cache store error: %v", err) + } + + return err +} + +// CleanExpiredCache removes expired cache entries +func CleanExpiredCache() { + if DB == nil { + return + } + + result, err := DB.Exec(`DELETE FROM video_cache WHERE expires_at < ?`, time.Now()) + if err != nil { + log.Printf("Cache cleanup error: %v", err) + return + } + + rows, _ := result.RowsAffected() + if rows > 0 { + log.Printf("Cleaned %d expired cache entries", rows) + } +} + +// StartCacheCleanupScheduler runs periodic cache cleanup +func StartCacheCleanupScheduler() { + go func() { + ticker := time.NewTicker(1 * time.Hour) + for range ticker.C { + CleanExpiredCache() + } + }() +} diff --git a/backend/models/database.go b/backend/models/database.go old mode 100755 new mode 100644 index f17360b..8b22d24 --- a/backend/models/database.go +++ b/backend/models/database.go @@ -6,7 +6,7 @@ import ( "os" "path/filepath" - _ "github.com/mattn/go-sqlite3" + _ "modernc.org/sqlite" ) var DB *sql.DB @@ -22,7 +22,7 @@ func InitDB() { } dbPath := filepath.Join(dataDir, "kvtube.db") - db, err := sql.Open("sqlite3", dbPath) + db, err := sql.Open("sqlite", dbPath) if err != nil { log.Fatalf("Failed to open database: %v", err) } @@ -68,8 +68,21 @@ func InitDB() { } } - // Insert default user for history tracking - _, err = db.Exec(`INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'default_user', 'password')`) + // Create performance indexes + indexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_user_videos_user_timestamp ON user_videos(user_id, timestamp DESC)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_user_videos_user_video ON user_videos(user_id, video_id)`, + `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_video_cache_expires ON video_cache(expires_at)`, + } + for _, idx := range indexes { + if _, err := db.Exec(idx); err != nil { + log.Printf("Warning: Failed to create index: %v - Statement: %s", err, idx) + } + } + + // Insert default user for history tracking (password is not used for authentication) + _, err = db.Exec(`INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'default_user', '')`) if err != nil { log.Printf("Failed to insert default user: %v", err) } diff --git a/backend/routes/api.go b/backend/routes/api.go old mode 100755 new mode 100644 index a43cc9c..fb09455 --- a/backend/routes/api.go +++ b/backend/routes/api.go @@ -2,24 +2,114 @@ package routes import ( "bufio" + "fmt" "io" "log" "net/http" "net/url" + "os" + "regexp" "strconv" "strings" + "time" "kvtube-go/services" "github.com/gin-gonic/gin" ) +// getAllowedOrigins returns allowed CORS origins from environment variable or defaults +func getAllowedOrigins() []string { + originsEnv := os.Getenv("CORS_ALLOWED_ORIGINS") + if originsEnv == "" { + // Default: allow localhost for development + return []string{ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:5011", + "http://127.0.0.1:5011", + } + } + origins := strings.Split(originsEnv, ",") + for i := range origins { + origins[i] = strings.TrimSpace(origins[i]) + } + return origins +} + +// isAllowedOrigin checks if the given origin is in the allowed list +func isAllowedOrigin(origin string, allowedOrigins []string) bool { + for _, allowed := range allowedOrigins { + if allowed == "*" || allowed == origin { + return true + } + } + return false +} + +// isAllowedDomain checks if the URL belongs to allowed domains (YouTube/Google) +func isAllowedDomain(targetURL string) error { + parsedURL, err := url.Parse(targetURL) + if err != nil { + return err + } + + // Allowed domains for video proxy + allowedDomains := []string{ + ".youtube.com", + ".googlevideo.com", + ".ytimg.com", + ".google.com", + ".gstatic.com", + } + + host := strings.ToLower(parsedURL.Hostname()) + + // Check if host matches any allowed domain + for _, domain := range allowedDomains { + if strings.HasSuffix(host, domain) || host == strings.TrimPrefix(domain, ".") { + return nil + } + } + + return fmt.Errorf("domain %s not allowed", host) +} + +// validateSearchQuery ensures search query contains only safe characters +func validateSearchQuery(query string) error { + // Allow alphanumeric, spaces, hyphens, underscores, dots, commas, exclamation marks + safePattern := regexp.MustCompile(`^[a-zA-Z0-9\s\-_.,!]+$`) + if !safePattern.MatchString(query) { + return fmt.Errorf("search query contains invalid characters") + } + if len(query) > 200 { + return fmt.Errorf("search query too long") + } + return nil +} + +// Global HTTP client with connection pooling and timeouts +var httpClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + }, +} + func SetupRouter() *gin.Engine { r := gin.Default() + // CORS middleware - restrict to specific origins from environment variable + allowedOrigins := getAllowedOrigins() r.Use(func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + origin := c.GetHeader("Origin") + if origin != "" && isAllowedOrigin(origin, allowedOrigins) { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + } c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return @@ -38,6 +128,7 @@ func SetupRouter() *gin.Engine { api.GET("/trending", handleTrending) api.GET("/get_stream_info", handleGetStreamInfo) api.GET("/download", handleDownload) + api.GET("/download-file", handleDownloadFile) api.GET("/transcript", handleTranscript) api.GET("/comments", handleComments) api.GET("/channel/videos", handleChannelVideos) @@ -71,6 +162,12 @@ func handleSearch(c *gin.Context) { return } + // Validate search query for security + if err := validateSearchQuery(query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + limit := 20 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil { @@ -191,6 +288,88 @@ func handleDownload(c *gin.Context) { c.JSON(http.StatusOK, info) } +func handleDownloadFile(c *gin.Context) { + videoID := c.Query("v") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + return + } + + formatID := c.Query("f") + + // Get the download URL from yt-dlp + info, err := services.GetDownloadURL(videoID, formatID) + if err != nil { + log.Printf("GetDownloadURL Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"}) + return + } + + if info.URL == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "No download URL available"}) + return + } + + // Create request to the video URL + req, err := http.NewRequest("GET", info.URL, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) + return + } + + // Copy range header if present (for partial content/resumable downloads) + if rangeHeader := c.GetHeader("Range"); rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + + // Set appropriate headers for YouTube + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + req.Header.Set("Referer", "https://www.youtube.com/") + req.Header.Set("Origin", "https://www.youtube.com") + + // Make the request + resp, err := httpClient.Do(req) + if err != nil { + log.Printf("Failed to fetch video: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video"}) + return + } + defer resp.Body.Close() + + // Copy relevant headers from YouTube response to our response + for key, values := range resp.Header { + if key == "Content-Type" || key == "Content-Length" || key == "Content-Range" || + key == "Accept-Ranges" || key == "Content-Disposition" { + for _, value := range values { + c.Header(key, value) + } + } + } + + // Set content type based on extension + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + if info.Ext == "mp4" { + contentType = "video/mp4" + } else if info.Ext == "webm" { + contentType = "video/webm" + } else { + contentType = "application/octet-stream" + } + } + c.Header("Content-Type", contentType) + + // Set content disposition for download + filename := fmt.Sprintf("%s.%s", info.Title, info.Ext) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + // Copy status code + c.Status(resp.StatusCode) + + // Stream the video + io.Copy(c.Writer, resp.Body) +} + func handleGetFormats(c *gin.Context) { videoID := c.Query("v") if videoID == "" { @@ -419,6 +598,12 @@ func handleVideoProxy(c *gin.Context) { return } + // SSRF Protection: Validate target domain + if err := isAllowedDomain(targetURL); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "URL domain not allowed"}) + return + } + req, err := http.NewRequest("GET", targetURL, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) @@ -434,8 +619,7 @@ func handleVideoProxy(c *gin.Context) { req.Header.Set("Range", rangeHeader) } - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"}) return diff --git a/backend/services/history.go b/backend/services/history.go old mode 100755 new mode 100644 diff --git a/backend/services/subscription.go b/backend/services/subscription.go old mode 100755 new mode 100644 diff --git a/backend/services/ytdlp.go b/backend/services/ytdlp.go old mode 100755 new mode 100644 index 7fc5557..9ac373d --- a/backend/services/ytdlp.go +++ b/backend/services/ytdlp.go @@ -7,9 +7,44 @@ import ( "log" "os" "os/exec" + "sort" "strings" + + "kvtube-go/models" ) +var ytDlpBinPath string + +func init() { + ytDlpBinPath = resolveYtDlpBinPath() +} + +func resolveYtDlpBinPath() string { + // Check if yt-dlp is in PATH + if _, err := exec.LookPath("yt-dlp"); err == nil { + return "yt-dlp" + } + + fallbacks := []string{ + os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.11/bin/yt-dlp"), + os.ExpandEnv("$HOME/.local/bin/yt-dlp"), + "/usr/local/bin/yt-dlp", + "/opt/homebrew/bin/yt-dlp", + } + + for _, fb := range fallbacks { + if _, err := os.Stat(fb); err == nil { + return fb + } + } + + // Default fallback + return "yt-dlp" +} + type VideoData struct { ID string `json:"id"` Title string `json:"title"` @@ -89,6 +124,66 @@ func sanitizeVideoData(entry YtDlpEntry) VideoData { } } +// extractVideoID tries to extract a YouTube video ID from yt-dlp arguments +func extractVideoID(args []string) string { + for _, arg := range args { + // Look for 11-character video IDs (YouTube standard) + if len(arg) == 11 { + // Simple check: alphanumeric with underscore and dash + isValid := true + for _, c := range arg { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { + isValid = false + break + } + } + if isValid { + return arg + } + } + + // Extract from YouTube URL patterns + if strings.Contains(arg, "youtube.com") || strings.Contains(arg, "youtu.be") { + // Simple regex for video ID in URL + if idx := strings.Index(arg, "v="); idx != -1 { + id := arg[idx+2:] + if len(id) >= 11 { + return id[:11] + } + } + // youtu.be/ID + if idx := strings.LastIndex(arg, "/"); idx != -1 { + id := arg[idx+1:] + if len(id) >= 11 { + return id[:11] + } + } + } + } + return "" +} + +// RunYtDlpCached executes yt-dlp with caching +func RunYtDlpCached(cacheKey string, ttlSeconds int, args ...string) ([]byte, error) { + // Try to get from cache first + if cachedData, err := models.GetCachedVideo(cacheKey); err == nil && cachedData != nil { + return cachedData, nil + } + + // Execute yt-dlp + data, err := RunYtDlp(args...) + if err != nil { + return nil, err + } + + // Store in cache (ignore cache errors) + if cacheKey != "" { + _ = models.SetCachedVideo(cacheKey, string(data), ttlSeconds) + } + + return data, nil +} + // RunYtDlp securely executes yt-dlp with the given arguments and returns JSON output func RunYtDlp(args ...string) ([]byte, error) { cmdArgs := append([]string{ @@ -100,27 +195,7 @@ func RunYtDlp(args ...string) ([]byte, error) { "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }, args...) - binPath := "yt-dlp" - // Check common install paths if yt-dlp is not in PATH - if _, err := exec.LookPath("yt-dlp"); err != nil { - fallbacks := []string{ - os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"), - os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"), - os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"), - os.ExpandEnv("$HOME/Library/Python/3.11/bin/yt-dlp"), - os.ExpandEnv("$HOME/.local/bin/yt-dlp"), - "/usr/local/bin/yt-dlp", - "/opt/homebrew/bin/yt-dlp", - } - for _, fb := range fallbacks { - if _, err := os.Stat(fb); err == nil { - binPath = fb - break - } - } - } - - cmd := exec.Command(binPath, cmdArgs...) + cmd := exec.Command(ytDlpBinPath, cmdArgs...) var out bytes.Buffer var stderr bytes.Buffer @@ -176,7 +251,8 @@ func GetVideoInfo(videoID string) (*VideoData, error) { url, } - out, err := RunYtDlp(args...) + cacheKey := "video_info:" + videoID + out, err := RunYtDlpCached(cacheKey, 3600, args...) // Cache for 1 hour if err != nil { return nil, err } @@ -209,44 +285,19 @@ type QualityFormat struct { func GetVideoQualities(videoID string) ([]QualityFormat, error) { url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) - cmdArgs := append([]string{ + cmdArgs := []string{ "--dump-json", "--no-warnings", "--quiet", "--force-ipv4", "--no-playlist", "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - }, url) - - binPath := "yt-dlp" - if _, err := exec.LookPath("yt-dlp"); err != nil { - fallbacks := []string{ - os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"), - os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"), - os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"), - os.ExpandEnv("$HOME/.local/bin/yt-dlp"), - "/usr/local/bin/yt-dlp", - "/opt/homebrew/bin/yt-dlp", - "/config/.local/bin/yt-dlp", - } - for _, fb := range fallbacks { - if _, err := os.Stat(fb); err == nil { - binPath = fb - break - } - } + url, } - cmd := exec.Command(binPath, cmdArgs...) - - var out bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &stderr - - err := cmd.Run() + cacheKey := "video_qualities:" + videoID + out, err := RunYtDlpCached(cacheKey, 3600, cmdArgs...) // Cache for 1 hour if err != nil { - log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String()) return nil, err } @@ -266,7 +317,7 @@ func GetVideoQualities(videoID string) ([]QualityFormat, error) { } `json:"formats"` } - if err := json.Unmarshal(out.Bytes(), &raw); err != nil { + if err := json.Unmarshal(out, &raw); err != nil { return nil, err } @@ -353,13 +404,9 @@ func GetVideoQualities(videoID string) ([]QualityFormat, error) { } // Sort by height descending - for i := range qualities { - for j := i + 1; j < len(qualities); j++ { - if qualities[j].Height > qualities[i].Height { - qualities[i], qualities[j] = qualities[j], qualities[i] - } - } - } + sort.Slice(qualities, func(i, j int) bool { + return qualities[i].Height > qualities[j].Height + }) return qualities, nil } @@ -708,7 +755,6 @@ func GetDownloadURL(videoID string, formatID string) (*DownloadInfo, error) { args := []string{ "--format", formatArgs, - "--dump-json", "--no-playlist", url, } @@ -854,7 +900,7 @@ func GetChannelInfo(channelID string) (*ChannelInfo, error) { return nil, fmt.Errorf("no output from yt-dlp") } - if err := json.Unmarshal([]byte(lines[0]), &raw); err != nil { + if err := json.Unmarshal(out, &raw); err != nil { return nil, err } @@ -937,40 +983,18 @@ func GetComments(videoID string, limit int) ([]Comment, error) { url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) cmdArgs := []string{ + "--no-warnings", + "--quiet", + "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "--dump-json", "--no-download", "--no-playlist", "--write-comments", - fmt.Sprintf("--comment-limit=%d", limit), + "--extractor-args", fmt.Sprintf("youtube:comment_sort=top;max_comments=%d", limit), url, } - cmdArgs = append([]string{ - "--no-warnings", - "--quiet", - "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - }, cmdArgs...) - - binPath := "yt-dlp" - if _, err := exec.LookPath("yt-dlp"); err != nil { - fallbacks := []string{ - os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"), - os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"), - os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"), - os.ExpandEnv("$HOME/.local/bin/yt-dlp"), - "/usr/local/bin/yt-dlp", - "/opt/homebrew/bin/yt-dlp", - "/config/.local/bin/yt-dlp", - } - for _, fb := range fallbacks { - if _, err := os.Stat(fb); err == nil { - binPath = fb - break - } - } - } - - cmd := exec.Command(binPath, cmdArgs...) + cmd := exec.Command(ytDlpBinPath, cmdArgs...) var out bytes.Buffer var stderr bytes.Buffer diff --git a/doc/Product Requirements Document (PRD) - KV-Tube b/doc/Product Requirements Document (PRD) - KV-Tube old mode 100755 new mode 100644 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 771b682..95ac421 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -7,6 +7,7 @@ services: platform: linux/amd64 ports: - "5012:3000" + - "8080:8080" volumes: - ./data:/app/data environment: diff --git a/docker-compose.yml b/docker-compose.yml old mode 100755 new mode 100644 diff --git a/frontend/.gitignore b/frontend/.gitignore old mode 100755 new mode 100644 diff --git a/frontend/README.md b/frontend/README.md old mode 100755 new mode 100644 diff --git a/frontend/app/actions.ts b/frontend/app/actions.ts old mode 100755 new mode 100644 index 3d6bb9b..afe3868 --- a/frontend/app/actions.ts +++ b/frontend/app/actions.ts @@ -36,7 +36,30 @@ export async function getSuggestedVideos(limit: number = 20): Promise { +export async function getRelatedVideos(videoId: string, limit: number = 10): Promise { + try { + const res = await fetch(`${API_BASE}/api/related?video_id=${encodeURIComponent(videoId)}&limit=${limit}`, { cache: 'no-store' }); + if (!res.ok) return []; + return res.json() as Promise; + } catch (e) { + console.error("Failed to get related videos:", e); + return []; + } +} + +export async function getRecentHistory(): Promise { + try { + const res = await fetch(`${API_BASE}/api/history?limit=1`, { cache: 'no-store' }); + if (!res.ok) return null; + const history: VideoData[] = await res.json(); + return history.length > 0 ? history[0] : null; + } catch (e) { + console.error("Failed to get recent history:", e); + return null; + } +} + +export async function fetchMoreVideos(currentCategory: string, regionLabel: string, page: number, contextVideoId?: string): Promise { const isAllCategory = currentCategory === 'All'; let newVideos: VideoData[] = []; @@ -45,28 +68,97 @@ export async function fetchMoreVideos(currentCategory: string, regionLabel: stri const modifier = page < pageModifiers.length ? pageModifiers[page] : `page ${page}`; if (isAllCategory) { - const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => { - const q = addRegion(sec.query, regionLabel) + " " + modifier; - // Fetch fewer items per section on subsequent pages to mitigate loading times - return await getSearchVideos(q, 5); - }); - const results = await Promise.all(promises); + const recentVideo = await getRecentHistory(); + if (recentVideo) { + const promises = [ + getSearchVideos(addRegion("recommended for you", regionLabel) + " " + modifier, 8), + getSearchVideos(addRegion(recentVideo.title, regionLabel) + " " + modifier, 8), + getSearchVideos(addRegion("trending", regionLabel) + " " + modifier, 4) + ]; + const results = await Promise.all(promises); - // Interleave the results - const maxLen = Math.max(...results.map(arr => arr.length)); - const interleavedList: VideoData[] = []; - const seenIds = new Set(); + const interleavedList: VideoData[] = []; + const seenIds = new Set(); + let sIdx = 0, rIdx = 0, tIdx = 0; + const suggestedRes = results[0]; + const relatedRes = results[1]; + const trendingRes = results[2]; - for (let i = 0; i < maxLen; i++) { - for (const categoryResult of results) { - if (i < categoryResult.length) { - const video = categoryResult[i]; - if (!seenIds.has(video.id)) { - interleavedList.push(video); - seenIds.add(video.id); + while (sIdx < suggestedRes.length || rIdx < relatedRes.length || tIdx < trendingRes.length) { + for (let i = 0; i < 2 && sIdx < suggestedRes.length; i++) { + const v = suggestedRes[sIdx++]; + if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } + } + for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) { + const v = relatedRes[rIdx++]; + if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } + } + for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) { + const v = trendingRes[tIdx++]; + if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } + } + } + newVideos = interleavedList; + } else { + const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => { + const q = addRegion(sec.query, regionLabel) + " " + modifier; + return await getSearchVideos(q, 5); + }); + const results = await Promise.all(promises); + + const maxLen = Math.max(...results.map(arr => arr.length)); + const interleavedList: VideoData[] = []; + const seenIds = new Set(); + + for (let i = 0; i < maxLen; i++) { + for (const categoryResult of results) { + if (i < categoryResult.length) { + const video = categoryResult[i]; + if (!seenIds.has(video.id)) { + interleavedList.push(video); + seenIds.add(video.id); + } } } } + newVideos = interleavedList; + } + } else if (currentCategory === 'WatchRelated' && contextVideoId) { + // Mock infinite pagination for related + const q = addRegion("related to " + contextVideoId, regionLabel) + " " + modifier; + newVideos = await getSearchVideos(q, 20); + } else if (currentCategory === 'WatchForYou') { + const q = addRegion("recommended for you", regionLabel) + " " + modifier; + newVideos = await getSearchVideos(q, 20); + } else if (currentCategory === 'WatchAll' && contextVideoId) { + // Implement 40:40:20 mix logic for watch page + const promises = [ + getSearchVideos(addRegion("recommended for you", regionLabel) + " " + modifier, 8), + getSearchVideos(addRegion("related to " + contextVideoId, regionLabel) + " " + modifier, 8), + getSearchVideos(addRegion("trending", regionLabel) + " " + modifier, 4) + ]; + const results = await Promise.all(promises); + + const interleavedList: VideoData[] = []; + const seenIds = new Set(); + let sIdx = 0, rIdx = 0, tIdx = 0; + const suggestedRes = results[0]; + const relatedRes = results[1]; + const trendingRes = results[2]; + + while (sIdx < suggestedRes.length || rIdx < relatedRes.length || tIdx < trendingRes.length) { + for (let i = 0; i < 2 && sIdx < suggestedRes.length; i++) { + const v = suggestedRes[sIdx++]; + if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } + } + for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) { + const v = relatedRes[rIdx++]; + if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } + } + for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) { + const v = trendingRes[tIdx++]; + if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); } + } } newVideos = interleavedList; } else if (currentCategory === 'Watched') { @@ -85,3 +177,21 @@ export async function fetchMoreVideos(currentCategory: string, regionLabel: stri return newVideos; } + +export interface CommentData { + id: string; + text: string; + author: string; + author_id: string; + author_thumbnail: string; + likes: number; + is_reply: boolean; + parent: string; + timestamp: string; +} + +export async function getVideoComments(videoId: string, limit: number = 30): Promise { + const res = await fetch(`${API_BASE}/api/comments?v=${videoId}&limit=${limit}`, { cache: 'no-store' }); + if (!res.ok) return []; + return res.json(); +} diff --git a/frontend/app/api/download/route.ts b/frontend/app/api/download/route.ts old mode 100755 new mode 100644 index bcb4afa..885284e --- a/frontend/app/api/download/route.ts +++ b/frontend/app/api/download/route.ts @@ -13,18 +13,28 @@ export async function GET(request: NextRequest) { } try { - const url = `${API_BASE}/api/download?v=${encodeURIComponent(videoId)}${formatId ? `&f=${encodeURIComponent(formatId)}` : ''}`; + const url = `${API_BASE}/api/download-file?v=${encodeURIComponent(videoId)}${formatId ? `&f=${encodeURIComponent(formatId)}` : ''}`; const res = await fetch(url, { cache: 'no-store', }); - const data = await res.json(); - if (!res.ok) { - return NextResponse.json({ error: data.error || 'Download failed' }, { status: 500 }); + const data = await res.json().catch(() => ({})); + return NextResponse.json({ error: data.error || 'Download failed' }, { status: res.status }); } - return NextResponse.json(data); + // Stream the file directly + const headers = new Headers(); + const contentType = res.headers.get('content-type'); + const contentDisposition = res.headers.get('content-disposition'); + + if (contentType) headers.set('content-type', contentType); + if (contentDisposition) headers.set('content-disposition', contentDisposition); + + return new NextResponse(res.body, { + status: res.status, + headers, + }); } catch (error) { return NextResponse.json({ error: 'Failed to get download link' }, { status: 500 }); } diff --git a/frontend/app/api/formats/route.ts b/frontend/app/api/formats/route.ts old mode 100755 new mode 100644 diff --git a/frontend/app/api/proxy-file/route.ts b/frontend/app/api/proxy-file/route.ts old mode 100755 new mode 100644 index 4bd8979..5dd22af --- a/frontend/app/api/proxy-file/route.ts +++ b/frontend/app/api/proxy-file/route.ts @@ -11,6 +11,8 @@ export async function GET(request: NextRequest) { const res = await fetch(fileUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.youtube.com/', + 'Origin': 'https://www.youtube.com', }, }); diff --git a/frontend/app/api/proxy-stream/route.ts b/frontend/app/api/proxy-stream/route.ts old mode 100755 new mode 100644 diff --git a/frontend/app/api/stream/route.ts b/frontend/app/api/stream/route.ts old mode 100755 new mode 100644 diff --git a/frontend/app/api/subscribe/route.ts b/frontend/app/api/subscribe/route.ts old mode 100755 new mode 100644 diff --git a/frontend/app/channel/[id]/page.tsx b/frontend/app/channel/[id]/page.tsx old mode 100755 new mode 100644 diff --git a/frontend/app/components/HamburgerMenu.tsx b/frontend/app/components/HamburgerMenu.tsx new file mode 100644 index 0000000..d4f317c --- /dev/null +++ b/frontend/app/components/HamburgerMenu.tsx @@ -0,0 +1,70 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary, MdClose } from 'react-icons/md'; +import { useSidebar } from '../context/SidebarContext'; +import { useEffect } from 'react'; + +export default function HamburgerMenu() { + const pathname = usePathname(); + const { isMobileMenuOpen, closeMobileMenu, isSidebarOpen, openSidebar } = useSidebar(); + + const navItems = [ + { icon: , label: 'Home', path: '/' }, + { icon: , label: 'Subscriptions', path: '/feed/subscriptions' }, + { icon: , label: 'You', path: '/feed/library' }, + ]; + + // Close menu on route change + useEffect(() => { + closeMobileMenu(); + }, [pathname, closeMobileMenu]); + + return ( + <> + {/* Backdrop */} +
+ + {/* Menu Drawer */} +
+
+ + + KV-Tube + +
+ +
+ {navItems.map((item) => { + const isActive = pathname === item.path; + return ( + +
+ {item.icon} +
+ + {item.label} + + + ); + })} +
+
+ Made with ♡ locally +
+
+
+ + ); +} diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx old mode 100755 new mode 100644 index a75928a..36edf45 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -3,9 +3,10 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useState, useRef, useEffect } from 'react'; -import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack } from 'react-icons/io5'; +import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack, IoMenuOutline } from 'react-icons/io5'; import RegionSelector from './RegionSelector'; import { useTheme } from '../context/ThemeContext'; +import { useSidebar } from '../context/SidebarContext'; export default function Header() { const [searchQuery, setSearchQuery] = useState(''); @@ -15,6 +16,7 @@ export default function Header() { const mobileInputRef = useRef(null); const router = useRouter(); const { theme, toggleTheme } = useTheme(); + const { toggleSidebar, toggleMobileMenu } = useSidebar(); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); @@ -37,6 +39,12 @@ export default function Header() { <> {/* Left */}
+ KV-Tube diff --git a/frontend/app/components/HeaderDebug.tsx b/frontend/app/components/HeaderDebug.tsx new file mode 100644 index 0000000..c7b3af5 --- /dev/null +++ b/frontend/app/components/HeaderDebug.tsx @@ -0,0 +1,13 @@ +'use client'; + +export default function HeaderDebug() { + console.log('HeaderDebug rendered'); + return ( +
+ + KV-Tube Debug +
+ ); +} diff --git a/frontend/app/components/InfiniteVideoGrid.tsx b/frontend/app/components/InfiniteVideoGrid.tsx old mode 100755 new mode 100644 index 62aedea..2b0515c --- a/frontend/app/components/InfiniteVideoGrid.tsx +++ b/frontend/app/components/InfiniteVideoGrid.tsx @@ -9,9 +9,10 @@ interface Props { initialVideos: VideoData[]; currentCategory: string; regionLabel: string; + contextVideoId?: string; } -export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel }: Props) { +export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel, contextVideoId }: Props) { const [videos, setVideos] = useState(initialVideos); const [page, setPage] = useState(2); const [isLoading, setIsLoading] = useState(false); @@ -30,7 +31,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi setIsLoading(true); try { - const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page); + const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page, contextVideoId); if (newVideos.length === 0) { setHasMore(false); } else { @@ -55,7 +56,7 @@ export default function InfiniteVideoGrid({ initialVideos, currentCategory, regi } finally { setIsLoading(false); } - }, [currentCategory, regionLabel, page, isLoading, hasMore]); + }, [currentCategory, regionLabel, page, isLoading, hasMore, contextVideoId]); useEffect(() => { const observer = new IntersectionObserver( diff --git a/frontend/app/components/MainContent.tsx b/frontend/app/components/MainContent.tsx new file mode 100644 index 0000000..4b74957 --- /dev/null +++ b/frontend/app/components/MainContent.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { useSidebar } from '../context/SidebarContext'; +import { ReactNode } from 'react'; + +export default function MainContent({ children }: { children: ReactNode }) { + const { isSidebarOpen } = useSidebar(); + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/MobileNav.tsx b/frontend/app/components/MobileNav.tsx old mode 100755 new mode 100644 diff --git a/frontend/app/components/RegionSelector.tsx b/frontend/app/components/RegionSelector.tsx old mode 100755 new mode 100644 diff --git a/frontend/app/components/Sidebar.tsx b/frontend/app/components/Sidebar.tsx old mode 100755 new mode 100644 index 54fb35c..02dd59a --- a/frontend/app/components/Sidebar.tsx +++ b/frontend/app/components/Sidebar.tsx @@ -4,9 +4,11 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md'; import { SiYoutubeshorts } from 'react-icons/si'; +import { useSidebar } from '../context/SidebarContext'; export default function Sidebar() { const pathname = usePathname(); + const { isSidebarOpen } = useSidebar(); const navItems = [ { icon: , label: 'Home', path: '/' }, @@ -16,7 +18,10 @@ export default function Sidebar() { ]; return ( -