Made the elo system work. Colorized the leaderboard. Better data structure. Improved the HTML. Custom form system. Added Sessions
This commit is contained in:
@@ -10,5 +10,5 @@ root = "."
|
|||||||
time = true
|
time = true
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
GIN_MODE = "debug" # change to "release" for production
|
GIN_MODE = "release" # change to "release" for production
|
||||||
APP_PORT = 18765
|
APP_PORT = 18765
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 753 KiB |
@@ -53,26 +53,11 @@ a, a:link, a:visited, a:hover, a:active {
|
|||||||
text-decoration: none; /* remove underline */
|
text-decoration: none; /* remove underline */
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.message {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
font-size: 0.9rem;
|
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;
|
background: #eaf4ff;
|
||||||
border: 1px solid #3498db;
|
border: 1px solid #3498db;
|
||||||
color: #2980b9;
|
color: #2980b9;
|
||||||
|
|||||||
36
go.mod
36
go.mod
@@ -3,17 +3,21 @@ module gitlab.gwdg.de/qrank/qrank
|
|||||||
go 1.24.5
|
go 1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // 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/gin-gonic/gin v1.10.1 // indirect
|
||||||
github.com/go-playground/locales v0.14.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/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // 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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.16.0 // indirect
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
golang.org/x/crypto v0.37.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/postgres v1.6.0 // indirect
|
gorm.io/driver/postgres v1.6.0 // indirect
|
||||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||||
|
|||||||
41
go.sum
41
go.sum
@@ -1,17 +1,29 @@
|
|||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
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.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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
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 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
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 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
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.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/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 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.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 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
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 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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=
|
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/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 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.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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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.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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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/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 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.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/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.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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 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.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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
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 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
25
src/auth.go
25
src/auth.go
@@ -8,19 +8,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getSessionUser(c *gin.Context) *User {
|
func RequireAuthMiddleware() gin.HandlerFunc {
|
||||||
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 {
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if u := getSessionUser(c); u != nil {
|
if u := getSessionUser(c); u != nil {
|
||||||
c.Set("user", u)
|
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) {
|
func findUserByHandle(handle string) (*User, error) {
|
||||||
h := strings.TrimSpace(handle)
|
h := strings.TrimSpace(handle)
|
||||||
if h == "" {
|
if h == "" {
|
||||||
|
|||||||
143
src/elo.go
Normal file
143
src/elo.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
38
src/main.go
38
src/main.go
@@ -6,6 +6,8 @@ import (
|
|||||||
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -50,13 +52,20 @@ func main() {
|
|||||||
}
|
}
|
||||||
lg.Println("using SQLite qrank.db")
|
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)
|
lg.Fatal("migrate:", err)
|
||||||
}
|
}
|
||||||
|
if err := db.SetupJoinTable(&User{}, "Games", &GameUser{}); err != nil {
|
||||||
|
lg.Fatal("setup jointable:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create engine
|
// Create engine
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
store := cookie.NewStore([]byte("secret"))
|
||||||
|
r.Use(sessions.Sessions("mysession", store))
|
||||||
|
r.Use(SessionHandlerMiddleware())
|
||||||
|
|
||||||
// Serve static files from the current directory
|
// Serve static files from the current directory
|
||||||
r.Static("/assets", "./assets")
|
r.Static("/assets", "./assets")
|
||||||
|
|
||||||
@@ -66,20 +75,25 @@ func main() {
|
|||||||
r.POST("/login", postLogin)
|
r.POST("/login", postLogin)
|
||||||
r.GET("/magic", getMagic)
|
r.GET("/magic", getMagic)
|
||||||
|
|
||||||
// Authenticated routes
|
authorized := r.Group("/")
|
||||||
r.GET("/enter", requireAuth(), getEnter)
|
authorized.Use(RequireAuthMiddleware())
|
||||||
r.POST("/enter", requireAuth(), postEnter)
|
{
|
||||||
|
|
||||||
// QR-prepped table routes
|
// Authenticated routes
|
||||||
r.GET("/t/:tslug/enter", requireAuth(), getEnter)
|
authorized.GET("/enter", getEnter)
|
||||||
r.POST("/t/:tslug/enter", requireAuth(), postEnter)
|
authorized.POST("/enter", postEnter)
|
||||||
|
|
||||||
r.GET("/history", requireAuth(), getHistory)
|
// QR-prepped table routes
|
||||||
r.GET("/leaderboard", requireAuth(), getLeaderboard)
|
authorized.GET("/table/:tableSlug", getEnter)
|
||||||
|
authorized.POST("/table/:tableSlug", postEnter)
|
||||||
|
|
||||||
r.GET("/u/:slug", requireAuth(), getUserView)
|
authorized.GET("/history", getHistory)
|
||||||
r.GET("/me", requireAuth(), getMe)
|
authorized.GET("/leaderboard", getLeaderboard)
|
||||||
r.POST("/me", requireAuth(), postMe)
|
|
||||||
|
authorized.GET("/user/:userSlug", getUserView)
|
||||||
|
authorized.GET("/me", getMe)
|
||||||
|
authorized.POST("/me", postMe)
|
||||||
|
}
|
||||||
|
|
||||||
// Start application with port
|
// Start application with port
|
||||||
bind := ":" + port
|
bind := ":" + port
|
||||||
|
|||||||
@@ -2,65 +2,70 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// One knows that the user is active when there exists a session for the user
|
// One knows that the user is active when there exists a session for the user
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
gorm.Model
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
|
|
||||||
Email string `gorm:"uniqueIndex;size:320"`
|
Email string `gorm:"uniqueIndex;size:320"`
|
||||||
Username string `gorm:"uniqueIndex;size:64"`
|
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
|
// Currently no expiry
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
gorm.Model
|
||||||
CreatedAt time.Time
|
UserID uint `gorm:"index"`
|
||||||
Token string `gorm:"uniqueIndex;size:128"`
|
User User
|
||||||
UserID uint `gorm:"index"`
|
|
||||||
User User
|
Token string `gorm:"uniqueIndex;size:128"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginToken struct {
|
type LoginToken struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
gorm.Model
|
||||||
CreatedAt time.Time
|
|
||||||
Token string `gorm:"uniqueIndex;size:128"`
|
Token string `gorm:"uniqueIndex;size:128"`
|
||||||
Email string `gorm:"index;size:320"`
|
Email string `gorm:"index;size:320"`
|
||||||
ExpiresAt time.Time `gorm:"index"`
|
ExpiresAt time.Time `gorm:"index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Table struct {
|
type Table struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
gorm.Model
|
||||||
CreatedAt time.Time
|
Name string
|
||||||
UpdatedAt time.Time
|
Slug string
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
219
src/routes.go
219
src/routes.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,7 +14,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func getEnter(c *gin.Context) {
|
func getEnter(c *gin.Context) {
|
||||||
// Simple render for now
|
|
||||||
tm.Render(c, "enter", gin.H{})
|
tm.Render(c, "enter", gin.H{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +34,9 @@ func postEnter(c *gin.Context) {
|
|||||||
|
|
||||||
// Require a winner
|
// Require a winner
|
||||||
if scoreA == scoreB {
|
if scoreA == scoreB {
|
||||||
tm.Render(c, "enter", gin.H{"Error": "Score A score must be different from score B",
|
SaveForm(c)
|
||||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
SetMessage(c, "There must be a winner")
|
||||||
|
c.Redirect(http.StatusSeeOther, "/enter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +47,9 @@ func postEnter(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
u, err := findUserByHandle(handle)
|
u, err := findUserByHandle(handle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q not found", handle),
|
SaveForm(c)
|
||||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
SetMessage(c, fmt.Sprintf(`Player "%v" does not exist`, handle))
|
||||||
|
c.Redirect(http.StatusSeeOther, "/enter")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
@@ -72,8 +74,9 @@ func postEnter(c *gin.Context) {
|
|||||||
|
|
||||||
// Check for at least one player on each side
|
// Check for at least one player on each side
|
||||||
if b1 == nil && b2 == nil || a1 == nil && a2 == nil {
|
if b1 == nil && b2 == nil || a1 == nil && a2 == nil {
|
||||||
tm.Render(c, "enter", gin.H{"Error": "At least one player required on each side",
|
SaveForm(c)
|
||||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
SetMessage(c, "There must be at least one player on each side")
|
||||||
|
c.Redirect(http.StatusSeeOther, "/enter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,52 +86,78 @@ func postEnter(c *gin.Context) {
|
|||||||
for i, u := range users {
|
for i, u := range users {
|
||||||
if u != nil {
|
if u != nil {
|
||||||
if seen[u.ID] {
|
if seen[u.ID] {
|
||||||
tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q specified multiple times", users[i].Username),
|
SaveForm(c)
|
||||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
SetMessage(c, fmt.Sprintf(`Player "%v" cannot play twice in one game`, users[i].Username))
|
||||||
|
c.Redirect(http.StatusSeeOther, "/enter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen[u.ID] = true
|
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
|
// Create game
|
||||||
g := Game{
|
g := Game{
|
||||||
ScoreA: scoreA,
|
ScoreA: scoreA,
|
||||||
ScoreB: scoreB,
|
ScoreB: scoreB,
|
||||||
TableID: tableID,
|
|
||||||
WinnerIsA: scoreA > scoreB,
|
|
||||||
}
|
}
|
||||||
if err := db.Create(&g).Error; err != nil {
|
if err := db.Create(&g).Error; err != nil {
|
||||||
c.String(http.StatusInternalServerError, "game create error")
|
c.String(http.StatusInternalServerError, "game create error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect players by side
|
var players []Player
|
||||||
players := []struct {
|
var playerbuf []float64
|
||||||
u *User
|
for i, user := range users {
|
||||||
side string
|
if user != nil {
|
||||||
}{
|
var side string
|
||||||
{a1, "A"},
|
if i > 1 {
|
||||||
{a2, "A"},
|
side = "B"
|
||||||
{b1, "B"},
|
} else {
|
||||||
{b2, "B"},
|
side = "A"
|
||||||
|
}
|
||||||
|
players = append(players, Player{u: user, side: side, dElo: 0})
|
||||||
|
playerbuf = append(playerbuf, user.Elo)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range players {
|
team1 := NewTeam(players[:len(players)/2], g.ScoreA)
|
||||||
if p.u != nil {
|
team2 := NewTeam(players[len(players)/2:], g.ScoreB)
|
||||||
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")
|
// Set new elo for all players
|
||||||
return
|
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)
|
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) {
|
func getHistory(c *gin.Context) {
|
||||||
// Load recent games with players
|
// Load recent games
|
||||||
var games []Game
|
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 {
|
type GRow struct {
|
||||||
Game
|
Game
|
||||||
PlayersA []User
|
PlayersA []UserElo
|
||||||
PlayersB []User
|
PlayersB []UserElo
|
||||||
|
WinnerIsA bool
|
||||||
}
|
}
|
||||||
var rows []GRow
|
var rows []GRow
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
var gps []GamePlayer
|
var gps []GameUser
|
||||||
db.Preload("User").Where("game_id = ?", g.ID).Find(&gps)
|
db.Model(&GameUser{}).Preload("User").Where("game_id = ?", g.ID).Find(&gps)
|
||||||
var a, b []User
|
var a, b []UserElo
|
||||||
for _, gp := range gps {
|
for _, gp := range gps {
|
||||||
if gp.Side == "A" {
|
if gp.Side == "A" {
|
||||||
a = append(a, gp.User)
|
a = append(a, UserElo{gp.User, roundFloat(gp.DeltaElo, 2)})
|
||||||
} else {
|
} 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})
|
tm.Render(c, "history", gin.H{"Games": rows})
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLeaderboard(c *gin.Context) {
|
func getLeaderboard(c *gin.Context) {
|
||||||
// Simple metric: wins = games where player's side has higher score
|
var users []User
|
||||||
type Row struct {
|
if err := db.Order("elo DESC").Find(&users).Error; err != nil {
|
||||||
Username, Slug string
|
c.String(http.StatusInternalServerError, "Error: %v", err)
|
||||||
Wins, Games int
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load last 1000 games for performance (simple MVP)
|
tm.Render(c, "leaderboard", gin.H{
|
||||||
var games []Game
|
"Users": users,
|
||||||
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})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserView(c *gin.Context) {
|
func getUserView(c *gin.Context) {
|
||||||
slug := c.Param("slug")
|
slug := c.Param("userSlug")
|
||||||
u, err := userBySlug(slug)
|
u, err := userBySlug(slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(404, "user not found")
|
c.String(404, "user not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
own := false
|
// Check if own user
|
||||||
if cu := getSessionUser(c); cu != nil && cu.ID == u.ID {
|
var own bool
|
||||||
|
if slug == u.Slug {
|
||||||
own = true
|
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) {
|
func getMe(c *gin.Context) {
|
||||||
u := getSessionUser(c)
|
u := getSessionUser(c)
|
||||||
if u == nil {
|
tm.Render(c, "user", gin.H{"User": u, "Own": true})
|
||||||
c.Redirect(302, "/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": true})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func postMe(c *gin.Context) {
|
func postMe(c *gin.Context) {
|
||||||
|
|||||||
85
src/session.go
Normal file
85
src/session.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -82,7 +82,12 @@ func (tm *TemplateManager) LoadTemplates() {
|
|||||||
func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error {
|
func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error {
|
||||||
print("\nRendering template:", name, "\n")
|
print("\nRendering template:", name, "\n")
|
||||||
u := getSessionUser(c)
|
u := getSessionUser(c)
|
||||||
|
|
||||||
|
// Prefil the data for the render
|
||||||
data["CurrentUser"] = u
|
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]
|
tpl, ok := tm.templates[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -91,7 +96,7 @@ func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error
|
|||||||
fmt.Print(tm.templates[name])
|
fmt.Print(tm.templates[name])
|
||||||
|
|
||||||
if err := tpl.ExecuteTemplate(c.Writer, tm.base, data); err != nil {
|
if err := tpl.ExecuteTemplate(c.Writer, tm.base, data); err != nil {
|
||||||
log.Println("tpl error:", err)
|
log.Println("template error:", err)
|
||||||
c.Status(500)
|
c.Status(500)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/utils.go
33
src/utils.go
@@ -134,36 +134,3 @@ func stripAfterDot(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
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}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,12 +8,10 @@
|
|||||||
<title>QRank</title>
|
<title>QRank</title>
|
||||||
|
|
||||||
<!-- Also apple support -->
|
<!-- Also apple support -->
|
||||||
<link rel="icon" type="image/png" href="/assets/favicon.ico">
|
<link rel="icon" type="image/png" href="/assets/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/assets/favicon.ico">
|
|
||||||
<link rel="shortcut icon" href="/assets/favicon.ico">
|
|
||||||
|
|
||||||
<!-- Only one stylesheet for now -->
|
<!-- Only one stylesheet for now -->
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" type="text/css" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -38,15 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Information messages for the user -->
|
<!-- Information messages for the user -->
|
||||||
{{if .Error}}
|
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||||
<div class="alert alert-error">{{.Error}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .Success}}
|
|
||||||
<div class="alert alert-success">{{.Success}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .Message}}
|
|
||||||
<div class="alert alert-info">{{.Message}}</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- This is shorthand for a define along with a template -->
|
<!-- This is shorthand for a define along with a template -->
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
|
|||||||
@@ -12,25 +12,25 @@
|
|||||||
<div style="flex:1;min-width:180px">
|
<div style="flex:1;min-width:180px">
|
||||||
<h3>Team A</h3>
|
<h3>Team A</h3>
|
||||||
<label class="label">Player A1</label>
|
<label class="label">Player A1</label>
|
||||||
<input class="input" name="a1" {{if .a1}}value="{{.a1}}"{{else}}value="{{.CurrentUser.Username}}"{{end}} placeholder="username or email">
|
<input class="input" name="a1" {{if (index .Form "a1")}}value="{{index .Form "a1"}}"{{else}}value="{{.CurrentUser.Username}}"{{end}} placeholder="username or email">
|
||||||
<div style="height:6px"></div>
|
<div style="height:6px"></div>
|
||||||
<label class="label">Player A2 (optional)</label>
|
<label class="label">Player A2 (optional)</label>
|
||||||
<input class="input" name="a2" {{if .a2}}value="{{.a2}}"{{end}} placeholder="username or email">
|
<input class="input" name="a2" value="{{index .Form "a2"}}" placeholder="username or email">
|
||||||
<div style="height:12px"></div>
|
<div style="height:12px"></div>
|
||||||
<label class="label">Score A</label>
|
<label class="label">Score A</label>
|
||||||
<input class="input" type="number" name="scoreA" {{if .scoreA}}value="{{.scoreA}}"{{else}}value="0"{{end}} min="0" max="10" required>
|
<input class="input" type="number" name="scoreA" value="{{index .Form "scoreA"}}" placeholder="0" min="0" max="10" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="flex:1;min-width:180px">
|
<div style="flex:1;min-width:180px">
|
||||||
<h3>Team B</h3>
|
<h3>Team B</h3>
|
||||||
<label class="label">Player B1</label>
|
<label class="label">Player B1</label>
|
||||||
<input class="input" name="b1" {{if .b1}}value="{{.b1}}"{{end}} placeholder="username or email">
|
<input class="input" name="b1" value="{{index .Form "b1"}}" placeholder="username or email">
|
||||||
<div style="height:6px"></div>
|
<div style="height:6px"></div>
|
||||||
<label class="label">Player B2 (optional)</label>
|
<label class="label">Player B2 (optional)</label>
|
||||||
<input class="input" name="b2" {{if .b2}}value="{{.b2}}"{{end}} placeholder="username or email">
|
<input class="input" name="b2" value="{{index .Form "b2"}}" placeholder="username or email">
|
||||||
<div style="height:12px"></div>
|
<div style="height:12px"></div>
|
||||||
<label class="label">Score B</label>
|
<label class="label">Score B</label>
|
||||||
<input class="input" type="number" name="scoreB" {{if .scoreB}}value="{{.scoreB}}"{{else}}value="0"{{end}} min="0" max="10" required>
|
<input class="input" type="number" name="scoreB" value="{{index .Form "scoreB"}}" placeholder="0" min="0" max="10" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
<div class="small">{{fmtTime .CreatedAt}}</div>
|
<div class="small">{{fmtTime .CreatedAt}}</div>
|
||||||
<div>
|
<div>
|
||||||
{{if .Table}}<span class="badge">{{.Table.Name}}</span> {{end}}
|
{{if .Table}}<span class="badge">{{.Table.Name}}</span> {{end}}
|
||||||
{{if .WinnerIsA}}{{range .PlayersA}}{{.Username}} {{end}}{{.ScoreA}}/{{.ScoreB}}{{else}}{{range .PlayersB}}{{.Username}} {{end}}{{.ScoreB}}/{{.ScoreA}}{{end}}
|
{{if .WinnerIsA}}<span style="color: goldenrod;"> {{range .PlayersA}}{{.Username}} {{end}}{{.ScoreA}}</span>/{{.ScoreB}}
|
||||||
|
{{else}} <span style="color: goldenrod;"> {{range .PlayersB}}{{.Username}} {{end}}{{.ScoreB}}</span>/{{.ScoreA}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="small">
|
<div class="small">
|
||||||
{{range .PlayersA}}@{{.Username}} {{end}}vs {{range .PlayersB}}@{{.Username}} {{end}}
|
{{range .PlayersA}}@{{.Username}} <span style="color: chocolate;">{{.DeltaElo}}</span> {{end}}vs {{range .PlayersB}}@{{.Username}} <span style="color: chocolate;">{{.DeltaElo}}</span> {{end}}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Leaderboard (by wins)</h2>
|
<h2>Leaderboard</h2>
|
||||||
<ol>
|
<ol>
|
||||||
{{range .Rows}}
|
{{range .Users}}
|
||||||
<li>@<a href="/u/{{.Slug}}">{{.Username}}</a> - {{.Wins}} wins ({{.Games}} games)</li>
|
<li><a href="/user/{{.Slug}}">@{{.Username}}</a> - {{.Elo}}</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li class="small">No players yet.</li>
|
<li class="small">No players yet.</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>@{{.Viewed.Username}}</h2>
|
<h2>@{{.User.Username}}</h2>
|
||||||
<p class="small">Joined {{fmtTime .Viewed.CreatedAt}}</p>
|
<p class="small">Joined {{fmtTime .User.CreatedAt}}</p>
|
||||||
<p>Game score {{.Stats.Games}}/{{.Stats.Wins}}/{{.Stats.Losses}}</p>
|
<p>Game score {{.User.GameCount}}/{{.User.WinCount}}/{{.User.LossCount}}</p>
|
||||||
|
<p>Current Elo {{.User.Elo}}</p>
|
||||||
{{if .Own}}
|
{{if .Own}}
|
||||||
<hr>
|
<hr>
|
||||||
<h3>Update your profile</h3>
|
<h3>Update your profile</h3>
|
||||||
<form method="POST" action="/me">
|
<form method="POST" action="/me">
|
||||||
<label class="label">Username</label>
|
<label class="label">Username</label>
|
||||||
<input class="input" name="username" value="{{.Viewed.Username}}" required>
|
<input class="input" name="username" value="{{.User.Username}}" required>
|
||||||
<div style="height:8px"></div>
|
<div style="height:8px"></div>
|
||||||
<button class="btn btn-primary">Save</button>
|
<button class="btn btn-primary">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user