Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e222a7dcd7
|
||
|
|
d9a2abb44f
|
||
|
|
3cce2158e9
|
@@ -23,6 +23,8 @@ FRONTEND_ORIGINS=https://localhost,https://127.0.0.1
|
|||||||
# Keep false unless you intentionally want fail-open rate limits / captcha.
|
# Keep false unless you intentionally want fail-open rate limits / captcha.
|
||||||
DEV_MODE=false
|
DEV_MODE=false
|
||||||
SEED_CURRICULUM=false
|
SEED_CURRICULUM=false
|
||||||
|
# One registration per client IP. Keep false behind NAT / shared egress (default).
|
||||||
|
REGISTRATION_ENFORCE_IP=false
|
||||||
# METRICS_TOKEN=
|
# METRICS_TOKEN=
|
||||||
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128
|
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ FRONTEND_ORIGINS=https://app.example.com
|
|||||||
|
|
||||||
DEV_MODE=false
|
DEV_MODE=false
|
||||||
SEED_CURRICULUM=false
|
SEED_CURRICULUM=false
|
||||||
|
# Keep false behind NAT until the edge forwards real client IPs.
|
||||||
|
REGISTRATION_ENFORCE_IP=false
|
||||||
METRICS_TOKEN=replace-with-metrics-scrape-token
|
METRICS_TOKEN=replace-with-metrics-scrape-token
|
||||||
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -12,10 +12,10 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/redis/go-redis/v9 v9.22.0
|
github.com/redis/go-redis/v9 v9.22.0
|
||||||
github.com/rs/cors v1.11.1
|
github.com/rs/cors v1.11.1
|
||||||
go.opentelemetry.io/otel v1.35.0
|
go.opentelemetry.io/otel v1.45.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0
|
go.opentelemetry.io/otel/sdk v1.45.0
|
||||||
go.opentelemetry.io/otel/trace v1.35.0
|
go.opentelemetry.io/otel/trace v1.45.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,26 +34,26 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
|
||||||
github.com/aws/smithy-go v1.28.1 // indirect
|
github.com/aws/smithy-go v1.28.1 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.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/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||||
google.golang.org/grpc v1.71.0 // indirect
|
google.golang.org/grpc v1.83.2 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+34
-32
@@ -38,16 +38,16 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
|||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
@@ -60,8 +60,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||||
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=
|
||||||
@@ -85,24 +85,24 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
|
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
|
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
@@ -117,14 +117,16 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
|||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
|
||||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
|
||||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
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=
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
|
writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := routeID(r, "id")
|
||||||
|
if id <= 0 {
|
||||||
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := s.store.GetUserByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": u})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query().Get("q")
|
query := r.URL.Query().Get("q")
|
||||||
role := r.URL.Query().Get("role")
|
role := r.URL.Query().Get("role")
|
||||||
@@ -389,27 +403,17 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
|
|||||||
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
target, err := s.store.GetUserByID(r.Context(), id)
|
if _, err := s.store.GetUserByID(r.Context(), id); err != nil {
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
SecurityAnswer string `json:"securityAnswer"`
|
NewPassword string `json:"newPassword"`
|
||||||
NewPassword string `json:"newPassword"`
|
|
||||||
}
|
}
|
||||||
if err := bodyJSON(r, &req); err != nil {
|
if err := bodyJSON(r, &req); err != nil {
|
||||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if target.SecurityAnswerHash == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "برای این کاربر سوال امنیتی تعریف نشده است")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.auth.CheckPassword(target.SecurityAnswerHash, strings.ToLower(strings.TrimSpace(req.SecurityAnswer))) {
|
|
||||||
writeErr(w, http.StatusForbidden, "جواب سوال امنیتی نادرست است")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if msg := validatePasswordOnly(req.NewPassword); msg != "" {
|
if msg := validatePasswordOnly(req.NewPassword); msg != "" {
|
||||||
writeErr(w, http.StatusBadRequest, msg)
|
writeErr(w, http.StatusBadRequest, msg)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی آواتار ممکن نشد")
|
writeErr(w, http.StatusInternalServerError, "بهروزرسانی آواتار ممکن نشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.signUserMedia(user)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
captchaTTL = 5 * time.Minute
|
captchaTTL = 2 * time.Minute
|
||||||
captchaKeyPrefix = "pargar:captcha:"
|
captchaKeyPrefix = "pargar:captcha:"
|
||||||
captchaRateLimit int64 = 60
|
captchaRateLimit int64 = 60
|
||||||
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
@@ -260,6 +260,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"challengeId": "dev",
|
"challengeId": "dev",
|
||||||
"imageBase64": img,
|
"imageBase64": img,
|
||||||
|
"expiresIn": int(captchaTTL.Seconds()),
|
||||||
}
|
}
|
||||||
if s.cfg.CaptchaExposeAnswer {
|
if s.cfg.CaptchaExposeAnswer {
|
||||||
resp["answer"] = "A2B3"
|
resp["answer"] = "A2B3"
|
||||||
@@ -297,6 +298,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"challengeId": id,
|
"challengeId": id,
|
||||||
"imageBase64": img,
|
"imageBase64": img,
|
||||||
|
"expiresIn": int(captchaTTL.Seconds()),
|
||||||
}
|
}
|
||||||
// Never expose plaintext answers in production. Local tests may opt in via
|
// Never expose plaintext answers in production. Local tests may opt in via
|
||||||
// CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true.
|
// CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true.
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ func TestCaptchaImageChallenge(t *testing.T) {
|
|||||||
if id == "" || img == "" || answer == "" {
|
if id == "" || img == "" || answer == "" {
|
||||||
t.Fatalf("captcha missing fields: %v", body)
|
t.Fatalf("captcha missing fields: %v", body)
|
||||||
}
|
}
|
||||||
|
exp, ok := body["expiresIn"].(float64)
|
||||||
|
if !ok || exp < 60 || exp > 180 {
|
||||||
|
t.Fatalf("expiresIn should be a short window: %v", body["expiresIn"])
|
||||||
|
}
|
||||||
if _, ok := body["question"]; ok {
|
if _, ok := body["question"]; ok {
|
||||||
t.Fatalf("legacy question field should be gone: %v", body)
|
t.Fatalf("legacy question field should be gone: %v", body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,12 +228,22 @@ func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http
|
|||||||
|
|
||||||
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
status := r.URL.Query().Get("status")
|
status := r.URL.Query().Get("status")
|
||||||
orders, err := s.store.ListPhysicalOrders(r.Context(), status)
|
p := parsePageParams(r)
|
||||||
|
orders, total, err := s.store.ListPhysicalOrders(r.Context(), status, p.PageSize, p.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارشها ممکن نشد")
|
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارشها ممکن نشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "physical": s.physicalSettings(r.Context())})
|
if orders == nil {
|
||||||
|
orders = []models.CertificatePhysicalOrder{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"orders": orders,
|
||||||
|
"total": total,
|
||||||
|
"page": p.Page,
|
||||||
|
"pageSize": p.PageSize,
|
||||||
|
"physical": s.physicalSettings(r.Context()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) {
|
|||||||
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin {
|
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
s.signUserMedia(&m)
|
||||||
safe = append(safe, map[string]any{
|
safe = append(safe, map[string]any{
|
||||||
"id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role,
|
"id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role,
|
||||||
"avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto,
|
"avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto,
|
||||||
@@ -111,10 +112,16 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
|
|||||||
unread++
|
unread++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
photo := p.AvatarPhoto
|
||||||
|
if photo != "" {
|
||||||
|
tmp := &models.User{ID: p.ID, AvatarPhoto: photo}
|
||||||
|
s.signUserMedia(tmp)
|
||||||
|
photo = tmp.AvatarPhoto
|
||||||
|
}
|
||||||
conv := conversation{
|
conv := conversation{
|
||||||
Partner: map[string]any{
|
Partner: map[string]any{
|
||||||
"id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role,
|
"id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role,
|
||||||
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": p.AvatarPhoto,
|
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
|
||||||
"online": s.hub.Online(p.ID),
|
"online": s.hub.Online(p.ID),
|
||||||
},
|
},
|
||||||
UnreadCount: unread,
|
UnreadCount: unread,
|
||||||
@@ -137,6 +144,7 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_ = s.markIncomingChatRead(r.Context(), u, partner)
|
||||||
|
|
||||||
if raw := r.URL.Query().Get("around"); raw != "" {
|
if raw := r.URL.Query().Get("around"); raw != "" {
|
||||||
aroundID, err := strconv.ParseInt(raw, 10, 64)
|
aroundID, err := strconv.ParseInt(raw, 10, 64)
|
||||||
@@ -156,7 +164,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
|
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
|
||||||
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
|
|
||||||
s.resignChatMessages(msgs)
|
s.resignChatMessages(msgs)
|
||||||
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
|
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
|
||||||
if pinned == nil {
|
if pinned == nil {
|
||||||
@@ -197,7 +204,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
|
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
|
||||||
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
|
|
||||||
s.resignChatMessages(msgs)
|
s.resignChatMessages(msgs)
|
||||||
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
|
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
|
||||||
if pinned == nil {
|
if pinned == nil {
|
||||||
@@ -337,6 +343,73 @@ func (s *Server) handleChatReact(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := currentUser(r)
|
||||||
|
partner := routeID(r, "partner")
|
||||||
|
if !s.canChatWith(r.Context(), u, partner) {
|
||||||
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgID := routeID(r, "id")
|
||||||
|
var req struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
Attachment *models.Attachment `json:"attachment,omitempty"`
|
||||||
|
}
|
||||||
|
if err := bodyJSON(r, &req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Body = strings.TrimSpace(req.Body)
|
||||||
|
if len(req.Body) > 4000 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
msg *models.ChatMessage
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if req.Attachment != nil {
|
||||||
|
att := req.Attachment
|
||||||
|
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
|
||||||
|
if att.Type != "image" && att.Type != "video" && att.Type != "audio" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "فقط عکس، ویدیو یا صدا را میتوان جایگزین کرد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := mediaKeyFromURL(att.URL)
|
||||||
|
if key == "" || !mediaKeyOwnedByUser(key, u.ID) {
|
||||||
|
writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q := strings.Index(att.URL, "?"); q >= 0 {
|
||||||
|
att.URL = att.URL[:q]
|
||||||
|
}
|
||||||
|
if att.Name == "" {
|
||||||
|
att.Name = "file"
|
||||||
|
}
|
||||||
|
msg, err = s.store.ReplaceChatAttachment(r.Context(), u.ID, partner, msgID, u.Role, req.Body, att.Type, att.URL, att.Name, att.Size)
|
||||||
|
} else {
|
||||||
|
if req.Body == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg, err = s.store.EditChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body)
|
||||||
|
}
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.resignChatMessage(msg)
|
||||||
|
event := map[string]any{"type": "chat_edit", "item": msg, "from": u.ID}
|
||||||
|
s.hub.Push(u.ID, event)
|
||||||
|
s.hub.Push(partner, event)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
u := currentUser(r)
|
u := currentUser(r)
|
||||||
partner := routeID(r, "partner")
|
partner := routeID(r, "partner")
|
||||||
@@ -473,13 +546,25 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.MarkChatRead(r.Context(), u.ID, partner); err != nil {
|
if err := s.markIncomingChatRead(r.Context(), u, partner); err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی وضعیت خواندهشدن ممکن نشد")
|
writeErr(w, http.StatusInternalServerError, "بهروزرسانی وضعیت خواندهشدن ممکن نشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User, partnerID int64) error {
|
||||||
|
if reader == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := s.store.MarkChatRead(ctx, reader.ID, partnerID, reader.Role)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.hub.Push(partnerID, map[string]any{"type": "chat_read", "from": reader.ID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
|
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
|
||||||
if actor == nil || partnerID <= 0 || actor.ID == partnerID {
|
if actor == nil || partnerID <= 0 || actor.ID == partnerID {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type chatExportMessage struct {
|
|||||||
ReplyTo *int64 `json:"replyTo,omitempty"`
|
ReplyTo *int64 `json:"replyTo,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
|
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
|
||||||
|
EditedAt *time.Time `json:"editedAt,omitempty"`
|
||||||
Attachment *chatExportAttachment `json:"attachment,omitempty"`
|
Attachment *chatExportAttachment `json:"attachment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +184,7 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
|
|||||||
ReplyTo: m.ReplyTo,
|
ReplyTo: m.ReplyTo,
|
||||||
Pinned: m.PinnedAt != nil,
|
Pinned: m.PinnedAt != nil,
|
||||||
PinnedAt: m.PinnedAt,
|
PinnedAt: m.PinnedAt,
|
||||||
|
EditedAt: m.EditedAt,
|
||||||
}
|
}
|
||||||
if m.Attachment != nil {
|
if m.Attachment != nil {
|
||||||
att := &chatExportAttachment{
|
att := &chatExportAttachment{
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestUniquePhone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
|
func TestAdminResetPassword(t *testing.T) {
|
||||||
e := setup(t)
|
e := setup(t)
|
||||||
admin := login(t, e, bootstrapAdminEmail)
|
admin := login(t, e, bootstrapAdminEmail)
|
||||||
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
|
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
|
||||||
@@ -59,14 +59,14 @@ func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
|
|||||||
uid := int64(body["user"].(map[string]any)["id"].(float64))
|
uid := int64(body["user"].(map[string]any)["id"].(float64))
|
||||||
|
|
||||||
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
|
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
|
||||||
"securityAnswer": "اشتباه", "newPassword": "newpass123",
|
"newPassword": "short",
|
||||||
})
|
})
|
||||||
if code != http.StatusForbidden {
|
if code != http.StatusBadRequest {
|
||||||
t.Fatalf("wrong answer should 403, got %d", code)
|
t.Fatalf("short password: %d", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
|
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
|
||||||
"securityAnswer": "تهران", "newPassword": "newpass123",
|
"newPassword": "newpass123",
|
||||||
})
|
})
|
||||||
if code != http.StatusOK {
|
if code != http.StatusOK {
|
||||||
t.Fatalf("reset: %d %v", code, body)
|
t.Fatalf("reset: %d %v", code, body)
|
||||||
|
|||||||
@@ -130,16 +130,8 @@ func (s *Server) handleRegisterInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := clientAddress(r)
|
ip, ok := s.registrationClientIP(w, r)
|
||||||
if ip == "" || ip == "unknown" {
|
if !ok {
|
||||||
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
|
||||||
return
|
|
||||||
} else if taken {
|
|
||||||
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,8 +284,15 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) {
|
||||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||||
|
switch models.InviteStatus(status) {
|
||||||
|
case "", models.InviteActive, models.InvitePaused, models.InviteRevoked, models.InviteExhausted:
|
||||||
|
default:
|
||||||
|
writeErr(w, http.StatusBadRequest, "وضعیت نامعتبر است")
|
||||||
|
return
|
||||||
|
}
|
||||||
p := parsePageParams(r)
|
p := parsePageParams(r)
|
||||||
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, p.PageSize, p.Offset)
|
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, status, p.PageSize, p.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "بارگذاری لینکهای عضویت ممکن نشد")
|
writeErr(w, http.StatusInternalServerError, "بارگذاری لینکهای عضویت ممکن نشد")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ func TestInviteRevoke(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInviteDuplicatePhoneAndIP(t *testing.T) {
|
func TestInviteDuplicatePhoneAndIP(t *testing.T) {
|
||||||
|
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||||
e := setup(t)
|
e := setup(t)
|
||||||
inv := createInvite(t, e, 5, "", nil)
|
inv := createInvite(t, e, 5, "", nil)
|
||||||
token := inv["token"].(string)
|
token := inv["token"].(string)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
s.signUserMedia(usr)
|
||||||
entries = append(entries, lbEntry{
|
entries = append(entries, lbEntry{
|
||||||
UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1,
|
UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1,
|
||||||
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
|
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"pargar/backend/internal/observability"
|
"pargar/backend/internal/observability"
|
||||||
|
"pargar/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -21,12 +23,25 @@ func (s *Server) handleLearningStudents(w http.ResponseWriter, r *http.Request)
|
|||||||
ctx, span := observability.StartSpan(r.Context(), "admin.learning.students")
|
ctx, span := observability.StartSpan(r.Context(), "admin.learning.students")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
q := r.URL.Query().Get("q")
|
q := r.URL.Query().Get("q")
|
||||||
rows, err := s.store.ListStudentLearning(ctx, q)
|
filter := r.URL.Query().Get("filter")
|
||||||
|
switch filter {
|
||||||
|
case "active", "locked":
|
||||||
|
default:
|
||||||
|
filter = ""
|
||||||
|
}
|
||||||
|
sort := r.URL.Query().Get("sort")
|
||||||
|
switch sort {
|
||||||
|
case "progress", "streak", "activity":
|
||||||
|
default:
|
||||||
|
sort = "xp"
|
||||||
|
}
|
||||||
|
p := parsePageParams(r)
|
||||||
|
rows, total, err := s.store.ListStudentLearning(ctx, q, filter, sort, p.PageSize, p.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد")
|
writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"students": rows})
|
writePage(w, rows, total, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -34,9 +49,13 @@ func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
id := routeID(r, "id")
|
id := routeID(r, "id")
|
||||||
detail, err := s.store.UserLearningDetail(ctx, id)
|
detail, err := s.store.UserLearningDetail(ctx, id)
|
||||||
if err != nil {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "بارگذاری جزئیات یادگیری ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"learning": detail})
|
writeJSON(w, http.StatusOK, map[string]any{"learning": detail})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,28 @@ import (
|
|||||||
"pargar/backend/internal/models"
|
"pargar/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// mediaSignWindow keeps one signature for a stretch of time. The same object
|
||||||
|
// then keeps the same URL, so clients do not reload a photo that did not change.
|
||||||
|
const mediaSignWindow = 15 * time.Minute
|
||||||
|
|
||||||
|
func stableMediaExpiry(now time.Time, ttl, window time.Duration) int64 {
|
||||||
|
if window <= 0 {
|
||||||
|
window = mediaSignWindow
|
||||||
|
}
|
||||||
|
if ttl < window {
|
||||||
|
ttl = window
|
||||||
|
}
|
||||||
|
w := int64(window.Seconds())
|
||||||
|
slot := now.Unix() / w
|
||||||
|
return (slot+1)*w + int64(ttl.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) signMediaURL(key string) string {
|
func (s *Server) signMediaURL(key string) string {
|
||||||
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
|
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
exp := time.Now().Add(s.cfg.MediaURLTTL).Unix()
|
exp := stableMediaExpiry(time.Now(), s.cfg.MediaURLTTL, mediaSignWindow)
|
||||||
sig := mediaHMAC(s.cfg.JWTSecret, key, exp)
|
sig := mediaHMAC(s.cfg.JWTSecret, key, exp)
|
||||||
return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s",
|
return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s",
|
||||||
s.cfg.PublicURL,
|
s.cfg.PublicURL,
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ import (
|
|||||||
"pargar/backend/internal/config"
|
"pargar/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestStableMediaExpiryHoldsInsideWindow(t *testing.T) {
|
||||||
|
start := time.Unix((1_700_000_000/900)*900, 0)
|
||||||
|
ttl := time.Hour
|
||||||
|
window := 15 * time.Minute
|
||||||
|
a := stableMediaExpiry(start, ttl, window)
|
||||||
|
b := stableMediaExpiry(start.Add(14*time.Minute), ttl, window)
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("same window should keep one expiry: %d vs %d", a, b)
|
||||||
|
}
|
||||||
|
c := stableMediaExpiry(start.Add(15*time.Minute), ttl, window)
|
||||||
|
if c == a {
|
||||||
|
t.Fatal("next window should mint a new expiry")
|
||||||
|
}
|
||||||
|
if c < a {
|
||||||
|
t.Fatal("expiry should move forward")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMediaSignatureRoundTrip(t *testing.T) {
|
func TestMediaSignatureRoundTrip(t *testing.T) {
|
||||||
s := &Server{cfg: &config.Config{JWTSecret: "test-secret-at-least-32-chars-long!!", PublicURL: "http://localhost", MediaURLTTL: time.Hour}}
|
s := &Server{cfg: &config.Config{JWTSecret: "test-secret-at-least-32-chars-long!!", PublicURL: "http://localhost", MediaURLTTL: time.Hour}}
|
||||||
url := s.signMediaURL("videos/lesson-1.mp4")
|
url := s.signMediaURL("videos/lesson-1.mp4")
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"pargar/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := routeID(r, "id")
|
||||||
|
if id <= 0 {
|
||||||
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := s.store.GetUserByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.signUserMedia(u)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"id": u.ID,
|
||||||
|
"name": u.Name,
|
||||||
|
"username": u.Username,
|
||||||
|
"role": u.Role,
|
||||||
|
"xp": u.XP,
|
||||||
|
"hearts": u.Hearts,
|
||||||
|
"streakCurrent": u.StreakCurrent,
|
||||||
|
"streakLongest": u.StreakLongest,
|
||||||
|
"avatarVariant": u.AvatarVariant,
|
||||||
|
"avatarPalette": u.AvatarPalette,
|
||||||
|
"avatarPhoto": u.AvatarPhoto,
|
||||||
|
"isLocked": u.IsLocked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Public self-service password recovery is disabled for students.
|
// Public self-service password recovery is disabled for students.
|
||||||
// Staff reset passwords from the admin panel (requires the account security answer).
|
// Staff set a new password from the admin profile. Public recovery stays off.
|
||||||
|
|
||||||
func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) {
|
||||||
writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است")
|
writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است")
|
||||||
|
|||||||
@@ -27,6 +27,29 @@ func (s *Server) registrationRequireWhitelist(r *http.Request) bool {
|
|||||||
return s.settingTruthy(r, "registration_require_whitelist", true)
|
return s.settingTruthy(r, "registration_require_whitelist", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registrationClientIP returns the IP to bind to a registration when enforcement is on.
|
||||||
|
// When REGISTRATION_ENFORCE_IP is false (default), returns "" so NAT / shared egress is ignored.
|
||||||
|
func (s *Server) registrationClientIP(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
if s.cfg == nil || !s.cfg.RegistrationEnforceIP {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
ip := clientAddress(r)
|
||||||
|
if ip == "" || ip == "unknown" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
taken, err := s.store.HasRegistrationIP(r.Context(), ip)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return ip, true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRegisterStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRegisterStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"enabled": s.registrationEnabled(r),
|
"enabled": s.registrationEnabled(r),
|
||||||
@@ -83,16 +106,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := clientAddress(r)
|
ip, ok := s.registrationClientIP(w, r)
|
||||||
if ip == "" || ip == "unknown" {
|
if !ok {
|
||||||
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
|
||||||
return
|
|
||||||
} else if taken {
|
|
||||||
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ func TestRegisterDuplicatePhone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterSameIPRejected(t *testing.T) {
|
func TestRegisterSameIPRejected(t *testing.T) {
|
||||||
|
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||||
e := setup(t)
|
e := setup(t)
|
||||||
enableRegistration(t, e)
|
enableRegistration(t, e)
|
||||||
whitelistPhones(t, e, "09121110007", "09121110008")
|
whitelistPhones(t, e, "09121110007", "09121110008")
|
||||||
@@ -278,7 +279,27 @@ func TestRegisterSameIPRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterSameIPAllowedWhenEnforcementOff(t *testing.T) {
|
||||||
|
t.Setenv("REGISTRATION_ENFORCE_IP", "false")
|
||||||
|
e := setup(t)
|
||||||
|
enableRegistration(t, e)
|
||||||
|
whitelistPhones(t, e, "09121110007", "09121110008")
|
||||||
|
|
||||||
|
headers := map[string]string{"X-Forwarded-For": "203.0.113.40"}
|
||||||
|
code, body := e.doHeaders(t, "POST", "/api/auth/register", "", headers,
|
||||||
|
registerBody(t, e, "09121110007", "reg-ipoff1@test.dev", "regipoff1"))
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("first: %d %v", code, body)
|
||||||
|
}
|
||||||
|
code, body = e.doHeaders(t, "POST", "/api/auth/register", "", headers,
|
||||||
|
registerBody(t, e, "09121110008", "reg-ipoff2@test.dev", "regipoff2"))
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("same IP must be allowed when REGISTRATION_ENFORCE_IP=false: %d %v", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterUsesForwardedIP(t *testing.T) {
|
func TestRegisterUsesForwardedIP(t *testing.T) {
|
||||||
|
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||||
e := setup(t)
|
e := setup(t)
|
||||||
enableRegistration(t, e)
|
enableRegistration(t, e)
|
||||||
whitelistPhones(t, e, "09121110009", "09121110010", "09121110011")
|
whitelistPhones(t, e, "09121110009", "09121110010", "09121110011")
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ func newRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("GET /api/chats/conversations", s.requireAuth(s.handleConversations))
|
mux.HandleFunc("GET /api/chats/conversations", s.requireAuth(s.handleConversations))
|
||||||
mux.HandleFunc("GET /api/chats/{partner}/messages", s.requireAuth(s.handleChatMessages))
|
mux.HandleFunc("GET /api/chats/{partner}/messages", s.requireAuth(s.handleChatMessages))
|
||||||
mux.HandleFunc("POST /api/chats/{partner}/messages", s.requireAuth(s.handleChatSend))
|
mux.HandleFunc("POST /api/chats/{partner}/messages", s.requireAuth(s.handleChatSend))
|
||||||
|
mux.HandleFunc("PUT /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatEdit))
|
||||||
mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete))
|
mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete))
|
||||||
|
mux.HandleFunc("GET /api/users/{id}", s.requireAuth(s.handlePublicProfile))
|
||||||
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/reaction", s.requireAuth(s.handleChatReact))
|
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/reaction", s.requireAuth(s.handleChatReact))
|
||||||
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/pin", s.requireAuth(s.handleChatPin))
|
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/pin", s.requireAuth(s.handleChatPin))
|
||||||
mux.HandleFunc("POST /api/chats/upload", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleChatUpload)))
|
mux.HandleFunc("POST /api/chats/upload", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleChatUpload)))
|
||||||
@@ -89,6 +91,7 @@ func newRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("GET /api/admin/users/{id}/learning", staff(s.handleUserLearning))
|
mux.HandleFunc("GET /api/admin/users/{id}/learning", staff(s.handleUserLearning))
|
||||||
// Account administration is admin-only; mentors may unlock locked students only.
|
// Account administration is admin-only; mentors may unlock locked students only.
|
||||||
mux.HandleFunc("GET /api/admin/users", admin(s.handleAdminListUsers))
|
mux.HandleFunc("GET /api/admin/users", admin(s.handleAdminListUsers))
|
||||||
|
mux.HandleFunc("GET /api/admin/users/{id}", admin(s.handleAdminGetUser))
|
||||||
mux.HandleFunc("POST /api/admin/users", admin(s.handleAdminCreateUser))
|
mux.HandleFunc("POST /api/admin/users", admin(s.handleAdminCreateUser))
|
||||||
mux.HandleFunc("POST /api/admin/users/{id}/lock", admin(s.handleAdminLockUser))
|
mux.HandleFunc("POST /api/admin/users/{id}/lock", admin(s.handleAdminLockUser))
|
||||||
mux.HandleFunc("POST /api/admin/users/{id}/unlock", staff(s.handleAdminUnlockUser))
|
mux.HandleFunc("POST /api/admin/users/{id}/unlock", staff(s.handleAdminUnlockUser))
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatEditOwnMessage(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
student, studentID := register(t, e, "Chat Editor", "chat-editor@test.dev")
|
||||||
|
admin := login(t, e, bootstrapAdminEmail)
|
||||||
|
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("me: %d %v", code, me)
|
||||||
|
}
|
||||||
|
adminID := int64(me["user"].(map[string]any)["id"].(float64))
|
||||||
|
|
||||||
|
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "سلام"})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("send: %d %v", code, body)
|
||||||
|
}
|
||||||
|
msg := body["message"].(map[string]any)
|
||||||
|
msgID := int64(msg["id"].(float64))
|
||||||
|
|
||||||
|
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": "سلام ویرایش"})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("edit: %d %v", code, body)
|
||||||
|
}
|
||||||
|
edited := body["message"].(map[string]any)
|
||||||
|
if edited["body"] != "سلام ویرایش" || edited["editedAt"] == nil {
|
||||||
|
t.Fatalf("expected edited body, got %v", edited)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{"body": "نه"})
|
||||||
|
if code != 404 {
|
||||||
|
t.Fatalf("partner edit should be rejected: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": " "})
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("empty edit: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("list: %d %v", code, body)
|
||||||
|
}
|
||||||
|
msgs := body["messages"].([]any)
|
||||||
|
last := msgs[len(msgs)-1].(map[string]any)
|
||||||
|
if last["body"] != "سلام ویرایش" || last["editedAt"] == nil {
|
||||||
|
t.Fatalf("listed message not edited: %v", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatReplaceAttachment(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
student, studentID := register(t, e, "Media Editor", "media-editor@test.dev")
|
||||||
|
admin := login(t, e, bootstrapAdminEmail)
|
||||||
|
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("me: %d %v", code, me)
|
||||||
|
}
|
||||||
|
adminID := int64(me["user"].(map[string]any)["id"].(float64))
|
||||||
|
|
||||||
|
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}
|
||||||
|
code, up := uploadChatFile(t, e, student, "one.png", png)
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("upload: %d %v", code, up)
|
||||||
|
}
|
||||||
|
first := up["attachment"].(map[string]any)
|
||||||
|
|
||||||
|
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
|
||||||
|
"body": "",
|
||||||
|
"attachment": first,
|
||||||
|
})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("send: %d %v", code, body)
|
||||||
|
}
|
||||||
|
msg := body["message"].(map[string]any)
|
||||||
|
msgID := int64(msg["id"].(float64))
|
||||||
|
oldPath := mediaPath(msg["attachment"].(map[string]any)["url"])
|
||||||
|
|
||||||
|
png[8] = 0x01
|
||||||
|
code, up = uploadChatFile(t, e, student, "two.png", png)
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("upload replacement: %d %v", code, up)
|
||||||
|
}
|
||||||
|
next := up["attachment"].(map[string]any)
|
||||||
|
|
||||||
|
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
|
||||||
|
"body": "",
|
||||||
|
"attachment": next,
|
||||||
|
})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("replace: %d %v", code, body)
|
||||||
|
}
|
||||||
|
edited := body["message"].(map[string]any)
|
||||||
|
newPath := mediaPath(edited["attachment"].(map[string]any)["url"])
|
||||||
|
if edited["editedAt"] == nil || newPath == "" || newPath == oldPath {
|
||||||
|
t.Fatalf("expected a replaced attachment, old %s new %v", oldPath, edited)
|
||||||
|
}
|
||||||
|
if edited["attachment"].(map[string]any)["type"] != "image" {
|
||||||
|
t.Fatalf("type: %v", edited["attachment"])
|
||||||
|
}
|
||||||
|
|
||||||
|
wrong := map[string]any{"type": "video", "url": next["url"], "name": "clip.mp4", "size": next["size"]}
|
||||||
|
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
|
||||||
|
"body": "",
|
||||||
|
"attachment": wrong,
|
||||||
|
})
|
||||||
|
if code != 404 {
|
||||||
|
t.Fatalf("different kind should miss: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
|
||||||
|
"body": "",
|
||||||
|
"attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1},
|
||||||
|
})
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("file replace: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{
|
||||||
|
"body": "",
|
||||||
|
"attachment": next,
|
||||||
|
})
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("partner replace: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatReadMarksOnlyIncoming(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
student, studentID := register(t, e, "Seen Student", "seen-student@test.dev")
|
||||||
|
admin := login(t, e, bootstrapAdminEmail)
|
||||||
|
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("me: %d %v", code, me)
|
||||||
|
}
|
||||||
|
adminID := int64(me["user"].(map[string]any)["id"].(float64))
|
||||||
|
|
||||||
|
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "از هنرجو"})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("student send: %d %v", code, body)
|
||||||
|
}
|
||||||
|
studentMsg := int64(body["message"].(map[string]any)["id"].(float64))
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("student list: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if chatMsg(t, body, studentMsg)["readAt"] != nil {
|
||||||
|
t.Fatalf("opening my own thread marked it seen: %v", chatMsg(t, body, studentMsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("admin list: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if chatMsg(t, body, studentMsg)["readAt"] == nil {
|
||||||
|
t.Fatalf("partner open should mark the incoming message seen")
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", admin, map[string]any{"body": "از ادمین"})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("admin send: %d %v", code, body)
|
||||||
|
}
|
||||||
|
adminMsg := int64(body["message"].(map[string]any)["id"].(float64))
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("admin list again: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if chatMsg(t, body, adminMsg)["readAt"] != nil {
|
||||||
|
t.Fatalf("admin own message was marked seen: %v", chatMsg(t, body, adminMsg))
|
||||||
|
}
|
||||||
|
if chatMsg(t, body, studentMsg)["readAt"] == nil {
|
||||||
|
t.Fatalf("already seen incoming message lost its receipt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
for _, raw := range body["messages"].([]any) {
|
||||||
|
msg := raw.(map[string]any)
|
||||||
|
if int64(msg["id"].(float64)) == id {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("message %d missing in %v", id, body["messages"])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaPath(raw any) string {
|
||||||
|
s, _ := raw.(string)
|
||||||
|
if i := strings.IndexByte(s, '?'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLearningDetailMatchesActiveStudents(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
admin := login(t, e, bootstrapAdminEmail)
|
||||||
|
|
||||||
|
code, body := e.do(t, "GET", "/api/admin/stats", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("stats: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if body["stats"].(map[string]any)["users"].(float64) != 0 {
|
||||||
|
t.Fatalf("overview should ignore staff, got %v", body["stats"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_, uid := register(t, e, "Active Student", "active-student@test.dev")
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/admin/stats", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("stats after student: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if body["stats"].(map[string]any)["users"].(float64) != 1 {
|
||||||
|
t.Fatalf("expected 1 active student, got %v", body["stats"])
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/admin/learning/overview", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("overview: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if body["overview"].(map[string]any)["studentsActive"].(float64) != 1 {
|
||||||
|
t.Fatalf("learning active mismatch: %v", body["overview"])
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/admin/users/"+itoa(uid)+"/learning", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("learning detail: %d %v", code, body)
|
||||||
|
}
|
||||||
|
user := body["learning"].(map[string]any)["user"].(map[string]any)
|
||||||
|
if user["name"] != "Active Student" {
|
||||||
|
t.Fatalf("detail user: %v", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/admin/users/999999/learning", admin, nil)
|
||||||
|
if code != 404 {
|
||||||
|
t.Fatalf("missing user: %d %v", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/admin/learning/students?page=1&pageSize=10", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("students page: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if body["total"].(float64) != 1 || len(body["items"].([]any)) != 1 {
|
||||||
|
t.Fatalf("expected one page row, got %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteStatusFilter(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
admin := login(t, e, bootstrapAdminEmail)
|
||||||
|
code, body := e.do(t, "POST", "/api/admin/invites", admin, map[string]any{"maxUses": 2, "label": "مهر"})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("create invite: %d %v", code, body)
|
||||||
|
}
|
||||||
|
code, body = e.do(t, "GET", "/api/admin/invites?status=active", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("active filter: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if body["total"].(float64) < 1 {
|
||||||
|
t.Fatalf("expected active invites, got %v", body)
|
||||||
|
}
|
||||||
|
code, body = e.do(t, "GET", "/api/admin/invites?status=revoked", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("revoked filter: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if body["total"].(float64) != 0 {
|
||||||
|
t.Fatalf("expected no revoked invites, got %v", body)
|
||||||
|
}
|
||||||
|
code, body = e.do(t, "GET", "/api/admin/invites?status=nope", admin, nil)
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("bad status: %d %v", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicProfileOmitsPrivateFields(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
student, studentID := register(t, e, "Public Name", "public-name@test.dev")
|
||||||
|
admin := login(t, e, bootstrapAdminEmail)
|
||||||
|
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("me: %d %v", code, me)
|
||||||
|
}
|
||||||
|
adminID := int64(me["user"].(map[string]any)["id"].(float64))
|
||||||
|
|
||||||
|
code, body := e.do(t, "GET", "/api/users/"+itoa(adminID), student, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("profile: %d %v", code, body)
|
||||||
|
}
|
||||||
|
user := body["user"].(map[string]any)
|
||||||
|
if user["name"] == nil || user["name"] == "" {
|
||||||
|
t.Fatalf("missing name: %v", user)
|
||||||
|
}
|
||||||
|
if _, ok := user["email"]; ok {
|
||||||
|
t.Fatalf("email must not be public: %v", user)
|
||||||
|
}
|
||||||
|
if _, ok := user["phone"]; ok {
|
||||||
|
t.Fatalf("phone must not be public: %v", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, adminView := e.do(t, "GET", "/api/admin/users/"+itoa(studentID), admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("admin user: %d %v", code, adminView)
|
||||||
|
}
|
||||||
|
managed := adminView["user"].(map[string]any)
|
||||||
|
if managed["email"] != "public-name@test.dev" {
|
||||||
|
t.Fatalf("admin should see email: %v", managed)
|
||||||
|
}
|
||||||
|
if _, ok := managed["passwordHash"]; ok {
|
||||||
|
t.Fatalf("password hash must stay hidden: %v", managed)
|
||||||
|
}
|
||||||
|
code, hidden := e.do(t, "GET", "/api/admin/users/"+itoa(adminID), student, nil)
|
||||||
|
if code != 403 {
|
||||||
|
t.Fatalf("student must not open admin user: %d %v", code, hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = e.do(t, "GET", "/api/users/"+itoa(studentID), admin, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("student profile: %d %v", code, body)
|
||||||
|
}
|
||||||
|
if body["user"].(map[string]any)["username"] != "public-name" {
|
||||||
|
t.Fatalf("username: %v", body["user"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,9 @@ type Config struct {
|
|||||||
PhysicalCertEnabled bool
|
PhysicalCertEnabled bool
|
||||||
PhysicalCertPriceIRR int
|
PhysicalCertPriceIRR int
|
||||||
PhysicalCertWindowDays int
|
PhysicalCertWindowDays int
|
||||||
|
// RegistrationEnforceIP limits one successful registration per client IP.
|
||||||
|
// Default false: behind NAT / shared egress the peer IP is not a real client identity.
|
||||||
|
RegistrationEnforceIP bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
@@ -140,6 +143,7 @@ func Load() *Config {
|
|||||||
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
|
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
|
||||||
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
|
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
|
||||||
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
|
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
|
||||||
|
RegistrationEnforceIP: getEnv("REGISTRATION_ENFORCE_IP", "false") == "true",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Own-message edits. Nullable so existing rows stay unedited.
|
||||||
|
ALTER TABLE chat_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
|
||||||
@@ -219,6 +219,7 @@ type ChatMessage struct {
|
|||||||
ReplyTo *int64 `json:"replyTo,omitempty"`
|
ReplyTo *int64 `json:"replyTo,omitempty"`
|
||||||
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
|
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
|
EditedAt *time.Time `json:"editedAt,omitempty"`
|
||||||
Attachment *Attachment `json:"attachment,omitempty"`
|
Attachment *Attachment `json:"attachment,omitempty"`
|
||||||
Reactions []ChatReaction `json:"reactions,omitempty"`
|
Reactions []ChatReaction `json:"reactions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVoiceNoteContentType(t *testing.T) {
|
||||||
|
if got := mimeType("chats/2-1.weba"); got != "audio/webm" {
|
||||||
|
t.Fatalf("weba: %s", got)
|
||||||
|
}
|
||||||
|
if got := mediaContentType("chats/2-1.weba", "application/octet-stream"); got != "audio/webm" {
|
||||||
|
t.Fatalf("generic store type must not hide the voice type: %s", got)
|
||||||
|
}
|
||||||
|
if got := mediaContentType("chats/2-1.bin", "application/pdf"); got != "application/pdf" {
|
||||||
|
t.Fatalf("unknown extension keeps the stored type: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -243,10 +243,11 @@ func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error {
|
|||||||
}
|
}
|
||||||
defer result.Body.Close()
|
defer result.Body.Close()
|
||||||
|
|
||||||
ct := mimeType(key)
|
stored := ""
|
||||||
if result.ContentType != nil && *result.ContentType != "" {
|
if result.ContentType != nil {
|
||||||
ct = *result.ContentType
|
stored = *result.ContentType
|
||||||
}
|
}
|
||||||
|
ct := mediaContentType(key, stored)
|
||||||
w.Header().Set("Content-Type", ct)
|
w.Header().Set("Content-Type", ct)
|
||||||
setInlineMediaHeaders(w, ct)
|
setInlineMediaHeaders(w, ct)
|
||||||
w.Header().Set("Accept-Ranges", "bytes")
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
@@ -290,8 +291,20 @@ func mimeType(key string) string {
|
|||||||
return "video/mp4"
|
return "video/mp4"
|
||||||
case ".webm":
|
case ".webm":
|
||||||
return "video/webm"
|
return "video/webm"
|
||||||
|
case ".weba":
|
||||||
|
return "audio/webm"
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return "audio/mpeg"
|
return "audio/mpeg"
|
||||||
|
case ".wav":
|
||||||
|
return "audio/wav"
|
||||||
|
case ".ogg", ".opus":
|
||||||
|
return "audio/ogg"
|
||||||
|
case ".m4a", ".aac":
|
||||||
|
return "audio/mp4"
|
||||||
|
case ".mov":
|
||||||
|
return "video/quicktime"
|
||||||
|
case ".m4v":
|
||||||
|
return "video/mp4"
|
||||||
case ".pdf":
|
case ".pdf":
|
||||||
return "application/pdf"
|
return "application/pdf"
|
||||||
default:
|
default:
|
||||||
@@ -299,6 +312,23 @@ func mimeType(key string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mediaContentType keeps a known extension ahead of a generic object-store type.
|
||||||
|
// Voice notes are stored as .weba; a default octet-stream plus nosniff stops playback.
|
||||||
|
func mediaContentType(key, stored string) string {
|
||||||
|
guessed := mimeType(key)
|
||||||
|
if guessed != "application/octet-stream" {
|
||||||
|
return guessed
|
||||||
|
}
|
||||||
|
stored = strings.TrimSpace(stored)
|
||||||
|
if i := strings.Index(stored, ";"); i >= 0 {
|
||||||
|
stored = strings.TrimSpace(stored[:i])
|
||||||
|
}
|
||||||
|
if stored != "" {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
return guessed
|
||||||
|
}
|
||||||
|
|
||||||
// setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact.
|
// setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact.
|
||||||
func setInlineMediaHeaders(w http.ResponseWriter, contentType string) {
|
func setInlineMediaHeaders(w http.ResponseWriter, contentType string) {
|
||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func (s *Store) CreateRegistrationInvite(
|
|||||||
return scanInvite(row)
|
return scanInvite(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
|
func (s *Store) ListRegistrationInvites(ctx context.Context, query, status string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
@@ -157,18 +157,21 @@ func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit
|
|||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
query = strings.TrimSpace(query)
|
query = strings.TrimSpace(query)
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
var total int64
|
var total int64
|
||||||
if err := s.pool.QueryRow(ctx, `
|
if err := s.pool.QueryRow(ctx, `
|
||||||
SELECT COUNT(*) FROM registration_invites
|
SELECT COUNT(*) FROM registration_invites
|
||||||
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil {
|
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
|
||||||
|
AND ($2 = '' OR status = $2)`, query, status).Scan(&total); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT `+inviteCols+`
|
SELECT `+inviteCols+`
|
||||||
FROM registration_invites
|
FROM registration_invites
|
||||||
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
|
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
|
||||||
|
AND ($2 = '' OR status = $2)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $2 OFFSET $3`, query, limit, offset)
|
LIMIT $3 OFFSET $4`, query, status, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -344,16 +347,14 @@ func EvaluateInviteAvailability(inv *models.RegistrationInvite, now time.Time) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterInviteStudent creates a student, consumes the IP, and atomically consumes one invite seat.
|
// RegisterInviteStudent creates a student, optionally records the client IP, and consumes one invite seat.
|
||||||
|
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
|
||||||
func (s *Store) RegisterInviteStudent(
|
func (s *Store) RegisterInviteStudent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
inviteID int64,
|
inviteID int64,
|
||||||
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
||||||
) (*models.User, error) {
|
) (*models.User, error) {
|
||||||
ip = strings.TrimSpace(ip)
|
ip = strings.TrimSpace(ip)
|
||||||
if ip == "" {
|
|
||||||
return nil, errors.New("empty registration ip")
|
|
||||||
}
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -366,12 +367,14 @@ func (s *Store) RegisterInviteStudent(
|
|||||||
return nil, ErrPhoneBlacklisted
|
return nil, ErrPhoneBlacklisted
|
||||||
}
|
}
|
||||||
|
|
||||||
var taken bool
|
if ip != "" {
|
||||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
var taken bool
|
||||||
return nil, err
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||||
}
|
return nil, err
|
||||||
if taken {
|
}
|
||||||
return nil, ErrRegistrationIPTaken
|
if taken {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tag, err := tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
@@ -436,12 +439,14 @@ func (s *Store) RegisterInviteStudent(
|
|||||||
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if ip != "" {
|
||||||
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
if _, err := tx.Exec(ctx, `
|
||||||
if isUniqueViolation(err) {
|
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||||
return nil, ErrRegistrationIPTaken
|
if isUniqueViolation(err) {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ import (
|
|||||||
|
|
||||||
// LearningOverview is cohort-level learning analytics for staff.
|
// LearningOverview is cohort-level learning analytics for staff.
|
||||||
type LearningOverview struct {
|
type LearningOverview struct {
|
||||||
StudentsActive int64 `json:"studentsActive"`
|
StudentsActive int64 `json:"studentsActive"`
|
||||||
StudentsLocked int64 `json:"studentsLocked"`
|
StudentsLocked int64 `json:"studentsLocked"`
|
||||||
StudentsTotal int64 `json:"studentsTotal"`
|
StudentsTotal int64 `json:"studentsTotal"`
|
||||||
LessonsTotal int64 `json:"lessonsTotal"`
|
LessonsTotal int64 `json:"lessonsTotal"`
|
||||||
Completions int64 `json:"completions"`
|
Completions int64 `json:"completions"`
|
||||||
Attempts7d int64 `json:"attempts7d"`
|
Attempts7d int64 `json:"attempts7d"`
|
||||||
Passes7d int64 `json:"passes7d"`
|
Passes7d int64 `json:"passes7d"`
|
||||||
PassRate7d float64 `json:"passRate7d"`
|
PassRate7d float64 `json:"passRate7d"`
|
||||||
AvgWatchPct float64 `json:"avgWatchPct"`
|
AvgWatchPct float64 `json:"avgWatchPct"`
|
||||||
XPAwarded7d int64 `json:"xpAwarded7d"`
|
XPAwarded7d int64 `json:"xpAwarded7d"`
|
||||||
StudentsStreaking int64 `json:"studentsStreaking"`
|
StudentsStreaking int64 `json:"studentsStreaking"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) LearningOverview(ctx context.Context) (*LearningOverview, error) {
|
func (s *Store) LearningOverview(ctx context.Context) (*LearningOverview, error) {
|
||||||
@@ -81,10 +81,40 @@ type StudentLearningRow struct {
|
|||||||
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
|
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLearningRow, error) {
|
func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string, limit, offset int) ([]StudentLearningRow, int64, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
var lessonsTotal int64
|
var lessonsTotal int64
|
||||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil {
|
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
where := `u.role = 'student'
|
||||||
|
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')`
|
||||||
|
switch filter {
|
||||||
|
case "active":
|
||||||
|
where += ` AND u.is_active AND NOT u.is_locked`
|
||||||
|
case "locked":
|
||||||
|
where += ` AND u.is_locked`
|
||||||
|
}
|
||||||
|
order := `u.xp DESC, u.id ASC`
|
||||||
|
switch sort {
|
||||||
|
case "progress":
|
||||||
|
order = `COALESCE(p.passed, 0) DESC, u.id ASC`
|
||||||
|
case "streak":
|
||||||
|
order = `u.streak_current DESC, u.id ASC`
|
||||||
|
case "activity":
|
||||||
|
order = `u.last_activity_date DESC NULLS LAST, u.id ASC`
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users u WHERE `+where, q).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current,
|
SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current,
|
||||||
@@ -105,12 +135,11 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
|
|||||||
WHERE created_at >= now() - interval '7 days'
|
WHERE created_at >= now() - interval '7 days'
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
) a ON a.user_id = u.id
|
) a ON a.user_id = u.id
|
||||||
WHERE u.role = 'student'
|
WHERE `+where+`
|
||||||
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')
|
ORDER BY `+order+`
|
||||||
ORDER BY u.xp DESC, u.id ASC
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
LIMIT 500`, q)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := make([]StudentLearningRow, 0)
|
out := make([]StudentLearningRow, 0)
|
||||||
@@ -122,7 +151,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
|
|||||||
&r.IsLocked, &r.IsActive, &r.LastActivityAt,
|
&r.IsLocked, &r.IsActive, &r.LastActivityAt,
|
||||||
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
|
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
if r.LessonsTotal > 0 {
|
if r.LessonsTotal > 0 {
|
||||||
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
|
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
|
||||||
@@ -132,21 +161,21 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
|
|||||||
}
|
}
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
type LessonLearningDetail struct {
|
type LessonLearningDetail struct {
|
||||||
LessonID int64 `json:"lessonId"`
|
LessonID int64 `json:"lessonId"`
|
||||||
ChapterID int64 `json:"chapterId"`
|
ChapterID int64 `json:"chapterId"`
|
||||||
ChapterTitle string `json:"chapterTitle"`
|
ChapterTitle string `json:"chapterTitle"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
WatchedPct float64 `json:"watchedPct"`
|
WatchedPct float64 `json:"watchedPct"`
|
||||||
QuizUnlocked bool `json:"quizUnlocked"`
|
QuizUnlocked bool `json:"quizUnlocked"`
|
||||||
PassedQuiz bool `json:"passedQuiz"`
|
PassedQuiz bool `json:"passedQuiz"`
|
||||||
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
|
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
|
||||||
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
|
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
|
||||||
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
|
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AttemptLearningDetail struct {
|
type AttemptLearningDetail struct {
|
||||||
@@ -178,11 +207,11 @@ type WatchSessionDetail struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserLearningDetail struct {
|
type UserLearningDetail struct {
|
||||||
User StudentLearningRow `json:"user"`
|
User StudentLearningRow `json:"user"`
|
||||||
Lessons []LessonLearningDetail `json:"lessons"`
|
Lessons []LessonLearningDetail `json:"lessons"`
|
||||||
Attempts []AttemptLearningDetail `json:"attempts"`
|
Attempts []AttemptLearningDetail `json:"attempts"`
|
||||||
XPEvents []XPEventDetail `json:"xpEvents"`
|
XPEvents []XPEventDetail `json:"xpEvents"`
|
||||||
Sessions []WatchSessionDetail `json:"sessions"`
|
Sessions []WatchSessionDetail `json:"sessions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLearningDetail, error) {
|
func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLearningDetail, error) {
|
||||||
@@ -190,30 +219,28 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
list, err := s.ListStudentLearning(ctx, "")
|
row := StudentLearningRow{
|
||||||
if err != nil {
|
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
|
||||||
|
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
|
||||||
|
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
|
||||||
|
}
|
||||||
|
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var row StudentLearningRow
|
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed); err != nil {
|
||||||
found := false
|
return nil, err
|
||||||
for _, r := range list {
|
|
||||||
if r.ID == userID {
|
|
||||||
row = r
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !found {
|
if row.LessonsTotal > 0 {
|
||||||
row = StudentLearningRow{
|
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
|
||||||
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
|
}
|
||||||
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
|
if err := s.pool.QueryRow(ctx, `
|
||||||
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
|
SELECT COUNT(*), COUNT(*) FILTER (WHERE status='passed')
|
||||||
}
|
FROM quiz_attempts
|
||||||
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal)
|
WHERE user_id=$1 AND created_at >= now() - interval '7 days'`, userID).Scan(&row.Attempts7d, &row.Passes7d); err != nil {
|
||||||
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed)
|
return nil, err
|
||||||
if row.LessonsTotal > 0 {
|
}
|
||||||
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
|
if row.Attempts7d > 0 {
|
||||||
}
|
row.PassRate7d = float64(row.Passes7d) / float64(row.Attempts7d) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}}
|
detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}}
|
||||||
@@ -221,7 +248,7 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
|
|||||||
lrows, err := s.pool.Query(ctx, `
|
lrows, err := s.pool.Query(ctx, `
|
||||||
SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order,
|
SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order,
|
||||||
COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at,
|
COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at,
|
||||||
COALESCE(la.status, ''), COALESCE(la.score_pct, 0)
|
COALESCE(la.status::text, ''), COALESCE(la.score_pct, 0)
|
||||||
FROM lessons l
|
FROM lessons l
|
||||||
JOIN chapters c ON c.id = l.chapter_id
|
JOIN chapters c ON c.id = l.chapter_id
|
||||||
LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1
|
LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestLearningOverviewSmoke(t *testing.T) {
|
|||||||
if o == nil {
|
if o == nil {
|
||||||
t.Fatal("nil overview")
|
t.Fatal("nil overview")
|
||||||
}
|
}
|
||||||
students, err := st.ListStudentLearning(ctx, "")
|
students, _, err := st.ListStudentLearning(ctx, "", "", "xp", 10, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-42
@@ -331,18 +331,15 @@ func (s *Store) HasRegistrationIP(ctx context.Context, ip string) (bool, error)
|
|||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPublicStudent creates a student and consumes the IP atomically.
|
// RegisterPublicStudent creates a student and optionally records the client IP.
|
||||||
// When requireWhitelist is true, the phone must be an available whitelist entry.
|
// When requireWhitelist is true, the phone must be an available whitelist entry.
|
||||||
// Concurrent same-IP registrations: one commits; the other rolls back on unique IP conflict.
|
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
|
||||||
func (s *Store) RegisterPublicStudent(
|
func (s *Store) RegisterPublicStudent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
||||||
requireWhitelist bool,
|
requireWhitelist bool,
|
||||||
) (*models.User, error) {
|
) (*models.User, error) {
|
||||||
ip = strings.TrimSpace(ip)
|
ip = strings.TrimSpace(ip)
|
||||||
if ip == "" {
|
|
||||||
return nil, errors.New("empty registration ip")
|
|
||||||
}
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -355,12 +352,14 @@ func (s *Store) RegisterPublicStudent(
|
|||||||
return nil, ErrPhoneBlacklisted
|
return nil, ErrPhoneBlacklisted
|
||||||
}
|
}
|
||||||
|
|
||||||
var taken bool
|
if ip != "" {
|
||||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
var taken bool
|
||||||
return nil, err
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||||
}
|
return nil, err
|
||||||
if taken {
|
}
|
||||||
return nil, ErrRegistrationIPTaken
|
if taken {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row := tx.QueryRow(ctx, `
|
row := tx.QueryRow(ctx, `
|
||||||
@@ -380,12 +379,14 @@ func (s *Store) RegisterPublicStudent(
|
|||||||
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if ip != "" {
|
||||||
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
if _, err := tx.Exec(ctx, `
|
||||||
if isUniqueViolation(err) {
|
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||||
return nil, ErrRegistrationIPTaken
|
if isUniqueViolation(err) {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -503,7 +504,26 @@ func (s *Store) GetPhysicalOrder(ctx context.Context, id int64) (*models.Certifi
|
|||||||
return scanPhysicalOrder(row)
|
return scanPhysicalOrder(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models.CertificatePhysicalOrder, error) {
|
func (s *Store) ListPhysicalOrders(ctx context.Context, status string, limit, offset int) ([]models.CertificatePhysicalOrder, int64, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
where := ""
|
||||||
|
args := []any{}
|
||||||
|
if status != "" {
|
||||||
|
where = ` WHERE o.status=$1`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM certificate_physical_orders o`+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
q := `
|
q := `
|
||||||
SELECT o.id, o.certificate_id, o.user_id, o.status, o.note,
|
SELECT o.id, o.certificate_id, o.user_id, o.status, o.note,
|
||||||
o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code,
|
o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code,
|
||||||
@@ -511,16 +531,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
|
|||||||
u.name, c.public_id
|
u.name, c.public_id
|
||||||
FROM certificate_physical_orders o
|
FROM certificate_physical_orders o
|
||||||
JOIN users u ON u.id = o.user_id
|
JOIN users u ON u.id = o.user_id
|
||||||
JOIN certificates c ON c.id = o.certificate_id`
|
JOIN certificates c ON c.id = o.certificate_id` + where + ` ORDER BY o.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
|
||||||
args := []any{}
|
args = append(args, limit, offset)
|
||||||
if status != "" {
|
|
||||||
q += ` WHERE o.status=$1`
|
|
||||||
args = append(args, status)
|
|
||||||
}
|
|
||||||
q += ` ORDER BY o.created_at DESC LIMIT 200`
|
|
||||||
rows, err := s.pool.Query(ctx, q, args...)
|
rows, err := s.pool.Query(ctx, q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := make([]models.CertificatePhysicalOrder, 0)
|
out := make([]models.CertificatePhysicalOrder, 0)
|
||||||
@@ -529,11 +544,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
|
|||||||
if err := rows.Scan(&o.ID, &o.CertificateID, &o.UserID, &o.Status, &o.Note,
|
if err := rows.Scan(&o.ID, &o.CertificateID, &o.UserID, &o.Status, &o.Note,
|
||||||
&o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode,
|
&o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode,
|
||||||
&o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil {
|
&o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
out = append(out, o)
|
out = append(out, o)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdatePhysicalOrder(ctx context.Context, id int64, status models.PhysicalOrderStatus, note, tracking string) (*models.CertificatePhysicalOrder, error) {
|
func (s *Store) UpdatePhysicalOrder(ctx context.Context, id int64, status models.PhysicalOrderStatus, note, tracking string) (*models.CertificatePhysicalOrder, error) {
|
||||||
@@ -1068,7 +1083,7 @@ func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, send
|
|||||||
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size)
|
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`,
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
|
||||||
userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize)
|
userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize)
|
||||||
return scanMessage(row)
|
return scanMessage(row)
|
||||||
}
|
}
|
||||||
@@ -1093,7 +1108,7 @@ func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, s
|
|||||||
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, created_at, pinned_at)
|
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, created_at, pinned_at)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`,
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
|
||||||
userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt)
|
userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt)
|
||||||
return scanMessage(row)
|
return scanMessage(row)
|
||||||
}
|
}
|
||||||
@@ -1137,6 +1152,48 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in
|
|||||||
return tag.RowsAffected() > 0, nil
|
return tag.RowsAffected() > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited.
|
||||||
|
// The new type must match the current attachment.
|
||||||
|
func (s *Store) ReplaceChatAttachment(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body, attType, attURL, attName string, attSize int64) (*models.ChatMessage, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE chat_messages
|
||||||
|
SET body=$1, edited_at=now(),
|
||||||
|
attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5
|
||||||
|
WHERE id=$6
|
||||||
|
AND ((user_id=$7 AND mentor_id=$8) OR (user_id=$8 AND mentor_id=$7))
|
||||||
|
AND sender_role=$9
|
||||||
|
AND (user_id=$7 OR mentor_id=$7)
|
||||||
|
AND attachment_type=$2
|
||||||
|
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
|
||||||
|
body, attType, attURL, attName, attSize, msgID, meID, partnerID, meRole.String())
|
||||||
|
m, err := scanMessage(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditChatMessage replaces the body of one of my own messages in this conversation.
|
||||||
|
func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body string) (*models.ChatMessage, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE chat_messages
|
||||||
|
SET body=$1, edited_at=now()
|
||||||
|
WHERE id=$2
|
||||||
|
AND ((user_id=$3 AND mentor_id=$4) OR (user_id=$4 AND mentor_id=$3))
|
||||||
|
AND sender_role=$5
|
||||||
|
AND (user_id=$3 OR mentor_id=$3)
|
||||||
|
AND ($1 <> '' OR attachment_url IS NOT NULL)
|
||||||
|
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
|
||||||
|
body, msgID, meID, partnerID, meRole.String())
|
||||||
|
m, err := scanMessage(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
|
||||||
const maxPinnedMessages = 3
|
const maxPinnedMessages = 3
|
||||||
|
|
||||||
// TogglePinChatMessage pins or unpins a message. If already at the pin limit,
|
// TogglePinChatMessage pins or unpins a message. If already at the pin limit,
|
||||||
@@ -1159,7 +1216,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
|
|||||||
out := s.pool.QueryRow(ctx, `
|
out := s.pool.QueryRow(ctx, `
|
||||||
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1
|
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1
|
||||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
|
||||||
return scanMessage(out)
|
return scanMessage(out)
|
||||||
}
|
}
|
||||||
var n int
|
var n int
|
||||||
@@ -1187,7 +1244,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
|
|||||||
out := s.pool.QueryRow(ctx, `
|
out := s.pool.QueryRow(ctx, `
|
||||||
UPDATE chat_messages SET pinned_at=now() WHERE id=$1
|
UPDATE chat_messages SET pinned_at=now() WHERE id=$1
|
||||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
|
||||||
return scanMessage(out)
|
return scanMessage(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,7 +1252,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
|
|||||||
func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) {
|
func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE pinned_at IS NOT NULL
|
WHERE pinned_at IS NOT NULL
|
||||||
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
||||||
@@ -1221,7 +1278,7 @@ func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int
|
|||||||
func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) {
|
func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
||||||
AND ($3 = 0 OR id < $3)
|
AND ($3 = 0 OR id < $3)
|
||||||
@@ -1269,7 +1326,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
|
|||||||
|
|
||||||
older, err := s.pool.Query(ctx, `
|
older, err := s.pool.Query(ctx, `
|
||||||
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
||||||
AND id <= $3
|
AND id <= $3
|
||||||
@@ -1301,7 +1358,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
|
|||||||
|
|
||||||
newer, err := s.pool.Query(ctx, `
|
newer, err := s.pool.Query(ctx, `
|
||||||
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
|
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
||||||
AND id > $3
|
AND id > $3
|
||||||
@@ -1344,7 +1401,7 @@ func scanMessage(row messageScanner) (*models.ChatMessage, error) {
|
|||||||
var attSize *int64
|
var attSize *int64
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo,
|
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo,
|
||||||
&attType, &attURL, &attName, &attSize, &m.PinnedAt,
|
&attType, &attURL, &attName, &attSize, &m.PinnedAt, &m.EditedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1371,11 +1428,20 @@ func derefInt(p *int64) int64 {
|
|||||||
return *p
|
return *p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) MarkChatRead(ctx context.Context, userID, mentorID int64) error {
|
// MarkChatRead stamps messages the other person sent. Opening the thread must not
|
||||||
_, err := s.pool.Exec(ctx, `
|
// mark my own messages as seen.
|
||||||
UPDATE chat_messages SET read_at=now() WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`,
|
func (s *Store) MarkChatRead(ctx context.Context, meID, partnerID int64, meRole models.Role) (int64, error) {
|
||||||
userID, mentorID)
|
tag, err := s.pool.Exec(ctx, `
|
||||||
return err
|
UPDATE chat_messages
|
||||||
|
SET read_at=now()
|
||||||
|
WHERE read_at IS NULL
|
||||||
|
AND sender_role <> $3
|
||||||
|
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))`,
|
||||||
|
meID, partnerID, meRole.String())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageInConversation reports whether a message belongs to a given pair.
|
// MessageInConversation reports whether a message belongs to a given pair.
|
||||||
@@ -1742,7 +1808,7 @@ func (s *Store) AllActiveUserIDs(ctx context.Context) ([]int64, error) {
|
|||||||
|
|
||||||
func (s *Store) Stats(ctx context.Context) (map[string]any, error) {
|
func (s *Store) Stats(ctx context.Context) (map[string]any, error) {
|
||||||
var users, lessons, questions, events, completions int64
|
var users, lessons, questions, events, completions int64
|
||||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users); err != nil {
|
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='student' AND is_active`).Scan(&users); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil {
|
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ x-backend-env: &backend-env
|
|||||||
STORAGE_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY must be set}
|
STORAGE_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY must be set}
|
||||||
STORAGE_BUCKET: ${STORAGE_BUCKET:-pargar}
|
STORAGE_BUCKET: ${STORAGE_BUCKET:-pargar}
|
||||||
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
||||||
|
REGISTRATION_ENFORCE_IP: ${REGISTRATION_ENFORCE_IP:-false}
|
||||||
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: pargar
|
name: pargar
|
||||||
description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage
|
description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage
|
||||||
type: application
|
type: application
|
||||||
version: 0.2.0
|
version: 0.2.1
|
||||||
appVersion: "0.1.0"
|
appVersion: "0.1.3"
|
||||||
keywords:
|
keywords:
|
||||||
- lms
|
- lms
|
||||||
- pargar
|
- pargar
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ spec:
|
|||||||
value: {{ .Values.backend.env.devMode | quote }}
|
value: {{ .Values.backend.env.devMode | quote }}
|
||||||
- name: SEED_CURRICULUM
|
- name: SEED_CURRICULUM
|
||||||
value: {{ .Values.backend.env.seedCurriculum | quote }}
|
value: {{ .Values.backend.env.seedCurriculum | quote }}
|
||||||
|
- name: REGISTRATION_ENFORCE_IP
|
||||||
|
value: {{ .Values.backend.env.registrationEnforceIP | quote }}
|
||||||
- name: ADMIN_NAME
|
- name: ADMIN_NAME
|
||||||
value: {{ .Values.backend.admin.name | quote }}
|
value: {{ .Values.backend.admin.name | quote }}
|
||||||
- name: ADMIN_USERNAME
|
- name: ADMIN_USERNAME
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ backend:
|
|||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
image:
|
image:
|
||||||
repository: backend
|
repository: backend
|
||||||
tag: "0.1.0"
|
tag: "0.1.3"
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
existingSecret: pargar-app
|
existingSecret: pargar-app
|
||||||
env:
|
env:
|
||||||
@@ -49,7 +49,7 @@ frontend:
|
|||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
image:
|
image:
|
||||||
repository: frontend
|
repository: frontend
|
||||||
tag: "0.1.0"
|
tag: "0.1.3"
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ backend:
|
|||||||
env:
|
env:
|
||||||
devMode: "false"
|
devMode: "false"
|
||||||
seedCurriculum: "false"
|
seedCurriculum: "false"
|
||||||
|
# false behind NAT / shared egress (peer IP is not a real client).
|
||||||
|
registrationEnforceIP: "false"
|
||||||
donationEnabled: "true"
|
donationEnabled: "true"
|
||||||
# Offline payment only — leave off until ops process is ready; enable in admin settings.
|
# Offline payment only — leave off until ops process is ready; enable in admin settings.
|
||||||
physicalCertEnabled: "false"
|
physicalCertEnabled: "false"
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ One of two patterns:
|
|||||||
|----------|-----------------|--------|
|
|----------|-----------------|--------|
|
||||||
| `DEV_MODE` | false | Open metrics without token, limited fail-open behavior |
|
| `DEV_MODE` | false | Open metrics without token, limited fail-open behavior |
|
||||||
| `SEED_CURRICULUM` | false (empty in DevMode → true) | Seed lessons/events |
|
| `SEED_CURRICULUM` | false (empty in DevMode → true) | Seed lessons/events |
|
||||||
|
| `REGISTRATION_ENFORCE_IP` | false | One registration per client IP; keep off behind NAT |
|
||||||
| `CAPTCHA_EXPOSE_ANSWER` | DevMode only | For tests; never in production |
|
| `CAPTCHA_EXPOSE_ANSWER` | DevMode only | For tests; never in production |
|
||||||
| `DONATION_ENABLED` | true | Support card |
|
| `DONATION_ENABLED` | true | Support card |
|
||||||
| `DONATION_NOTE` | Copy text | |
|
| `DONATION_NOTE` | Copy text | |
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
- Whitelist and blacklist are **mutually exclusive**; import resolves conflicts.
|
- Whitelist and blacklist are **mutually exclusive**; import resolves conflicts.
|
||||||
- A phone already tied to an account cannot re-enter either list as a fresh entry.
|
- A phone already tied to an account cannot re-enter either list as a fresh entry.
|
||||||
- One account per phone and per IP on registration paths.
|
- One account per phone. Optional one-account-per-IP (`REGISTRATION_ENFORCE_IP`, default off — unsuitable behind NAT).
|
||||||
- Blacklist blocks all registration paths (public + invite) and keeps a failed-attempt history tab.
|
- Blacklist blocks all registration paths (public + invite) and keeps a failed-attempt history tab.
|
||||||
|
|
||||||
## Certificates
|
## Certificates
|
||||||
|
|||||||
+5
-1
@@ -25,6 +25,10 @@ Public password recovery (`/api/auth/recovery/*` and `/recovery`) is disabled fo
|
|||||||
- Only peers in `TRUSTED_PROXY_CIDRS` are trusted for `X-Forwarded-*` / `X-Real-IP`.
|
- Only peers in `TRUSTED_PROXY_CIDRS` are trusted for `X-Forwarded-*` / `X-Real-IP`.
|
||||||
- CORS: `FRONTEND_ORIGINS` + `PUBLIC_URL` with `AllowCredentials: true`.
|
- CORS: `FRONTEND_ORIGINS` + `PUBLIC_URL` with `AllowCredentials: true`.
|
||||||
|
|
||||||
|
## Registration IP binding
|
||||||
|
|
||||||
|
`REGISTRATION_ENFORCE_IP` (default **false**) optionally limits one successful public/invite registration per client IP. Leave it off when the app sits behind NAT or shared egress and cannot see real client addresses. Enable only after the edge forwards trustworthy `X-Forwarded-For` / `X-Real-IP` via `TRUSTED_PROXY_CIDRS`.
|
||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
|
|
||||||
| Scope | Approximate behavior |
|
| Scope | Approximate behavior |
|
||||||
@@ -34,7 +38,7 @@ Public password recovery (`/api/auth/recovery/*` and `/recovery`) is disabled fo
|
|||||||
| External chat export | ~10/min/IP |
|
| External chat export | ~10/min/IP |
|
||||||
| Per-user uploads | Separately limited |
|
| Per-user uploads | Separately limited |
|
||||||
|
|
||||||
`/api/health` is excluded from the global ceiling.
|
`/api/health` is excluded from the global ceiling. Note: per-IP rate limits still use the peer/forwarded address — configure trusted proxies when you need per-client fairness behind NAT.
|
||||||
|
|
||||||
## Uploads
|
## Uploads
|
||||||
|
|
||||||
|
|||||||
Generated
+161
-190
@@ -23,7 +23,7 @@
|
|||||||
"emoji-datasource-apple": "^15.1.2",
|
"emoji-datasource-apple": "^15.1.2",
|
||||||
"framer-motion": "^12.0.0",
|
"framer-motion": "^12.0.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "^15.1.0",
|
"next": "15.5.27",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -114,9 +114,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -132,13 +132,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -154,20 +154,20 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -177,9 +177,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -193,9 +193,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -209,9 +209,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -225,9 +225,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -241,9 +241,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -257,9 +257,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -273,9 +273,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -289,9 +289,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -305,9 +305,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -321,9 +321,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -337,9 +337,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -355,13 +355,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -377,13 +377,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -399,13 +399,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -421,13 +421,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -443,13 +443,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -465,13 +465,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -487,13 +487,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -509,13 +509,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -529,16 +529,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -548,9 +548,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -567,9 +567,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -586,9 +586,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -655,15 +655,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz",
|
||||||
"integrity": "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==",
|
"integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz",
|
||||||
"integrity": "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==",
|
"integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -677,9 +677,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz",
|
||||||
"integrity": "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==",
|
"integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -693,9 +693,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz",
|
||||||
"integrity": "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==",
|
"integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -709,9 +709,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz",
|
||||||
"integrity": "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==",
|
"integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -725,9 +725,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz",
|
||||||
"integrity": "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==",
|
"integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -741,9 +741,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz",
|
||||||
"integrity": "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==",
|
"integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -757,9 +757,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz",
|
||||||
"integrity": "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==",
|
"integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -773,9 +773,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz",
|
||||||
"integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==",
|
"integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2291,12 +2291,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.25",
|
"version": "15.5.27",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz",
|
||||||
"integrity": "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==",
|
"integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.25",
|
"@next/env": "15.5.27",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@@ -2309,14 +2309,14 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.5.25",
|
"@next/swc-darwin-arm64": "15.5.27",
|
||||||
"@next/swc-darwin-x64": "15.5.25",
|
"@next/swc-darwin-x64": "15.5.27",
|
||||||
"@next/swc-linux-arm64-gnu": "15.5.25",
|
"@next/swc-linux-arm64-gnu": "15.5.27",
|
||||||
"@next/swc-linux-arm64-musl": "15.5.25",
|
"@next/swc-linux-arm64-musl": "15.5.27",
|
||||||
"@next/swc-linux-x64-gnu": "15.5.25",
|
"@next/swc-linux-x64-gnu": "15.5.27",
|
||||||
"@next/swc-linux-x64-musl": "15.5.25",
|
"@next/swc-linux-x64-musl": "15.5.27",
|
||||||
"@next/swc-win32-arm64-msvc": "15.5.25",
|
"@next/swc-win32-arm64-msvc": "15.5.27",
|
||||||
"@next/swc-win32-x64-msvc": "15.5.25",
|
"@next/swc-win32-x64-msvc": "15.5.27",
|
||||||
"sharp": "^0.34.3 || ^0.35.4"
|
"sharp": "^0.34.3 || ^0.35.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2352,34 +2352,6 @@
|
|||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
|
||||||
"version": "8.4.31",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
|
||||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/postcss/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"nanoid": "^3.3.6",
|
|
||||||
"picocolors": "^1.0.0",
|
|
||||||
"source-map-js": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || >=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2390,7 +2362,6 @@
|
|||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2555,9 +2526,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2572,31 +2543,31 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-darwin-arm64": "0.35.4",
|
"@img/sharp-darwin-arm64": "0.35.5",
|
||||||
"@img/sharp-darwin-x64": "0.35.4",
|
"@img/sharp-darwin-x64": "0.35.5",
|
||||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||||
"@img/sharp-linux-arm": "0.35.4",
|
"@img/sharp-linux-arm": "0.35.5",
|
||||||
"@img/sharp-linux-arm64": "0.35.4",
|
"@img/sharp-linux-arm64": "0.35.5",
|
||||||
"@img/sharp-linux-ppc64": "0.35.4",
|
"@img/sharp-linux-ppc64": "0.35.5",
|
||||||
"@img/sharp-linux-riscv64": "0.35.4",
|
"@img/sharp-linux-riscv64": "0.35.5",
|
||||||
"@img/sharp-linux-s390x": "0.35.4",
|
"@img/sharp-linux-s390x": "0.35.5",
|
||||||
"@img/sharp-linux-x64": "0.35.4",
|
"@img/sharp-linux-x64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||||
"@img/sharp-win32-arm64": "0.35.4",
|
"@img/sharp-win32-arm64": "0.35.5",
|
||||||
"@img/sharp-win32-ia32": "0.35.4",
|
"@img/sharp-win32-ia32": "0.35.5",
|
||||||
"@img/sharp-win32-x64": "0.35.4"
|
"@img/sharp-win32-x64": "0.35.5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
@@ -2615,9 +2586,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"emoji-datasource-apple": "^15.1.2",
|
"emoji-datasource-apple": "^15.1.2",
|
||||||
"framer-motion": "^12.0.0",
|
"framer-motion": "^12.0.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "^15.1.0",
|
"next": "15.5.27",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -44,5 +44,10 @@
|
|||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"postcss": "8.5.28",
|
||||||
|
"sharp": "0.35.5",
|
||||||
|
"source-map-js": "1.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CheckCircle2, Loader2, RefreshCw, ShieldCheck, Timer, Zap } from "lucide-react";
|
import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react";
|
||||||
import { BrandMark } from "@/components/brand-mark";
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
import { useAuth } from "@/lib/auth-store";
|
import { useAuth } from "@/lib/auth-store";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
|
||||||
|
|
||||||
const perks = [
|
const perks = [
|
||||||
{ icon: Timer, text: "زمان تماشای راستیآزماییشده — بدون میانبر" },
|
{ icon: Timer, text: "زمان تماشای راستیآزماییشده — بدون میانبر" },
|
||||||
@@ -22,11 +23,6 @@ const perks = [
|
|||||||
{ icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یکبهیک" },
|
{ icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یکبهیک" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type CaptchaChallenge = {
|
|
||||||
challengeId: string;
|
|
||||||
imageBase64: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setSession = useAuth((s) => s.setSession);
|
const setSession = useAuth((s) => s.setSession);
|
||||||
@@ -34,7 +30,7 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [captchaAnswer, setCaptchaAnswer] = useState("");
|
const [captchaAnswer, setCaptchaAnswer] = useState("");
|
||||||
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
|
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
|
||||||
const [captchaLoading, setCaptchaLoading] = useState(true);
|
const [captchaKey, setCaptchaKey] = useState(0);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -44,29 +40,11 @@ export default function LoginPage() {
|
|||||||
queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"),
|
queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadCaptcha = useCallback(async () => {
|
|
||||||
setCaptchaLoading(true);
|
|
||||||
setCaptchaAnswer("");
|
|
||||||
try {
|
|
||||||
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
|
|
||||||
setChallenge(data);
|
|
||||||
} catch (err) {
|
|
||||||
setChallenge(null);
|
|
||||||
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
|
|
||||||
} finally {
|
|
||||||
setCaptchaLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadCaptcha();
|
|
||||||
}, [loadCaptcha]);
|
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
setError("ابتدا کد امنیتی را بارگذاری کنید");
|
setError("ابتدا کد امنیتی را بارگذاری کنید");
|
||||||
void loadCaptcha();
|
setCaptchaKey((k) => k + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -83,7 +61,7 @@ export default function LoginPage() {
|
|||||||
router.push("/cap");
|
router.push("/cap");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(toUserError(err, "ورود ناموفق بود"));
|
setError(toUserError(err, "ورود ناموفق بود"));
|
||||||
void loadCaptcha();
|
setCaptchaKey((k) => k + 1);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -153,54 +131,14 @@ export default function LoginPage() {
|
|||||||
placeholder="رمز عبور"
|
placeholder="رمز عبور"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<CaptchaField
|
||||||
<div className="flex items-center justify-between gap-2">
|
key={captchaKey}
|
||||||
<Label htmlFor="captcha">کد امنیتی</Label>
|
id="captcha"
|
||||||
<button
|
value={captchaAnswer}
|
||||||
type="button"
|
onChange={setCaptchaAnswer}
|
||||||
onClick={() => void loadCaptcha()}
|
onChallenge={setChallenge}
|
||||||
disabled={captchaLoading}
|
onError={setError}
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
|
/>
|
||||||
>
|
|
||||||
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
|
|
||||||
تازهسازی
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-stretch gap-3">
|
|
||||||
<div
|
|
||||||
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{captchaLoading || !challenge?.imageBase64 ? (
|
|
||||||
<span className="text-sm text-muted-foreground">…</span>
|
|
||||||
) : (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={`data:image/png;base64,${challenge.imageBase64}`}
|
|
||||||
alt="کد امنیتی"
|
|
||||||
width={160}
|
|
||||||
height={52}
|
|
||||||
className="h-full w-full object-contain select-none"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="captcha"
|
|
||||||
type="text"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCapitalize="characters"
|
|
||||||
spellCheck={false}
|
|
||||||
value={captchaAnswer}
|
|
||||||
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
|
|
||||||
required
|
|
||||||
dir="ltr"
|
|
||||||
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
|
|
||||||
placeholder="کد تصویر"
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center justify-between gap-3 text-sm">
|
<label className="flex items-center justify-between gap-3 text-sm">
|
||||||
<span>مرا به یاد داشته باش</span>
|
<span>مرا به یاد داشته باش</span>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -215,7 +153,7 @@ export default function LoginPage() {
|
|||||||
variant="gradient"
|
variant="gradient"
|
||||||
className="h-12 w-full"
|
className="h-12 w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={loading || captchaLoading || !challenge}
|
disabled={loading || !challenge}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} ورود
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} ورود
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Flame, Heart, Sparkles, User as UserIcon } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth-store";
|
||||||
|
import type { PublicProfile } from "@/lib/types";
|
||||||
|
import { Panel } from "@/components/panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
|
||||||
|
import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
|
||||||
|
|
||||||
|
const AdminProfileManage = dynamic(
|
||||||
|
() => import("@/components/admin/profile-manage").then((m) => m.AdminProfileManage),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
function roleLabel(role: string) {
|
||||||
|
if (role === "mentor") return "منتور";
|
||||||
|
if (role === "admin") return "ادمین";
|
||||||
|
return "هنرجو";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicProfilePage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const id = Number(params.id);
|
||||||
|
const enabled = Number.isFinite(id) && id > 0;
|
||||||
|
const isAdmin = useAuth((s) => s.user?.role === "admin");
|
||||||
|
|
||||||
|
const profileQ = useQuery({
|
||||||
|
queryKey: ["public-profile", id],
|
||||||
|
queryFn: () => http.get<{ user: PublicProfile }>(`/api/users/${id}`),
|
||||||
|
enabled,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl py-16 text-center text-sm text-muted-foreground">
|
||||||
|
کاربر پیدا نشد.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileQ.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-4" aria-busy="true" aria-label="در حال بارگذاری پروفایل">
|
||||||
|
<Skeleton className="h-40 rounded-2xl" />
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-20 rounded-2xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileQ.isError || !profileQ.data?.user) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-4 py-16 text-center">
|
||||||
|
<p className="text-sm font-bold text-destructive">
|
||||||
|
{toUserError(profileQ.error, "کاربر پیدا نشد")}
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/unwrap">بازگشت</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = profileQ.data.user;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<Panel
|
||||||
|
title={person.name}
|
||||||
|
description={roleLabel(person.role)}
|
||||||
|
icon={<UserIcon className="h-5 w-5" />}
|
||||||
|
tint="bg-primary/10 text-primary"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
|
||||||
|
<UserAvatar name={person.name} className="h-20 w-20" {...avatarPropsOf(person)} />
|
||||||
|
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
|
||||||
|
<h1 className="text-2xl font-black tracking-tight">{person.name}</h1>
|
||||||
|
<p className="font-latin text-sm font-bold" dir="ltr">
|
||||||
|
@{person.username}
|
||||||
|
</p>
|
||||||
|
{person.isLocked && (
|
||||||
|
<p className="text-xs font-bold text-destructive">حساب قفل است</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} />
|
||||||
|
<Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلبها" value={formatFaNumber(person.hearts)} />
|
||||||
|
<Stat
|
||||||
|
icon={<Flame className="h-4 w-4 text-accent" />}
|
||||||
|
label="زنجیرهٔ فعلی"
|
||||||
|
value={formatStreakLabel(person.streakCurrent)}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
label="بلندترین زنجیره"
|
||||||
|
value={formatStreakLabel(person.streakLongest)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && <AdminProfileManage userId={person.id} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3">
|
||||||
|
{icon}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-lg font-black tabular-nums">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Heart,
|
Heart,
|
||||||
KeyRound,
|
|
||||||
Layers,
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
@@ -454,6 +454,10 @@ function UsersPanel() {
|
|||||||
(currentUser?.role === "mentor" && u.role === "student");
|
(currentUser?.role === "mentor" && u.role === "student");
|
||||||
return (
|
return (
|
||||||
<div key={u.id} className="flex items-center gap-3 px-3 py-2.5">
|
<div key={u.id} className="flex items-center gap-3 px-3 py-2.5">
|
||||||
|
<Link
|
||||||
|
href={`/profile/${u.id}`}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-md outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
<UserAvatar name={u.name} className="h-9 w-9" {...avatarPropsOf(u)} />
|
<UserAvatar name={u.name} className="h-9 w-9" {...avatarPropsOf(u)} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="flex items-center gap-2 truncate text-sm font-medium">
|
<p className="flex items-center gap-2 truncate text-sm font-medium">
|
||||||
@@ -475,6 +479,7 @@ function UsersPanel() {
|
|||||||
{u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)}
|
{u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
<div className="flex shrink-0 gap-1.5">
|
<div className="flex shrink-0 gap-1.5">
|
||||||
{canMutate && u.id !== currentUser?.id && (
|
{canMutate && u.id !== currentUser?.id && (
|
||||||
u.isLocked ? (
|
u.isLocked ? (
|
||||||
@@ -515,7 +520,7 @@ function UsersPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserDialog({
|
export function UserDialog({
|
||||||
user,
|
user,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -529,18 +534,13 @@ function UserDialog({
|
|||||||
const [form, setForm] = useState<User>(user ?? ({} as User));
|
const [form, setForm] = useState<User>(user ?? ({} as User));
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [securityAnswer, setSecurityAnswer] = useState("");
|
const [securityAnswer, setSecurityAnswer] = useState("");
|
||||||
const [resetAnswer, setResetAnswer] = useState("");
|
|
||||||
const [resetPassword, setResetPassword] = useState("");
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setForm(user);
|
setForm(user);
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setSecurityAnswer("");
|
setSecurityAnswer("");
|
||||||
setResetAnswer("");
|
|
||||||
setResetPassword("");
|
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -633,29 +633,6 @@ function UserDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doReset = async () => {
|
|
||||||
if (!form?.id) return;
|
|
||||||
const pwErr = validatePassword(resetPassword);
|
|
||||||
if (!resetAnswer.trim() || pwErr) {
|
|
||||||
toast.error(pwErr ?? "جواب امنیتی و رمز جدید (حداقل ۸ کاراکتر) لازم است");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setResetting(true);
|
|
||||||
try {
|
|
||||||
await http.post(`/api/admin/users/${form.id}/reset-password`, {
|
|
||||||
securityAnswer: resetAnswer,
|
|
||||||
newPassword: resetPassword,
|
|
||||||
});
|
|
||||||
toast.success("رمز عبور ریست شد");
|
|
||||||
setResetAnswer("");
|
|
||||||
setResetPassword("");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(toUserError(e));
|
|
||||||
} finally {
|
|
||||||
setResetting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
@@ -664,7 +641,7 @@ function UserDialog({
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isNew
|
{isNew
|
||||||
? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر."
|
? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر."
|
||||||
: "اطلاعات کاربر را بهروزرسانی کنید. ریست رمز فقط با جواب امنیتی."}
|
: "اطلاعات کاربر را بهروزرسانی کنید."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
@@ -768,40 +745,6 @@ function UserDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isNew && (
|
|
||||||
<div className="space-y-2 rounded-md border border-dashed border-border p-3">
|
|
||||||
<p className="text-sm font-medium">ریست رمز با سوال امنیتی</p>
|
|
||||||
{form.securityQuestion ? (
|
|
||||||
<p className="text-xs text-muted-foreground">سوال: {form.securityQuestion}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-destructive">سوال امنیتی تعریف نشده است.</p>
|
|
||||||
)}
|
|
||||||
<PasswordInput
|
|
||||||
value={resetAnswer}
|
|
||||||
onChange={(e) => setResetAnswer(e.target.value)}
|
|
||||||
placeholder="جواب امنیتی"
|
|
||||||
dir="rtl"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
value={resetPassword}
|
|
||||||
onChange={(e) => setResetPassword(e.target.value)}
|
|
||||||
placeholder="رمز جدید (حداقل ۸ کاراکتر)"
|
|
||||||
minLength={8}
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={resetting || !form.hasSecurityAnswer}
|
|
||||||
onClick={doReset}
|
|
||||||
>
|
|
||||||
{resetting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />}
|
|
||||||
ریست رمز
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>انصراف</Button>
|
<Button variant="outline" onClick={onClose}>انصراف</Button>
|
||||||
|
|||||||
@@ -68,16 +68,18 @@ export function InvitesPanel() {
|
|||||||
const [editBusy, setEditBusy] = useState(false);
|
const [editBusy, setEditBusy] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
const [status, setStatus] = useState<"" | RegistrationInvite["status"]>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [q]);
|
}, [q, status]);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "invites", q, page],
|
queryKey: ["admin", "invites", q, status, page],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams(buildPageQuery(page));
|
const params = new URLSearchParams(buildPageQuery(page));
|
||||||
if (q.trim()) params.set("q", q.trim());
|
if (q.trim()) params.set("q", q.trim());
|
||||||
|
if (status) params.set("status", status);
|
||||||
return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`);
|
return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -296,14 +298,43 @@ export function InvitesPanel() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<ListToolbar>
|
<ListToolbar>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["", "همه"],
|
||||||
|
["active", "فعال"],
|
||||||
|
["paused", "متوقف"],
|
||||||
|
["exhausted", "تکمیلشده"],
|
||||||
|
["revoked", "لغو شده"],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<Button
|
||||||
|
key={value || "all"}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={status === value ? "default" : "outline"}
|
||||||
|
onClick={() => setStatus(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="جستجو با برچسب…"
|
placeholder="جستجو با برچسب…"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
/>
|
/>
|
||||||
{q && (
|
{(q || status) && (
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={() => setQ("")}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setQ("");
|
||||||
|
setStatus("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
پاک کردن
|
پاک کردن
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -315,7 +346,7 @@ export function InvitesPanel() {
|
|||||||
)}
|
)}
|
||||||
{!isLoading && invites.length === 0 && (
|
{!isLoading && invites.length === 0 && (
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
{q.trim() ? "نتیجهای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
|
{q.trim() || status ? "نتیجهای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Flame,
|
Flame,
|
||||||
@@ -34,6 +34,12 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
import { cn, formatFaNumber } from "@/lib/utils";
|
import { cn, formatFaNumber } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
ListPagination,
|
||||||
|
buildPageQuery,
|
||||||
|
type Paginated,
|
||||||
|
} from "@/components/admin/list-pagination";
|
||||||
|
|
||||||
type SortKey = "xp" | "progress" | "activity" | "streak";
|
type SortKey = "xp" | "progress" | "activity" | "streak";
|
||||||
type FilterKey = "all" | "active" | "locked";
|
type FilterKey = "all" | "active" | "locked";
|
||||||
@@ -58,6 +64,11 @@ export function LearningPanel() {
|
|||||||
const [sort, setSort] = useState<SortKey>("xp");
|
const [sort, setSort] = useState<SortKey>("xp");
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [q, filter, sort]);
|
||||||
|
|
||||||
const overviewQ = useQuery({
|
const overviewQ = useQuery({
|
||||||
queryKey: ["admin", "learning", "overview"],
|
queryKey: ["admin", "learning", "overview"],
|
||||||
@@ -66,11 +77,17 @@ export function LearningPanel() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const studentsQ = useQuery({
|
const studentsQ = useQuery({
|
||||||
queryKey: ["admin", "learning", "students", q],
|
queryKey: ["admin", "learning", "students", q, filter, sort, page],
|
||||||
queryFn: () =>
|
queryFn: () => {
|
||||||
http.get<{ students: StudentLearningRow[] }>(
|
const params = new URLSearchParams(buildPageQuery(page));
|
||||||
`/api/admin/learning/students?q=${encodeURIComponent(q)}`
|
if (q.trim()) params.set("q", q.trim());
|
||||||
),
|
if (filter !== "all") params.set("filter", filter);
|
||||||
|
params.set("sort", sort);
|
||||||
|
return http.get<Paginated<StudentLearningRow>>(
|
||||||
|
`/api/admin/learning/students?${params.toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,27 +115,9 @@ export function LearningPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const overview = overviewQ.data?.overview;
|
const overview = overviewQ.data?.overview;
|
||||||
const students = useMemo(() => {
|
const students = studentsQ.data?.items ?? [];
|
||||||
let rows = [...(studentsQ.data?.students ?? [])];
|
const studentsTotal = studentsQ.data?.total ?? 0;
|
||||||
if (filter === "active") rows = rows.filter((r) => r.isActive && !r.isLocked);
|
const studentsPageSize = studentsQ.data?.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||||
if (filter === "locked") rows = rows.filter((r) => r.isLocked);
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
switch (sort) {
|
|
||||||
case "progress":
|
|
||||||
return b.progressPct - a.progressPct;
|
|
||||||
case "streak":
|
|
||||||
return b.streakCurrent - a.streakCurrent;
|
|
||||||
case "activity": {
|
|
||||||
const at = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0;
|
|
||||||
const bt = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0;
|
|
||||||
return bt - at;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return b.xp - a.xp;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}, [studentsQ.data?.students, filter, sort]);
|
|
||||||
|
|
||||||
const cards = overview
|
const cards = overview
|
||||||
? [
|
? [
|
||||||
@@ -177,7 +176,7 @@ export function LearningPanel() {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">جدول هنرجویان</CardTitle>
|
<CardTitle className="text-base">جدول هنرجویان</CardTitle>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{formatFaNumber(students.length)} نفر · برای جزئیات روی ردیف بزنید
|
{formatFaNumber(studentsTotal)} نفر · برای جزئیات روی ردیف بزنید
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -295,6 +294,12 @@ export function LearningPanel() {
|
|||||||
<p className="py-10 text-center text-sm text-muted-foreground">هنرجویی یافت نشد.</p>
|
<p className="py-10 text-center text-sm text-muted-foreground">هنرجویی یافت نشد.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ListPagination
|
||||||
|
page={page}
|
||||||
|
pageSize={studentsPageSize}
|
||||||
|
total={studentsTotal}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -305,9 +310,11 @@ export function LearningPanel() {
|
|||||||
{detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
|
{detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{detailQ.data
|
{detailQ.isError
|
||||||
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
|
? toUserError(detailQ.error, "بارگذاری جزئیات ممکن نشد")
|
||||||
: "در حال بارگذاری…"}
|
: detailQ.data
|
||||||
|
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
|
||||||
|
: "در حال بارگذاری…"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-5 px-6 py-5">
|
<div className="space-y-5 px-6 py-5">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
|
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
|
||||||
@@ -11,6 +11,11 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "@/components/providers";
|
import { toast } from "@/components/providers";
|
||||||
import { formatFaNumber } from "@/lib/utils";
|
import { formatFaNumber } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
ListPagination,
|
||||||
|
buildPageQuery,
|
||||||
|
} from "@/components/admin/list-pagination";
|
||||||
|
|
||||||
const STATUS_LABEL: Record<PhysicalOrderStatus, string> = {
|
const STATUS_LABEL: Record<PhysicalOrderStatus, string> = {
|
||||||
requested: "درخواستشده",
|
requested: "درخواستشده",
|
||||||
@@ -27,16 +32,29 @@ const NEXT: Partial<Record<PhysicalOrderStatus, PhysicalOrderStatus>> = {
|
|||||||
export function PhysicalOrdersPanel() {
|
export function PhysicalOrdersPanel() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [notes, setNotes] = useState<Record<number, string>>({});
|
const [notes, setNotes] = useState<Record<number, string>>({});
|
||||||
const [tracking, setTracking] = useState<Record<number, string>>({});
|
const [tracking, setTracking] = useState<Record<number, string>>({});
|
||||||
const [busy, setBusy] = useState<number | null>(null);
|
const [busy, setBusy] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "physical-orders", statusFilter],
|
queryKey: ["admin", "physical-orders", statusFilter, page],
|
||||||
queryFn: () =>
|
queryFn: () => {
|
||||||
http.get<{ orders: CertificatePhysicalOrder[]; physical: PhysicalCertSettings }>(
|
const params = new URLSearchParams(buildPageQuery(page));
|
||||||
`/api/admin/physical-orders?status=${encodeURIComponent(statusFilter)}`,
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
),
|
return http.get<{
|
||||||
|
orders: CertificatePhysicalOrder[];
|
||||||
|
physical: PhysicalCertSettings;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>(`/api/admin/physical-orders?${params.toString()}`);
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const update = async (id: number, status: PhysicalOrderStatus) => {
|
const update = async (id: number, status: PhysicalOrderStatus) => {
|
||||||
@@ -151,6 +169,12 @@ export function PhysicalOrdersPanel() {
|
|||||||
{!isLoading && (data?.orders ?? []).length === 0 && (
|
{!isLoading && (data?.orders ?? []).length === 0 && (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
|
||||||
)}
|
)}
|
||||||
|
<ListPagination
|
||||||
|
page={page}
|
||||||
|
pageSize={data?.pageSize ?? DEFAULT_PAGE_SIZE}
|
||||||
|
total={data?.total ?? 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { KeyRound, Loader2, Lock, Mail, Pencil, Phone, Shield, ShieldQuestion, Trash2, Unlock } from "lucide-react";
|
||||||
|
import { UserDialog } from "@/components/admin-studio";
|
||||||
|
import { useConfirm } from "@/components/admin/use-confirm";
|
||||||
|
import { Panel } from "@/components/panel";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth-store";
|
||||||
|
import { validatePassword } from "@/lib/validation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { User } from "@/lib/types";
|
||||||
|
|
||||||
|
export function AdminProfileManage({ userId }: { userId: number }) {
|
||||||
|
const me = useAuth((s) => s.user);
|
||||||
|
const router = useRouter();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { confirm, dialog } = useConfirm();
|
||||||
|
const [busy, setBusy] = useState<"lock" | "delete" | "reset" | null>(null);
|
||||||
|
const [editing, setEditing] = useState<User | null>(null);
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [nextPassword, setNextPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
|
||||||
|
const accountQ = useQuery({
|
||||||
|
queryKey: ["admin", "user", userId],
|
||||||
|
queryFn: () => http.get<{ user: User }>(`/api/admin/users/${userId}`),
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "user", userId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["public-profile", userId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = accountQ.data?.user;
|
||||||
|
const isSelf = account?.id === me?.id;
|
||||||
|
|
||||||
|
const act = async (kind: "lock" | "unlock") => {
|
||||||
|
setBusy("lock");
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/users/${userId}/${kind}`);
|
||||||
|
toast.success(kind === "lock" ? "حساب قفل شد" : "حساب فعال شد");
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeReset = () => {
|
||||||
|
setResetOpen(false);
|
||||||
|
setNextPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPassword = async () => {
|
||||||
|
const pwErr = validatePassword(nextPassword, confirmPassword);
|
||||||
|
if (pwErr) {
|
||||||
|
toast.error(pwErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy("reset");
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/users/${userId}/reset-password`, { newPassword: nextPassword });
|
||||||
|
toast.success("رمز عبور عوض شد");
|
||||||
|
closeReset();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async () => {
|
||||||
|
if (!account) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await confirm(
|
||||||
|
"حذف کاربر",
|
||||||
|
`حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشتناپذیر است.`,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy("delete");
|
||||||
|
try {
|
||||||
|
await http.del(`/api/admin/users/${account.id}`);
|
||||||
|
toast.success("کاربر حذف شد");
|
||||||
|
router.push("/admin?tab=users");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{dialog}
|
||||||
|
<Panel
|
||||||
|
title="مدیریت حساب"
|
||||||
|
description="فقط ادمین این بخش را میبیند."
|
||||||
|
icon={<Shield className="h-5 w-5" />}
|
||||||
|
tint="bg-destructive/10 text-destructive"
|
||||||
|
>
|
||||||
|
{accountQ.isPending && (
|
||||||
|
<div className="space-y-2" aria-busy="true">
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accountQ.isError && (
|
||||||
|
<p className="text-sm font-bold text-destructive">{toUserError(accountQ.error, "حساب پیدا نشد")}</p>
|
||||||
|
)}
|
||||||
|
{account && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<dl className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<AccountFact icon={<Mail className="h-3.5 w-3.5" />} label="ایمیل" value={account.email} ltr />
|
||||||
|
<AccountFact
|
||||||
|
icon={<Phone className="h-3.5 w-3.5" />}
|
||||||
|
label="تلفن"
|
||||||
|
value={account.phone || "ثبت نشده"}
|
||||||
|
ltr
|
||||||
|
/>
|
||||||
|
<AccountFact
|
||||||
|
className="sm:col-span-2"
|
||||||
|
icon={<ShieldQuestion className="h-3.5 w-3.5" />}
|
||||||
|
label="سوال امنیتی"
|
||||||
|
value={account.securityQuestion || "تعریف نشده"}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
<div className="space-y-2 border-t border-border/70 pt-4">
|
||||||
|
{!isSelf &&
|
||||||
|
(account.isLocked ? (
|
||||||
|
<Button size="sm" variant="success" disabled={busy !== null} onClick={() => act("unlock")}>
|
||||||
|
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||||
|
فعالسازی
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => act("lock")}>
|
||||||
|
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Lock className="h-3.5 w-3.5" />}
|
||||||
|
قفل
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<div className={cn("grid gap-2", isSelf ? "sm:grid-cols-2" : "sm:grid-cols-3")}>
|
||||||
|
<Button size="sm" variant="outline" className="w-full" onClick={() => setEditing(account)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> ویرایش
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="w-full" disabled={busy !== null} onClick={() => setResetOpen(true)}>
|
||||||
|
<KeyRound className="h-3.5 w-3.5" /> ریست رمز
|
||||||
|
</Button>
|
||||||
|
{!isSelf && (
|
||||||
|
<Button size="sm" variant="destructive" className="w-full" disabled={busy !== null} onClick={del}>
|
||||||
|
{busy === "delete" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
<UserDialog user={editing} onClose={() => setEditing(null)} onSaved={refresh} />
|
||||||
|
<Dialog open={resetOpen} onOpenChange={(open) => !open && closeReset()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>ریست رمز</DialogTitle>
|
||||||
|
<DialogDescription>رمز جدید همین حساب را بنویسید.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="reset-password">رمز جدید</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="reset-password"
|
||||||
|
value={nextPassword}
|
||||||
|
onChange={(e) => setNextPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="حداقل ۸ کاراکتر"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="reset-password-confirm">تکرار رمز</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="reset-password-confirm"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="همان رمز را دوباره بنویسید"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={closeReset}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void resetPassword()} disabled={busy === "reset"}>
|
||||||
|
{busy === "reset" ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||||
|
ذخیره رمز
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountFact({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
ltr,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
ltr?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-xl border border-border/80 bg-muted/25 px-3.5 py-3", className)}>
|
||||||
|
<dt className="flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className={cn("mt-1.5 text-sm font-semibold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ export function StatsPanel() {
|
|||||||
|
|
||||||
const s = data?.stats;
|
const s = data?.stats;
|
||||||
const cards = [
|
const cards = [
|
||||||
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "حسابهای فعال در سامانه" },
|
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "هنرجویان با حساب فعال" },
|
||||||
{ label: "درسها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درسهای ثبتشده در درخت" },
|
{ label: "درسها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درسهای ثبتشده در درخت" },
|
||||||
{ label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" },
|
{ label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" },
|
||||||
{ label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاهها" },
|
{ label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاهها" },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [
|
|||||||
"/quiz",
|
"/quiz",
|
||||||
"/notifications",
|
"/notifications",
|
||||||
"/lockout",
|
"/lockout",
|
||||||
|
"/profile",
|
||||||
];
|
];
|
||||||
|
|
||||||
function needsAuth(p: string) {
|
function needsAuth(p: string) {
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export type CaptchaChallenge = {
|
||||||
|
challengeId: string;
|
||||||
|
imageBase64: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_SECONDS = 120;
|
||||||
|
|
||||||
|
export function CaptchaField({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onChallenge,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onChallenge: (challenge: CaptchaChallenge | null) => void;
|
||||||
|
onError?: (message: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [left, setLeft] = useState(FALLBACK_SECONDS);
|
||||||
|
const gen = useRef(0);
|
||||||
|
const rotating = useRef(false);
|
||||||
|
const onChallengeRef = useRef(onChallenge);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
onChallengeRef.current = onChallenge;
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
|
||||||
|
const load = useCallback(async (silent: boolean) => {
|
||||||
|
if (silent && rotating.current) return;
|
||||||
|
if (silent) rotating.current = true;
|
||||||
|
const ticket = ++gen.current;
|
||||||
|
if (!silent) setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
|
||||||
|
if (ticket !== gen.current) return;
|
||||||
|
setChallenge(data);
|
||||||
|
onChallengeRef.current(data);
|
||||||
|
setLeft(Math.max(15, Math.round(data.expiresIn || FALLBACK_SECONDS)));
|
||||||
|
onChangeRef.current("");
|
||||||
|
onErrorRef.current?.(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (ticket !== gen.current) return;
|
||||||
|
if (!silent) {
|
||||||
|
setChallenge(null);
|
||||||
|
onChallengeRef.current(null);
|
||||||
|
}
|
||||||
|
onErrorRef.current?.(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
rotating.current = false;
|
||||||
|
if (ticket === gen.current && !silent) setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(false);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!challenge) return;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setLeft((n) => {
|
||||||
|
if (n <= 1) {
|
||||||
|
void load(true);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return n - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [challenge, load]);
|
||||||
|
|
||||||
|
const minutes = String(Math.floor(left / 60)).padStart(1, "0");
|
||||||
|
const seconds = String(left % 60).padStart(2, "0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label htmlFor={id}>کد امنیتی</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
تازهسازی
|
||||||
|
{challenge && (
|
||||||
|
<span className="tabular-nums text-muted-foreground" dir="ltr">
|
||||||
|
{minutes}:{seconds}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-stretch gap-3">
|
||||||
|
<div
|
||||||
|
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{challenge?.imageBase64 ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${challenge.imageBase64}`}
|
||||||
|
alt="کد امنیتی"
|
||||||
|
width={160}
|
||||||
|
height={52}
|
||||||
|
className="h-full w-full object-contain select-none"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">{loading ? "…" : "—"}</span>
|
||||||
|
)}
|
||||||
|
{challenge && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-x-0 bottom-0 h-0.5 bg-primary/15"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-full bg-primary transition-[width] duration-1000 ease-linear"
|
||||||
|
style={{ width: `${Math.min(100, (left / Math.max(challenge.expiresIn || FALLBACK_SECONDS, 1)) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
spellCheck={false}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value.toUpperCase())}
|
||||||
|
required
|
||||||
|
dir="ltr"
|
||||||
|
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
|
||||||
|
placeholder="کد تصویر"
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Crown, Sparkles, Timer, Trophy } from "lucide-react";
|
import { Crown, Sparkles, Timer, Trophy } from "lucide-react";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
@@ -110,10 +111,11 @@ export function LeaderboardPanel() {
|
|||||||
{rest.map((e) => {
|
{rest.map((e) => {
|
||||||
const isMe = !!user && e.userId === user.id;
|
const isMe = !!user && e.userId === user.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
key={e.userId}
|
key={e.userId}
|
||||||
|
href={`/profile/${e.userId}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-xl border px-4 py-2.5",
|
"flex items-center gap-3 rounded-xl border px-4 py-2.5 transition-colors hover:bg-muted/40",
|
||||||
isMe ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border bg-card"
|
isMe ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border bg-card"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -135,7 +137,7 @@ export function LeaderboardPanel() {
|
|||||||
<span className="shrink-0 text-sm font-bold text-gold">
|
<span className="shrink-0 text-sm font-bold text-gold">
|
||||||
{formatFaNumber(Math.round(e.xp))} امتیاز
|
{formatFaNumber(Math.round(e.xp))} امتیاز
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{rest.length === 0 && entries.length === 0 && (
|
{rest.length === 0 && entries.length === 0 && (
|
||||||
@@ -197,9 +199,10 @@ function PodiumCard({
|
|||||||
}) {
|
}) {
|
||||||
const isMe = myId != null && entry.userId === myId;
|
const isMe = myId != null && entry.userId === myId;
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
|
href={`/profile/${entry.userId}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm",
|
"flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm transition-colors hover:bg-muted/40",
|
||||||
tall ? "pb-6 pt-5" : "pb-4",
|
tall ? "pb-6 pt-5" : "pb-4",
|
||||||
isMe && "border-primary/50 ring-2 ring-primary/30",
|
isMe && "border-primary/50 ring-2 ring-primary/30",
|
||||||
className
|
className
|
||||||
@@ -225,7 +228,7 @@ function PodiumCard({
|
|||||||
>
|
>
|
||||||
{entry.rank === 1 ? <Crown className="h-4 w-4" /> : formatFaNumber(entry.rank)}
|
{entry.rank === 1 ? <Crown className="h-4 w-4" /> : formatFaNumber(entry.rank)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowLeft, ArrowRight, Loader2, RefreshCw, UserPlus } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Loader2, UserPlus } from "lucide-react";
|
||||||
import { useAuth } from "@/lib/auth-store";
|
import { useAuth } from "@/lib/auth-store";
|
||||||
import { http, toUserError } from "@/lib/api";
|
import { http, toUserError } from "@/lib/api";
|
||||||
import type { User } from "@/lib/types";
|
import type { User } from "@/lib/types";
|
||||||
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { PasswordInput } from "@/components/ui/password-input";
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
|
||||||
import {
|
import {
|
||||||
validateEmail,
|
validateEmail,
|
||||||
validateName,
|
validateName,
|
||||||
@@ -30,11 +31,6 @@ const STEPS = [
|
|||||||
{ id: 3, title: "گذرواژه", hint: "رمز و کد امنیتی" },
|
{ id: 3, title: "گذرواژه", hint: "رمز و کد امنیتی" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type CaptchaChallenge = {
|
|
||||||
challengeId: string;
|
|
||||||
imageBase64: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RegisterFormProps = {
|
type RegisterFormProps = {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
@@ -73,30 +69,10 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
|||||||
const [securityAnswer, setSecurityAnswer] = useState("");
|
const [securityAnswer, setSecurityAnswer] = useState("");
|
||||||
const [captchaAnswer, setCaptchaAnswer] = useState("");
|
const [captchaAnswer, setCaptchaAnswer] = useState("");
|
||||||
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
|
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
|
||||||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
const [captchaKey, setCaptchaKey] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadCaptcha = useCallback(async () => {
|
|
||||||
setCaptchaLoading(true);
|
|
||||||
setCaptchaAnswer("");
|
|
||||||
try {
|
|
||||||
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
|
|
||||||
setChallenge(data);
|
|
||||||
} catch (err) {
|
|
||||||
setChallenge(null);
|
|
||||||
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
|
|
||||||
} finally {
|
|
||||||
setCaptchaLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (step === 3) {
|
|
||||||
void loadCaptcha();
|
|
||||||
}
|
|
||||||
}, [step, loadCaptcha]);
|
|
||||||
|
|
||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
@@ -152,7 +128,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
|||||||
}
|
}
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
setError("ابتدا کد امنیتی را بارگذاری کنید");
|
setError("ابتدا کد امنیتی را بارگذاری کنید");
|
||||||
void loadCaptcha();
|
setCaptchaKey((k) => k + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -176,7 +152,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
|||||||
router.push("/cap");
|
router.push("/cap");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(toUserError(err, "ثبتنام ناموفق بود"));
|
setError(toUserError(err, "ثبتنام ناموفق بود"));
|
||||||
void loadCaptcha();
|
setCaptchaKey((k) => k + 1);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -322,54 +298,14 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
|||||||
placeholder="همان رمز را دوباره وارد کنید"
|
placeholder="همان رمز را دوباره وارد کنید"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="space-y-2">
|
<CaptchaField
|
||||||
<div className="flex items-center justify-between gap-2">
|
key={captchaKey}
|
||||||
<Label htmlFor="reg-captcha">کد امنیتی</Label>
|
id="reg-captcha"
|
||||||
<button
|
value={captchaAnswer}
|
||||||
type="button"
|
onChange={setCaptchaAnswer}
|
||||||
onClick={() => void loadCaptcha()}
|
onChallenge={setChallenge}
|
||||||
disabled={captchaLoading}
|
onError={setError}
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
|
/>
|
||||||
>
|
|
||||||
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
|
|
||||||
تازهسازی
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-stretch gap-3">
|
|
||||||
<div
|
|
||||||
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{captchaLoading || !challenge?.imageBase64 ? (
|
|
||||||
<span className="text-sm text-muted-foreground">…</span>
|
|
||||||
) : (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={`data:image/png;base64,${challenge.imageBase64}`}
|
|
||||||
alt="کد امنیتی"
|
|
||||||
width={160}
|
|
||||||
height={52}
|
|
||||||
className="h-full w-full object-contain select-none"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="reg-captcha"
|
|
||||||
type="text"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCapitalize="characters"
|
|
||||||
spellCheck={false}
|
|
||||||
value={captchaAnswer}
|
|
||||||
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
|
|
||||||
required
|
|
||||||
dir="ltr"
|
|
||||||
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
|
|
||||||
placeholder="کد تصویر"
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -400,7 +336,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
|
|||||||
variant="gradient"
|
variant="gradient"
|
||||||
className="h-12 min-w-0 flex-1"
|
className="h-12 min-w-0 flex-1"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={loading || captchaLoading || !challenge}
|
disabled={loading || !challenge}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import BoringAvatar from "boring-avatars";
|
import BoringAvatar from "boring-avatars";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { mediaObjectKey } from "@/lib/media";
|
||||||
|
|
||||||
const AVATAR_COLORS = ["#7d6df2", "#0f9b8e", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#65a30d", "#f59e0b"];
|
const AVATAR_COLORS = ["#7d6df2", "#0f9b8e", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#65a30d", "#f59e0b"];
|
||||||
export { AVATAR_COLORS };
|
export { AVATAR_COLORS };
|
||||||
@@ -41,6 +42,40 @@ export function avatarPropsOf(u: AvatarOwner): { src: string | null; variant: Av
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useSettledPhoto(src?: string | null): string | null {
|
||||||
|
const [painted, setPainted] = useState<string | null>(null);
|
||||||
|
const paintedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!src) {
|
||||||
|
paintedRef.current = null;
|
||||||
|
setPainted(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (src === paintedRef.current) return;
|
||||||
|
let cancel = false;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancel) return;
|
||||||
|
paintedRef.current = src;
|
||||||
|
setPainted(src);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
if (cancel) return;
|
||||||
|
if (mediaObjectKey(src) !== mediaObjectKey(paintedRef.current)) {
|
||||||
|
paintedRef.current = null;
|
||||||
|
setPainted(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = src;
|
||||||
|
return () => {
|
||||||
|
cancel = true;
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
return painted;
|
||||||
|
}
|
||||||
|
|
||||||
export function UserAvatar({
|
export function UserAvatar({
|
||||||
name,
|
name,
|
||||||
src,
|
src,
|
||||||
@@ -54,12 +89,17 @@ export function UserAvatar({
|
|||||||
palette?: string[];
|
palette?: string[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
if (src) {
|
const painted = useSettledPhoto(src);
|
||||||
|
if (painted) {
|
||||||
return (
|
return (
|
||||||
<Avatar className={cn("ring-1 ring-black/5 dark:ring-white/10", className)}>
|
<span
|
||||||
<AvatarImage src={src} alt={name} />
|
className={cn(
|
||||||
<AvatarFallback />
|
"relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full bg-background ring-1 ring-black/5 dark:ring-white/10",
|
||||||
</Avatar>
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img src={painted} alt={name} className="h-full w-full object-cover" draggable={false} />
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { mediaObjectKey } from "@/lib/media";
|
||||||
|
import { loadCachedMedia } from "@/lib/transfer";
|
||||||
|
|
||||||
|
/** Local bytes for an image or similar object. A replacement has a new key and loads again. Video is not cached. */
|
||||||
|
export function useCachedObjectUrl(url: string, cacheable = true): string {
|
||||||
|
const key = mediaObjectKey(url);
|
||||||
|
const [local, setLocal] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cacheable || !url) {
|
||||||
|
setLocal("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dead = false;
|
||||||
|
let objectUrl = "";
|
||||||
|
void loadCachedMedia(url)
|
||||||
|
.then((blob) => {
|
||||||
|
if (dead) return;
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setLocal(objectUrl);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!dead) setLocal(url);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [cacheable, key, url]);
|
||||||
|
|
||||||
|
if (!cacheable) return url;
|
||||||
|
return local;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Storage path of a signed /media URL, without the rotating signature. */
|
||||||
|
export function mediaObjectKey(src: string | null | undefined): string {
|
||||||
|
if (!src) return "";
|
||||||
|
const cut = src.indexOf("?");
|
||||||
|
const path = cut >= 0 ? src.slice(0, cut) : src;
|
||||||
|
const i = path.indexOf("/media/");
|
||||||
|
return i >= 0 ? path.slice(i) : path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useAuth } from "./auth-store";
|
||||||
|
import { ApiError, refreshSession, throwFromResponse } from "./api";
|
||||||
|
import { mediaObjectKey } from "./media";
|
||||||
|
|
||||||
|
const mediaCacheName = "pargar-media-v1";
|
||||||
|
|
||||||
|
function apiBase(): string {
|
||||||
|
const fromEnv = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
if (fromEnv !== undefined && fromEnv !== "") return fromEnv.replace(/\/$/, "");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
||||||
|
}
|
||||||
|
return Math.random().toString(16).slice(2, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOnce(): Promise<string | null> {
|
||||||
|
await refreshSession();
|
||||||
|
return useAuth.getState().accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
type XHRResult = { status: number; body: string; blob: Blob | null };
|
||||||
|
|
||||||
|
function xhrSend(
|
||||||
|
method: "GET" | "POST",
|
||||||
|
url: string,
|
||||||
|
opts: {
|
||||||
|
token: string | null;
|
||||||
|
body?: FormData;
|
||||||
|
responseType: "json" | "blob";
|
||||||
|
onProgress?: (ratio: number) => void;
|
||||||
|
},
|
||||||
|
): Promise<XHRResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open(method, url);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.setRequestHeader("X-Request-ID", requestId());
|
||||||
|
if (opts.token) xhr.setRequestHeader("Authorization", `Bearer ${opts.token}`);
|
||||||
|
xhr.responseType = opts.responseType === "blob" ? "blob" : "text";
|
||||||
|
const progress = (e: ProgressEvent) => {
|
||||||
|
if (!opts.onProgress) return;
|
||||||
|
if (e.lengthComputable && e.total > 0) opts.onProgress(Math.min(1, e.loaded / e.total));
|
||||||
|
};
|
||||||
|
if (method === "POST") xhr.upload.onprogress = progress;
|
||||||
|
else xhr.onprogress = progress;
|
||||||
|
xhr.onload = () => {
|
||||||
|
resolve({
|
||||||
|
status: xhr.status,
|
||||||
|
body: opts.responseType === "json" ? String(xhr.response ?? "") : "",
|
||||||
|
blob: opts.responseType === "blob" ? (xhr.response as Blob) : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new ApiError("ارتباط قطع شد", 0));
|
||||||
|
xhr.send(opts.body ?? null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multipart upload that reports how full the transfer is. */
|
||||||
|
export async function apiFormProgress<T>(
|
||||||
|
path: string,
|
||||||
|
form: FormData,
|
||||||
|
onProgress?: (ratio: number) => void,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${apiBase()}${path}`;
|
||||||
|
const send = (token: string | null) =>
|
||||||
|
xhrSend("POST", url, { token, body: form, responseType: "json", onProgress });
|
||||||
|
|
||||||
|
let res = await send(useAuth.getState().accessToken);
|
||||||
|
if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
|
||||||
|
res = await send(await refreshOnce());
|
||||||
|
}
|
||||||
|
if (res.status < 200 || res.status >= 300) {
|
||||||
|
const fake = new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||||
|
await throwFromResponse(fake, `Upload failed (${res.status})`);
|
||||||
|
}
|
||||||
|
onProgress?.(1);
|
||||||
|
return JSON.parse(res.body) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download into memory and report fill ratio. Does not open a tab. */
|
||||||
|
export async function fetchBlob(
|
||||||
|
url: string,
|
||||||
|
onProgress?: (ratio: number) => void,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const res = await xhrSend("GET", url, { token: null, responseType: "blob", onProgress });
|
||||||
|
if (res.status < 200 || res.status >= 300 || !res.blob) {
|
||||||
|
throw new ApiError("دانلود ممکن نشد", res.status);
|
||||||
|
}
|
||||||
|
onProgress?.(1);
|
||||||
|
return res.blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mediaCache(): Promise<Cache | null> {
|
||||||
|
if (typeof caches === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return await caches.open(mediaCacheName);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes already saved for this object. A new photo, video, or voice is a new key. */
|
||||||
|
export async function readCachedMedia(url: string): Promise<Blob | null> {
|
||||||
|
try {
|
||||||
|
const key = mediaObjectKey(url);
|
||||||
|
if (!key) return null;
|
||||||
|
const cache = await mediaCache();
|
||||||
|
const hit = await cache?.match(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
return hit.blob();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCachedMedia(url: string, blob: Blob): Promise<void> {
|
||||||
|
try {
|
||||||
|
const key = mediaObjectKey(url);
|
||||||
|
if (!key) return;
|
||||||
|
const cache = await mediaCache();
|
||||||
|
if (!cache) return;
|
||||||
|
await cache.put(
|
||||||
|
key,
|
||||||
|
new Response(blob, { headers: { "Content-Type": blob.type || "application/octet-stream" } }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// A failed cache still leaves the file ready to save.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(blob: Blob, filename: string) {
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = filename || "file";
|
||||||
|
a.rel = "noopener";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a remote file after it has finished downloading.
|
||||||
|
* Image, audio, and other files are kept by object key, so a later save does not fetch again.
|
||||||
|
* Video is streamed and is not kept this way.
|
||||||
|
*/
|
||||||
|
export async function saveRemoteFile(
|
||||||
|
url: string,
|
||||||
|
filename: string,
|
||||||
|
onProgress?: (ratio: number) => void,
|
||||||
|
opts?: { cache?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const cache = opts?.cache !== false;
|
||||||
|
if (cache) {
|
||||||
|
const hit = await readCachedMedia(url);
|
||||||
|
if (hit) {
|
||||||
|
onProgress?.(1);
|
||||||
|
triggerDownload(hit, filename);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const blob = await fetchBlob(url, onProgress);
|
||||||
|
if (cache) await writeCachedMedia(url, blob);
|
||||||
|
triggerDownload(blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
|
||||||
|
export async function loadCachedMedia(url: string): Promise<Blob> {
|
||||||
|
const hit = await readCachedMedia(url);
|
||||||
|
if (hit) return hit;
|
||||||
|
const blob = await fetchBlob(url);
|
||||||
|
await writeCachedMedia(url, blob);
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
@@ -193,6 +193,7 @@ export interface ChatMessage {
|
|||||||
replyTo?: number;
|
replyTo?: number;
|
||||||
pinnedAt?: string;
|
pinnedAt?: string;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
|
editedAt?: string;
|
||||||
attachment?: ChatAttachment;
|
attachment?: ChatAttachment;
|
||||||
reactions?: ChatReaction[];
|
reactions?: ChatReaction[];
|
||||||
}
|
}
|
||||||
@@ -264,6 +265,21 @@ export interface MCQQuestion {
|
|||||||
explanation: string;
|
explanation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicProfile {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
xp: number;
|
||||||
|
hearts: number;
|
||||||
|
streakCurrent: number;
|
||||||
|
streakLongest: number;
|
||||||
|
avatarVariant?: User["avatarVariant"];
|
||||||
|
avatarPalette?: string;
|
||||||
|
avatarPhoto?: string;
|
||||||
|
isLocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
users: number;
|
users: number;
|
||||||
lessons: number;
|
lessons: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user