Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions components/backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ require (
github.com/onsi/ginkgo/v2 v2.27.3
github.com/onsi/gomega v1.38.3
github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.27.0
google.golang.org/api v0.189.0
k8s.io/api v0.34.0
k8s.io/apimachinery v0.34.0
k8s.io/client-go v0.34.0
Expand Down Expand Up @@ -55,6 +57,7 @@ require (
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
Expand Down Expand Up @@ -90,14 +93,12 @@ require (
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/api v0.189.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/grpc v1.64.1 // indirect
google.golang.org/protobuf v1.36.7 // indirect
Expand Down
5 changes: 5 additions & 0 deletions components/backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
Expand Down Expand Up @@ -308,6 +310,9 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg=
google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 h1:QW9+G6Fir4VcRXVH8x3LilNAb6cxBGLa6+GM4hRwexE=
google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
Expand Down
240 changes: 240 additions & 0 deletions components/backend/handlers/drive_file_grants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package handlers

import (
"net/http"
"strings"
"time"

"ambient-code-backend/models"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)

// DriveFileGrantsHandler handles file grant CRUD endpoints.
type DriveFileGrantsHandler struct {
storage *DriveStorage
oauthConfig *oauth2.Config
}

// NewDriveFileGrantsHandler creates a new handler with the given storage.
func NewDriveFileGrantsHandler(storage *DriveStorage, oauthConfig *oauth2.Config) *DriveFileGrantsHandler {
return &DriveFileGrantsHandler{storage: storage, oauthConfig: oauthConfig}
}

// HandleUpdateFileGrants replaces the current file grant set with the provided list.
// PUT /api/projects/:projectName/integrations/google-drive/files
func (h *DriveFileGrantsHandler) HandleUpdateFileGrants(c *gin.Context) {
projectName := c.Param("projectName")
userID := getUserID(c)

var req models.UpdateFileGrantsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid request",
"details": err.Error(),
})
return
}

if len(req.Files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "at least one file is required",
"details": "files array must not be empty",
})
return
}

// Get the integration to find the integration ID
integration, err := h.storage.GetIntegration(c.Request.Context(), projectName, userID)
if err != nil || integration == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "no active Google Drive integration found",
})
return
}

// Get existing file grants for comparison
existingGrants, err := h.storage.ListFileGrants(c.Request.Context(), integration.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to list existing file grants",
"details": err.Error(),
})
return
}
existingByFileID := make(map[string]models.FileGrant)
for _, g := range existingGrants {
existingByFileID[g.GoogleFileID] = g
}
Comment on lines +58 to +70
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle error from ListFileGrants to avoid silent metadata loss.

If ListFileGrants fails (e.g., malformed data in storage), the code proceeds with an empty map, treating all files as new and losing existing GrantedAt timestamps. Consider returning an error or logging the failure.

Proposed fix
 	// Get existing file grants for comparison
-	existingGrants, _ := h.storage.ListFileGrants(c.Request.Context(), integration.ID)
+	existingGrants, err := h.storage.ListFileGrants(c.Request.Context(), integration.ID)
+	if err != nil {
+		// Log but continue - treat as empty to allow recovery
+		// log.Printf("warning: failed to list existing grants: %v", err)
+	}
 	existingByFileID := make(map[string]models.FileGrant)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Get existing file grants for comparison
