Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ lite
.vscode/
.idea/
*.swp
.gstack/
85 changes: 85 additions & 0 deletions internal/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,30 @@ func (s *server) handleNewDB(w http.ResponseWriter, r *http.Request) {
isAuthed := authedUser != nil
isPaid := isAuthed && authedUser.PlanTier == "paid"

// Idempotent-by-name for authenticated callers: if the user already owns
// an active postgres resource with this name, return it. Makes the
// "store $DATABASE_URL, re-run the script" pattern safe across runs.
if isAuthed {
if existing := s.lookupExistingNamed(ctx, authedUser.ID, "postgres", name); existing != nil {
resp := map[string]any{
"ok": true,
"id": existing.id,
"token": existing.token,
"name": name,
"connection_url": existing.connectionURL,
"tier": existing.tier,
"limits": map[string]any{"storage_mb": s.cfg.Postgres.StorageMB, "connections": s.cfg.Postgres.ConnLimit},
"note": fmt.Sprintf("Returning your existing %q database. Delete it via DELETE /api/me/resources/%s to provision a new one with this name.", name, existing.token),
}
if existing.expiresAt.Valid {
resp["expires_at"] = existing.expiresAt.Time
resp["limits"].(map[string]any)["expires_in"] = s.cfg.Limits.AnonTTL
}
writeJSON(w, http.StatusOK, resp)
return
}
}

if !isAuthed {
exceeded, existing := s.checkLimitAndIncrement(ctx, fp, "postgres")
if exceeded {
Expand Down Expand Up @@ -217,6 +241,27 @@ func (s *server) handleNewWebhook(w http.ResponseWriter, r *http.Request) {
authedUser := s.authUser(r)
isAuthed := authedUser != nil
isPaid := isAuthed && authedUser.PlanTier == "paid"
// Idempotent-by-name for authenticated callers — see handleNewDB for rationale.
if isAuthed {
if existing := s.lookupExistingNamed(ctx, authedUser.ID, "webhook", name); existing != nil {
resp := map[string]any{
"ok": true,
"id": existing.id,
"token": existing.token,
"name": name,
"receive_url": existing.connectionURL,
"tier": existing.tier,
"limits": map[string]any{"requests_stored": s.cfg.Limits.WebhookMaxStored},
"note": fmt.Sprintf("Returning your existing %q webhook. Delete it via DELETE /api/me/resources/%s to provision a new one with this name.", name, existing.token),
}
if existing.expiresAt.Valid {
resp["expires_at"] = existing.expiresAt.Time
resp["limits"].(map[string]any)["expires_in"] = s.cfg.Limits.AnonTTL
}
writeJSON(w, http.StatusOK, resp)
return
}
}

if !isAuthed {
exceeded, existing := s.checkLimitAndIncrement(ctx, fp, "webhook")
Expand Down Expand Up @@ -379,6 +424,46 @@ type existingResource struct {
keyPrefix string
}

// namedResource is what lookupExistingNamed returns — just the fields a
// "returning your existing resource" response needs to reconstruct.
type namedResource struct {
id string
token string
connectionURL string
tier string
expiresAt sql.NullTime
}

// lookupExistingNamed makes POST /db/new and POST /webhook/new idempotent by
// name for authenticated callers. If the user already owns an active
// resource of (resourceType, name), the handler returns that one instead
// of spinning up a duplicate — preserving the re-run-my-script pattern
// ("store DATABASE_URL in .env, re-run provisions tomorrow, same DB").
// Unauthed callers don't use this (fingerprint dedup already handles abuse).
// Returns nil on no-match or on any DB error (caller falls through to
// create; worst case is a duplicate, not a 5xx).
func (s *server) lookupExistingNamed(ctx context.Context, userID uuid.UUID, resourceType, name string) *namedResource {
var r namedResource
err := s.db.QueryRowContext(ctx,
`SELECT id, token, connection_url, tier, expires_at
FROM resources
WHERE migrated_to_user_id = $1
AND resource_type = $2
AND name = $3
AND status = 'active'
LIMIT 1`,
userID, resourceType, name,
).Scan(&r.id, &r.token, &r.connectionURL, &r.tier, &r.expiresAt)
if err != nil {
if err != sql.ErrNoRows {
slog.WarnContext(ctx, "lookupExistingNamed: query failed; falling through to create",
"error", err, "user_id", userID, "name", name, "type", resourceType)
}
return nil
}
return &r
}

// checkLimitAndIncrement atomically increments the provision counter and checks
// whether the limit is exceeded. Returns (exceeded, existingResource).
// If Redis is down, falls back to counting resources in Postgres.
Expand Down
Loading