diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5a104dc1..172703b9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -166,6 +166,16 @@ jobs: "path": "/nycu-oauth", "secret_name": "CLIENT_SECRET", "env_name": "NYCU_OAUTH_CLIENT_SECRET" + }, + { + "path": "/google-sheets", + "secret_name": "SERVICE_ACCOUNT_EMAIL", + "env_name": "GOOGLE_SERVICE_ACCOUNT_EMAIL" + }, + { + "path": "/google-sheets", + "secret_name": "SERVICE_ACCOUNT_KEY", + "env_name": "GOOGLE_SERVICE_ACCOUNT_KEY" } ] } @@ -231,6 +241,16 @@ jobs: "path": "/nycu-oauth", "secret_name": "CLIENT_SECRET", "env_name": "NYCU_OAUTH_CLIENT_SECRET" + }, + { + "path": "/google-sheets", + "secret_name": "SERVICE_ACCOUNT_EMAIL", + "env_name": "GOOGLE_SERVICE_ACCOUNT_EMAIL" + }, + { + "path": "/google-sheets", + "secret_name": "SERVICE_ACCOUNT_KEY", + "env_name": "GOOGLE_SERVICE_ACCOUNT_KEY" } ] } diff --git a/cmd/backend/main.go b/cmd/backend/main.go index bf24cf53..96da84c6 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -270,6 +270,8 @@ func main() { mux.Handle("POST /api/forms/{formId}/publish", authMiddleware.HandlerFunc(publishHandler.PublishForm)) mux.Handle("POST /api/orgs/{slug}/forms", tenantCasbinAuthMiddleware.HandlerFunc(formHandler.CreateUnderOrgHandler)) mux.Handle("GET /api/orgs/{slug}/forms", tenantBasicMiddleware.HandlerFunc(formHandler.ListByOrgHandler)) + mux.Handle("GET /api/forms/google-sheet-email", authMiddleware.HandlerFunc(formHandler.GetGoogleSheetEmailHandler)) + mux.Handle("POST /api/forms/google-sheet/verify", authMiddleware.HandlerFunc(formHandler.VerifyGoogleSheetHandler)) // Question routes mux.Handle("GET /api/forms/{formId}/sections", authMiddleware.HandlerFunc(questionHandler.ListHandler)) diff --git a/go.mod b/go.mod index 18513a78..ab4b7712 100644 --- a/go.mod +++ b/go.mod @@ -15,18 +15,21 @@ require ( github.com/joho/godotenv v1.5.1 github.com/ory/dockertest/v3 v3.12.0 github.com/stretchr/testify v1.11.1 - go.opentelemetry.io/otel v1.37.0 + go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 - go.opentelemetry.io/otel/sdk v1.37.0 - go.opentelemetry.io/otel/trace v1.37.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 go.uber.org/zap v1.27.0 - golang.org/x/oauth2 v0.30.0 - google.golang.org/grpc v1.73.0 + golang.org/x/oauth2 v0.35.0 + google.golang.org/api v0.266.0 + google.golang.org/grpc v1.78.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -35,6 +38,7 @@ require ( github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -43,6 +47,7 @@ require ( github.com/docker/docker v28.4.0+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -52,7 +57,10 @@ require ( github.com/golang-migrate/migrate/v4 v4.18.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -76,19 +84,20 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index b91dd1c2..25b4543b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -36,6 +40,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +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/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -94,10 +100,16 @@ 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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 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/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -149,8 +161,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -169,24 +181,24 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -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/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= @@ -201,10 +213,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -217,14 +229,20 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= +google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/errors.go b/internal/errors.go index b89afc6a..d2ca167b 100644 --- a/internal/errors.go +++ b/internal/errors.go @@ -84,11 +84,13 @@ var ( ErrSearchTooLong = errors.New("search string exceeds maximum length") // Form Errors - ErrFormNotFound = errors.New("form not found") - ErrFormNotDraft = fmt.Errorf("form is not in draft status") - ErrFormDeadlinePassed = errors.New("form deadline has passed") + ErrFormNotFound = errors.New("form not found") + ErrFormNotDraft = fmt.Errorf("form is not in draft status") + ErrFormDeadlinePassed = errors.New("form deadline has passed") ErrCoverImageTooLarge = errors.New("cover image exceeds maximum size") ErrCoverImageInvalidFormat = errors.New("cover image format is invalid") + ErrGoogleSheetURLInvalid = errors.New("invalid google sheet url format") + ErrGoogleSheetAccessDenied = errors.New("cannot access google sheet") // Question Errors ErrQuestionNotFound = errors.New("question not found") @@ -208,6 +210,10 @@ func ErrorHandler(err error) problem.Problem { return problem.NewValidateProblem("cover image exceeds maximum size (max 2MB)") case errors.Is(err, ErrCoverImageInvalidFormat): return problem.NewValidateProblem("cover image must be a WebP file") + case errors.Is(err, ErrGoogleSheetURLInvalid): + return problem.NewValidateProblem("invalid google sheet url format") + case errors.Is(err, ErrGoogleSheetAccessDenied): + return problem.NewValidateProblem("cannot access google sheet, please share with service account") // Inbox Errors case errors.Is(err, ErrInvalidIsReadParameter): diff --git a/internal/form/handler.go b/internal/form/handler.go index 888d7283..1915cf2c 100644 --- a/internal/form/handler.go +++ b/internal/form/handler.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "regexp" "time" handlerutil "github.com/NYCU-SDC/summer/pkg/handler" @@ -76,6 +77,27 @@ type Response struct { type CoverUploadResponse struct { ImageURL string `json:"imageUrl"` } +type GoogleSheetEmailResponse struct { + Email string `json:"email"` +} +type GoogleSheetVerifyRequest struct { + GoogleSheetURL string `json:"googleSheetUrl" validate:"required"` +} + +type GoogleSheetVerifyResponse struct { + IsValid bool `json:"isValid"` +} + +type emailGetter interface { + GetServiceAccountEmail() string +} + +type verifier interface { + VerifySpreadsheetReadable(ctx context.Context, spreadsheetID string) error +} + +// Google IDs allow alphanumeric characters, hyphens, and underscores. +var spreadsheetIDPattern = regexp.MustCompile(`spreadsheets/d/([a-zA-Z0-9_-]+)`) type SectionRequest struct { Title string `json:"title" validate:"required"` @@ -234,6 +256,27 @@ func (h *Handler) PatchHandler(w http.ResponseWriter, r *http.Request) { return } + // Google Sheet setting is form-scoped; validate accessibility before persisting. + if req.GoogleSheetUrl != nil && *req.GoogleSheetUrl != "" { + spreadsheetID, err := extractSpreadsheetID(*req.GoogleSheetUrl) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + + v, ok := h.store.(verifier) + if !ok { + h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) + return + } + + err = v.VerifySpreadsheetReadable(traceCtx, spreadsheetID) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + } + currentUser, ok := user.GetFromContext(traceCtx) if !ok { h.problemWriter.WriteError(traceCtx, w, internal.ErrNoUserInContext, logger) @@ -685,6 +728,67 @@ func (h *Handler) GetFontsHandler(w http.ResponseWriter, r *http.Request) { handlerutil.WriteJSONResponse(w, http.StatusOK, fonts) } +func (h *Handler) GetGoogleSheetEmailHandler(w http.ResponseWriter, r *http.Request) { + traceCtx, span := h.tracer.Start(r.Context(), "GetGoogleSheetEmailHandler") + defer span.End() + logger := logutil.WithContext(traceCtx, h.logger) + + getter, ok := h.store.(emailGetter) + if !ok { + h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) + return + } + + email := getter.GetServiceAccountEmail() + if email == "" { + h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) + return + } + + handlerutil.WriteJSONResponse(w, http.StatusOK, GoogleSheetEmailResponse{Email: email}) +} + +func (h *Handler) VerifyGoogleSheetHandler(w http.ResponseWriter, r *http.Request) { + traceCtx, span := h.tracer.Start(r.Context(), "VerifyGoogleSheetHandler") + defer span.End() + logger := logutil.WithContext(traceCtx, h.logger) + + var req GoogleSheetVerifyRequest + err := handlerutil.ParseAndValidateRequestBody(traceCtx, h.validator, r, &req) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + + spreadsheetID, err := extractSpreadsheetID(req.GoogleSheetURL) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + + v, ok := h.store.(verifier) + if !ok { + h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) + return + } + + err = v.VerifySpreadsheetReadable(traceCtx, spreadsheetID) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + + handlerutil.WriteJSONResponse(w, http.StatusOK, GoogleSheetVerifyResponse{IsValid: true}) +} + +func extractSpreadsheetID(sheetURL string) (string, error) { + matches := spreadsheetIDPattern.FindStringSubmatch(sheetURL) + if len(matches) < 2 { + return "", internal.ErrGoogleSheetURLInvalid + } + return matches[1], nil +} + func (h *Handler) UpdateSectionHandler(w http.ResponseWriter, r *http.Request) { traceCtx, span := h.tracer.Start(r.Context(), "UpdateSectionHandler") defer span.End() diff --git a/internal/form/service.go b/internal/form/service.go index 7664d02f..51a8713e 100644 --- a/internal/form/service.go +++ b/internal/form/service.go @@ -1,9 +1,12 @@ package form import ( + "NYCU-SDC/core-system-backend/internal" "NYCU-SDC/core-system-backend/internal/form/response" "context" "errors" + "fmt" + "os" "slices" handlerutil "github.com/NYCU-SDC/summer/pkg/handler" @@ -16,6 +19,9 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" ) type Querier interface { @@ -441,3 +447,54 @@ func (s *Service) GetCoverImage(ctx context.Context, id uuid.UUID) ([]byte, erro return imageData, nil } + +func (s *Service) GetServiceAccountEmail() string { + return os.Getenv("GOOGLE_SERVICE_ACCOUNT_EMAIL") +} + +func (s *Service) VerifySpreadsheetReadable(ctx context.Context, spreadsheetID string) error { + ctx, span := s.tracer.Start(ctx, "VerifySpreadsheetReadable") + defer span.End() + logger := logutil.WithContext(ctx, s.logger) + + serviceAccountKey := os.Getenv("GOOGLE_SERVICE_ACCOUNT_KEY") + if serviceAccountKey == "" { + err := fmt.Errorf("%w: GOOGLE_SERVICE_ACCOUNT_KEY is not set", internal.ErrInternalServerError) + span.RecordError(err) + logger.Error("missing google service account key", zap.Error(err)) + return err + } + + // initialize google sheets api client + srv, err := sheets.NewService( + ctx, + option.WithAuthCredentialsJSON(option.ServiceAccount, []byte(serviceAccountKey)), + option.WithScopes(sheets.SpreadsheetsReadonlyScope), + ) + if err != nil { + err = fmt.Errorf("failed to create sheets service: %w", err) + span.RecordError(err) + logger.Error("failed to create sheets service", zap.Error(err)) + return err + } + + // try to access + _, err = srv.Spreadsheets.Get(spreadsheetID). + Fields("spreadsheetId"). + Context(ctx). + Do() + if err != nil { + email := s.GetServiceAccountEmail() + if email != "" { + err = fmt.Errorf("%w: %s", internal.ErrGoogleSheetAccessDenied, email) + } else { + err = fmt.Errorf("failed to access spreadsheet: %w", err) + } + + span.RecordError(err) + logger.Warn("failed to access spreadsheet", zap.String("spreadsheet_id", spreadsheetID), zap.Error(err)) + return err + } + + return nil +}