diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..4d1aecf --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +.git +tmp +*.test +media/* +!media/sample.mp4 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..cbde5fd --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,24 @@ +# build artifacts (root binaries only — do not ignore cmd/server/) +bin/ +/server +/pargar +*.exe +*.test + +# local media uploads (keep seed sample.mp4 for Docker/CI) +media/* +!media/sample.mp4 +!media/.gitkeep + +# env +.env +.env.* + +# logs / scratch +*.log +tmp/ +.tmp/ + +# IDE +.idea/ +.vscode/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..65ef586 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.27-alpine AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/pargar ./cmd/server + +FROM alpine:3.22 + +RUN adduser -D -H -u 10001 app + +WORKDIR /app + +COPY --from=builder /out/pargar ./pargar +COPY --from=builder /src/media/sample.mp4 ./media/sample.mp4 +RUN chown -R app:app /app + +USER app + +EXPOSE 8080 + +CMD ["./pargar"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..072e16f --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,59 @@ +module pargar/backend + +go 1.27.1 + +require ( + github.com/aws/aws-sdk-go-v2 v1.47.1 + github.com/aws/aws-sdk-go-v2/config v1.33.6 + github.com/aws/aws-sdk-go-v2/credentials v1.20.6 + github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.11.0 + github.com/redis/go-redis/v9 v9.22.0 + github.com/rs/cors v1.11.1 + go.opentelemetry.io/otel v1.35.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 + go.opentelemetry.io/otel/sdk v1.35.0 + go.opentelemetry.io/otel/trace v1.35.0 + golang.org/x/crypto v0.57.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.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/smithy-go v1.28.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..9f329ec --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,131 @@ +github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw= +github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= +github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug= +github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +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.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +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/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/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.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/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/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +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/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +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/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..770e6df --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,320 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Port string + DatabaseURL string + RedisURL string + JWTSecret string + PublicURL string + FrontendOrigins []string + TrustedProxyCIDRs []*net.IPNet + MetricsToken string + StorageDriver string + StorageDir string + StorageEndpoint string + StorageRegion string + StorageAccess string + StorageSecret string + StorageBucket string + HeartRegenHours int + AccessTokenTTL time.Duration + RefreshTokenTTL time.Duration // remember-me / long-lived refresh + RefreshTokenTTLSession time.Duration // short-lived refresh when rememberMe is false + MediaURLTTL time.Duration + DevMode bool + // CaptchaExposeAnswer returns plaintext captcha answers in GET /api/auth/captcha. + // Must never be enabled outside local tests (requires DevMode as well). + CaptchaExposeAnswer bool + SeedCurriculum bool + OTELEndpoint string + OTELServiceName string + DonationEnabled bool + DonationNote string + PhysicalCertEnabled bool + PhysicalCertPriceIRR int + PhysicalCertWindowDays int +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback +} + +// defaultTrustedProxyCIDRs covers loopback and typical Docker/private networks. +const defaultTrustedProxyCIDRs = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" + +func parseCIDRs(raw string) ([]*net.IPNet, error) { + parts := strings.Split(raw, ",") + out := make([]*net.IPNet, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + _, network, err := net.ParseCIDR(p) + if err != nil { + return nil, err + } + out = append(out, network) + } + return out, nil +} + +func Load() *Config { + jwtSecret := getEnv("JWT_SECRET", "") + if jwtSecret == "" { + jwtSecret = developmentSecret() + } + // Fail closed: production-safe default unless explicitly enabled. + devMode := getEnv("DEV_MODE", "false") == "true" + origins := parseOrigins(getEnv("FRONTEND_ORIGINS", "")) + if len(origins) == 0 { + origins = []string{ + "https://localhost", "https://127.0.0.1", + "http://localhost:3000", "http://127.0.0.1:3000", + "http://localhost:3001", "http://127.0.0.1:3001", + "http://localhost", "http://127.0.0.1", + } + } + seedCurriculum := getEnv("SEED_CURRICULUM", "") == "true" + if getEnv("SEED_CURRICULUM", "") == "" && devMode { + seedCurriculum = true + } + cidrs, err := parseCIDRs(getEnv("TRUSTED_PROXY_CIDRS", defaultTrustedProxyCIDRs)) + if err != nil { + // Fall back to defaults rather than refusing to boot on a typo in optional env. + cidrs, _ = parseCIDRs(defaultTrustedProxyCIDRs) + } + return &Config{ + Port: getEnv("PORT", "8080"), + DatabaseURL: resolveDatabaseURL(), + RedisURL: resolveRedisURL(), + JWTSecret: jwtSecret, + PublicURL: strings.TrimRight(getEnv("PUBLIC_URL", "https://localhost"), "/"), + FrontendOrigins: origins, + TrustedProxyCIDRs: cidrs, + MetricsToken: strings.TrimSpace(getEnv("METRICS_TOKEN", "")), + StorageDriver: getEnv("STORAGE_DRIVER", "s3"), + StorageDir: getEnv("STORAGE_DIR", "media"), + StorageEndpoint: getEnv("STORAGE_ENDPOINT", "http://rustfs:9000"), + StorageRegion: getEnv("STORAGE_REGION", "us-east-1"), + StorageAccess: getEnv("STORAGE_ACCESS_KEY", ""), + StorageSecret: getEnv("STORAGE_SECRET_KEY", ""), + StorageBucket: getEnv("STORAGE_BUCKET", "pargar"), + HeartRegenHours: getEnvInt("HEART_REGEN_HOURS", 4), + AccessTokenTTL: 15 * time.Minute, + RefreshTokenTTL: 30 * 24 * time.Hour, + RefreshTokenTTLSession: 24 * time.Hour, + MediaURLTTL: 2 * time.Hour, + DevMode: devMode, + CaptchaExposeAnswer: devMode && getEnv("CAPTCHA_EXPOSE_ANSWER", "") == "true", + SeedCurriculum: seedCurriculum, + OTELEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", ""), + OTELServiceName: getEnv("OTEL_SERVICE_NAME", "pargar-api"), + DonationEnabled: getEnv("DONATION_ENABLED", "true") == "true", + DonationNote: getEnv("DONATION_NOTE", "اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل می‌شود."), + PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true", + PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000), + PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30), + } +} + +// resolveDatabaseURL prefers DATABASE_URL; otherwise builds from POSTGRES_* with proper encoding. +func resolveDatabaseURL() string { + if u := strings.TrimSpace(os.Getenv("DATABASE_URL")); u != "" { + return u + } + pass := os.Getenv("POSTGRES_PASSWORD") + host := getEnv("POSTGRES_HOST", "") + if pass == "" || host == "" { + return "postgres://pargar:local-dev-only-password@localhost:5432/pargar?sslmode=disable" + } + user := getEnv("POSTGRES_USER", "pargar") + db := getEnv("POSTGRES_DB", "pargar") + ssl := getEnv("POSTGRES_SSLMODE", "disable") + port := getEnv("POSTGRES_PORT", "5432") + u := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(user, pass), + Host: net.JoinHostPort(host, port), + Path: "/" + db, + } + q := u.Query() + q.Set("sslmode", ssl) + u.RawQuery = q.Encode() + return u.String() +} + +// resolveRedisURL prefers REDIS_URL; otherwise builds from REDIS_HOST + REDIS_PASSWORD. +func resolveRedisURL() string { + if u := strings.TrimSpace(os.Getenv("REDIS_URL")); u != "" { + return u + } + host := getEnv("REDIS_HOST", "") + if host == "" { + return "redis://localhost:6379" + } + port := getEnv("REDIS_PORT", "6379") + pass := os.Getenv("REDIS_PASSWORD") + u := &url.URL{ + Scheme: "redis", + Host: net.JoinHostPort(host, port), + } + if pass != "" { + u.User = url.UserPassword("", pass) + } + if db := strings.TrimSpace(os.Getenv("REDIS_DB")); db != "" { + u.Path = "/" + db + } + return u.String() +} + +func parseOrigins(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, strings.TrimRight(p, "/")) + } + } + return out +} + +var insecureSecretMarkers = []string{ + "change-me", + "local-dev-only", + "insecure-default", + "password", + "secret", + "admin", +} + +func isInsecureSecret(v string) bool { + s := strings.ToLower(strings.TrimSpace(v)) + if s == "" { + return true + } + // Exact trivial values. + for _, m := range insecureSecretMarkers { + if s == m || s == m+"123" || s == m+"!" { + return true + } + } + // Known chart / compose placeholders. + placeholders := []string{"change-me", "local-dev-only", "insecure-default"} + for _, p := range placeholders { + if strings.Contains(s, p) { + return true + } + } + return false +} + +func (c *Config) Validate() error { + if c.DevMode { + return nil + } + jwt := strings.TrimSpace(os.Getenv("JWT_SECRET")) + if len(jwt) < 32 { + return errors.New("JWT_SECRET must be at least 32 characters when DEV_MODE is false") + } + if isInsecureSecret(jwt) { + return errors.New("JWT_SECRET looks like a placeholder; set a strong random value") + } + adminPass := getEnv("ADMIN_PASSWORD", "") + if adminPass == "" { + return errors.New("ADMIN_PASSWORD must be set when DEV_MODE is false") + } + if isInsecureSecret(adminPass) || len(adminPass) < 12 { + return errors.New("ADMIN_PASSWORD looks weak or like a placeholder; set a strong value") + } + if c.StorageDriver == "s3" { + if c.StorageAccess == "" || c.StorageSecret == "" { + return errors.New("STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY must be set when DEV_MODE is false") + } + if isInsecureSecret(c.StorageSecret) { + return errors.New("STORAGE_SECRET_KEY looks like a placeholder; set a strong random value") + } + } + return nil +} + +func (c *Config) AllowedOrigins() []string { + seen := map[string]bool{} + var out []string + add := func(o string) { + o = strings.TrimRight(strings.TrimSpace(o), "/") + if o == "" || seen[o] { + return + } + seen[o] = true + out = append(out, o) + } + for _, o := range c.FrontendOrigins { + add(o) + } + add(c.PublicURL) + return out +} + +// IsTrustedProxy reports whether ip is allowed to supply X-Real-IP / X-Forwarded-For. +func (c *Config) IsTrustedProxy(ip net.IP) bool { + if ip == nil { + return false + } + for _, network := range c.TrustedProxyCIDRs { + if network.Contains(ip) { + return true + } + } + return false +} + +// CookieSecure reports whether session cookies should set the Secure flag. +func (c *Config) CookieSecure() bool { + return strings.HasPrefix(strings.ToLower(c.PublicURL), "https://") +} + +func developmentSecret() string { + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + panic("could not generate development JWT secret") + } + return hex.EncodeToString(raw[:]) +} + +// MustValidate is a helper for tests asserting Validate messages. +func FormatValidateError(err error) string { + if err == nil { + return "" + } + return fmt.Sprintf("%v", err) +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..480b904 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestValidateRejectsPlaceholderSecrets(t *testing.T) { + t.Setenv("DEV_MODE", "false") + t.Setenv("JWT_SECRET", "change-me-to-at-least-32-characters-long") + t.Setenv("ADMIN_PASSWORD", "change-me-strong-admin-password") + t.Setenv("STORAGE_ACCESS_KEY", "ak") + t.Setenv("STORAGE_SECRET_KEY", "change-me-long-rustfs-secret") + + cfg := Load() + cfg.DevMode = false + cfg.StorageDriver = "s3" + cfg.StorageAccess = "ak" + cfg.StorageSecret = "change-me-long-rustfs-secret" + if err := cfg.Validate(); err == nil { + t.Fatal("expected Validate to reject placeholder JWT") + } +} + +func TestValidateAcceptsStrongSecrets(t *testing.T) { + t.Setenv("DEV_MODE", "false") + jwt := strings.Repeat("a1B2c3D4", 6) // 48 chars, no placeholder markers + t.Setenv("JWT_SECRET", jwt) + t.Setenv("ADMIN_PASSWORD", "Tr0ub4dor&3-not-default") + cfg := Load() + cfg.DevMode = false + cfg.StorageDriver = "s3" + cfg.StorageAccess = "AKIAEXAMPLE" + cfg.StorageSecret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } +} + +func TestResolveDatabaseURLEncodesPassword(t *testing.T) { + os.Unsetenv("DATABASE_URL") + t.Setenv("POSTGRES_USER", "pargar") + t.Setenv("POSTGRES_PASSWORD", "p@ss:word/with%chars") + t.Setenv("POSTGRES_HOST", "db.internal") + t.Setenv("POSTGRES_DB", "pargar") + t.Setenv("POSTGRES_SSLMODE", "disable") + u := resolveDatabaseURL() + if !strings.Contains(u, "p%40ss%3Aword%2Fwith%25chars") { + t.Fatalf("password not encoded in URL: %s", u) + } + if !strings.Contains(u, "@db.internal:") { + t.Fatalf("host missing: %s", u) + } +} + +func TestResolveRedisURLEncodesPassword(t *testing.T) { + os.Unsetenv("REDIS_URL") + t.Setenv("REDIS_HOST", "redis.internal") + t.Setenv("REDIS_PASSWORD", "r@dis:pass") + u := resolveRedisURL() + if !strings.Contains(u, "%40") || !strings.Contains(u, "%3A") { + t.Fatalf("redis password not encoded: %s", u) + } +} + +func TestIsInsecureSecret(t *testing.T) { + if !isInsecureSecret("change-me-postgres") { + t.Fatal("expected change-me to be insecure") + } + if isInsecureSecret(strings.Repeat("xY9k", 12)) { + t.Fatal("random-looking secret should be ok") + } +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..b581e0b --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,19 @@ +package db + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, fmt.Errorf("connect db: %w", err) + } + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("ping db: %w", err) + } + return pool, nil +} diff --git a/backend/internal/db/embed.go b/backend/internal/db/embed.go new file mode 100644 index 0000000..7e91bdb --- /dev/null +++ b/backend/internal/db/embed.go @@ -0,0 +1,8 @@ +package db + +import "embed" + +// MigrationFiles is the embedded set of .sql migration files. +// +//go:embed migrations/*.sql +var MigrationFiles embed.FS diff --git a/backend/internal/db/migrate.go b/backend/internal/db/migrate.go new file mode 100644 index 0000000..494228f --- /dev/null +++ b/backend/internal/db/migrate.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "fmt" + "io/fs" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Migrate runs all *.sql files in the migrations fs in lexicographic order. +// Tracks applied migrations in schema_migrations. +func Migrate(ctx context.Context, pool *pgxpool.Pool, migrations fs.FS) error { + if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil { + return fmt.Errorf("create migrations table: %w", err) + } + entries, err := fs.ReadDir(migrations, ".") + if err != nil { + return err + } + var names []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + + for _, name := range names { + applied, err := pool.Exec(ctx, `SELECT 1 FROM schema_migrations WHERE version=$1`, name) + if err != nil { + return err + } + if applied.RowsAffected() > 0 { + continue + } + body, err := fs.ReadFile(migrations, name) + if err != nil { + return err + } + tx, err := pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("apply %s: %w", name, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil { + _ = tx.Rollback(ctx) + return err + } + if err := tx.Commit(ctx); err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/db/migrations/0001_init.sql b/backend/internal/db/migrations/0001_init.sql new file mode 100644 index 0000000..7e2778f --- /dev/null +++ b/backend/internal/db/migrations/0001_init.sql @@ -0,0 +1,205 @@ +-- 0001_init.sql +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TYPE user_role AS ENUM ('student', 'mentor', 'admin'); +CREATE TYPE notif_category AS ENUM ('progress', 'gamification', 'mentor', 'event'); +CREATE TYPE quiz_status AS ENUM ('in_progress', 'passed', 'failed'); +CREATE TYPE event_type AS ENUM ('meet', 'zoom', 'workshop'); + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role user_role NOT NULL DEFAULT 'student', + xp INT NOT NULL DEFAULT 0, + hearts INT NOT NULL DEFAULT 3, + hearts_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + streak_current INT NOT NULL DEFAULT 0, + streak_longest INT NOT NULL DEFAULT 0, + last_activity_date DATE, + is_locked BOOLEAN NOT NULL DEFAULT false, + locked_at TIMESTAMPTZ, + unlocked_by BIGINT REFERENCES users(id), + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE chapters ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL DEFAULT 'leaf', + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE TABLE lessons ( + id BIGSERIAL PRIMARY KEY, + chapter_id BIGINT NOT NULL REFERENCES chapters(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + video_key TEXT NOT NULL DEFAULT '', + duration_seconds INT NOT NULL DEFAULT 0, + completion_threshold_pct INT NOT NULL DEFAULT 85, + requires_lesson_id BIGINT REFERENCES lessons(id), + x INT NOT NULL DEFAULT 0, + y INT NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + xp_reward INT NOT NULL DEFAULT 50, + is_active BOOLEAN NOT NULL DEFAULT true +); + +CREATE TABLE mcq_questions ( + id BIGSERIAL PRIMARY KEY, + lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + position INT NOT NULL DEFAULT 0, + question TEXT NOT NULL, + options JSONB NOT NULL, + answer_index INT NOT NULL, + explanation TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE watch_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + lesson_id BIGINT NOT NULL REFERENCES lessons(id), + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_position FLOAT NOT NULL DEFAULT 0, + UNIQUE (user_id, lesson_id) +); + +CREATE TABLE watch_heartbeats ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + lesson_id BIGINT NOT NULL REFERENCES lessons(id), + session_id BIGINT NOT NULL REFERENCES watch_sessions(id), + position FLOAT NOT NULL, + delta FLOAT NOT NULL, + seq INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, lesson_id, seq) +); + +CREATE TABLE lesson_progress ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + lesson_id BIGINT NOT NULL REFERENCES lessons(id), + watched_seconds FLOAT NOT NULL DEFAULT 0, + watched_pct FLOAT NOT NULL DEFAULT 0, + last_position FLOAT NOT NULL DEFAULT 0, + quiz_unlocked BOOLEAN NOT NULL DEFAULT false, + passed_quiz BOOLEAN NOT NULL DEFAULT false, + quiz_completed_at TIMESTAMPTZ, + UNIQUE (user_id, lesson_id) +); + +CREATE TABLE quiz_attempts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + lesson_id BIGINT NOT NULL REFERENCES lessons(id), + answers JSONB NOT NULL DEFAULT '[]', + correct_count INT NOT NULL DEFAULT 0, + total INT NOT NULL DEFAULT 0, + score_pct FLOAT NOT NULL DEFAULT 0, + status quiz_status NOT NULL DEFAULT 'in_progress', + hearts_lost INT NOT NULL DEFAULT 0, + passed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE xp_events ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + amount INT NOT NULL, + source TEXT NOT NULL, + ref_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE weekly_leaderboard ( + week TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id), + xp INT NOT NULL DEFAULT 0, + PRIMARY KEY (week, user_id) +); + +CREATE TABLE events ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + event_type event_type NOT NULL DEFAULT 'meet', + external_url TEXT NOT NULL DEFAULT '', + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + ics_uuid UUID NOT NULL DEFAULT gen_random_uuid(), + is_active BOOLEAN NOT NULL DEFAULT true, + created_by BIGINT REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE event_rsvps ( + id BIGSERIAL PRIMARY KEY, + event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (event_id, user_id) +); + +CREATE TABLE chat_messages ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + mentor_id BIGINT NOT NULL REFERENCES users(id), + sender_role user_role NOT NULL DEFAULT 'student', + body TEXT NOT NULL, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE notifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + category notif_category NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + route TEXT NOT NULL DEFAULT '', + data JSONB NOT NULL DEFAULT '{}', + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_notifications_user ON notifications (user_id, created_at DESC); + +CREATE TABLE notification_prefs ( + user_id BIGINT PRIMARY KEY REFERENCES users(id), + progress BOOLEAN NOT NULL DEFAULT true, + gamification BOOLEAN NOT NULL DEFAULT true, + mentor BOOLEAN NOT NULL DEFAULT true, + event BOOLEAN NOT NULL DEFAULT true +); + +CREATE TABLE video_assets ( + id BIGSERIAL PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + mime TEXT NOT NULL DEFAULT 'video/mp4', + size_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + token_hash TEXT UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE reminders_sent ( + user_id BIGINT NOT NULL REFERENCES users(id), + kind TEXT NOT NULL, + ref TEXT NOT NULL, + sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, kind, ref) +); \ No newline at end of file diff --git a/backend/internal/db/migrations/0002_chat_attachments.sql b/backend/internal/db/migrations/0002_chat_attachments.sql new file mode 100644 index 0000000..1842320 --- /dev/null +++ b/backend/internal/db/migrations/0002_chat_attachments.sql @@ -0,0 +1,5 @@ +ALTER TABLE chat_messages + ADD COLUMN IF NOT EXISTS attachment_type TEXT, + ADD COLUMN IF NOT EXISTS attachment_url TEXT, + ADD COLUMN IF NOT EXISTS attachment_name TEXT, + ADD COLUMN IF NOT EXISTS attachment_size BIGINT; \ No newline at end of file diff --git a/backend/internal/db/migrations/0003_chat_replies.sql b/backend/internal/db/migrations/0003_chat_replies.sql new file mode 100644 index 0000000..955c696 --- /dev/null +++ b/backend/internal/db/migrations/0003_chat_replies.sql @@ -0,0 +1 @@ +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS reply_to BIGINT; \ No newline at end of file diff --git a/backend/internal/db/migrations/0004_chat_reactions.sql b/backend/internal/db/migrations/0004_chat_reactions.sql new file mode 100644 index 0000000..e766f1f --- /dev/null +++ b/backend/internal/db/migrations/0004_chat_reactions.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS chat_reactions ( + id BIGSERIAL PRIMARY KEY, + message_id BIGINT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(message_id, user_id, emoji) +); +CREATE INDEX IF NOT EXISTS idx_chat_reactions_msg ON chat_reactions(message_id); diff --git a/backend/internal/db/migrations/0005_avatar.sql b/backend/internal/db/migrations/0005_avatar.sql new file mode 100644 index 0000000..700a7d3 --- /dev/null +++ b/backend/internal/db/migrations/0005_avatar.sql @@ -0,0 +1,3 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_variant TEXT NOT NULL DEFAULT 'beam'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_palette TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_photo TEXT NOT NULL DEFAULT ''; \ No newline at end of file diff --git a/backend/internal/db/migrations/0006_username.sql b/backend/internal/db/migrations/0006_username.sql new file mode 100644 index 0000000..19497b9 --- /dev/null +++ b/backend/internal/db/migrations/0006_username.sql @@ -0,0 +1,19 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT; + +UPDATE users +SET username = lower(split_part(email, '@', 1)) +WHERE username IS NULL OR username = ''; + +UPDATE users u +SET username = u.username || '-' || u.id::text +WHERE u.id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY username ORDER BY id) AS rn + FROM users + ) ranked + WHERE ranked.rn > 1 +); + +ALTER TABLE users ALTER COLUMN username SET NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS users_username_uidx ON users (lower(username)); diff --git a/backend/internal/db/migrations/0007_hearts_limit.sql b/backend/internal/db/migrations/0007_hearts_limit.sql new file mode 100644 index 0000000..1f02de8 --- /dev/null +++ b/backend/internal/db/migrations/0007_hearts_limit.sql @@ -0,0 +1,10 @@ +-- Keep the database invariant aligned with the application-level hearts guard. +UPDATE users +SET hearts = 3, + is_locked = true, + locked_at = COALESCE(locked_at, now()), + hearts_updated_at = now() +WHERE hearts > 3; + +ALTER TABLE users + ADD CONSTRAINT users_hearts_max CHECK (hearts BETWEEN 0 AND 3); diff --git a/backend/internal/db/migrations/0008_prod_indexes.sql b/backend/internal/db/migrations/0008_prod_indexes.sql new file mode 100644 index 0000000..525f62a --- /dev/null +++ b/backend/internal/db/migrations/0008_prod_indexes.sql @@ -0,0 +1,8 @@ +-- Hot-path indexes and heartbeat retention helpers. +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_user_lesson ON quiz_attempts (user_id, lesson_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_chat_messages_pair ON chat_messages (user_id, mentor_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_lesson_progress_user ON lesson_progress (user_id); +CREATE INDEX IF NOT EXISTS idx_watch_heartbeats_created ON watch_heartbeats (created_at); +CREATE INDEX IF NOT EXISTS idx_events_starts ON events (starts_at) WHERE is_active; +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens (user_id) WHERE revoked_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_xp_events_user ON xp_events (user_id, created_at DESC); diff --git a/backend/internal/db/migrations/0009_identity_cert.sql b/backend/internal/db/migrations/0009_identity_cert.sql new file mode 100644 index 0000000..7847b19 --- /dev/null +++ b/backend/internal/db/migrations/0009_identity_cert.sql @@ -0,0 +1,54 @@ +-- Identity, Telegram, security Q/A, certificates, physical orders + +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_id BIGINT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_username TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN IF NOT EXISTS security_question TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN IF NOT EXISTS security_answer_hash TEXT NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX IF NOT EXISTS users_phone_uidx + ON users (phone) WHERE phone <> ''; + +CREATE UNIQUE INDEX IF NOT EXISTS users_telegram_id_uidx + ON users (telegram_id) WHERE telegram_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS certificates ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + public_id TEXT NOT NULL UNIQUE, + full_name TEXT NOT NULL, + issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + UNIQUE (user_id) +); + +CREATE INDEX IF NOT EXISTS certificates_public_id_idx ON certificates (public_id); + +CREATE TYPE physical_order_status AS ENUM ('requested', 'paid', 'shipped', 'cancelled'); + +CREATE TABLE IF NOT EXISTS certificate_physical_orders ( + id BIGSERIAL PRIMARY KEY, + certificate_id BIGINT NOT NULL REFERENCES certificates(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status physical_order_status NOT NULL DEFAULT 'requested', + note TEXT NOT NULL DEFAULT '', + window_ends_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS cert_physical_orders_user_idx + ON certificate_physical_orders (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS cert_physical_orders_status_idx + ON certificate_physical_orders (status, created_at DESC); + +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); + +INSERT INTO app_settings (key, value) VALUES + ('physical_cert_enabled', 'true'), + ('physical_cert_price_irr', '2500000'), + ('physical_cert_window_days', '30') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/internal/db/migrations/0010_chat_pins.sql b/backend/internal/db/migrations/0010_chat_pins.sql new file mode 100644 index 0000000..6b0e1f2 --- /dev/null +++ b/backend/internal/db/migrations/0010_chat_pins.sql @@ -0,0 +1,5 @@ +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_chat_messages_pinned + ON chat_messages (user_id, mentor_id, pinned_at DESC) + WHERE pinned_at IS NOT NULL; diff --git a/backend/internal/db/migrations/0011_physical_shipping.sql b/backend/internal/db/migrations/0011_physical_shipping.sql new file mode 100644 index 0000000..8c224ef --- /dev/null +++ b/backend/internal/db/migrations/0011_physical_shipping.sql @@ -0,0 +1,24 @@ +-- Shipping details + tracking for physical certificates + +ALTER TABLE certificate_physical_orders + ADD COLUMN IF NOT EXISTS recipient_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS address TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS city TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS postal_code TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS tracking_code TEXT NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS unlock_requests ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS unlock_requests_user_created_idx + ON unlock_requests (user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS unlock_requests_open_idx + ON unlock_requests (created_at DESC) + WHERE resolved_at IS NULL; diff --git a/backend/internal/db/migrations/0012_site_settings.sql b/backend/internal/db/migrations/0012_site_settings.sql new file mode 100644 index 0000000..c5647f3 --- /dev/null +++ b/backend/internal/db/migrations/0012_site_settings.sql @@ -0,0 +1,9 @@ +-- Public/community settings defaults + sponsors (admin-editable) + +INSERT INTO app_settings (key, value) VALUES + ('telegram_channel_username', ''), + ('telegram_channel_title', 'کانال پرگار'), + ('donation_enabled', 'true'), + ('donation_note', 'اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل می‌شود.'), + ('sponsors', '[{"name":"نمونه اسپانسر","url":"https://example.com","blurb":"حامی یادگیری واقعی در پرگار"}]') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/internal/db/migrations/0013_registration.sql b/backend/internal/db/migrations/0013_registration.sql new file mode 100644 index 0000000..d13971c --- /dev/null +++ b/backend/internal/db/migrations/0013_registration.sql @@ -0,0 +1,13 @@ +-- Gated public registration: IP one-shot + admin toggle + +CREATE TABLE IF NOT EXISTS registration_ips ( + ip TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS registration_ips_user_idx ON registration_ips (user_id); + +INSERT INTO app_settings (key, value) VALUES + ('registration_enabled', 'false') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/internal/db/migrations/0014_registration_invites.sql b/backend/internal/db/migrations/0014_registration_invites.sql new file mode 100644 index 0000000..ec690b8 --- /dev/null +++ b/backend/internal/db/migrations/0014_registration_invites.sql @@ -0,0 +1,19 @@ +-- Capacity-limited registration invite / membership links + +CREATE TABLE IF NOT EXISTS registration_invites ( + id BIGSERIAL PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + token_enc TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + max_uses INT NOT NULL CHECK (max_uses > 0), + used_count INT NOT NULL DEFAULT 0 CHECK (used_count >= 0), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'revoked', 'exhausted')), + expires_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT registration_invites_used_lte_max CHECK (used_count <= max_uses) +); + +CREATE INDEX IF NOT EXISTS registration_invites_status_idx + ON registration_invites (status, created_at DESC); diff --git a/backend/internal/db/migrations/0015_registration_phone_whitelist.sql b/backend/internal/db/migrations/0015_registration_phone_whitelist.sql new file mode 100644 index 0000000..af3510e --- /dev/null +++ b/backend/internal/db/migrations/0015_registration_phone_whitelist.sql @@ -0,0 +1,19 @@ +-- One-shot phone whitelist for public registration + +CREATE TABLE IF NOT EXISTS registration_phone_whitelist ( + id BIGSERIAL PRIMARY KEY, + phone TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'available' + CHECK (status IN ('available', 'consumed')), + consumed_at TIMESTAMPTZ, + consumed_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT registration_phone_whitelist_consumed_consistency CHECK ( + (status = 'available' AND consumed_at IS NULL AND consumed_user_id IS NULL) + OR (status = 'consumed' AND consumed_at IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS registration_phone_whitelist_status_idx + ON registration_phone_whitelist (status, created_at DESC); diff --git a/backend/internal/db/migrations/0016_invite_paused.sql b/backend/internal/db/migrations/0016_invite_paused.sql new file mode 100644 index 0000000..c0e91c1 --- /dev/null +++ b/backend/internal/db/migrations/0016_invite_paused.sql @@ -0,0 +1,8 @@ +-- Allow temporarily pausing registration invites without revoking them + +ALTER TABLE registration_invites + DROP CONSTRAINT IF EXISTS registration_invites_status_check; + +ALTER TABLE registration_invites + ADD CONSTRAINT registration_invites_status_check + CHECK (status IN ('active', 'paused', 'revoked', 'exhausted')); diff --git a/backend/internal/db/migrations/0017_registration_phone_blacklist.sql b/backend/internal/db/migrations/0017_registration_phone_blacklist.sql new file mode 100644 index 0000000..69d4f3c --- /dev/null +++ b/backend/internal/db/migrations/0017_registration_phone_blacklist.sql @@ -0,0 +1,11 @@ +-- Phone blacklist: blocks all registration (public and invite) + +CREATE TABLE IF NOT EXISTS registration_phone_blacklist ( + id BIGSERIAL PRIMARY KEY, + phone TEXT NOT NULL UNIQUE, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS registration_phone_blacklist_created_at_idx + ON registration_phone_blacklist (created_at DESC); diff --git a/backend/internal/db/migrations/0018_blacklist_attempt_history.sql b/backend/internal/db/migrations/0018_blacklist_attempt_history.sql new file mode 100644 index 0000000..7d963a7 --- /dev/null +++ b/backend/internal/db/migrations/0018_blacklist_attempt_history.sql @@ -0,0 +1,19 @@ +-- Audit log when a blacklisted phone tries to register (public or invite). + +CREATE TABLE IF NOT EXISTS registration_blacklist_attempts ( + id BIGSERIAL PRIMARY KEY, + phone TEXT NOT NULL, + path TEXT NOT NULL CHECK (path IN ('public', 'invite')), + invite_id BIGINT REFERENCES registration_invites(id) ON DELETE SET NULL, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + attempted_username TEXT NOT NULL DEFAULT '', + attempted_email TEXT NOT NULL DEFAULT '', + attempted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS registration_blacklist_attempts_phone_idx + ON registration_blacklist_attempts (phone); + +CREATE INDEX IF NOT EXISTS registration_blacklist_attempts_attempted_at_idx + ON registration_blacklist_attempts (attempted_at DESC); diff --git a/backend/internal/db/migrations/0019_chat_export_tokens.sql b/backend/internal/db/migrations/0019_chat_export_tokens.sql new file mode 100644 index 0000000..08fcc86 --- /dev/null +++ b/backend/internal/db/migrations/0019_chat_export_tokens.sql @@ -0,0 +1,17 @@ +-- Time-limited, revocable download tokens for project-wide chat export links. + +CREATE TABLE IF NOT EXISTS chat_export_tokens ( + id BIGSERIAL PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + token_enc TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS chat_export_tokens_active_idx + ON chat_export_tokens (expires_at DESC) + WHERE revoked_at IS NULL; diff --git a/backend/internal/db/migrations/0020_chat_export_single_use.sql b/backend/internal/db/migrations/0020_chat_export_single_use.sql new file mode 100644 index 0000000..cdb1eb2 --- /dev/null +++ b/backend/internal/db/migrations/0020_chat_export_single_use.sql @@ -0,0 +1,8 @@ +-- Chat export download links are single-use after a successful download. + +ALTER TABLE chat_export_tokens + ADD COLUMN IF NOT EXISTS used_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS chat_export_tokens_used_idx + ON chat_export_tokens (used_at) + WHERE used_at IS NOT NULL; diff --git a/backend/internal/db/migrations/0021_challenges.sql b/backend/internal/db/migrations/0021_challenges.sql new file mode 100644 index 0000000..bcf3697 --- /dev/null +++ b/backend/internal/db/migrations/0021_challenges.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS challenges ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + target_xp INT NOT NULL CHECK (target_xp > 0), + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (ends_at >= starts_at) +); + +CREATE INDEX IF NOT EXISTS idx_challenges_active_window + ON challenges (is_active, starts_at, ends_at); + +CREATE TABLE IF NOT EXISTS challenge_progress ( + challenge_id BIGINT NOT NULL REFERENCES challenges(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + xp INT NOT NULL DEFAULT 0 CHECK (xp >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (challenge_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_challenge_progress_user ON challenge_progress (user_id); diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..0654adb --- /dev/null +++ b/backend/internal/models/models.go @@ -0,0 +1,330 @@ +package models + +import ( + "encoding/json" + "time" +) + +type Role string + +const ( + RoleStudent Role = "student" + RoleMentor Role = "mentor" + RoleAdmin Role = "admin" +) + +func (r Role) String() string { return string(r) } + +// IsStaff reports whether the role is exempt from student heart/lockout limits. +func (r Role) IsStaff() bool { + return r == RoleAdmin || r == RoleMentor +} + +type User struct { + ID int64 `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Name string `json:"name"` + Role Role `json:"role"` + XP int `json:"xp"` + Hearts int `json:"hearts"` + HeartsUpdatedAt time.Time `json:"heartsUpdatedAt"` + StreakCurrent int `json:"streakCurrent"` + StreakLongest int `json:"streakLongest"` + LastActivityDate *time.Time `json:"lastActivityDate,omitempty"` + IsLocked bool `json:"isLocked"` + LockedAt *time.Time `json:"lockedAt,omitempty"` + UnlockedBy *int64 `json:"unlockedBy,omitempty"` + IsActive bool `json:"isActive"` + AvatarVariant string `json:"avatarVariant"` + AvatarPalette string `json:"avatarPalette"` + AvatarPhoto string `json:"avatarPhoto,omitempty"` + Phone string `json:"phone"` + TelegramID *int64 `json:"-"` + TelegramUsername string `json:"-"` + SecurityQuestion string `json:"securityQuestion,omitempty"` + SecurityAnswerHash string `json:"-"` + HasSecurityAnswer bool `json:"hasSecurityAnswer"` + CreatedAt time.Time `json:"createdAt"` +} + +type Certificate struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + PublicID string `json:"publicId"` + FullName string `json:"fullName"` + IssuedAt time.Time `json:"issuedAt"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` +} + +type PhysicalOrderStatus string + +const ( + PhysicalRequested PhysicalOrderStatus = "requested" + PhysicalPaid PhysicalOrderStatus = "paid" + PhysicalShipped PhysicalOrderStatus = "shipped" + PhysicalCancelled PhysicalOrderStatus = "cancelled" +) + +type CertificatePhysicalOrder struct { + ID int64 `json:"id"` + CertificateID int64 `json:"certificateId"` + UserID int64 `json:"userId"` + Status PhysicalOrderStatus `json:"status"` + Note string `json:"note"` + RecipientName string `json:"recipientName"` + Phone string `json:"phone"` + Address string `json:"address"` + City string `json:"city"` + PostalCode string `json:"postalCode"` + TrackingCode string `json:"trackingCode"` + WindowEndsAt time.Time `json:"windowEndsAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + UserName string `json:"userName,omitempty"` + PublicID string `json:"publicId,omitempty"` +} + +type UnlockRequest struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + Note string `json:"note"` + CreatedAt time.Time `json:"createdAt"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + UserName string `json:"userName,omitempty"` +} + +type NotificationPrefs struct { + UserID int64 `json:"userId"` + Progress bool `json:"progress"` + Gamification bool `json:"gamification"` + Mentor bool `json:"mentor"` + Event bool `json:"event"` +} + +type Chapter struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + SortOrder int `json:"sortOrder"` +} + +type Lesson struct { + ID int64 `json:"id"` + ChapterID int64 `json:"chapterId"` + Title string `json:"title"` + Description string `json:"description"` + VideoKey string `json:"videoKey"` + DurationSeconds int `json:"durationSeconds"` + CompletionThresholdPct int `json:"completionThresholdPct"` + RequiresLessonID *int64 `json:"requiresLessonId,omitempty"` + X int `json:"x"` + Y int `json:"y"` + SortOrder int `json:"sortOrder"` + XPReward int `json:"xpReward"` + IsActive bool `json:"isActive"` +} + +type MCQQuestion struct { + ID int64 `json:"id"` + LessonID int64 `json:"lessonId"` + Position int `json:"position"` + Question string `json:"question"` + Options []string `json:"options"` + AnswerIndex int `json:"answerIndex"` + Explanation string `json:"explanation"` +} + +type LessonProgress struct { + UserID int64 `json:"userId"` + LessonID int64 `json:"lessonId"` + WatchedSeconds float64 `json:"watchedSeconds"` + WatchedPct float64 `json:"watchedPct"` + LastPosition float64 `json:"lastPosition"` + QuizUnlocked bool `json:"quizUnlocked"` + PassedQuiz bool `json:"passedQuiz"` + QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"` +} + +type WatchSession struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + LessonID int64 `json:"lessonId"` + StartedAt time.Time `json:"startedAt"` + LastHeartbeatAt time.Time `json:"lastHeartbeatAt"` + LastPosition float64 `json:"lastPosition"` +} + +type QuizQuestionView struct { + ID int64 `json:"id"` + Position int `json:"position"` + Question string `json:"question"` + Options []string `json:"options"` +} + +type QuizAttempt struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + LessonID int64 `json:"lessonId"` + Answers []int `json:"-"` + CorrectCount int `json:"correctCount"` + Total int `json:"total"` + ScorePct float64 `json:"scorePct"` + Status string `json:"status"` + HeartsLost int `json:"heartsLost"` + PassedAt *time.Time `json:"passedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type Event struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + EventType string `json:"eventType"` + ExternalURL string `json:"externalUrl"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + IsActive bool `json:"isActive"` + CreatedAt time.Time `json:"createdAt"` + CreatedBy *int64 `json:"-"` + Rsvped bool `json:"rsvped,omitempty"` +} + +// Challenge is a timed XP goal (typically monthly) for students. +type Challenge struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + TargetXP int `json:"targetXp"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + IsActive bool `json:"isActive"` + CreatedAt time.Time `json:"createdAt"` + CreatedBy *int64 `json:"-"` + // Progress fields for the requesting student (optional). + MyXP int `json:"myXp,omitempty"` + Completed bool `json:"completed,omitempty"` +} + +type ChatMessage struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + MentorID int64 `json:"mentorId"` + SenderRole Role `json:"senderRole"` + Body string `json:"body"` + ReadAt *time.Time `json:"readAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ReplyTo *int64 `json:"replyTo,omitempty"` + PinnedAt *time.Time `json:"pinnedAt,omitempty"` + Pinned bool `json:"pinned"` + Attachment *Attachment `json:"attachment,omitempty"` + Reactions []ChatReaction `json:"reactions,omitempty"` +} + +type ChatReaction struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Mine bool `json:"mine"` +} + +type Attachment struct { + Type string `json:"type"` // image | video | audio | file + URL string `json:"url"` + Name string `json:"name"` + Size int64 `json:"size"` +} + +type Notification struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + Category string `json:"category"` + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + Route string `json:"route"` + Data json.RawMessage `json:"data"` + ReadAt *time.Time `json:"readAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type InviteStatus string + +const ( + InviteActive InviteStatus = "active" + InvitePaused InviteStatus = "paused" + InviteRevoked InviteStatus = "revoked" + InviteExhausted InviteStatus = "exhausted" +) + +// RegistrationInvite is a capacity-limited membership registration link. +type RegistrationInvite struct { + ID int64 `json:"id"` + Label string `json:"label"` + MaxUses int `json:"maxUses"` + UsedCount int `json:"usedCount"` + Remaining int `json:"remaining"` + Status InviteStatus `json:"status"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + CreatedBy *int64 `json:"createdBy,omitempty"` + CreatedAt time.Time `json:"createdAt"` + // Token is the raw invite token; only populated when decrypting for admin URL display. + Token string `json:"token,omitempty"` + URL string `json:"url,omitempty"` +} + +// ChatExportToken is a time-limited, single-use download link for the project chat archive. +type ChatExportToken struct { + ID int64 `json:"id"` + Label string `json:"label"` + ExpiresAt time.Time `json:"expiresAt"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` + UsedAt *time.Time `json:"usedAt,omitempty"` + CreatedBy *int64 `json:"createdBy,omitempty"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + Active bool `json:"active"` + // Token/URL are populated for admin display after decrypt. + Token string `json:"token,omitempty"` + URL string `json:"url,omitempty"` +} + +type WhitelistStatus string + +const ( + WhitelistAvailable WhitelistStatus = "available" + WhitelistConsumed WhitelistStatus = "consumed" +) + +// RegistrationPhoneWhitelist is a one-shot phone allowed for public registration. +type RegistrationPhoneWhitelist struct { + ID int64 `json:"id"` + Phone string `json:"phone"` + Status WhitelistStatus `json:"status"` + ConsumedAt *time.Time `json:"consumedAt,omitempty"` + ConsumedUserID *int64 `json:"consumedUserId,omitempty"` + CreatedBy *int64 `json:"createdBy,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// RegistrationPhoneBlacklist blocks a phone from all registration paths. +type RegistrationPhoneBlacklist struct { + ID int64 `json:"id"` + Phone string `json:"phone"` + CreatedBy *int64 `json:"createdBy,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// RegistrationBlacklistAttempt records a failed registration try with a blacklisted phone. +type RegistrationBlacklistAttempt struct { + ID int64 `json:"id"` + Phone string `json:"phone"` + Path string `json:"path"` // public | invite + InviteID *int64 `json:"inviteId,omitempty"` + IP string `json:"ip"` + UserAgent string `json:"userAgent,omitempty"` + AttemptedUsername string `json:"attemptedUsername,omitempty"` + AttemptedEmail string `json:"attemptedEmail,omitempty"` + AttemptedAt time.Time `json:"attemptedAt"` +} diff --git a/backend/internal/observability/metrics.go b/backend/internal/observability/metrics.go new file mode 100644 index 0000000..44252d4 --- /dev/null +++ b/backend/internal/observability/metrics.go @@ -0,0 +1,219 @@ +package observability + +import ( + "fmt" + "net/http" + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Lightweight in-process Prometheus text metrics (no promhttp / compress deps). + +type counterVec struct { + mu sync.Mutex + name string + help string + labels []string + values map[string]*atomic.Int64 +} + +func newCounterVec(name, help string, labels []string) *counterVec { + return &counterVec{ + name: name, + help: help, + labels: labels, + values: map[string]*atomic.Int64{}, + } +} + +func (c *counterVec) WithLabelValues(vals ...string) *atomic.Int64 { + key := strings.Join(vals, "\x00") + c.mu.Lock() + defer c.mu.Unlock() + if v, ok := c.values[key]; ok { + return v + } + v := &atomic.Int64{} + c.values[key] = v + return v +} + +func (c *counterVec) Inc(vals ...string) { + c.WithLabelValues(vals...).Add(1) +} + +func (c *counterVec) write(b *strings.Builder) { + c.mu.Lock() + defer c.mu.Unlock() + fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s counter\n", c.name, c.help, c.name) + keys := make([]string, 0, len(c.values)) + for k := range c.values { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + parts := strings.Split(k, "\x00") + var labelPairs []string + for i, lab := range c.labels { + val := "" + if i < len(parts) { + val = parts[i] + } + labelPairs = append(labelPairs, fmt.Sprintf(`%s="%s"`, lab, escapeLabel(val))) + } + fmt.Fprintf(b, "%s{%s} %d\n", c.name, strings.Join(labelPairs, ","), c.values[k].Load()) + } +} + +type counter struct { + name string + help string + value atomic.Int64 +} + +func newCounter(name, help string) *counter { + return &counter{name: name, help: help} +} + +func (c *counter) Inc() { c.value.Add(1) } + +func (c *counter) write(b *strings.Builder) { + fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", c.name, c.help, c.name, c.name, c.value.Load()) +} + +type histVec struct { + mu sync.Mutex + name string + help string + buckets []float64 + obs map[string]*histObs +} + +type histObs struct { + counts []atomic.Uint64 + sum atomic.Uint64 // milliseconds * 1000 for precision via float bits — use float64 via mutex instead + sumMu sync.Mutex + sumF float64 + count atomic.Uint64 +} + +func newHistVec(name, help string, buckets []float64) *histVec { + return &histVec{ + name: name, + help: help, + buckets: buckets, + obs: map[string]*histObs{}, + } +} + +func (h *histVec) Observe(method, path string, seconds float64) { + key := method + "\x00" + path + h.mu.Lock() + o, ok := h.obs[key] + if !ok { + o = &histObs{counts: make([]atomic.Uint64, len(h.buckets))} + h.obs[key] = o + } + h.mu.Unlock() + for i, b := range h.buckets { + if seconds <= b { + o.counts[i].Add(1) + } + } + o.count.Add(1) + o.sumMu.Lock() + o.sumF += seconds + o.sumMu.Unlock() +} + +func (h *histVec) write(b *strings.Builder) { + h.mu.Lock() + defer h.mu.Unlock() + fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s histogram\n", h.name, h.help, h.name) + keys := make([]string, 0, len(h.obs)) + for k := range h.obs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + parts := strings.Split(k, "\x00") + method, path := parts[0], "" + if len(parts) > 1 { + path = parts[1] + } + base := fmt.Sprintf(`method="%s",path="%s"`, escapeLabel(method), escapeLabel(path)) + o := h.obs[k] + for i, bound := range h.buckets { + fmt.Fprintf(b, `%s_bucket{%s,le="%g"} %d`+"\n", h.name, base, bound, o.counts[i].Load()) + } + fmt.Fprintf(b, `%s_bucket{%s,le="+Inf"} %d`+"\n", h.name, base, o.count.Load()) + o.sumMu.Lock() + sum := o.sumF + o.sumMu.Unlock() + fmt.Fprintf(b, "%s_sum{%s} %g\n", h.name, base, sum) + fmt.Fprintf(b, "%s_count{%s} %d\n", h.name, base, o.count.Load()) + } +} + +func escapeLabel(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", `\n`) + return s +} + +var ( + httpRequests = newCounterVec("pargar_http_requests_total", "Total HTTP requests", []string{"method", "path", "status"}) + httpDuration = newHistVec("pargar_http_request_duration_seconds", "HTTP request duration", []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}) + quizSubmits = newCounterVec("pargar_quiz_submits_total", "Quiz submit outcomes", []string{"result"}) + lockouts = newCounter("pargar_account_lockouts_total", "Accounts locked after heart depletion") + heartbeats = newCounter("pargar_watch_heartbeats_total", "Accepted watch heartbeats") + videoUploads = newCounter("pargar_video_uploads_total", "Admin video uploads") + logins = newCounterVec("pargar_auth_logins_total", "Login attempts", []string{"result"}) +) + +// Compatibility wrappers matching previous call sites. + +type labeledInc struct{ vec *counterVec } + +func (l labeledInc) WithLabelValues(vals ...string) interface{ Inc() } { + return labeledCounter{c: l.vec.WithLabelValues(vals...)} +} + +type labeledCounter struct{ c *atomic.Int64 } + +func (l labeledCounter) Inc() { l.c.Add(1) } + +var ( + HTTPRequests = labeledInc{httpRequests} + QuizSubmits = labeledInc{quizSubmits} + Logins = labeledInc{logins} + Lockouts = lockouts + Heartbeats = heartbeats + VideoUploads = videoUploads +) + +// ObserveHTTP records request metrics. +func ObserveHTTP(method, path string, status int, d time.Duration) { + httpRequests.Inc(method, path, fmt.Sprintf("%d", status)) + httpDuration.Observe(method, path, d.Seconds()) +} + +// MetricsHandler serves Prometheus text exposition. +func MetricsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b strings.Builder + httpRequests.write(&b) + httpDuration.write(&b) + quizSubmits.write(&b) + lockouts.write(&b) + heartbeats.write(&b) + videoUploads.write(&b) + logins.write(&b) + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + _, _ = w.Write([]byte(b.String())) + }) +} diff --git a/backend/internal/observability/observability.go b/backend/internal/observability/observability.go new file mode 100644 index 0000000..f60ff3e --- /dev/null +++ b/backend/internal/observability/observability.go @@ -0,0 +1,111 @@ +package observability + +import ( + "context" + "log/slog" + "os" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// Config holds optional OpenTelemetry settings. +type Config struct { + ServiceName string + OTLPEndpoint string // empty = noop tracer +} + +var ( + logger *slog.Logger + tracer trace.Tracer = noop.NewTracerProvider().Tracer("pargar") +) + +// SetupJSONLogger installs a JSON slog default logger. +func SetupJSONLogger() *slog.Logger { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) + logger = slog.New(handler) + slog.SetDefault(logger) + return logger +} + +func L() *slog.Logger { + if logger == nil { + return slog.Default() + } + return logger +} + +// Activity emits a structured domain activity log line. +func Activity(action string, attrs ...any) { + args := make([]any, 0, len(attrs)+2) + args = append(args, "action", action) + args = append(args, attrs...) + L().Info("activity", args...) +} + +// SetupTracing configures OTLP HTTP exporter when endpoint is set; otherwise noop. +// Returns a shutdown func (always non-nil). +func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error, error) { + name := strings.TrimSpace(cfg.ServiceName) + if name == "" { + name = "pargar-api" + } + endpoint := strings.TrimSpace(cfg.OTLPEndpoint) + if endpoint == "" { + tp := noop.NewTracerProvider() + otel.SetTracerProvider(tp) + tracer = tp.Tracer(name) + L().Info("otel tracing disabled (no OTEL_EXPORTER_OTLP_ENDPOINT)") + return func(context.Context) error { return nil }, nil + } + + endpoint = strings.TrimPrefix(endpoint, "https://") + endpoint = strings.TrimPrefix(endpoint, "http://") + + exp, err := otlptracehttp.New(ctx, + otlptracehttp.WithEndpoint(endpoint), + otlptracehttp.WithInsecure(), + ) + if err != nil { + return nil, err + } + res, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(name), + ), + ) + if err != nil { + return nil, err + } + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exp), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.TraceContext{}) + tracer = tp.Tracer(name) + L().Info("otel tracing enabled", "endpoint", endpoint, "service", name) + return tp.Shutdown, nil +} + +func Tracer() trace.Tracer { return tracer } + +// StartSpan starts a named span from context. +func StartSpan(ctx context.Context, name string) (context.Context, trace.Span) { + return Tracer().Start(ctx, name) +} + +// DurationMs helper for middleware logs. +func DurationMs(start time.Time) int64 { + return time.Since(start).Milliseconds() +} diff --git a/backend/internal/observability/observability_test.go b/backend/internal/observability/observability_test.go new file mode 100644 index 0000000..28d49c6 --- /dev/null +++ b/backend/internal/observability/observability_test.go @@ -0,0 +1,19 @@ +package observability + +import "testing" + +func TestSetupTracingNoop(t *testing.T) { + shutdown, err := SetupTracing(t.Context(), Config{ServiceName: "test", OTLPEndpoint: ""}) + if err != nil { + t.Fatal(err) + } + if shutdown == nil { + t.Fatal("expected shutdown func") + } + if err := shutdown(t.Context()); err != nil { + t.Fatal(err) + } + ctx, span := StartSpan(t.Context(), "test.span") + _ = ctx + span.End() +} diff --git a/backend/internal/redisdb/redis.go b/backend/internal/redisdb/redis.go new file mode 100644 index 0000000..76d7e70 --- /dev/null +++ b/backend/internal/redisdb/redis.go @@ -0,0 +1,26 @@ +package redisdb + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +type Client struct { + rdb *redis.Client +} + +func Connect(ctx context.Context, url string) (*Client, error) { + opts, err := redis.ParseURL(url) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + rdb := redis.NewClient(opts) + if err := rdb.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("ping redis: %w", err) + } + return &Client{rdb: rdb}, nil +} + +func (c *Client) RDB() *redis.Client { return c.rdb } diff --git a/backend/internal/storage/sample.go b/backend/internal/storage/sample.go new file mode 100644 index 0000000..84e644b --- /dev/null +++ b/backend/internal/storage/sample.go @@ -0,0 +1,56 @@ +package storage + +import ( + "fmt" + "os" + "path/filepath" +) + +const SampleVideoKey = "sample.mp4" + +// EnsureSampleMP4 uploads the color-bars sample video when the storage key is missing. +// candidates are filesystem paths checked in order (first readable wins). +func EnsureSampleMP4(stg Storage, candidates ...string) error { + if stg == nil { + return fmt.Errorf("storage is nil") + } + if stg.Exists(SampleVideoKey) { + return nil + } + for _, path := range candidates { + if path == "" { + continue + } + f, err := os.Open(path) + if err != nil { + continue + } + info, err := f.Stat() + if err != nil { + f.Close() + continue + } + if info.IsDir() || info.Size() == 0 { + f.Close() + continue + } + _, err = stg.Put(SampleVideoKey, f, info.Size()) + f.Close() + if err != nil { + return fmt.Errorf("upload %s: %w", SampleVideoKey, err) + } + return nil + } + return fmt.Errorf("%s missing in storage and no readable candidate found among %v", SampleVideoKey, candidates) +} + +// DefaultSampleCandidates returns common relative locations for the checked-in sample. +func DefaultSampleCandidates() []string { + cwd, _ := os.Getwd() + return []string{ + filepath.Join(cwd, "media", SampleVideoKey), + filepath.Join(cwd, "backend", "media", SampleVideoKey), + filepath.Join("media", SampleVideoKey), + filepath.Join("backend", "media", SampleVideoKey), + } +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go new file mode 100644 index 0000000..d05e82f --- /dev/null +++ b/backend/internal/storage/storage.go @@ -0,0 +1,311 @@ +package storage + +import ( + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// Storage abstracts media file persistence. +type Storage interface { + Save(key string, file multipart.File, size int64) (string, error) + // Put writes raw bytes (used for seed / sample media). + Put(key string, r io.Reader, size int64) (string, error) + Open(key string) (io.ReadCloser, time.Time, string, error) + // Exists reports whether key is present. + Exists(key string) bool + // Serve streams a key with HTTP Range support when possible. + Serve(w http.ResponseWriter, r *http.Request, key string) error + // URL returns the absolute serving URL for a key (or "" if none). + URL(key string) string +} + +// Local implements Storage using the local filesystem, served by the API at /media/. +type Local struct { + dir string + baseURL string +} + +func NewLocal(dir, publicURL string) *Local { + return &Local{dir: dir, baseURL: publicURL} +} + +// cleanLocalKey keeps nested keys like chats/1-x.png while blocking path traversal. +func cleanLocalKey(key string) (string, error) { + key = strings.TrimSpace(strings.TrimPrefix(key, "/")) + key = filepath.ToSlash(key) + if key == "" || strings.Contains(key, "\\") || strings.Contains(key, "..") { + return "", fmt.Errorf("invalid key") + } + parts := strings.Split(key, "/") + for _, p := range parts { + if p == "" || p == "." || p == ".." || filepath.Base(p) != p { + return "", fmt.Errorf("invalid key") + } + } + return key, nil +} + +func (l *Local) Save(key string, file multipart.File, size int64) (string, error) { + return l.Put(key, file, size) +} + +func (l *Local) Put(key string, r io.Reader, size int64) (string, error) { + clean, err := cleanLocalKey(key) + if err != nil { + return "", err + } + dst := filepath.Join(l.dir, filepath.FromSlash(clean)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", err + } + out, err := os.Create(dst) + if err != nil { + return "", err + } + defer out.Close() + if size > 0 { + if _, err := io.CopyN(out, r, size); err != nil && err != io.EOF { + return "", err + } + } else { + if _, err := io.Copy(out, r); err != nil { + return "", err + } + } + return l.URL(clean), nil +} + +func (l *Local) Exists(key string) bool { + clean, err := cleanLocalKey(key) + if err != nil { + return false + } + _, err = os.Stat(filepath.Join(l.dir, filepath.FromSlash(clean))) + return err == nil +} + +func (l *Local) Open(key string) (io.ReadCloser, time.Time, string, error) { + clean, err := cleanLocalKey(key) + if err != nil { + return nil, time.Time{}, "", err + } + f, err := os.Open(filepath.Join(l.dir, filepath.FromSlash(clean))) + if err != nil { + return nil, time.Time{}, "", err + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, time.Time{}, "", err + } + return f, info.ModTime(), mimeType(clean), nil +} + +func (l *Local) Serve(w http.ResponseWriter, r *http.Request, key string) error { + clean, err := cleanLocalKey(key) + if err != nil { + return err + } + path := filepath.Join(l.dir, filepath.FromSlash(clean)) + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return err + } + setInlineMediaHeaders(w, mimeType(clean)) + // Empty name avoids Content-Disposition attachment hints from some clients. + http.ServeContent(w, r, "", info.ModTime(), f) + return nil +} + +func (l *Local) URL(key string) string { + if key == "" { + return "" + } + clean, err := cleanLocalKey(key) + if err != nil { + return "" + } + return l.baseURL + "/media/" + clean +} + +func (l *Local) Dir() string { return l.dir } + +type S3 struct { + client *s3.Client + bucket string + baseURL string +} + +func NewS3(ctx context.Context, endpoint, region, accessKey, secretKey, bucket, publicURL string) (*S3, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + ) + if err != nil { + return nil, err + } + client := s3.NewFromConfig(cfg, func(options *s3.Options) { + options.BaseEndpoint = aws.String(endpoint) + options.UsePathStyle = true + }) + storage := &S3{client: client, bucket: bucket, baseURL: strings.TrimRight(publicURL, "/")} + if _, err := client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}); err != nil { + if _, createErr := client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}); createErr != nil { + return nil, createErr + } + } + return storage, nil +} + +func (s *S3) Save(key string, file multipart.File, size int64) (string, error) { + return s.Put(key, file, size) +} + +func (s *S3) Put(key string, r io.Reader, size int64) (string, error) { + objectKey := cleanKey(key) + if objectKey == "" { + return "", fmt.Errorf("invalid key") + } + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(objectKey), + Body: r, + } + if size > 0 { + input.ContentLength = aws.Int64(size) + } + _, err := s.client.PutObject(context.Background(), input) + if err != nil { + return "", err + } + return s.URL(key), nil +} + +func (s *S3) Exists(key string) bool { + objectKey := cleanKey(key) + if objectKey == "" { + return false + } + _, err := s.client.HeadObject(context.Background(), &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(objectKey), + }) + return err == nil +} + +func (s *S3) Open(key string) (io.ReadCloser, time.Time, string, error) { + objectKey := cleanKey(key) + if objectKey == "" { + return nil, time.Time{}, "", fmt.Errorf("invalid key") + } + result, err := s.client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(objectKey), + }) + if err != nil { + return nil, time.Time{}, "", err + } + return result.Body, time.Time{}, mimeType(key), nil +} + +func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error { + objectKey := cleanKey(key) + if objectKey == "" { + return fmt.Errorf("invalid key") + } + input := &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(objectKey), + } + if rng := r.Header.Get("Range"); rng != "" { + input.Range = aws.String(rng) + } + result, err := s.client.GetObject(r.Context(), input) + if err != nil { + return err + } + defer result.Body.Close() + + ct := mimeType(key) + if result.ContentType != nil && *result.ContentType != "" { + ct = *result.ContentType + } + w.Header().Set("Content-Type", ct) + setInlineMediaHeaders(w, ct) + w.Header().Set("Accept-Ranges", "bytes") + if result.ContentLength != nil { + w.Header().Set("Content-Length", fmt.Sprintf("%d", *result.ContentLength)) + } + if result.ContentRange != nil { + w.Header().Set("Content-Range", *result.ContentRange) + w.WriteHeader(http.StatusPartialContent) + } else { + w.WriteHeader(http.StatusOK) + } + _, err = io.Copy(w, result.Body) + return err +} + +func (s *S3) URL(key string) string { + return s.baseURL + "/media/" + cleanKey(key) +} + +func cleanKey(key string) string { + clean := filepath.ToSlash(filepath.Clean(key)) + clean = strings.TrimPrefix(clean, "/") + if clean == "" || clean == "." || strings.Contains(clean, "..") { + return "" + } + return clean +} + +func mimeType(key string) string { + switch strings.ToLower(filepath.Ext(key)) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".mp4": + return "video/mp4" + case ".webm": + return "video/webm" + case ".mp3": + return "audio/mpeg" + case ".pdf": + return "application/pdf" + default: + return "application/octet-stream" + } +} + +// setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact. +func setInlineMediaHeaders(w http.ResponseWriter, contentType string) { + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.Header().Set("Content-Disposition", "inline") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("X-Robots-Tag", "noindex, nofollow") +} diff --git a/backend/media/.gitkeep b/backend/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/media/sample.mp4 b/backend/media/sample.mp4 new file mode 100644 index 0000000..2c799db Binary files /dev/null and b/backend/media/sample.mp4 differ