existingGrants, _ := h.storage.ListFileGrants(c.Request.Context(), integration.ID)
existingByFileID := make(map[string]models.FileGrant)
for _, g := range existingGrants {
existingByFileID[g.GoogleFileID] = g
}
// Get existing file grants for comparison
existingGrants, err := h.storage.ListFileGrants(c.Request.Context(), integration.ID)
if err != nil {
// Log but continue - treat as empty to allow recovery
// log.Printf("warning: failed to list existing grants: %v", err)
}
existingByFileID := make(map[string]models.FileGrant)
for _, g := range existingGrants {
existingByFileID[g.GoogleFileID] = g
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/backend/handlers/drive_file_grants.go` around lines 61 - 66, The
call to h.storage.ListFileGrants is currently ignoring errors which can cause
loss of existing GrantedAt metadata; change the block that assigns
existingGrants and builds existingByFileID to handle the returned error from
h.storage.ListFileGrants(c.Request.Context(), integration.ID): if an error
occurs, either return the error from the handler (propagate via the handler's
error response) or log the failure with details and abort processing so you
don't treat all files as new; keep the rest of the logic that builds
existingByFileID (iterating over existingGrants and indexing by GoogleFileID)
but only run it when err == nil to preserve GrantedAt timestamps when
ListFileGrants succeeds.


// Build the new grant set
newByFileID := make(map[string]bool)
var newGrants []models.FileGrant
added := 0

for _, pf := range req.Files {
newByFileID[pf.ID] = true

if existing, found := existingByFileID[pf.ID]; found {
// Keep existing grant (preserve timestamps)
existing.Reactivate()
newGrants = append(newGrants, existing)
} else {
// Create new grant from picker file
grant := pf.ToFileGrant(integration.ID)
if err := grant.Validate(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid file data",
"details": err.Error(),
})
return
}
newGrants = append(newGrants, *grant)
added++
}
}

// Count removed files
removed := 0
for fileID := range existingByFileID {
if !newByFileID[fileID] {
removed++
}
}

// Persist the updated file grants
if err := h.storage.UpdateFileGrants(c.Request.Context(), integration.ID, newGrants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to update file grants",
"details": err.Error(),
})
return
}

// Update the file count on the integration
integration.FileCount = len(newGrants)
_ = h.storage.SaveIntegration(c.Request.Context(), integration)

c.JSON(http.StatusOK, models.UpdateFileGrantsResponse{
Files: newGrants,
Added: added,
Removed: removed,
})
}

// HandleListFileGrants returns all file grants for the integration.
// GET /api/projects/:projectName/integrations/google-drive/files
func (h *DriveFileGrantsHandler) HandleListFileGrants(c *gin.Context) {
projectName := c.Param("projectName")
userID := getUserID(c)

integration, err := h.storage.GetIntegration(c.Request.Context(), projectName, userID)
if err != nil || integration == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "no active Google Drive integration found",
})
return
}

grants, err := h.storage.ListFileGrants(c.Request.Context(), integration.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to list file grants",
"details": err.Error(),
})
return
}

// Optionally verify file availability via Drive API (T025)
verifyAvailability := c.Query("verify") == "true"
if verifyAvailability {
grants = h.verifyFileAvailability(c, integration, grants)
}

c.JSON(http.StatusOK, models.ListFileGrantsResponse{
Files: grants,
TotalCount: len(grants),
})
}

// verifyFileAvailability checks each file grant against the Drive API
// and updates status to "unavailable" for deleted/inaccessible files.
func (h *DriveFileGrantsHandler) verifyFileAvailability(
c *gin.Context,
integration *models.DriveIntegration,
grants []models.FileGrant,
) []models.FileGrant {
userID := getUserID(c)

accessToken, refreshToken, expiresAt, err := h.storage.GetTokens(
c.Request.Context(), integration.ProjectName, userID,
)
if err != nil {
return grants
}

token := &oauth2.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
Expiry: expiresAt,
}
tokenSource := h.oauthConfig.TokenSource(c.Request.Context(), token)

srv, err := drive.NewService(c.Request.Context(), option.WithTokenSource(tokenSource))
if err != nil {
return grants
}

updated := false
now := time.Now().UTC()
for i := range grants {
if grants[i].Status != models.FileGrantStatusActive {
continue
}

_, err := srv.Files.Get(grants[i].GoogleFileID).
Fields("id").
SupportsAllDrives(true).
Do()

if err != nil {
grants[i].MarkUnavailable()
updated = true
} else {
grants[i].LastVerifiedAt = &now
}
}

if updated {
_ = h.storage.UpdateFileGrants(c.Request.Context(), integration.ID, grants)
}

return grants
}

// CheckDriveAccess is a helper that detects revoked access (T027).
// Call this when any Drive API operation returns 401/403.
func CheckDriveAccess(storage *DriveStorage, c *gin.Context, projectName, userID string, apiErr error) {
if apiErr == nil {
return
}

errMsg := apiErr.Error()
isAuthError := false
for _, code := range []string{"401", "403", "invalid_grant", "Token has been expired or revoked"} {
if strings.Contains(errMsg, code) {
isAuthError = true
break
}
}

if isAuthError {
integration, err := storage.GetIntegration(c.Request.Context(), projectName, userID)
if err == nil {
integration.Disconnect()
_ = storage.SaveIntegration(c.Request.Context(), integration)
}
}
}
Loading
Loading