From 3fbd066f41a03c8b3806ffd1dd32b3426be95daf Mon Sep 17 00:00:00 2001 From: Tiffany <886fan@gmail.com> Date: Wed, 11 Feb 2026 05:51:41 +0000 Subject: [PATCH 1/9] feat: implement google-sheet api --- .github/workflows/release.yaml | 20 ++++++++ cmd/backend/main.go | 2 + internal/errors.go | 12 +++-- internal/form/handler.go | 88 ++++++++++++++++++++++++++++++++++ internal/form/service.go | 57 ++++++++++++++++++++++ 5 files changed, 176 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 44a90a50..de79bc34 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -156,6 +156,16 @@ jobs: "path": "/google-oauth", "secret_name": "CLIENT_SECRET", "env_name": "GOOGLE_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" } ] } @@ -211,6 +221,16 @@ jobs: "path": "/google-oauth", "secret_name": "CLIENT_SECRET", "env_name": "GOOGLE_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 5adb9b14..800cbef0 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -263,6 +263,8 @@ func main() { mux.Handle("DELETE /api/forms/{id}", authMiddleware.HandlerFunc(formHandler.DeleteHandler)) 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)) mux.Handle("POST /api/forms/{id}/publish", authMiddleware.HandlerFunc(publishHandler.PublishForm)) mux.Handle("POST /api/forms/recipients/preview", authMiddleware.HandlerFunc(publishHandler.PreviewForm)) diff --git a/internal/errors.go b/internal/errors.go index b9f1b85f..da995d99 100644 --- a/internal/errors.go +++ b/internal/errors.go @@ -56,9 +56,11 @@ 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") + ErrGoogleSheetURLInvalid = errors.New("invalid google sheet url format") + ErrGoogleSheetAccessDenied = errors.New("cannot access google sheet") // Question Errors ErrQuestionNotFound = errors.New("question not found") @@ -149,6 +151,10 @@ func ErrorHandler(err error) problem.Problem { return problem.NewNotFoundProblem("form not found") case errors.Is(err, ErrFormNotDraft): return problem.NewValidateProblem("form is not in draft status") + 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 b4f8ca8f..501c45d8 100644 --- a/internal/form/handler.go +++ b/internal/form/handler.go @@ -6,6 +6,8 @@ import ( "context" "fmt" "net/http" + "net/url" + "strings" "time" handlerutil "github.com/NYCU-SDC/summer/pkg/handler" @@ -38,6 +40,16 @@ type Response struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } +type GoogleSheetEmailResponse struct { + Email string `json:"email"` +} +type GoogleSheetVerifyRequest struct { + GoogleSheetURL string `json:"googleSheetUrl" validate:"required"` +} + +type GoogleSheetVerifyResponse struct { + IsValid bool `json:"isValid"` +} // ToResponse converts a Form storage model into an API Response. // Ensures deadline is null when empty/invalid. @@ -368,3 +380,79 @@ func (h *Handler) ListByOrgHandler(w http.ResponseWriter, r *http.Request) { handlerutil.WriteJSONResponse(w, http.StatusOK, responses) } + +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) + + type emailGetter interface { + GetServiceAccountEmail() string + } + 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 + if err := handlerutil.ParseAndValidateRequestBody(traceCtx, h.validator, r, &req); 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 + } + + type verifier interface { + VerifySpreadsheetReadable(ctx context.Context, spreadsheetID string) error + } + v, ok := h.store.(verifier) + if !ok { + h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) + return + } + + if err := v.VerifySpreadsheetReadable(traceCtx, spreadsheetID); err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + + handlerutil.WriteJSONResponse(w, http.StatusOK, GoogleSheetVerifyResponse{IsValid: true}) +} + +func extractSpreadsheetID(sheetURL string) (string, error) { + u, err := url.Parse(sheetURL) + if err != nil { + return "", fmt.Errorf("invalid url: %w", err) + } + + parts := strings.Split(u.Path, "/") + // /spreadsheets/d//edit + for i := 0; i < len(parts); i++ { + if parts[i] == "spreadsheets" && i+2 < len(parts) && parts[i+1] == "d" { + id := parts[i+2] + if id != "" { + return id, nil + } + } + } + return "", fmt.Errorf("invalid Google Sheets URL") +} diff --git a/internal/form/service.go b/internal/form/service.go index 7aa5076e..cd98a0d0 100644 --- a/internal/form/service.go +++ b/internal/form/service.go @@ -1,8 +1,11 @@ package form import ( + "NYCU-SDC/core-system-backend/internal" "NYCU-SDC/core-system-backend/internal/form/response" "context" + "fmt" + "os" "slices" databaseutil "github.com/NYCU-SDC/summer/pkg/database" @@ -12,6 +15,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 { @@ -272,3 +278,54 @@ func (s *Service) ListFormsOfUser(ctx context.Context, unitIDs []uuid.UUID, user return userForms, 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("GOOGLE_SERVICE_ACCOUNT_KEY is not set") + 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.WithCredentialsJSON([]byte(serviceAccountKey)), + option.WithScopes(sheets.SpreadsheetsScope), + ) + 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 +} From 92ce7e9d41943ced8df12cc0d9bc08a45ae6bf79 Mon Sep 17 00:00:00 2001 From: Tiffany <886fan@gmail.com> Date: Wed, 11 Feb 2026 11:14:20 +0000 Subject: [PATCH 2/9] fix: add google sheets api dependency --- go.mod | 33 ++++++++++++++++--------- go.sum | 78 ++++++++++++++++++++++++++++++++++++---------------------- 2 files changed, 69 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index 80d58f7b..54a41d3e 100644 --- a/go.mod +++ b/go.mod @@ -14,24 +14,28 @@ 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 github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // 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 @@ -40,6 +44,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 @@ -49,7 +54,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 @@ -73,19 +81,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 1c94c710..c430db05 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= @@ -30,6 +34,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= @@ -86,10 +92,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= @@ -141,8 +153,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= @@ -161,24 +173,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= @@ -191,10 +203,10 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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.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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -203,14 +215,20 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -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= From 5a082a7e84dbb1d118189f7cd3ea4d8e2e902bbd Mon Sep 17 00:00:00 2001 From: Tiffany <886fan@gmail.com> Date: Wed, 11 Feb 2026 17:53:32 +0000 Subject: [PATCH 3/9] fix: modify deprecated function --- internal/form/service.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/form/service.go b/internal/form/service.go index cd98a0d0..83f750b9 100644 --- a/internal/form/service.go +++ b/internal/form/service.go @@ -299,8 +299,8 @@ func (s *Service) VerifySpreadsheetReadable(ctx context.Context, spreadsheetID s // initialize google sheets api client srv, err := sheets.NewService( ctx, - option.WithCredentialsJSON([]byte(serviceAccountKey)), - option.WithScopes(sheets.SpreadsheetsScope), + option.WithAuthCredentialsJSON(option.ServiceAccount, []byte(serviceAccountKey)), + option.WithScopes(sheets.SpreadsheetsReadonlyScope), ) if err != nil { err = fmt.Errorf("failed to create sheets service: %w", err) From 128e39a23980de2dcac793b7760b36bf1687fe74 Mon Sep 17 00:00:00 2001 From: Tiffany <886fan@gmail.com> Date: Thu, 12 Feb 2026 11:12:00 +0000 Subject: [PATCH 4/9] fix: remove redeclared types --- internal/form/handler.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/internal/form/handler.go b/internal/form/handler.go index 8829d83f..4fded884 100644 --- a/internal/form/handler.go +++ b/internal/form/handler.go @@ -72,16 +72,6 @@ type GoogleSheetVerifyRequest struct { GoogleSheetURL string `json:"googleSheetUrl" validate:"required"` } -type GoogleSheetVerifyResponse struct { - IsValid bool `json:"isValid"` -} -type GoogleSheetEmailResponse struct { - Email string `json:"email"` -} -type GoogleSheetVerifyRequest struct { - GoogleSheetURL string `json:"googleSheetUrl" validate:"required"` -} - type GoogleSheetVerifyResponse struct { IsValid bool `json:"isValid"` } From 0f7a0d13c89ba6d7b81f1e4a42dc339771f8598b Mon Sep 17 00:00:00 2001 From: Tiffany <886fan@gmail.com> Date: Fri, 13 Feb 2026 16:51:52 +0000 Subject: [PATCH 5/9] fix: fix convention problems in form/handler.go & form/service.go --- internal/form/handler.go | 24 ++++++++++++++---------- internal/form/service.go | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/internal/form/handler.go b/internal/form/handler.go index 4fded884..75b05ef0 100644 --- a/internal/form/handler.go +++ b/internal/form/handler.go @@ -76,6 +76,14 @@ type GoogleSheetVerifyResponse struct { IsValid bool `json:"isValid"` } +type emailGetter interface { + GetServiceAccountEmail() string +} + +type verifier interface { + VerifySpreadsheetReadable(ctx context.Context, spreadsheetID string) error +} + // ToResponse converts a Form storage model into an API Response. // Ensures deadline, publishTime is null when empty/invalid. func ToResponse(form Form, unitName string, orgName string, editor user.User, emails []string) Response { @@ -580,9 +588,6 @@ func (h *Handler) GetGoogleSheetEmailHandler(w http.ResponseWriter, r *http.Requ defer span.End() logger := logutil.WithContext(traceCtx, h.logger) - type emailGetter interface { - GetServiceAccountEmail() string - } getter, ok := h.store.(emailGetter) if !ok { h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) @@ -604,7 +609,8 @@ func (h *Handler) VerifyGoogleSheetHandler(w http.ResponseWriter, r *http.Reques logger := logutil.WithContext(traceCtx, h.logger) var req GoogleSheetVerifyRequest - if err := handlerutil.ParseAndValidateRequestBody(traceCtx, h.validator, r, &req); err != nil { + err := handlerutil.ParseAndValidateRequestBody(traceCtx, h.validator, r, &req) + if err != nil { h.problemWriter.WriteError(traceCtx, w, err, logger) return } @@ -615,16 +621,14 @@ func (h *Handler) VerifyGoogleSheetHandler(w http.ResponseWriter, r *http.Reques return } - type verifier interface { - VerifySpreadsheetReadable(ctx context.Context, spreadsheetID string) error - } v, ok := h.store.(verifier) if !ok { h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) return } - if err := v.VerifySpreadsheetReadable(traceCtx, spreadsheetID); err != nil { + err = v.VerifySpreadsheetReadable(traceCtx, spreadsheetID) + if err != nil { h.problemWriter.WriteError(traceCtx, w, err, logger) return } @@ -635,7 +639,7 @@ func (h *Handler) VerifyGoogleSheetHandler(w http.ResponseWriter, r *http.Reques func extractSpreadsheetID(sheetURL string) (string, error) { u, err := url.Parse(sheetURL) if err != nil { - return "", fmt.Errorf("invalid url: %w", err) + return "", internal.ErrGoogleSheetURLInvalid } parts := strings.Split(u.Path, "/") @@ -648,5 +652,5 @@ func extractSpreadsheetID(sheetURL string) (string, error) { } } } - return "", fmt.Errorf("invalid Google Sheets URL") + return "", internal.ErrGoogleSheetURLInvalid } diff --git a/internal/form/service.go b/internal/form/service.go index 2cc6dee1..71e174c5 100644 --- a/internal/form/service.go +++ b/internal/form/service.go @@ -404,7 +404,7 @@ func (s *Service) VerifySpreadsheetReadable(ctx context.Context, spreadsheetID s serviceAccountKey := os.Getenv("GOOGLE_SERVICE_ACCOUNT_KEY") if serviceAccountKey == "" { - err := fmt.Errorf("GOOGLE_SERVICE_ACCOUNT_KEY is not set") + 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 From 500137a0bfb88c8073c0f3f70210a8ccfa3dd922 Mon Sep 17 00:00:00 2001 From: Tiffany <886fan@gmail.com> Date: Fri, 13 Feb 2026 16:58:55 +0000 Subject: [PATCH 6/9] refactor: extract spreadsheet ID using RegEx --- internal/form/handler.go | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/internal/form/handler.go b/internal/form/handler.go index 75b05ef0..cc53794f 100644 --- a/internal/form/handler.go +++ b/internal/form/handler.go @@ -8,8 +8,7 @@ import ( "fmt" "io" "net/http" - "net/url" - "strings" + "regexp" "time" handlerutil "github.com/NYCU-SDC/summer/pkg/handler" @@ -84,6 +83,9 @@ 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_-]+)`) + // ToResponse converts a Form storage model into an API Response. // Ensures deadline, publishTime is null when empty/invalid. func ToResponse(form Form, unitName string, orgName string, editor user.User, emails []string) Response { @@ -637,20 +639,9 @@ func (h *Handler) VerifyGoogleSheetHandler(w http.ResponseWriter, r *http.Reques } func extractSpreadsheetID(sheetURL string) (string, error) { - u, err := url.Parse(sheetURL) - if err != nil { + matches := spreadsheetIDPattern.FindStringSubmatch(sheetURL) + if len(matches) < 2 { return "", internal.ErrGoogleSheetURLInvalid } - - parts := strings.Split(u.Path, "/") - // /spreadsheets/d//edit - for i := 0; i < len(parts); i++ { - if parts[i] == "spreadsheets" && i+2 < len(parts) && parts[i+1] == "d" { - id := parts[i+2] - if id != "" { - return id, nil - } - } - } - return "", internal.ErrGoogleSheetURLInvalid + return matches[1], nil } From e0e2f58f7f8161e997a4ac0ee0c9a9f592a92260 Mon Sep 17 00:00:00 2001 From: dytsou Date: Thu, 19 Feb 2026 17:51:30 +0800 Subject: [PATCH 7/9] refactor: update Google Sheet API routes to use formId as a path parameter --- cmd/backend/main.go | 4 ++-- internal/form/handler.go | 28 +++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index b012554d..374a6df0 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -264,8 +264,8 @@ func main() { mux.Handle("POST /api/forms/{id}/cover", authMiddleware.HandlerFunc(formHandler.UploadCoverImageHandler)) mux.Handle("GET /api/forms/{id}/cover", authMiddleware.HandlerFunc(formHandler.GetCoverImageHandler)) mux.Handle("GET /api/forms/fonts", authMiddleware.HandlerFunc(formHandler.GetFontsHandler)) - mux.Handle("GET /api/forms/google-sheet-email", authMiddleware.HandlerFunc(formHandler.GetGoogleSheetEmailHandler)) - mux.Handle("POST /api/forms/google-sheet/verify", authMiddleware.HandlerFunc(formHandler.VerifyGoogleSheetHandler)) + mux.Handle("GET /api/forms/{formId}/google-sheet-email", authMiddleware.HandlerFunc(formHandler.GetGoogleSheetEmailHandler)) + mux.Handle("POST /api/forms/{formId}/google-sheet/verify", authMiddleware.HandlerFunc(formHandler.VerifyGoogleSheetHandler)) // Question routes mux.Handle("GET /api/forms/{id}/sections", authMiddleware.HandlerFunc(questionHandler.ListHandler)) diff --git a/internal/form/handler.go b/internal/form/handler.go index cc53794f..5bee5d70 100644 --- a/internal/form/handler.go +++ b/internal/form/handler.go @@ -590,6 +590,19 @@ func (h *Handler) GetGoogleSheetEmailHandler(w http.ResponseWriter, r *http.Requ defer span.End() logger := logutil.WithContext(traceCtx, h.logger) + formIDStr := r.PathValue("formId") + formID, err := handlerutil.ParseUUID(formIDStr) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + + _, err = h.store.GetByID(traceCtx, formID) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + getter, ok := h.store.(emailGetter) if !ok { h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) @@ -610,8 +623,21 @@ func (h *Handler) VerifyGoogleSheetHandler(w http.ResponseWriter, r *http.Reques defer span.End() logger := logutil.WithContext(traceCtx, h.logger) + formIDStr := r.PathValue("formId") + formID, err := handlerutil.ParseUUID(formIDStr) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + + _, err = h.store.GetByID(traceCtx, formID) + if err != nil { + h.problemWriter.WriteError(traceCtx, w, err, logger) + return + } + var req GoogleSheetVerifyRequest - err := handlerutil.ParseAndValidateRequestBody(traceCtx, h.validator, r, &req) + err = handlerutil.ParseAndValidateRequestBody(traceCtx, h.validator, r, &req) if err != nil { h.problemWriter.WriteError(traceCtx, w, err, logger) return From fb91d8cd89aafd1eab69a32a3dc46bd32cc97a92 Mon Sep 17 00:00:00 2001 From: dytsou Date: Thu, 19 Feb 2026 18:41:29 +0800 Subject: [PATCH 8/9] feat: add validation for Google Sheet accessibility in PatchHandler --- internal/form/handler.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/form/handler.go b/internal/form/handler.go index 817a5086..c773d3a1 100644 --- a/internal/form/handler.go +++ b/internal/form/handler.go @@ -256,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) From 9b45c57f818a3febf81de6435cd972ff96c165e2 Mon Sep 17 00:00:00 2001 From: dytsou Date: Thu, 19 Feb 2026 19:43:11 +0800 Subject: [PATCH 9/9] Revert "refactor: update Google Sheet API routes to use formId as a path parameter" This reverts commit e0e2f58f7f8161e997a4ac0ee0c9a9f592a92260. --- cmd/backend/main.go | 4 ++-- internal/form/handler.go | 28 +--------------------------- 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index a1840344..96da84c6 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -270,8 +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/{formId}/google-sheet-email", authMiddleware.HandlerFunc(formHandler.GetGoogleSheetEmailHandler)) - mux.Handle("POST /api/forms/{formId}/google-sheet/verify", authMiddleware.HandlerFunc(formHandler.VerifyGoogleSheetHandler)) + 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/internal/form/handler.go b/internal/form/handler.go index c773d3a1..1915cf2c 100644 --- a/internal/form/handler.go +++ b/internal/form/handler.go @@ -733,19 +733,6 @@ func (h *Handler) GetGoogleSheetEmailHandler(w http.ResponseWriter, r *http.Requ defer span.End() logger := logutil.WithContext(traceCtx, h.logger) - formIDStr := r.PathValue("formId") - formID, err := handlerutil.ParseUUID(formIDStr) - if err != nil { - h.problemWriter.WriteError(traceCtx, w, err, logger) - return - } - - _, err = h.store.GetByID(traceCtx, formID) - if err != nil { - h.problemWriter.WriteError(traceCtx, w, err, logger) - return - } - getter, ok := h.store.(emailGetter) if !ok { h.problemWriter.WriteError(traceCtx, w, internal.ErrInternalServerError, logger) @@ -766,21 +753,8 @@ func (h *Handler) VerifyGoogleSheetHandler(w http.ResponseWriter, r *http.Reques defer span.End() logger := logutil.WithContext(traceCtx, h.logger) - formIDStr := r.PathValue("formId") - formID, err := handlerutil.ParseUUID(formIDStr) - if err != nil { - h.problemWriter.WriteError(traceCtx, w, err, logger) - return - } - - _, err = h.store.GetByID(traceCtx, formID) - if err != nil { - h.problemWriter.WriteError(traceCtx, w, err, logger) - return - } - var req GoogleSheetVerifyRequest - err = handlerutil.ParseAndValidateRequestBody(traceCtx, h.validator, r, &req) + err := handlerutil.ParseAndValidateRequestBody(traceCtx, h.validator, r, &req) if err != nil { h.problemWriter.WriteError(traceCtx, w, err, logger) return