diff --git a/.air.conf b/.air.conf index 76974d2..37e367e 100644 --- a/.air.conf +++ b/.air.conf @@ -10,5 +10,5 @@ root = "." time = true [env] - GIN_MODE = "debug" # change to "release" for production + GIN_MODE = "release" # change to "release" for production APP_PORT = 18765 \ No newline at end of file diff --git a/assets/favicon.ico b/assets/favicon.ico deleted file mode 100644 index aeafd58..0000000 Binary files a/assets/favicon.ico and /dev/null differ diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000..568b09c Binary files /dev/null and b/assets/favicon.png differ diff --git a/assets/logo.png b/assets/logo.png deleted file mode 100644 index d0e8eec..0000000 Binary files a/assets/logo.png and /dev/null differ diff --git a/assets/styles.css b/assets/styles.css index c61934b..0574157 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -53,26 +53,11 @@ a, a:link, a:visited, a:hover, a:active { text-decoration: none; /* remove underline */ } -.alert { +.message { padding: 10px 14px; border-radius: 6px; margin: 10px 0; font-size: 0.9rem; -} - -.alert-error { - background: #ffe5e5; - border: 1px solid #e74c3c; - color: #c0392b; -} - -.alert-success { - background: #e6ffed; - border: 1px solid #2ecc71; - color: #27ae60; -} - -.alert-info { background: #eaf4ff; border: 1px solid #3498db; color: #2980b9; diff --git a/go.mod b/go.mod index cc46972..497b1dd 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,21 @@ module gitlab.gwdg.de/qrank/qrank go 1.24.5 require ( - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect + github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/iasm v0.2.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sessions v1.0.4 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect github.com/gin-gonic/gin v1.10.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -21,22 +25,22 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // 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.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + golang.org/x/arch v0.16.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect diff --git a/go.sum b/go.sum index 99d5945..7cc5ec0 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,29 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= +github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/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/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= +github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= 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-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -20,9 +32,19 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 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-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -40,6 +62,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 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= @@ -54,6 +78,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= @@ -66,6 +92,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -73,20 +100,34 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= +golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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= diff --git a/src/auth.go b/src/auth.go index 2b78bce..37e578c 100644 --- a/src/auth.go +++ b/src/auth.go @@ -8,19 +8,7 @@ import ( "github.com/gin-gonic/gin" ) -func getSessionUser(c *gin.Context) *User { - cookie, err := c.Cookie("session") - if err != nil || cookie == "" { - return nil - } - var s Session - if err := db.Preload("User").Where("token = ?", cookie).First(&s).Error; err != nil { - return nil - } - return &s.User -} - -func requireAuth() gin.HandlerFunc { +func RequireAuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if u := getSessionUser(c); u != nil { c.Set("user", u) @@ -32,17 +20,6 @@ func requireAuth() gin.HandlerFunc { } } -func setSessionCookie(c *gin.Context, token string) { - // Very long-lived cookie for one year - maxAge := 365 * 24 * 60 * 60 - httpOnly := true - secure := strings.HasPrefix(baseURL, "https://") - sameSite := http.SameSiteLaxMode - c.SetCookie("session", token, maxAge, "/", cookieDomain, secure, httpOnly) - // Workaround to set SameSite explicitly via header - c.Header("Set-Cookie", (&http.Cookie{Name: "session", Value: token, Path: "/", Domain: cookieDomain, MaxAge: maxAge, Secure: secure, HttpOnly: httpOnly, SameSite: sameSite}).String()) -} - func findUserByHandle(handle string) (*User, error) { h := strings.TrimSpace(handle) if h == "" { diff --git a/src/elo.go b/src/elo.go new file mode 100644 index 0000000..276e8e3 --- /dev/null +++ b/src/elo.go @@ -0,0 +1,143 @@ +// Package elo implements a practical Elo rating update for head-to-head sports +// such as table soccer. This variant has: +// - No home/away asymmetry +// - Margin of victory (MoV) factor using a robust bounded form +// - Simple burn-in: higher K for the first N games +// - No draws (inputs must produce a clear winner) +// - Rating floor +// - Per-match delta cap +// - No time weighting and no explicit uncertainty modeling + +package main + +import ( + "errors" + "math" +) + +var eloCfg = Config{ + Mu0: 1500, + Scale: 400, + KNew: 48, + KStd: 24, + BurnInGames: 20, + RatingFloor: 500, + MaxPerMatchDelta: 60, + MaxGoals: 10, +} + +type Config struct { + Mu0 float64 + Scale float64 // KNew applies to players during burn-in (first BurnInGames matches). + KNew float64 + KStd float64 + BurnInGames int + RatingFloor float64 + MaxPerMatchDelta float64 + MaxGoals int +} + +type Team struct { + Players []Player + Score int + AverageRating float64 +} + +// Collect players by side +type Player struct { + u *User + side string + dElo float64 +} + +func NewTeam(players []Player, score int) *Team { + var t1s float64 = 0 + for _, player := range players { + t1s += float64(player.u.Elo) + + } + t1a := t1s / float64(len(players)) + + return &Team{Players: players, Score: score, AverageRating: t1a} +} + +func GetNewElo(teams []*Team) error { + // Update the teams + t1 := teams[0] + t2 := teams[1] + if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals { + return errors.New("goals out of allowed range") + } + if t2.Score == t1.Score { + return errors.New("draws are not supported by this variant") + } + + // Expected score (Bradley–Terry with logistic base-10) + for i, t := range teams { + otherTeam := teams[1-i] + for i, p := range t.Players { + newRating, err := CalculateRating(p.u.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.u.GameCount) + if err != nil { + return err + } + t.Players[i].u.Elo = roundFloat(newRating, 1) + } + } + + return nil +} + +func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64, err error) { + // Observed score (no draws) + var sA float64 + if goalsA > goalsB { + sA = 1.0 + } else { + sA = 0.0 + } + + // Margin of victory factor with clamped goal difference + diff := goalsA - goalsB + if diff < 0 { + diff = -diff + } + if diff < 1 { + diff = 1 + } + if diff > eloCfg.MaxGoals { + diff = eloCfg.MaxGoals + } + + // Raw delta before caps + eA := expected(eloCfg.Scale, rA, rB) + mov := movFactor(rA, rB, diff) + + var Keff float64 + if gamesA <= eloCfg.BurnInGames { + Keff = eloCfg.KNew + } else { + Keff = eloCfg.KStd + } + + delta := Keff * mov * (sA - eA) + + // Apply symmetric per-match cap + if delta > eloCfg.MaxPerMatchDelta { + delta = eloCfg.MaxPerMatchDelta + } else if delta < -eloCfg.MaxPerMatchDelta { + delta = -eloCfg.MaxPerMatchDelta + } + + return rA + delta, nil +} + +// expected returns the win probability for player A against B +func expected(scale, rA, rB float64) float64 { + return 1.0 / (1.0 + math.Pow(10.0, -(rA-rB)/scale)) +} + +// movFactor returns a bounded margin-of-victory multiplier. +func movFactor(rA, rB float64, diff int) float64 { + fd := float64(diff) + return math.Log(fd+1.0) * 2.2 / (math.Abs(rA-rB)*0.001 + 2.2) +} diff --git a/src/main.go b/src/main.go index 136ecb5..c4fc4a7 100644 --- a/src/main.go +++ b/src/main.go @@ -6,6 +6,8 @@ import ( "strconv" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -50,13 +52,20 @@ func main() { } lg.Println("using SQLite qrank.db") - if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GamePlayer{}); err != nil { + if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GameUser{}); err != nil { lg.Fatal("migrate:", err) } + if err := db.SetupJoinTable(&User{}, "Games", &GameUser{}); err != nil { + lg.Fatal("setup jointable:", err) + } // Create engine r := gin.Default() + store := cookie.NewStore([]byte("secret")) + r.Use(sessions.Sessions("mysession", store)) + r.Use(SessionHandlerMiddleware()) + // Serve static files from the current directory r.Static("/assets", "./assets") @@ -66,20 +75,25 @@ func main() { r.POST("/login", postLogin) r.GET("/magic", getMagic) - // Authenticated routes - r.GET("/enter", requireAuth(), getEnter) - r.POST("/enter", requireAuth(), postEnter) + authorized := r.Group("/") + authorized.Use(RequireAuthMiddleware()) + { - // QR-prepped table routes - r.GET("/t/:tslug/enter", requireAuth(), getEnter) - r.POST("/t/:tslug/enter", requireAuth(), postEnter) + // Authenticated routes + authorized.GET("/enter", getEnter) + authorized.POST("/enter", postEnter) - r.GET("/history", requireAuth(), getHistory) - r.GET("/leaderboard", requireAuth(), getLeaderboard) + // QR-prepped table routes + authorized.GET("/table/:tableSlug", getEnter) + authorized.POST("/table/:tableSlug", postEnter) - r.GET("/u/:slug", requireAuth(), getUserView) - r.GET("/me", requireAuth(), getMe) - r.POST("/me", requireAuth(), postMe) + authorized.GET("/history", getHistory) + authorized.GET("/leaderboard", getLeaderboard) + + authorized.GET("/user/:userSlug", getUserView) + authorized.GET("/me", getMe) + authorized.POST("/me", postMe) + } // Start application with port bind := ":" + port diff --git a/src/models.go b/src/models.go index 1cf0c75..bbb31e6 100644 --- a/src/models.go +++ b/src/models.go @@ -2,65 +2,70 @@ package main import ( "time" + + "gorm.io/gorm" ) // One knows that the user is active when there exists a session for the user type User struct { - ID uint `gorm:"primaryKey"` - CreatedAt time.Time - UpdatedAt time.Time + gorm.Model Email string `gorm:"uniqueIndex;size:320"` Username string `gorm:"uniqueIndex;size:64"` - Slug string `gorm:"uniqueIndex;size:80"` + Slug string `gorm:"uniqueIndex;size:128"` + + Elo float64 `gorm:"default:1500"` // Current Elo rating + GameCount int `gorm:"default:0"` + WinCount int `gorm:"default:0"` + LossCount int `gorm:"default:0"` + + Games []Game `gorm:"many2many:game_users;"` +} + +type Game struct { + gorm.Model + TableID *uint `gorm:"index"` + Table *Table + + // Test if this is needed + Users []User `gorm:"many2many:game_users;"` + + ScoreA int + ScoreB int +} + +// Join table between Game and User with extra fields +type GameUser struct { + gorm.Model + GameID uint `gorm:"primaryKey"` + UserID uint `gorm:"primaryKey"` + + Side string `gorm:"size:1"` + DeltaElo float64 + + // Eager loading + Game Game + User User } // Currently no expiry type Session struct { - ID uint `gorm:"primaryKey"` - CreatedAt time.Time - Token string `gorm:"uniqueIndex;size:128"` - UserID uint `gorm:"index"` - User User + gorm.Model + UserID uint `gorm:"index"` + User User + + Token string `gorm:"uniqueIndex;size:128"` } type LoginToken struct { - ID uint `gorm:"primaryKey"` - CreatedAt time.Time + gorm.Model Token string `gorm:"uniqueIndex;size:128"` Email string `gorm:"index;size:320"` ExpiresAt time.Time `gorm:"index"` } type Table struct { - ID uint `gorm:"primaryKey"` - CreatedAt time.Time - UpdatedAt time.Time - Name string - Slug string `gorm:"uniqueIndex;size:80"` -} - -type Game struct { - ID uint `gorm:"primaryKey"` - CreatedAt time.Time `gorm:"index"` - TableID *uint `gorm:"index"` - Table *Table - ScoreA int - ScoreB int - WinnerIsA bool -} - -type GamePlayer struct { - ID uint `gorm:"primaryKey"` - GameID uint `gorm:"index"` - UserID uint `gorm:"index"` - Side string `gorm:"size:1;index"` // "A" or "B" - User User -} - -// Also house the common models that are not in the database here -type stats struct { - Games, - Wins, - Losses int + gorm.Model + Name string + Slug string } diff --git a/src/routes.go b/src/routes.go index 8f00e4c..c889c19 100644 --- a/src/routes.go +++ b/src/routes.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + "math" "net/http" "strings" "time" @@ -13,7 +14,6 @@ import ( ) func getEnter(c *gin.Context) { - // Simple render for now tm.Render(c, "enter", gin.H{}) } @@ -34,8 +34,9 @@ func postEnter(c *gin.Context) { // Require a winner if scoreA == scoreB { - tm.Render(c, "enter", gin.H{"Error": "Score A score must be different from score B", - "a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB}) + SaveForm(c) + SetMessage(c, "There must be a winner") + c.Redirect(http.StatusSeeOther, "/enter") return } @@ -46,8 +47,9 @@ func postEnter(c *gin.Context) { } u, err := findUserByHandle(handle) if err != nil { - tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q not found", handle), - "a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB}) + SaveForm(c) + SetMessage(c, fmt.Sprintf(`Player "%v" does not exist`, handle)) + c.Redirect(http.StatusSeeOther, "/enter") return nil, err } return u, nil @@ -72,8 +74,9 @@ func postEnter(c *gin.Context) { // Check for at least one player on each side if b1 == nil && b2 == nil || a1 == nil && a2 == nil { - tm.Render(c, "enter", gin.H{"Error": "At least one player required on each side", - "a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB}) + SaveForm(c) + SetMessage(c, "There must be at least one player on each side") + c.Redirect(http.StatusSeeOther, "/enter") return } @@ -83,52 +86,78 @@ func postEnter(c *gin.Context) { for i, u := range users { if u != nil { if seen[u.ID] { - tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q specified multiple times", users[i].Username), - "a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB}) + SaveForm(c) + SetMessage(c, fmt.Sprintf(`Player "%v" cannot play twice in one game`, users[i].Username)) + c.Redirect(http.StatusSeeOther, "/enter") return } seen[u.ID] = true } } - // Look up table if provided - var tableID *uint - if slug := c.Param("tslug"); slug != "" { - var t Table - if err := db.Where("slug = ?", slug).First(&t).Error; err == nil { - tableID = &t.ID - } - } - // Create game g := Game{ - ScoreA: scoreA, - ScoreB: scoreB, - TableID: tableID, - WinnerIsA: scoreA > scoreB, + ScoreA: scoreA, + ScoreB: scoreB, } if err := db.Create(&g).Error; err != nil { c.String(http.StatusInternalServerError, "game create error") return } - // Collect players by side - players := []struct { - u *User - side string - }{ - {a1, "A"}, - {a2, "A"}, - {b1, "B"}, - {b2, "B"}, + var players []Player + var playerbuf []float64 + for i, user := range users { + if user != nil { + var side string + if i > 1 { + side = "B" + } else { + side = "A" + } + players = append(players, Player{u: user, side: side, dElo: 0}) + playerbuf = append(playerbuf, user.Elo) + } + } - for _, p := range players { - if p.u != nil { - if err := db.Create(&GamePlayer{GameID: g.ID, UserID: p.u.ID, Side: p.side}).Error; err != nil { - c.String(http.StatusInternalServerError, "failed to assign player") - return - } + team1 := NewTeam(players[:len(players)/2], g.ScoreA) + team2 := NewTeam(players[len(players)/2:], g.ScoreB) + + // Set new elo for all players + GetNewElo([]*Team{team1, team2}) + + var winningSide string + if g.ScoreA > g.ScoreB { + winningSide = "A" + } else { + winningSide = "B" + } + + for i, p := range players { + db.Save(p.u) + p.dElo = players[i].u.Elo - playerbuf[i] + + var updateString string + if p.side == winningSide { + updateString = "win_count" + } else { + updateString = "loss_count" + + } + var expr = gorm.Expr(updateString+" + ?", 1) + + // Update win or loss + err = db.Model(&User{}). + Where("id = ?", p.u.ID). + UpdateColumn(updateString, expr).UpdateColumn("game_count", gorm.Expr("game_count + ?", 1)).Error + if err != nil { + fmt.Println(err) + } + + if err := db.Create(&GameUser{GameID: g.ID, UserID: p.u.ID, Side: p.side, DeltaElo: p.dElo}).Error; err != nil { + c.String(http.StatusInternalServerError, "failed to assign player") + return } } @@ -143,122 +172,78 @@ func getIndex(c *gin.Context) { getLogin(c) } +func roundFloat(val float64, precision uint) float64 { + ratio := math.Pow(10, float64(precision)) + return math.Round(val*ratio) / ratio +} + func getHistory(c *gin.Context) { - // Load recent games with players + // Load recent games var games []Game - db.Order("created_at desc").Limit(100).Preload("Table").Find(&games) + db.Order("created_at desc").Find(&games) + + type UserElo struct { + User + DeltaElo float64 + } type GRow struct { Game - PlayersA []User - PlayersB []User + PlayersA []UserElo + PlayersB []UserElo + WinnerIsA bool } var rows []GRow for _, g := range games { - var gps []GamePlayer - db.Preload("User").Where("game_id = ?", g.ID).Find(&gps) - var a, b []User + var gps []GameUser + db.Model(&GameUser{}).Preload("User").Where("game_id = ?", g.ID).Find(&gps) + var a, b []UserElo for _, gp := range gps { if gp.Side == "A" { - a = append(a, gp.User) + a = append(a, UserElo{gp.User, roundFloat(gp.DeltaElo, 2)}) } else { - b = append(b, gp.User) + b = append(b, UserElo{gp.User, roundFloat(gp.DeltaElo, 2)}) } } - rows = append(rows, GRow{Game: g, PlayersA: a, PlayersB: b}) + rows = append(rows, GRow{Game: g, PlayersA: a, PlayersB: b, WinnerIsA: g.ScoreA > g.ScoreB}) + + log.Printf("%+v", rows[0].PlayersA[0].Username) } tm.Render(c, "history", gin.H{"Games": rows}) } func getLeaderboard(c *gin.Context) { - // Simple metric: wins = games where player's side has higher score - type Row struct { - Username, Slug string - Wins, Games int + var users []User + if err := db.Order("elo DESC").Find(&users).Error; err != nil { + c.String(http.StatusInternalServerError, "Error: %v", err) + return } - // Load last 1000 games for performance (simple MVP) - var games []Game - db.Order("created_at desc").Limit(1000).Find(&games) - - // Build map of gameID->winnerSide - winner := map[uint]string{} - for _, g := range games { - side := "" - if g.ScoreA > g.ScoreB { - side = "A" - } else if g.ScoreB > g.ScoreA { - side = "B" - } - winner[g.ID] = side - } - - // Count per user - type Cnt struct{ Wins, Games int } - counts := map[uint]*Cnt{} - users := map[uint]User{} - - for _, g := range games { - var gps []GamePlayer - db.Preload("User").Where("game_id = ?", g.ID).Find(&gps) - for _, gp := range gps { - if counts[gp.UserID] == nil { - counts[gp.UserID] = &Cnt{} - users[gp.UserID] = gp.User - } - counts[gp.UserID].Games++ - if winner[g.ID] != "" && winner[g.ID] == gp.Side { - counts[gp.UserID].Wins++ - } - } - } - - rows := []Row{} - for uid, cnt := range counts { - u := users[uid] - rows = append(rows, Row{Username: u.Username, Slug: u.Slug, Wins: cnt.Wins, Games: cnt.Games}) - } - - // Sort by wins desc, then games desc, then username - // Simple insertion sort to avoid extra imports - for i := 1; i < len(rows); i++ { - j := i - for j > 0 && (rows[j-1].Wins < rows[j].Wins || (rows[j-1].Wins == rows[j].Wins && (rows[j-1].Games < rows[j].Games || (rows[j-1].Games == rows[j].Games && rows[j-1].Username > rows[j].Username)))) { - rows[j-1], rows[j] = rows[j], rows[j-1] - j-- - } - } - - if len(rows) > 100 { - rows = rows[:100] - } - - tm.Render(c, "leaderboard", gin.H{"Rows": rows}) + tm.Render(c, "leaderboard", gin.H{ + "Users": users, + }) } func getUserView(c *gin.Context) { - slug := c.Param("slug") + slug := c.Param("userSlug") u, err := userBySlug(slug) if err != nil { c.String(404, "user not found") return } - own := false - if cu := getSessionUser(c); cu != nil && cu.ID == u.ID { + // Check if own user + var own bool + if slug == u.Slug { own = true } - tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": own}) + tm.Render(c, "user", gin.H{"User": u, "Own": own}) } func getMe(c *gin.Context) { u := getSessionUser(c) - if u == nil { - c.Redirect(302, "/login") - return - } - tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": true}) + tm.Render(c, "user", gin.H{"User": u, "Own": true}) } func postMe(c *gin.Context) { diff --git a/src/session.go b/src/session.go new file mode 100644 index 0000000..5c5480a --- /dev/null +++ b/src/session.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// Middleware injects form data from session into context for templates +func SessionHandlerMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + raw := session.Get("form_data") + + form := map[string]string{} + if raw != nil { + if data, ok := raw.(string); ok { + _ = json.Unmarshal([]byte(data), &form) + } + // clear after first use + session.Delete("form_data") + _ = session.Save() + } + + // make available in context + c.Set("form", form) + + // Set message in context when there is one available in the session + message := session.Get("message") + c.Set("mes", message) + session.Delete("message") + + _ = session.Save() + c.Next() + } +} + +// SaveForm stores submitted POST form data into the session +func SaveForm(c *gin.Context) { + session := sessions.Default(c) + form := map[string]string{} + for key, values := range c.Request.PostForm { + if len(values) > 0 { + form[key] = values[0] + } + } + + if b, err := json.Marshal(form); err == nil { + session.Set("form_data", string(b)) + _ = session.Save() + } +} + +// SaveForm stores submitted POST form data into the session +func SetMessage(c *gin.Context, message string) { + session := sessions.Default(c) + session.Set("message", message) + _ = session.Save() +} + +func setSessionCookie(c *gin.Context, token string) { + // Very long-lived cookie for one year + maxAge := 365 * 24 * 60 * 60 + httpOnly := true + secure := strings.HasPrefix(baseURL, "https://") + sameSite := http.SameSiteLaxMode + c.SetCookie("session", token, maxAge, "/", cookieDomain, secure, httpOnly) + // Workaround to set SameSite explicitly via header + c.Header("Set-Cookie", (&http.Cookie{Name: "session", Value: token, Path: "/", Domain: cookieDomain, MaxAge: maxAge, Secure: secure, HttpOnly: httpOnly, SameSite: sameSite}).String()) +} + +func getSessionUser(c *gin.Context) *User { + cookie, err := c.Cookie("session") + if err != nil || cookie == "" { + return nil + } + var s Session + if err := db.Preload("User").Where("token = ?", cookie).First(&s).Error; err != nil { + return nil + } + return &s.User +} diff --git a/src/templates.go b/src/templates.go index 8dc607d..9d66046 100644 --- a/src/templates.go +++ b/src/templates.go @@ -82,7 +82,12 @@ func (tm *TemplateManager) LoadTemplates() { func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error { print("\nRendering template:", name, "\n") u := getSessionUser(c) + + // Prefil the data for the render data["CurrentUser"] = u + // Try to get information from current context that is filled from the session middleware + data["Form"], _ = c.Get("form") + data["Message"], _ = c.Get("mes") tpl, ok := tm.templates[name] if !ok { @@ -91,7 +96,7 @@ func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error fmt.Print(tm.templates[name]) if err := tpl.ExecuteTemplate(c.Writer, tm.base, data); err != nil { - log.Println("tpl error:", err) + log.Println("template error:", err) c.Status(500) return err } diff --git a/src/utils.go b/src/utils.go index 7e4f6ee..b14db76 100644 --- a/src/utils.go +++ b/src/utils.go @@ -134,36 +134,3 @@ func stripAfterDot(s string) string { } return s } - -func getStatsFromUser(u *User) stats { - var gps []GamePlayer - var games, wins, losses int - db.Where("user_id = ?", u.ID).Find(&gps) - gameMap := map[uint]string{} - for _, gp := range gps { - gameMap[gp.GameID] = gp.Side - } - - if len(gameMap) > 0 { - var gamesL []Game - ids := make([]uint, 0, len(gameMap)) - for gid := range gameMap { - ids = append(ids, gid) - } - db.Find(&gamesL, ids) - for _, g := range gamesL { - games++ - if g.ScoreA == g.ScoreB { - continue - } - if g.ScoreA > g.ScoreB && gameMap[g.ID] == "A" { - wins++ - } else if g.ScoreB > g.ScoreA && gameMap[g.ID] == "B" { - wins++ - } else { - losses++ - } - } - } - return stats{Games: games, Wins: wins, Losses: losses} -} diff --git a/templates/base.html b/templates/base.html index 74482c0..8c3b7df 100644 --- a/templates/base.html +++ b/templates/base.html @@ -8,12 +8,10 @@
Joined {{fmtTime .Viewed.CreatedAt}}
-Game score {{.Stats.Games}}/{{.Stats.Wins}}/{{.Stats.Losses}}
+Joined {{fmtTime .User.CreatedAt}}
+Game score {{.User.GameCount}}/{{.User.WinCount}}/{{.User.LossCount}}
+Current Elo {{.User.Elo}}
{{if .Own}}