feat(backend): bootstrap Go module, config, and data plane
Introduce configuration validation, Postgres migrations, Redis/storage adapters, and the seed sample video.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
tmp
|
||||
*.test
|
||||
media/*
|
||||
!media/sample.mp4
|
||||
@@ -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/
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
)
|
||||
+131
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS reply_to BIGINT;
|
||||
@@ -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);
|
||||
@@ -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 '';
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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()))
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user