1 Commits
51 changed files with 856 additions and 2620 deletions
+43 -63
View File
@@ -1,87 +1,67 @@
<p align="center"> # Pargar
<img src="frontend/public/logo.png" alt="Pargar mark" width="96" />
</p>
<h1 align="center">Pargar</h1> A gated LMS for bootcamps: verified watch → quiz with hearts → skill-tree unlock → mentor chat, events, weekly league, monthly challenges, and certificates.
<p align="center"> | Layer | Stack |
<strong>A bootcamp learning path with no shortcuts.</strong><br /> |-------|--------|
Verified watch, quizzes with hearts, a skill tree, and a mentor. The product UI is Persian (پرگار). | API | Go 1.27+ · Postgres · Redis · S3/RustFS · WebSocket |
</p> | UI | Next.js 15 · React Query · Tailwind · Vazirmatn FD |
| Edge | Traefik v3.7 (Compose and k3s) |
<p align="center"> ## Quick start (local)
<a href="https://github.com/Panahifarah/pargar/releases/tag/v0.1.3"><img src="https://img.shields.io/github/v/tag/Panahifarah/pargar?label=release&color=5B4CDB" alt="release" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-5B4CDB" alt="AGPL-3.0" /></a>
</p>
Pargar is a gated LMS for a bootcamp. A student watches the lesson for real, passes that lesson's quiz, and only then opens the next branch of the skill tree. Alongside the path: mentor chat, events, a weekly league, a monthly challenge, and a certificate.
Public registration sits behind a phone whitelist. Invite links work on their own. Students cannot recover a password themselves; an admin sets a new one from that person's profile.
## The path
| Step | What happens |
| --- | --- |
| Verified watch | Watch time is checked with periodic heartbeats. Skipping ahead does not finish the lesson. |
| Quiz with hearts | Each wrong answer spends a heart. An empty heart meter locks the account until staff reviews it. |
| Skill tree | The next branch opens only after its parent is passed. Staff unlock the following lesson. |
| Community | Mentor chat, events, a weekly league that resets on Monday, and a monthly challenge. |
| Certificate | An unlisted digital link. A physical copy, when enabled, is offline payment only. |
Three roles: a **student** walks the path, a **mentor** sees curriculum and student learning, and an **admin** also manages users, invites, site settings, and physical certificate orders.
Product detail: [docs/product.md](docs/product.md)
## Run it locally
Docker is required. Copy `.env.example` and replace every `replace-with-…` value. The admin password is `ADMIN_PASSWORD`. The default username is `admin`.
```bash ```bash
cp .env.example .env cp .env.example .env # fill in real secrets
./scripts/compose-dev.sh up -d --build ./scripts/compose-dev.sh up -d --build
``` ```
The app is at [https://localhost](https://localhost). Traefik uses its own certificate, so the browser shows one warning. API health: - App: **https://localhost** (Traefik default cert → one browser warning is expected)
- Traefik dashboard: http://127.0.0.1:8088
- Health: `curl -k https://localhost/api/health`
k3s:
```bash ```bash
curl -k https://localhost/api/health ./scripts/k3s-load-images.sh
./scripts/helm-k3s.sh
curl -k https://pargar.local/api/health
``` ```
k3s and production: [docs/deployment.md](docs/deployment.md) Deployment details: [docs/deployment.md](docs/deployment.md)
## Stack ## Documentation
| Layer | Technology | | Doc | Contents |
| --- | --- | |-----|----------|
| API | Go, Postgres, Redis, S3/RustFS, WebSocket | | [docs/README.md](docs/README.md) | Doc index and reading map |
| UI | Next.js 15, React, Tailwind | | [docs/architecture.md](docs/architecture.md) | System architecture and data flow |
| Edge | Traefik, on Compose and Helm | | [docs/product.md](docs/product.md) | Student / admin paths and features |
| [docs/development.md](docs/development.md) | Development, tests, code conventions |
| [docs/deployment.md](docs/deployment.md) | Compose · Helm/k3s · production |
| [docs/security.md](docs/security.md) | Auth, secrets, rate limits, uploads |
| [docs/operations.md](docs/operations.md) | Backup, metrics, troubleshooting |
| [docs/api.md](docs/api.md) | API contract and key routes |
| [docs/configuration.md](docs/configuration.md) | Environment variable reference |
The browser talks to one origin. Traefik splits the UI from `/api`. Detail: [docs/architecture.md](docs/architecture.md) Domain summary for agents: [CONTEXT.md](CONTEXT.md)
## Repository ## Repository layout
```text ```text
backend/ API, migrations, seed data backend/ Go API, migrations, seed, media/sample.mp4
frontend/ Next.js UI frontend/ Next.js App Router
deploy/ Compose, Traefik, Helm chart deploy/ Compose + Traefik + Helm chart
scripts/ Local run, k3s, backup scripts/ compose / k3s / backup / regenerate-sample
docs/ Engineering docs docs/ engineering documentation
``` ```
| Doc | Read it for | ## Tests
| --- | --- |
| [Product](docs/product.md) | Student path, admin, invites, certificates |
| [Architecture](docs/architecture.md) | Data flow and service boundaries |
| [Development](docs/development.md) | Local workflow and tests |
| [Deployment](docs/deployment.md) | Compose, Helm, production |
| [Security](docs/security.md) | Sessions, secrets, rate limits, uploads |
| [Operations](docs/operations.md) | Backup, metrics, troubleshooting |
| [API](docs/api.md) | Route contract |
| [Configuration](docs/configuration.md) | Environment variables |
Index: [docs/README.md](docs/README.md) ```bash
cd backend && go test ./internal/api/... ./internal/config/...
cd frontend && bunx tsc --noEmit
```
## License ## License
Released under [AGPL-3.0](LICENSE). This project is released under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0).
+15 -15
View File
@@ -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.45.0 go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0
go.opentelemetry.io/otel/sdk v1.45.0 go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/trace v1.45.0 go.opentelemetry.io/otel/trace v1.35.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/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // 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.4 // indirect github.com/go-logr/logr v1.4.2 // 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.29.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // 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.2.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.opentelemetry.io/proto/otlp v1.5.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-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.83.2 // indirect google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.5 // indirect
) )
+32 -34
View File
@@ -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/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/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.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
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.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
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 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
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,16 +117,14 @@ 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=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
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 h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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=
+11 -15
View File
@@ -22,20 +22,6 @@ 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")
@@ -403,17 +389,27 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
return return
} }
if _, err := s.store.GetUserByID(r.Context(), id); err != nil { target, err := s.store.GetUserByID(r.Context(), id)
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
-1
View File
@@ -306,7 +306,6 @@ 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})
} }
+1 -3
View File
@@ -22,7 +22,7 @@ import (
) )
const ( const (
captchaTTL = 2 * time.Minute captchaTTL = 5 * time.Minute
captchaKeyPrefix = "pargar:captcha:" captchaKeyPrefix = "pargar:captcha:"
captchaRateLimit int64 = 60 captchaRateLimit int64 = 60
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@@ -260,7 +260,6 @@ 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"
@@ -298,7 +297,6 @@ 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.
-4
View File
@@ -30,10 +30,6 @@ 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)
} }
+2 -12
View File
@@ -228,22 +228,12 @@ 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")
p := parsePageParams(r) orders, err := s.store.ListPhysicalOrders(r.Context(), status)
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
} }
if orders == nil { writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "physical": s.physicalSettings(r.Context())})
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) {
+4 -89
View File
@@ -37,7 +37,6 @@ 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,
@@ -112,16 +111,10 @@ 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": photo, "avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": p.AvatarPhoto,
"online": s.hub.Online(p.ID), "online": s.hub.Online(p.ID),
}, },
UnreadCount: unread, UnreadCount: unread,
@@ -144,7 +137,6 @@ 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)
@@ -164,6 +156,7 @@ 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 {
@@ -204,6 +197,7 @@ 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 {
@@ -343,73 +337,6 @@ 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")
@@ -546,25 +473,13 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return return
} }
if err := s.markIncomingChatRead(r.Context(), u, partner); err != nil { if err := s.store.MarkChatRead(r.Context(), u.ID, 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
-2
View File
@@ -57,7 +57,6 @@ 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"`
} }
@@ -184,7 +183,6 @@ 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{
+5 -5
View File
@@ -45,7 +45,7 @@ func TestUniquePhone(t *testing.T) {
} }
} }
func TestAdminResetPassword(t *testing.T) { func TestAdminResetPasswordRequiresSecurityAnswer(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 TestAdminResetPassword(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{
"newPassword": "short", "securityAnswer": "اشتباه", "newPassword": "newpass123",
}) })
if code != http.StatusBadRequest { if code != http.StatusForbidden {
t.Fatalf("short password: %d", code) t.Fatalf("wrong answer should 403, got %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{
"newPassword": "newpass123", "securityAnswer": "تهران", "newPassword": "newpass123",
}) })
if code != http.StatusOK { if code != http.StatusOK {
t.Fatalf("reset: %d %v", code, body) t.Fatalf("reset: %d %v", code, body)
+1 -8
View File
@@ -284,15 +284,8 @@ 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, status, p.PageSize, p.Offset) invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, p.PageSize, p.Offset)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد") writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد")
return return
-1
View File
@@ -63,7 +63,6 @@ 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,
+3 -22
View File
@@ -1,11 +1,9 @@
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) {
@@ -23,25 +21,12 @@ 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")
filter := r.URL.Query().Get("filter") rows, err := s.store.ListStudentLearning(ctx, q)
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
} }
writePage(w, rows, total, p) writeJSON(w, http.StatusOK, map[string]any{"students": rows})
} }
func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
@@ -49,12 +34,8 @@ 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 errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری جزئیات یادگیری ممکن نشد") writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"learning": detail}) writeJSON(w, http.StatusOK, map[string]any{"learning": detail})
+1 -17
View File
@@ -13,28 +13,12 @@ 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 := stableMediaExpiry(time.Now(), s.cfg.MediaURLTTL, mediaSignWindow) exp := time.Now().Add(s.cfg.MediaURLTTL).Unix()
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,
-18
View File
@@ -7,24 +7,6 @@ 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")
-42
View File
@@ -1,42 +0,0 @@
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,
},
})
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
) )
// Public self-service password recovery is disabled for students. // Public self-service password recovery is disabled for students.
// Staff set a new password from the admin profile. Public recovery stays off. // Staff reset passwords from the admin panel (requires the account security answer).
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, "بازیابی رمز عمومی غیرفعال است")
-3
View File
@@ -56,9 +56,7 @@ 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)))
@@ -91,7 +89,6 @@ 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))
-330
View File
@@ -1,330 +0,0 @@
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"])
}
}
@@ -1,3 +0,0 @@
-- Own-message edits. Nullable so existing rows stay unedited.
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
-1
View File
@@ -219,7 +219,6 @@ 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"`
} }
@@ -77,12 +77,10 @@ func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error,
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Schemaless: a non-empty but different schema URL here (from an older
// semconv pin) makes resource.Merge fail as soon as the SDK's
// resource.Default() schema URL changes across otel upgrades.
res, err := resource.Merge( res, err := resource.Merge(
resource.Default(), resource.Default(),
resource.NewSchemaless( resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(name), semconv.ServiceName(name),
), ),
) )
-15
View File
@@ -1,15 +0,0 @@
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)
}
}
+3 -33
View File
@@ -243,11 +243,10 @@ func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error {
} }
defer result.Body.Close() defer result.Body.Close()
stored := "" ct := mimeType(key)
if result.ContentType != nil { if result.ContentType != nil && *result.ContentType != "" {
stored = *result.ContentType ct = *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")
@@ -291,20 +290,8 @@ 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:
@@ -312,23 +299,6 @@ 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 != "" {
+3 -6
View File
@@ -146,7 +146,7 @@ func (s *Store) CreateRegistrationInvite(
return scanInvite(row) return scanInvite(row)
} }
func (s *Store) ListRegistrationInvites(ctx context.Context, query, status string, limit, offset int) ([]models.RegistrationInvite, int64, error) { func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
if limit <= 0 { if limit <= 0 {
limit = 10 limit = 10
} }
@@ -157,21 +157,18 @@ func (s *Store) ListRegistrationInvites(ctx context.Context, query, status strin
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||'%') WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil {
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 $3 OFFSET $4`, query, status, limit, offset) LIMIT $2 OFFSET $3`, query, limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
+27 -54
View File
@@ -81,40 +81,10 @@ type StudentLearningRow struct {
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"` LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
} }
func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string, limit, offset int) ([]StudentLearningRow, int64, error) { func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLearningRow, 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, 0, err return nil, 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,
@@ -135,11 +105,12 @@ func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string,
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 `+where+` WHERE u.role = 'student'
ORDER BY `+order+` AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')
LIMIT $2 OFFSET $3`, q, limit, offset) ORDER BY u.xp DESC, u.id ASC
LIMIT 500`, q)
if err != nil { if err != nil {
return nil, 0, err return nil, err
} }
defer rows.Close() defer rows.Close()
out := make([]StudentLearningRow, 0) out := make([]StudentLearningRow, 0)
@@ -151,7 +122,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string,
&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, 0, err return nil, 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
@@ -161,7 +132,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string,
} }
out = append(out, r) out = append(out, r)
} }
return out, total, rows.Err() return out, rows.Err()
} }
type LessonLearningDetail struct { type LessonLearningDetail struct {
@@ -219,28 +190,30 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
if err != nil { if err != nil {
return nil, err return nil, err
} }
row := StudentLearningRow{ list, err := s.ListStudentLearning(ctx, "")
if err != nil {
return nil, err
}
var row StudentLearningRow
found := false
for _, r := range list {
if r.ID == userID {
row = r
found = true
break
}
}
if !found {
row = StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email, ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent, XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate, 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 { _ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal)
return nil, err _ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed)
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed); err != nil {
return nil, err
}
if row.LessonsTotal > 0 { if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100 row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
} }
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*), COUNT(*) FILTER (WHERE status='passed')
FROM quiz_attempts
WHERE user_id=$1 AND created_at >= now() - interval '7 days'`, userID).Scan(&row.Attempts7d, &row.Passes7d); err != nil {
return nil, err
}
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{}}
@@ -248,7 +221,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::text, ''), COALESCE(la.score_pct, 0) COALESCE(la.status, ''), 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
+1 -1
View File
@@ -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, "", "", "xp", 10, 0) students, err := st.ListStudentLearning(ctx, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+26 -91
View File
@@ -504,26 +504,7 @@ 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, limit, offset int) ([]models.CertificatePhysicalOrder, int64, error) { func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models.CertificatePhysicalOrder, 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,
@@ -531,11 +512,16 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string, limit, of
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` + where + ` ORDER BY o.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2) JOIN certificates c ON c.id = o.certificate_id`
args = append(args, limit, offset) args := []any{}
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, 0, err return nil, err
} }
defer rows.Close() defer rows.Close()
out := make([]models.CertificatePhysicalOrder, 0) out := make([]models.CertificatePhysicalOrder, 0)
@@ -544,11 +530,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string, limit, of
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, 0, err return nil, err
} }
out = append(out, o) out = append(out, o)
} }
return out, total, rows.Err() return out, 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) {
@@ -1083,7 +1069,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, edited_at`, attachment_type, attachment_url, attachment_name, attachment_size, pinned_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)
} }
@@ -1108,7 +1094,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, edited_at`, attachment_type, attachment_url, attachment_name, attachment_size, pinned_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)
} }
@@ -1152,48 +1138,6 @@ 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,
@@ -1216,7 +1160,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, edited_at`, msgID) attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
return scanMessage(out) return scanMessage(out)
} }
var n int var n int
@@ -1244,7 +1188,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, edited_at`, msgID) attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
return scanMessage(out) return scanMessage(out)
} }
@@ -1252,7 +1196,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, edited_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_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))
@@ -1278,7 +1222,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, edited_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_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)
@@ -1326,7 +1270,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, edited_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_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
@@ -1358,7 +1302,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, edited_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_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
@@ -1401,7 +1345,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, &m.EditedAt, &attType, &attURL, &attName, &attSize, &m.PinnedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -1428,20 +1372,11 @@ func derefInt(p *int64) int64 {
return *p return *p
} }
// MarkChatRead stamps messages the other person sent. Opening the thread must not func (s *Store) MarkChatRead(ctx context.Context, userID, mentorID int64) error {
// mark my own messages as seen. _, err := s.pool.Exec(ctx, `
func (s *Store) MarkChatRead(ctx context.Context, meID, partnerID int64, meRole models.Role) (int64, error) { UPDATE chat_messages SET read_at=now() WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`,
tag, err := s.pool.Exec(ctx, ` userID, mentorID)
UPDATE chat_messages return err
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.
@@ -1808,7 +1743,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 WHERE role='student' AND is_active`).Scan(&users); err != nil { if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users`).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 {
+2 -2
View File
@@ -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.1 version: 0.2.0
appVersion: "0.1.3" appVersion: "0.1.0"
keywords: keywords:
- lms - lms
- pargar - pargar
+2 -2
View File
@@ -22,7 +22,7 @@ backend:
replicaCount: 2 replicaCount: 2
image: image:
repository: backend repository: backend
tag: "0.1.3" tag: "0.1.0"
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.3" tag: "0.1.0"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
resources: resources:
requests: requests:
+190 -161
View File
@@ -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.5.27", "next": "^15.1.0",
"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.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"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.4" "@img/sharp-libvips-darwin-arm64": "1.3.3"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"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.4" "@img/sharp-libvips-darwin-x64": "1.3.3"
} }
}, },
"node_modules/@img/sharp-freebsd-wasm32": { "node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
], ],
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.5" "@img/sharp-wasm32": "0.35.4"
}, },
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"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.4", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -337,9 +337,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"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.4" "@img/sharp-libvips-linux-arm": "1.3.3"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"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.4" "@img/sharp-libvips-linux-arm64": "1.3.3"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"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.4" "@img/sharp-libvips-linux-ppc64": "1.3.3"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"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.4" "@img/sharp-libvips-linux-riscv64": "1.3.3"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"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.4" "@img/sharp-libvips-linux-s390x": "1.3.3"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"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.4" "@img/sharp-libvips-linux-x64": "1.3.3"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"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.4" "@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"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.4" "@img/sharp-libvips-linuxmusl-x64": "1.3.3"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"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.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.5" "@img/sharp-wasm32": "0.35.4"
}, },
"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.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -567,9 +567,9 @@
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -586,9 +586,9 @@
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -655,15 +655,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.5.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.25.tgz",
"integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==", "integrity": "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.5.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.25.tgz",
"integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==", "integrity": "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -677,9 +677,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.5.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.25.tgz",
"integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==", "integrity": "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==",
"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.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.25.tgz",
"integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==", "integrity": "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==",
"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.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.25.tgz",
"integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==", "integrity": "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==",
"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.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.25.tgz",
"integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==", "integrity": "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==",
"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.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.25.tgz",
"integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==", "integrity": "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==",
"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.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.25.tgz",
"integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==", "integrity": "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==",
"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.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.25.tgz",
"integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==", "integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2291,12 +2291,12 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.5.27", "version": "15.5.25",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.25.tgz",
"integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==", "integrity": "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "15.5.27", "@next/env": "15.5.25",
"@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.27", "@next/swc-darwin-arm64": "15.5.25",
"@next/swc-darwin-x64": "15.5.27", "@next/swc-darwin-x64": "15.5.25",
"@next/swc-linux-arm64-gnu": "15.5.27", "@next/swc-linux-arm64-gnu": "15.5.25",
"@next/swc-linux-arm64-musl": "15.5.27", "@next/swc-linux-arm64-musl": "15.5.25",
"@next/swc-linux-x64-gnu": "15.5.27", "@next/swc-linux-x64-gnu": "15.5.25",
"@next/swc-linux-x64-musl": "15.5.27", "@next/swc-linux-x64-musl": "15.5.25",
"@next/swc-win32-arm64-msvc": "15.5.27", "@next/swc-win32-arm64-msvc": "15.5.25",
"@next/swc-win32-x64-msvc": "15.5.27", "@next/swc-win32-x64-msvc": "15.5.25",
"sharp": "^0.34.3 || ^0.35.4" "sharp": "^0.34.3 || ^0.35.4"
}, },
"peerDependencies": { "peerDependencies": {
@@ -2352,6 +2352,34 @@
"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",
@@ -2362,6 +2390,7 @@
"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",
@@ -2526,9 +2555,9 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.35.5", "version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -2543,31 +2572,31 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.5", "@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.5", "@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.5" "@img/sharp-win32-x64": "0.35.4"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/node": { "@types/node": {
@@ -2586,9 +2615,9 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.2", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
+1 -6
View File
@@ -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.5.27", "next": "^15.1.0",
"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,10 +44,5 @@
"@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"
} }
} }
+76 -14
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useEffect, 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, ShieldCheck, Timer, Zap } from "lucide-react"; import { CheckCircle2, Loader2, RefreshCw, 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,7 +15,6 @@ 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: "زمان تماشای راستی‌آزمایی‌شده — بدون میان‌بر" },
@@ -23,6 +22,11 @@ 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);
@@ -30,7 +34,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 [captchaKey, setCaptchaKey] = useState(0); const [captchaLoading, setCaptchaLoading] = useState(true);
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);
@@ -40,11 +44,29 @@ 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("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaKey((k) => k + 1); void loadCaptcha();
return; return;
} }
setLoading(true); setLoading(true);
@@ -61,7 +83,7 @@ export default function LoginPage() {
router.push("/cap"); router.push("/cap");
} catch (err) { } catch (err) {
setError(toUserError(err, "ورود ناموفق بود")); setError(toUserError(err, "ورود ناموفق بود"));
setCaptchaKey((k) => k + 1); void loadCaptcha();
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -131,14 +153,54 @@ export default function LoginPage() {
placeholder="رمز عبور" placeholder="رمز عبور"
/> />
</div> </div>
<CaptchaField <div className="space-y-2">
key={captchaKey} <div className="flex items-center justify-between gap-2">
id="captcha" <Label htmlFor="captcha">کد امنیتی</Label>
value={captchaAnswer} <button
onChange={setCaptchaAnswer} type="button"
onChallenge={setChallenge} onClick={() => void loadCaptcha()}
onError={setError} disabled={captchaLoading}
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
@@ -153,7 +215,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 || !challenge} disabled={loading || captchaLoading || !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>
-130
View File
@@ -1,130 +0,0 @@
"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>
);
}
+65 -8
View File
@@ -1,7 +1,6 @@
"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 {
@@ -17,6 +16,7 @@ import {
Globe, Globe,
GripVertical, GripVertical,
Heart, Heart,
KeyRound,
Layers, Layers,
Loader2, Loader2,
Lock, Lock,
@@ -454,10 +454,6 @@ 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">
@@ -479,7 +475,6 @@ 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 ? (
@@ -520,7 +515,7 @@ function UsersPanel() {
); );
} }
export function UserDialog({ function UserDialog({
user, user,
onClose, onClose,
onSaved, onSaved,
@@ -534,13 +529,18 @@ export 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,6 +633,29 @@ export 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">
@@ -641,7 +664,7 @@ export function UserDialog({
<DialogDescription> <DialogDescription>
{isNew {isNew
? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر." ? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر."
: "اطلاعات کاربر را به‌روزرسانی کنید."} : "اطلاعات کاربر را به‌روزرسانی کنید. ریست رمز فقط با جواب امنیتی."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="grid gap-3">
@@ -745,6 +768,40 @@ export 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,18 +68,16 @@ 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, status]); }, [q]);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["admin", "invites", q, status, page], queryKey: ["admin", "invites", q, 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()}`);
}, },
}); });
@@ -298,43 +296,14 @@ 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 || status) && ( {q && (
<Button <Button type="button" size="sm" variant="ghost" onClick={() => setQ("")}>
type="button"
size="sm"
variant="ghost"
onClick={() => {
setQ("");
setStatus("");
}}
>
پاک کردن پاک کردن
</Button> </Button>
)} )}
@@ -346,7 +315,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() || status ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."} {q.trim() ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
</p> </p>
)} )}
<div className="space-y-2"> <div className="space-y-2">
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useMemo, useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertTriangle, AlertTriangle,
Flame, Flame,
@@ -34,12 +34,6 @@ 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";
@@ -64,11 +58,6 @@ 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"],
@@ -77,17 +66,11 @@ export function LearningPanel() {
}); });
const studentsQ = useQuery({ const studentsQ = useQuery({
queryKey: ["admin", "learning", "students", q, filter, sort, page], queryKey: ["admin", "learning", "students", q],
queryFn: () => { queryFn: () =>
const params = new URLSearchParams(buildPageQuery(page)); http.get<{ students: StudentLearningRow[] }>(
if (q.trim()) params.set("q", q.trim()); `/api/admin/learning/students?q=${encodeURIComponent(q)}`
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,
}); });
@@ -115,9 +98,27 @@ export function LearningPanel() {
}; };
const overview = overviewQ.data?.overview; const overview = overviewQ.data?.overview;
const students = studentsQ.data?.items ?? []; const students = useMemo(() => {
const studentsTotal = studentsQ.data?.total ?? 0; let rows = [...(studentsQ.data?.students ?? [])];
const studentsPageSize = studentsQ.data?.pageSize ?? DEFAULT_PAGE_SIZE; if (filter === "active") rows = rows.filter((r) => r.isActive && !r.isLocked);
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
? [ ? [
@@ -176,7 +177,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(studentsTotal)} نفر · برای جزئیات روی ردیف بزنید {formatFaNumber(students.length)} نفر · برای جزئیات روی ردیف بزنید
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -294,12 +295,6 @@ 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>
@@ -310,9 +305,7 @@ export function LearningPanel() {
{detailQ.data?.learning.user.name ?? "جزئیات یادگیری"} {detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{detailQ.isError {detailQ.data
? toUserError(detailQ.error, "بارگذاری جزئیات ممکن نشد")
: detailQ.data
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر` ? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
: "در حال بارگذاری…"} : "در حال بارگذاری…"}
</DialogDescription> </DialogDescription>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { 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,11 +11,6 @@ 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: "درخواست‌شده",
@@ -32,29 +27,16 @@ 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, page], queryKey: ["admin", "physical-orders", statusFilter],
queryFn: () => { queryFn: () =>
const params = new URLSearchParams(buildPageQuery(page)); http.get<{ orders: CertificatePhysicalOrder[]; physical: PhysicalCertSettings }>(
if (statusFilter) params.set("status", statusFilter); `/api/admin/physical-orders?status=${encodeURIComponent(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) => {
@@ -169,12 +151,6 @@ 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>
); );
@@ -1,245 +0,0 @@
"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: "جلسات و کارگاه‌ها" },
-1
View File
@@ -24,7 +24,6 @@ const PROTECTED_PREFIXES = [
"/quiz", "/quiz",
"/notifications", "/notifications",
"/lockout", "/lockout",
"/profile",
]; ];
function needsAuth(p: string) { function needsAuth(p: string) {
-155
View File
@@ -1,155 +0,0 @@
"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,7 +1,6 @@
"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";
@@ -111,11 +110,10 @@ 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 (
<Link <div
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 transition-colors hover:bg-muted/40", "flex items-center gap-3 rounded-xl border px-4 py-2.5",
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"
)} )}
> >
@@ -137,7 +135,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>
</Link> </div>
); );
})} })}
{rest.length === 0 && entries.length === 0 && ( {rest.length === 0 && entries.length === 0 && (
@@ -199,10 +197,9 @@ function PodiumCard({
}) { }) {
const isMe = myId != null && entry.userId === myId; const isMe = myId != null && entry.userId === myId;
return ( return (
<Link <div
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 transition-colors hover:bg-muted/40", "flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm",
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
@@ -228,7 +225,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>
</Link> </div>
); );
} }
File diff suppressed because it is too large Load Diff
+78 -14
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState, type ReactNode } from "react"; import { useCallback, useEffect, 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, UserPlus } from "lucide-react"; import { ArrowLeft, ArrowRight, Loader2, RefreshCw, 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,7 +12,6 @@ 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,
@@ -31,6 +30,11 @@ 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;
@@ -69,10 +73,30 @@ 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 [captchaKey, setCaptchaKey] = useState(0); const [captchaLoading, setCaptchaLoading] = 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);
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) {
@@ -128,7 +152,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
} }
if (!challenge) { if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید"); setError("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaKey((k) => k + 1); void loadCaptcha();
return; return;
} }
setLoading(true); setLoading(true);
@@ -152,7 +176,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
router.push("/cap"); router.push("/cap");
} catch (err) { } catch (err) {
setError(toUserError(err, "ثبت‌نام ناموفق بود")); setError(toUserError(err, "ثبت‌نام ناموفق بود"));
setCaptchaKey((k) => k + 1); void loadCaptcha();
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -298,14 +322,54 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
placeholder="همان رمز را دوباره وارد کنید" placeholder="همان رمز را دوباره وارد کنید"
/> />
</Field> </Field>
<CaptchaField <div className="space-y-2">
key={captchaKey} <div className="flex items-center justify-between gap-2">
id="reg-captcha" <Label htmlFor="reg-captcha">کد امنیتی</Label>
value={captchaAnswer} <button
onChange={setCaptchaAnswer} type="button"
onChallenge={setChallenge} onClick={() => void loadCaptcha()}
onError={setError} disabled={captchaLoading}
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>
</> </>
)} )}
@@ -336,7 +400,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 || !challenge} disabled={loading || captchaLoading || !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}
+6 -46
View File
@@ -1,9 +1,8 @@
"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 };
@@ -42,40 +41,6 @@ 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,
@@ -89,17 +54,12 @@ export function UserAvatar({
palette?: string[]; palette?: string[];
className?: string; className?: string;
}) { }) {
const painted = useSettledPhoto(src); if (src) {
if (painted) {
return ( return (
<span <Avatar className={cn("ring-1 ring-black/5 dark:ring-white/10", className)}>
className={cn( <AvatarImage src={src} alt={name} />
"relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full bg-background ring-1 ring-black/5 dark:ring-white/10", <AvatarFallback />
className </Avatar>
)}
>
<img src={painted} alt={name} className="h-full w-full object-cover" draggable={false} />
</span>
); );
} }
return ( return (
-36
View File
@@ -1,36 +0,0 @@
"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;
}
-8
View File
@@ -1,8 +0,0 @@
/** 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;
}
-179
View File
@@ -1,179 +0,0 @@
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;
}
-16
View File
@@ -193,7 +193,6 @@ 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[];
} }
@@ -265,21 +264,6 @@ 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;