Compare commits

..

1 Commits

Author SHA1 Message Date
Jakob Schott 1475b7429d copied changes from PR 6341, commit 8f8c5cf509 2025-08-07 13:58:31 +02:00
223 changed files with 5475 additions and 12504 deletions
-232
View File
@@ -1,232 +0,0 @@
---
description: Security best practices and guidelines for writing GitHub Actions and workflows
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
---
# GitHub Actions Security Best Practices
## Required Security Measures
### 1. Set Minimum GITHUB_TOKEN Permissions
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
```yaml
permissions:
contents: read
# Only add additional permissions if absolutely necessary:
# pull-requests: write # for commenting on PRs
# issues: write # for creating/updating issues
# checks: write # for publishing check results
```
### 2. Add Harden-Runner as First Step
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
```yaml
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit # or 'block' for stricter security
```
### 3. Pin Actions to Full Commit SHA
**Always** pin third-party actions to their full commit SHA, not tags:
```yaml
# ❌ BAD - uses mutable tag
- uses: actions/checkout@v4
# ✅ GOOD - pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### 4. Secure Variable Handling
Prevent command injection by properly quoting variables:
```yaml
# ❌ BAD - potential command injection
run: echo "Processing ${{ inputs.user_input }}"
# ✅ GOOD - properly quoted
env:
USER_INPUT: ${{ inputs.user_input }}
run: echo "Processing ${USER_INPUT}"
```
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
### 5. Environment Variables for Secrets
Store sensitive data in environment variables, not inline:
```yaml
# ❌ BAD
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
# ✅ GOOD
env:
API_TOKEN: ${{ secrets.TOKEN }}
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
```
## Workflow Structure Best Practices
### Required Workflow Elements
```yaml
name: "Descriptive Workflow Name"
on:
# Define specific triggers
push:
branches: [main]
pull_request:
branches: [main]
# Always set explicit permissions
permissions:
contents: read
jobs:
job-name:
name: "Descriptive Job Name"
runs-on: ubuntu-latest
timeout-minutes: 30 # tune per job; standardize repo-wide
# Set job-level permissions if different from workflow level
permissions:
contents: read
steps:
# Always start with Harden-Runner on ubuntu-latest
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
# Pin all actions to commit SHA
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### Input Validation for Actions
For composite actions, always validate inputs:
```yaml
inputs:
user_input:
description: "User provided input"
required: true
runs:
using: "composite"
steps:
- name: Validate input
shell: bash
run: |
# Harden shell and validate input format/content before use
set -euo pipefail
USER_INPUT="${{ inputs.user_input }}"
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "❌ Invalid input format"
exit 1
fi
```
## Docker Security in Actions
### Pin Docker Images to Digests
```yaml
# ❌ BAD - mutable tag
container: node:18
# ✅ GOOD - pinned to digest
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
```
## Common Patterns
### Secure File Operations
```yaml
- name: Process files securely
shell: bash
env:
FILE_PATH: ${{ inputs.file_path }}
run: |
set -euo pipefail # Fail on errors, undefined vars, pipe failures
# Use absolute paths and validate
SAFE_PATH=$(realpath "${FILE_PATH}")
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
echo "❌ Path outside workspace"
exit 1
fi
```
### Artifact Handling
```yaml
- name: Upload artifacts securely
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: build-artifacts
path: |
dist/
!dist/**/*.log # Exclude sensitive files
retention-days: 30
```
### GHCR authentication for pulls/scans
```yaml
# Minimal permissions required for GHCR pulls/scans
permissions:
contents: read
packages: read
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
```
## Security Checklist
- [ ] Minimum GITHUB_TOKEN permissions set
- [ ] Harden-Runner added to all ubuntu-latest jobs
- [ ] All third-party actions pinned to commit SHA
- [ ] Input validation implemented for custom actions
- [ ] Variables properly quoted in shell scripts
- [ ] Secrets stored in environment variables
- [ ] Docker images pinned to digests (if used)
- [ ] Error handling with `set -euo pipefail`
- [ ] File paths validated and sanitized
- [ ] No sensitive data in logs or outputs
- [ ] GHCR login performed before pulls/scans (packages: read)
- [ ] Job timeouts configured (`timeout-minutes`)
## Recommended Additional Workflows
Consider adding these security-focused workflows to your repository:
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
3. **Dependabot Configuration** - Automated dependency updates
## Resources
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
@@ -1,312 +0,0 @@
name: Build and Push Docker Image
description: |
Unified Docker build and push action for both ECR and GHCR registries.
Supports:
- ECR builds for Formbricks Cloud deployment
- GHCR builds for community self-hosting
- Automatic version resolution and tagging
- Conditional signing and deployment tags
inputs:
registry_type:
description: "Registry type: 'ecr' or 'ghcr'"
required: true
# Version input
version:
description: "Explicit version (SemVer only, e.g., 1.2.3). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
experimental_mode:
description: "Enable experimental timestamped versions"
required: false
default: "false"
# ECR specific inputs
ecr_registry:
description: "ECR registry URL (required for ECR builds)"
required: false
ecr_repository:
description: "ECR repository name (required for ECR builds)"
required: false
ecr_region:
description: "ECR AWS region (required for ECR builds)"
required: false
aws_role_arn:
description: "AWS role ARN for ECR authentication (required for ECR builds)"
required: false
# GHCR specific inputs
ghcr_image_name:
description: "GHCR image name (required for GHCR builds)"
required: false
# Deployment options
deploy_production:
description: "Tag image for production deployment"
required: false
default: "false"
deploy_staging:
description: "Tag image for staging deployment"
required: false
default: "false"
is_prerelease:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
default: "false"
# Build options
dockerfile:
description: "Path to Dockerfile"
required: false
default: "apps/web/Dockerfile"
context:
description: "Build context"
required: false
default: "."
outputs:
image_tag:
description: "Resolved image tag used for the build"
value: ${{ steps.version.outputs.version }}
registry_tags:
description: "Complete registry tags that were pushed"
value: ${{ steps.build.outputs.tags }}
image_digest:
description: "Image digest from the build"
value: ${{ steps.build.outputs.digest }}
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
ECR_REGION: ${{ inputs.ecr_region }}
AWS_ROLE_ARN: ${{ inputs.aws_role_arn }}
GHCR_IMAGE_NAME: ${{ inputs.ghcr_image_name }}
run: |
set -euo pipefail
if [[ "$REGISTRY_TYPE" != "ecr" && "$REGISTRY_TYPE" != "ghcr" ]]; then
echo "ERROR: registry_type must be 'ecr' or 'ghcr', got: $REGISTRY_TYPE"
exit 1
fi
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
if [[ -z "$ECR_REGISTRY" || -z "$ECR_REPOSITORY" || -z "$ECR_REGION" || -z "$AWS_ROLE_ARN" ]]; then
echo "ERROR: ECR builds require ecr_registry, ecr_repository, ecr_region, and aws_role_arn"
exit 1
fi
fi
if [[ "$REGISTRY_TYPE" == "ghcr" ]]; then
if [[ -z "$GHCR_IMAGE_NAME" ]]; then
echo "ERROR: GHCR builds require ghcr_image_name"
exit 1
fi
fi
echo "SUCCESS: Input validation passed for $REGISTRY_TYPE build"
- name: Resolve Docker version
id: version
uses: ./.github/actions/resolve-docker-version
with:
version: ${{ inputs.version }}
current_branch: ${{ github.ref_name }}
experimental_mode: ${{ inputs.experimental_mode }}
- name: Update package.json version
uses: ./.github/actions/update-package-version
with:
version: ${{ steps.version.outputs.version }}
- name: Configure AWS credentials (ECR only)
if: ${{ inputs.registry_type == 'ecr' }}
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.2.0
with:
role-to-assume: ${{ inputs.aws_role_arn }}
aws-region: ${{ inputs.ecr_region }}
- name: Log in to Amazon ECR (ECR only)
if: ${{ inputs.registry_type == 'ecr' }}
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
- name: Set up Docker build tools
uses: ./.github/actions/docker-build-setup
with:
registry: ${{ inputs.registry_type == 'ghcr' && 'ghcr.io' || '' }}
setup_cosign: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
skip_login_on_pr: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
- name: Build ECR tag list
if: ${{ inputs.registry_type == 'ecr' }}
id: ecr-tags
shell: bash
env:
IMAGE_TAG: ${{ steps.version.outputs.version }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
run: |
set -euo pipefail
# Start with the base image tag
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
# Handle automatic tagging based on release type
if [[ "${IS_PRERELEASE}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag for prerelease"
elif [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag for stable release"
fi
# Handle manual deployment overrides
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag (manual override)"
fi
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag (manual override)"
fi
echo "ECR tags generated:"
echo -e "${TAGS}"
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Generate additional GHCR tags for releases
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
id: ghcr-extra-tags
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
IMAGE_NAME: ${{ inputs.ghcr_image_name }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
run: |
set -euo pipefail
# Start with base version tag
TAGS="ghcr.io/${IMAGE_NAME}:${VERSION}"
# For proper SemVer releases, add major.minor and major tags
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# Extract major and minor versions
MAJOR=$(echo "${VERSION}" | cut -d. -f1)
MINOR=$(echo "${VERSION}" | cut -d. -f2)
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}.${MINOR}"
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}"
echo "Added SemVer tags: ${MAJOR}.${MINOR}, ${MAJOR}"
fi
# Add latest tag for stable releases
if [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:latest"
echo "Added latest tag for stable release"
fi
echo "Generated GHCR tags:"
echo -e "${TAGS}"
# Debug: Show what will be passed to Docker build
echo "DEBUG: Tags for Docker build step:"
echo -e "${TAGS}"
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Build GHCR metadata (experimental)
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' }}
id: ghcr-meta-experimental
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ inputs.ghcr_image_name }}
tags: |
type=ref,event=branch
type=raw,value=${{ steps.version.outputs.version }}
- name: Debug Docker build tags
shell: bash
run: |
echo "=== DEBUG: Docker Build Configuration ==="
echo "Registry Type: ${{ inputs.registry_type }}"
echo "Experimental Mode: ${{ inputs.experimental_mode }}"
echo "Event Name: ${{ github.event_name }}"
echo "Is Prerelease: ${{ inputs.is_prerelease }}"
echo "Version: ${{ steps.version.outputs.version }}"
if [[ "${{ inputs.registry_type }}" == "ecr" ]]; then
echo "ECR Tags: ${{ steps.ecr-tags.outputs.tags }}"
elif [[ "${{ inputs.experimental_mode }}" == "true" ]]; then
echo "GHCR Experimental Tags: ${{ steps.ghcr-meta-experimental.outputs.tags }}"
else
echo "GHCR Extra Tags: ${{ steps.ghcr-extra-tags.outputs.tags }}"
fi
- name: Build and push Docker image
id: build
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ env.DEPOT_PROJECT_TOKEN }}
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }}
labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }}
secrets: |
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
- name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
shell: bash
env:
TAGS: ${{ inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags || steps.ghcr-extra-tags.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
- name: Output build summary
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
IMAGE_TAG: ${{ steps.version.outputs.version }}
VERSION_SOURCE: ${{ steps.version.outputs.source }}
run: |
echo "SUCCESS: Built and pushed Docker image to $REGISTRY_TYPE"
echo "Image Tag: $IMAGE_TAG (source: $VERSION_SOURCE)"
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
echo "ECR Registry: ${{ inputs.ecr_registry }}"
echo "ECR Repository: ${{ inputs.ecr_repository }}"
else
echo "GHCR Image: ghcr.io/${{ inputs.ghcr_image_name }}"
fi
@@ -1,106 +0,0 @@
name: Docker Build Setup
description: |
Sets up common Docker build tools and authentication with security validation.
Security Features:
- Registry URL validation
- Input sanitization
- Conditional setup based on event type
- Post-setup verification
Supports Depot CLI, Cosign signing, and Docker registry authentication.
inputs:
registry:
description: "Docker registry hostname to login to (e.g., ghcr.io, registry.example.com:5000). No paths allowed."
required: false
default: "ghcr.io"
setup_cosign:
description: "Whether to install cosign for image signing"
required: false
default: "true"
skip_login_on_pr:
description: "Whether to skip registry login on pull requests"
required: false
default: "true"
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY: ${{ inputs.registry }}
SETUP_COSIGN: ${{ inputs.setup_cosign }}
SKIP_LOGIN_ON_PR: ${{ inputs.skip_login_on_pr }}
run: |
set -euo pipefail
# Security: Validate registry input - must be hostname[:port] only, no paths
# Allow empty registry for cases where login is handled externally (e.g., ECR)
if [[ -n "$REGISTRY" ]]; then
if [[ "$REGISTRY" =~ / ]]; then
echo "ERROR: Invalid registry format: $REGISTRY"
echo "Registry must be host[:port] with no path (e.g., 'ghcr.io' or 'registry.example.com:5000')"
echo "Path components like 'ghcr.io/org' are not allowed as they break docker login"
exit 1
fi
# Validate hostname with optional port format
if [[ ! "$REGISTRY" =~ ^[a-zA-Z0-9.-]+(\:[0-9]+)?$ ]]; then
echo "ERROR: Invalid registry hostname format: $REGISTRY"
echo "Registry must be a valid hostname optionally with port (e.g., 'ghcr.io' or 'registry.example.com:5000')"
exit 1
fi
fi
# Validate boolean inputs
if [[ "$SETUP_COSIGN" != "true" && "$SETUP_COSIGN" != "false" ]]; then
echo "ERROR: setup_cosign must be 'true' or 'false', got: $SETUP_COSIGN"
exit 1
fi
if [[ "$SKIP_LOGIN_ON_PR" != "true" && "$SKIP_LOGIN_ON_PR" != "false" ]]; then
echo "ERROR: skip_login_on_pr must be 'true' or 'false', got: $SKIP_LOGIN_ON_PR"
exit 1
fi
echo "SUCCESS: Input validation passed"
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Install cosign
# Install cosign when requested AND when we might actually sign images
# (i.e., non-PR contexts or when we login on PRs)
if: ${{ inputs.setup_cosign == 'true' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Log into registry
if: ${{ inputs.registry != '' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ inputs.registry }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Verify setup completion
shell: bash
run: |
set -euo pipefail
# Verify Depot CLI is available
if ! command -v depot >/dev/null 2>&1; then
echo "ERROR: Depot CLI not found in PATH"
exit 1
fi
# Verify cosign if it should be installed (same conditions as install step)
if [[ "${{ inputs.setup_cosign }}" == "true" ]] && [[ "${{ inputs.skip_login_on_pr }}" == "false" || "${{ github.event_name }}" != "pull_request" ]]; then
if ! command -v cosign >/dev/null 2>&1; then
echo "ERROR: Cosign not found in PATH despite being requested"
exit 1
fi
fi
echo "SUCCESS: Docker build setup completed successfully"
@@ -1,192 +0,0 @@
name: Resolve Docker Version
description: |
Resolves and validates Docker-compatible SemVer versions for container builds with comprehensive security.
Security Features:
- Command injection protection
- Input sanitization and validation
- Docker tag character restrictions
- Length limits and boundary checks
- Safe branch name handling
Supports multiple modes: release, manual override, branch auto-detection, and experimental timestamped versions.
inputs:
version:
description: "Explicit version (SemVer only, e.g., 1.2.3-beta). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
current_branch:
description: "Current branch name for auto-detection"
required: true
experimental_mode:
description: "Enable experimental mode with timestamp-based versions"
required: false
default: "false"
outputs:
version:
description: "Resolved Docker-compatible SemVer version"
value: ${{ steps.resolve.outputs.version }}
source:
description: "Source of version (release|override|branch)"
value: ${{ steps.resolve.outputs.source }}
normalized:
description: "Whether the version was normalized (true/false)"
value: ${{ steps.resolve.outputs.normalized }}
runs:
using: "composite"
steps:
- name: Resolve and validate Docker version
id: resolve
shell: bash
env:
EXPLICIT_VERSION: ${{ inputs.version }}
CURRENT_BRANCH: ${{ inputs.current_branch }}
EXPERIMENTAL_MODE: ${{ inputs.experimental_mode }}
run: |
set -euo pipefail
# Function to validate SemVer format (Docker-compatible, no '+' build metadata)
validate_semver() {
local version="$1"
local context="$2"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid $context format. Must be semver without build metadata (e.g., 1.2.3, 1.2.3-alpha)"
echo "Provided: $version"
echo "Note: Docker tags cannot contain '+' characters. Use prerelease identifiers instead."
exit 1
fi
}
# Function to generate branch-based version
generate_branch_version() {
local branch="$1"
local use_timestamp="${2:-true}"
local timestamp
if [[ "$use_timestamp" == "true" ]]; then
timestamp=$(date +%s)
else
timestamp=""
fi
# Sanitize branch name for Docker compatibility
local sanitized_branch=$(echo "$branch" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
# Additional safety: truncate if too long (reserve space for prefix and timestamp)
if (( ${#sanitized_branch} > 80 )); then
sanitized_branch="${sanitized_branch:0:80}"
echo "INFO: Branch name truncated for Docker compatibility" >&2
fi
local version
# Generate version based on branch name (unified approach)
# All branches get alpha versions with sanitized branch name
if [[ -n "$timestamp" ]]; then
version="0.0.0-alpha-$sanitized_branch-$timestamp"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
else
version="0.0.0-alpha-$sanitized_branch"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
fi
echo "$version"
}
# Input validation and sanitization
if [[ -z "$CURRENT_BRANCH" ]]; then
echo "ERROR: current_branch input is required"
exit 1
fi
# Security: Validate inputs to prevent command injection
# Use grep to check for dangerous characters (more reliable than bash regex)
validate_input() {
local input="$1"
local name="$2"
# Check for dangerous characters using grep
if echo "$input" | grep -q '[;|&`$(){}\\[:space:]]'; then
echo "ERROR: $name contains potentially dangerous characters: $input"
echo "Input should only contain letters, numbers, hyphens, underscores, dots, and forward slashes"
return 1
fi
return 0
}
# Validate current branch
if ! validate_input "$CURRENT_BRANCH" "Branch name"; then
exit 1
fi
# Validate explicit version if provided
if [[ -n "$EXPLICIT_VERSION" ]] && ! validate_input "$EXPLICIT_VERSION" "Explicit version"; then
exit 1
fi
# Main resolution logic (ultra-simplified)
NORMALIZED="false"
if [[ -n "$EXPLICIT_VERSION" ]]; then
# Use provided explicit version (from either workflow_call or manual input)
validate_semver "$EXPLICIT_VERSION" "explicit version"
# Normalize to lowercase for Docker/ECR compatibility
RESOLVED_VERSION="${EXPLICIT_VERSION,,}"
if [[ "$EXPLICIT_VERSION" != "$RESOLVED_VERSION" ]]; then
NORMALIZED="true"
echo "INFO: Original version contained uppercase characters, normalized: $EXPLICIT_VERSION -> $RESOLVED_VERSION"
fi
SOURCE="explicit"
echo "INFO: Using explicit version: $RESOLVED_VERSION"
else
# Auto-generate version from branch name
if [[ "$EXPERIMENTAL_MODE" == "true" ]]; then
# Use timestamped version generation
echo "INFO: Experimental mode: generating timestamped version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "true")
SOURCE="experimental"
else
# Standard branch version (no timestamp)
echo "INFO: Auto-detecting version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "false")
SOURCE="branch"
fi
echo "Generated version: $RESOLVED_VERSION"
fi
# Final validation - ensure result is valid Docker tag
if [[ -z "$RESOLVED_VERSION" ]]; then
echo "ERROR: Failed to resolve version"
exit 1
fi
if (( ${#RESOLVED_VERSION} > 128 )); then
echo "ERROR: Version must be at most 128 characters (Docker limitation)"
echo "Generated version: $RESOLVED_VERSION (${#RESOLVED_VERSION} chars)"
exit 1
fi
if [[ ! "$RESOLVED_VERSION" =~ ^[a-z0-9._-]+$ ]]; then
echo "ERROR: Version contains invalid characters for Docker tags"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
if [[ "$RESOLVED_VERSION" =~ ^[.-] || "$RESOLVED_VERSION" =~ [.-]$ ]]; then
echo "ERROR: Version must not start or end with '.' or '-'"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
# Output results
echo "SUCCESS: Resolved Docker version: $RESOLVED_VERSION (source: $SOURCE)"
echo "version=$RESOLVED_VERSION" >> $GITHUB_OUTPUT
echo "source=$SOURCE" >> $GITHUB_OUTPUT
echo "normalized=$NORMALIZED" >> $GITHUB_OUTPUT
@@ -1,160 +0,0 @@
name: Update Package Version
description: |
Safely updates package.json version with comprehensive validation and atomic operations.
Security Features:
- Path traversal protection
- SemVer validation with length limits
- Atomic file operations with backup/recovery
- JSON validation before applying changes
This action is designed to be secure by default and prevent common attack vectors.
inputs:
version:
description: "Version to set in package.json (must be valid SemVer)"
required: true
package_path:
description: "Path to package.json file"
required: false
default: "./apps/web/package.json"
outputs:
updated_version:
description: "The version that was actually set in package.json"
value: ${{ steps.update.outputs.updated_version }}
runs:
using: "composite"
steps:
- name: Update and verify package.json version
id: update
shell: bash
env:
VERSION: ${{ inputs.version }}
PACKAGE_PATH: ${{ inputs.package_path }}
run: |
set -euo pipefail
# Validate inputs
if [[ -z "$VERSION" ]]; then
echo "ERROR: version input is required"
exit 1
fi
# Security: Validate package_path to prevent path traversal attacks
# Only allow paths within the workspace and must end with package.json
if [[ "$PACKAGE_PATH" =~ \.\./|^/|^~ ]]; then
echo "ERROR: Invalid package path - path traversal detected: $PACKAGE_PATH"
echo "Package path must be relative to workspace root and cannot contain '../', start with '/', or '~'"
exit 1
fi
if [[ ! "$PACKAGE_PATH" =~ package\.json$ ]]; then
echo "ERROR: Package path must end with 'package.json': $PACKAGE_PATH"
exit 1
fi
# Resolve to absolute path within workspace for additional security
WORKSPACE_ROOT="${GITHUB_WORKSPACE:-$(pwd)}"
# Use realpath to resolve both paths and handle symlinks properly
WORKSPACE_ROOT=$(realpath "$WORKSPACE_ROOT")
RESOLVED_PATH=$(realpath "${WORKSPACE_ROOT}/${PACKAGE_PATH}")
# Ensure WORKSPACE_ROOT has a trailing slash for proper prefix matching
WORKSPACE_ROOT="${WORKSPACE_ROOT}/"
# Use shell string matching to ensure RESOLVED_PATH is within workspace
# This is more secure than regex and handles edge cases properly
if [[ "$RESOLVED_PATH" != "$WORKSPACE_ROOT"* ]]; then
echo "ERROR: Resolved path is outside workspace: $RESOLVED_PATH"
echo "Workspace root: $WORKSPACE_ROOT"
exit 1
fi
if [[ ! -f "$RESOLVED_PATH" ]]; then
echo "ERROR: package.json not found at: $RESOLVED_PATH"
exit 1
fi
# Use resolved path for operations
PACKAGE_PATH="$RESOLVED_PATH"
# Validate SemVer format with additional security checks
if [[ ${#VERSION} -gt 128 ]]; then
echo "ERROR: Version string too long (${#VERSION} chars, max 128): $VERSION"
exit 1
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid SemVer format: $VERSION"
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE]"
echo "Only alphanumeric characters, dots, and hyphens allowed in prerelease"
exit 1
fi
# Additional validation: Check for reasonable version component sizes
# Extract base version (MAJOR.MINOR.PATCH) without prerelease/build metadata
if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
else
echo "ERROR: Could not extract base version from: $VERSION"
exit 1
fi
# Split version components safely
IFS='.' read -ra VERSION_PARTS <<< "$BASE_VERSION"
# Validate component sizes (should have exactly 3 parts due to regex above)
if (( ${VERSION_PARTS[0]} > 999 || ${VERSION_PARTS[1]} > 999 || ${VERSION_PARTS[2]} > 999 )); then
echo "ERROR: Version components too large (max 999 each): $VERSION"
echo "Components: ${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
exit 1
fi
echo "Updating package.json version to: $VERSION"
# Create backup for atomic operations
BACKUP_PATH="${PACKAGE_PATH}.backup.$$"
cp "$PACKAGE_PATH" "$BACKUP_PATH"
# Use jq to safely update the version field with error handling
if ! jq --arg version "$VERSION" '.version = $version' "$PACKAGE_PATH" > "${PACKAGE_PATH}.tmp"; then
echo "ERROR: jq failed to process package.json"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Validate the generated JSON before applying changes
if ! jq empty "${PACKAGE_PATH}.tmp" 2>/dev/null; then
echo "ERROR: Generated invalid JSON"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Atomic move operation
if ! mv "${PACKAGE_PATH}.tmp" "$PACKAGE_PATH"; then
echo "ERROR: Failed to update package.json"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Verify the update was successful
UPDATED_VERSION=$(jq -r '.version' "$PACKAGE_PATH" 2>/dev/null)
if [[ "$UPDATED_VERSION" != "$VERSION" ]]; then
echo "ERROR: Version update failed!"
echo "Expected: $VERSION"
echo "Actual: $UPDATED_VERSION"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Clean up backup on success
rm -f "$BACKUP_PATH"
echo "SUCCESS: Updated package.json version to: $UPDATED_VERSION"
echo "updated_version=$UPDATED_VERSION" >> $GITHUB_OUTPUT
@@ -0,0 +1,134 @@
name: "Upload Sentry Sourcemaps"
description: "Extract sourcemaps from Docker image and upload to Sentry"
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
release_version:
description: "Sentry release version (e.g., v1.2.3)"
required: true
sentry_auth_token:
description: "Sentry authentication token"
required: true
environment:
description: "Sentry environment (e.g., production, staging)"
required: false
default: "staging"
runs:
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Sentry auth token
shell: bash
env:
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/organizations/formbricks/")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "200" ]; then
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
if [ -f /tmp/sentry_response.json ]; then
echo "Response body:"
cat /tmp/sentry_response.json
fi
exit 1
fi
echo "✅ Sentry auth token validated successfully"
# Clean up temp file
rm -f /tmp/sentry_response.json
- name: Extract sourcemaps from Docker image
shell: bash
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}
run: |
set -euo pipefail
# Validate docker image format (basic validation)
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
echo "Provided: $DOCKER_IMAGE"
exit 1
fi
echo "📦 Extracting sourcemaps from Docker image: $DOCKER_IMAGE"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
cleanup_container() {
# Capture the current exit code to preserve it
local original_exit_code=$?
echo "🧹 Cleaning up Docker container..."
# Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container $CONTAINER_ID removed"
fi
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
# Extract .next directory containing sourcemaps
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
# Verify sourcemaps exist
if [ ! -d "./extracted-next/static/chunks" ]; then
echo "❌ Error: .next/static/chunks directory not found in Docker image"
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
exit 1
fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found $sourcemap_count sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
exit 1
fi
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: "./extracted-next/"
- name: Clean up extracted files
shell: bash
if: always()
run: |
set -euo pipefail
# Clean up extracted files
rm -rf ./extracted-next
echo "🧹 Cleaned up extracted files"
@@ -0,0 +1,82 @@
name: "Apply issue labels to PR"
on:
pull_request_target:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
permissions:
contents: none
issues: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
async function getLinkedIssues(owner, repo, prNumber) {
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 10) {
nodes {
number
labels(first: 10) {
nodes {
name
}
}
}
}
}
}
}`;
const variables = {
owner: owner,
repo: repo,
prNumber: prNumber,
};
const result = await github.graphql(query, variables);
return result.repository.pullRequest.closingIssuesReferences.nodes;
}
const pr = context.payload.pull_request;
const linkedIssues = await getLinkedIssues(
context.repo.owner,
context.repo.repo,
pr.number
);
const labelsToAdd = new Set();
for (const issue of linkedIssues) {
if (issue.labels && issue.labels.nodes) {
for (const label of issue.labels.nodes) {
labelsToAdd.add(label.name);
}
}
}
if (labelsToAdd.size) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: Array.from(labelsToAdd),
});
}
-88
View File
@@ -1,88 +0,0 @@
name: Build Cloud Deployment Images
# This workflow builds Formbricks Docker images for ECR deployment:
# - workflow_call: Used by releases with explicit SemVer versions
# - workflow_dispatch: Auto-detects version from current branch or uses override
on:
workflow_dispatch:
inputs:
version_override:
description: "Override version (SemVer only, e.g., 1.2.3). Leave empty to auto-detect from branch."
required: false
type: string
deploy_production:
description: "Tag image for production deployment"
required: false
default: false
type: boolean
deploy_staging:
description: "Tag image for staging deployment"
required: false
default: false
type: boolean
workflow_call:
inputs:
image_tag:
description: "Image tag to push (required for workflow_call)"
required: true
type: string
IS_PRERELEASE:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
type: boolean
default: false
outputs:
IMAGE_TAG:
description: "Normalized image tag used for the build"
value: ${{ jobs.build-and-push.outputs.IMAGE_TAG }}
TAGS:
description: "Newline-separated list of ECR tags pushed"
value: ${{ jobs.build-and-push.outputs.TAGS }}
permissions:
contents: read
id-token: write
env:
ECR_REGION: ${{ vars.ECR_REGION }}
# ECR settings are sourced from repository/environment variables for portability across envs/forks
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
IMAGE_TAG: ${{ steps.build.outputs.image_tag }}
TAGS: ${{ steps.build.outputs.registry_tags }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build and push cloud deployment image
id: build
uses: ./.github/actions/build-and-push-docker
with:
registry_type: "ecr"
ecr_registry: ${{ env.ECR_REGISTRY }}
ecr_repository: ${{ env.ECR_REPOSITORY }}
ecr_region: ${{ env.ECR_REGION }}
aws_role_arn: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
version: ${{ inputs.version_override || inputs.image_tag }}
deploy_production: ${{ inputs.deploy_production }}
deploy_staging: ${{ inputs.deploy_staging }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
env:
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+1 -3
View File
@@ -6,14 +6,12 @@ on:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read
+27
View File
@@ -0,0 +1,27 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0
+6 -11
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: "The version of the Docker image to release (clean SemVer, e.g., 1.2.3)"
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
required: true
type: string
REPOSITORY:
@@ -37,22 +37,17 @@ on:
permissions:
id-token: write
contents: read
contents: write
jobs:
helmfile-deploy:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4.2.2
- name: Tailscale
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
@@ -71,7 +66,7 @@ jobs:
env:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
env:
@@ -89,7 +84,7 @@ jobs:
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
env:
+5 -42
View File
@@ -21,10 +21,10 @@ jobs:
name: Validate Docker Build
runs-on: ubuntu-latest
# Add PostgreSQL and Redis service containers
# Add PostgreSQL service container
services:
postgres:
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
@@ -38,27 +38,15 @@ jobs:
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
ports:
- 6379:6379
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
uses: actions/checkout@v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v6
env:
GITHUB_SHA: ${{ github.sha }}
with:
@@ -72,7 +60,6 @@ jobs:
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
redis_url=redis://localhost:6379
- name: Verify and Initialize PostgreSQL
run: |
@@ -102,29 +89,6 @@ jobs:
echo "Network configuration:"
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Verify Redis/Valkey Connection
run: |
echo "Verifying Redis/Valkey connection..."
# Install Redis client to test connection
sudo apt-get update && sudo apt-get install -y redis-tools
# Test connection using redis-cli with timeout and proper error handling
echo "Testing Redis connection with 30 second timeout..."
if timeout 30 bash -c 'until redis-cli -h localhost -p 6379 ping >/dev/null 2>&1; do
echo "Waiting for Redis to be ready..."
sleep 2
done'; then
echo "✅ Redis connection successful"
redis-cli -h localhost -p 6379 info server | head -5
else
echo "❌ Redis connection failed after 30 seconds"
exit 1
fi
# Show network configuration for Redis
echo "Redis network configuration:"
netstat -tulpn | grep 6379 || echo "No process listening on port 6379"
- name: Test Docker Image with Health Check
shell: bash
env:
@@ -142,7 +106,6 @@ jobs:
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-e REDIS_URL="redis://host.docker.internal:6379" \
-d "formbricks-test:$GITHUB_SHA"
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
@@ -1,70 +0,0 @@
name: Docker Security Scan
on:
schedule:
- cron: "0 2 * * *" # Daily at 2 AM UTC
workflow_dispatch:
workflow_run:
workflows: ["Docker Release to Github"]
types: [completed]
permissions:
contents: read
packages: read
security-events: write
jobs:
scan:
name: Vulnerability Scan
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout (for SARIF fingerprinting only)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Determine ref and commit for upload
id: gitref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "workflow_run" ]]; then
echo "ref=refs/heads/${HEAD_BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
else
echo "ref=${GITHUB_REF}" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
with:
image-ref: "ghcr.io/${{ github.repository }}:latest"
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH,MEDIUM,LOW"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
if: ${{ always() }}
with:
sarif_file: "trivy-results.sarif"
ref: ${{ steps.gitref.outputs.ref }}
sha: ${{ steps.gitref.outputs.sha }}
category: "trivy-container-scan"
+1 -1
View File
@@ -182,4 +182,4 @@ jobs:
- name: Output App Logs
if: failure()
run: cat app.log
run: cat app.log
+37 -56
View File
@@ -7,75 +7,56 @@ on:
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs:
docker-build-community:
name: Build & release community docker image
permissions:
contents: read
packages: write
id-token: write
docker-build:
name: Build & release docker image
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
permissions:
contents: read
id-token: write
uses: ./.github/workflows/build-and-push-ecr.yml
secrets: inherit
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
needs:
- docker-build-community
helm-chart-release:
name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
- docker-build-community
- docker-build
with:
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
VERSION: ${{ needs.docker-build.outputs.VERSION }}
verify-cloud-build:
name: Verify Cloud Build Outputs
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs:
- docker-build
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ env.ENVIRONMENT }}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
timeout-minutes: 5 # Simple verification should be quick
needs:
- docker-build-cloud
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Display ECR build outputs
env:
IMAGE_TAG: ${{ needs.docker-build-cloud.outputs.IMAGE_TAG }}
TAGS: ${{ needs.docker-build-cloud.outputs.TAGS }}
run: |
set -euo pipefail
echo "✅ ECR Build Completed Successfully"
echo "Image Tag: ${IMAGE_TAG}"
echo "ECR Tags:"
printf '%s\n' "${TAGS}"
move-stable-tag:
name: Move stable tag to release
permissions:
contents: write # Required for tag push operations in called workflow
uses: ./.github/workflows/move-stable-tag.yml
contents: read
needs:
- docker-build-community # Ensure release is successful first
with:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
- docker-build
- deploy-formbricks-cloud
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ env.ENVIRONMENT }}
-96
View File
@@ -1,96 +0,0 @@
name: Move Stable Tag
on:
workflow_call:
inputs:
release_tag:
description: "The release tag name (e.g., 1.2.3)"
required: true
type: string
commit_sha:
description: "The commit SHA to point the stable tag to"
required: true
type: string
is_prerelease:
description: "Whether this is a prerelease (stable tag won't be moved for prereleases)"
required: false
type: boolean
default: false
permissions:
contents: read
# Prevent concurrent stable tag operations to avoid race conditions
concurrency:
group: move-stable-tag-${{ github.repository }}
cancel-in-progress: true
jobs:
move-stable-tag:
name: Move stable tag to release
runs-on: ubuntu-latest
timeout-minutes: 10 # Prevent hung git operations
permissions:
contents: write # Required to push tags
# Only move stable tag for non-prerelease versions
if: ${{ !inputs.is_prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Full history needed for tag operations
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.release_tag }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
# Validate release tag format
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
echo "Provided: $RELEASE_TAG"
exit 1
fi
# Validate commit SHA format (40 character hex)
if [[ ! "$COMMIT_SHA" =~ ^[a-f0-9]{40}$ ]]; then
echo "❌ Error: Invalid commit SHA format. Expected 40 character hex string"
echo "Provided: $COMMIT_SHA"
exit 1
fi
echo "✅ Input validation passed"
echo "Release tag: $RELEASE_TAG"
echo "Commit SHA: $COMMIT_SHA"
- name: Move stable tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Verify the commit exists
if ! git cat-file -e "$COMMIT_SHA"; then
echo "❌ Error: Commit $COMMIT_SHA does not exist in this repository"
exit 1
fi
# Move stable tag to the release commit
echo "📌 Moving stable tag to commit: $COMMIT_SHA (release: $RELEASE_TAG)"
git tag -f stable "$COMMIT_SHA"
git push origin stable --force
echo "✅ Successfully moved stable tag to release $RELEASE_TAG"
echo "🔗 Stable tag now points to: https://github.com/${{ github.repository }}/commit/$COMMIT_SHA"
@@ -1,50 +1,187 @@
name: Build Community Testing Images
name: Docker Release to Github Experimental
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
# to test fixes and features before official releases. Images are pushed to GHCR with
# timestamped experimental versions for easy identification and testing.
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
workflow_dispatch:
inputs:
version_override:
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
required: false
type: string
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
packages: write
id-token: write
jobs:
build-community-testing:
name: Build Community Testing Image
build:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
outputs:
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
echo "Using SemVer tag: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_TAG"
echo "Using tag as prerelease: $VERSION"
fi
else
# Running from branch, use branch name as prerelease
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_BRANCH"
echo "Using branch as prerelease: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set Sentry environment in .env
run: |
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
echo "SENTRY_ENVIRONMENT=staging" >> .env
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
else
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
fi
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Extract image info for sourcemap upload
id: extract_image_info
run: |
# Use the first readable tag from metadata action output
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
# Use the generated version for Sentry release
RELEASE_VERSION="$VERSION"
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
echo "Docker image: $DOCKER_IMAGE"
echo "Release version: $RELEASE_VERSION"
echo "Available tags: ${{ steps.meta.outputs.tags }}"
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- build
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Build and push community testing image
uses: ./.github/actions/build-and-push-docker
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
registry_type: "ghcr"
ghcr_image_name: "${{ github.repository }}-experimental"
experimental_mode: "true"
version: ${{ inputs.version_override }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: staging
+87 -47
View File
@@ -1,4 +1,4 @@
name: Release Community Docker Images
name: Docker Release to Github
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
@@ -23,14 +23,12 @@ env:
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
@@ -43,60 +41,102 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version from tag
- name: Get Release Tag
id: extract_release_tag
run: |
set -euo pipefail
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v}
# Extract tag name with fallback logic for different trigger contexts
if [[ -n "${RELEASE_TAG:-}" ]]; then
TAG="$RELEASE_TAG"
echo "Using RELEASE_TAG override: $TAG"
elif [[ "$GITHUB_REF_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] || [[ "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
TAG="$GITHUB_REF_NAME"
echo "Using GITHUB_REF_NAME (looks like tag): $TAG"
else
# Fallback: extract from GITHUB_REF for direct tag triggers
TAG="${GITHUB_REF#refs/tags/}"
if [[ -z "$TAG" || "$TAG" == "$GITHUB_REF" ]]; then
TAG="$GITHUB_REF_NAME"
echo "Using GITHUB_REF_NAME as final fallback: $TAG"
else
echo "Extracted from GITHUB_REF: $TAG"
fi
fi
# Strip v-prefix if present (normalize to clean SemVer)
TAG=${TAG#[vV]}
# Validate SemVer format (supports prereleases like 4.0.0-rc.1)
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid tag format '$TAG'. Expected SemVer (e.g., 1.2.3, 4.0.0-rc.1)"
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using version: $TAG"
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Build and push community release image
id: build
uses: ./.github/actions/build-and-push-docker
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry_type: "ghcr"
ghcr_image_name: ${{ env.IMAGE_NAME }}
version: ${{ steps.extract_release_tag.outputs.VERSION }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
+4 -25
View File
@@ -19,7 +19,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
@@ -59,35 +59,14 @@ jobs:
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
- name: Push Helm chart to GitHub Container Registry
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
echo "Pushing Helm chart to registry: formbricks-${VERSION}.tgz"
helm push "formbricks-${VERSION}.tgz" oci://ghcr.io/formbricks/helm-charts
echo "✅ Successfully pushed Helm chart to registry"
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
+81
View File
@@ -0,0 +1,81 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "17 17 * * 6"
push:
branches: ["main"]
workflow_dispatch:
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Add this permission
actions: write # Required for artifact upload
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sarif
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif
@@ -56,3 +56,11 @@ jobs:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 🙏
@@ -14,14 +14,12 @@ on:
paths:
- "infra/terraform/**"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -35,7 +33,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
@@ -0,0 +1,43 @@
name: Upload Sentry Sourcemaps (Manual)
on:
workflow_dispatch:
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
type: string
release_version:
description: "Release version (e.g., v1.2.3)"
required: true
type: string
tag_version:
description: "Docker image tag (leave empty to use release_version)"
required: false
type: string
permissions:
contents: read
jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -0,0 +1,32 @@
name: "Welcome new contributors"
on:
issues:
types: opened
pull_request_target:
types: opened
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcoming New Users
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
issue-message: |
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊
-4
View File
@@ -31,10 +31,6 @@
{
"language": "pt-PT",
"path": "./apps/web/locales/pt-PT.json"
},
{
"language": "ro-RO",
"path": "./apps/web/locales/ro-RO.json"
}
],
"forceMode": "OVERRIDE"
@@ -60,6 +60,7 @@ const mockResponses = [
userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" },
url: "http://localhost:3000",
},
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -73,6 +74,7 @@ const mockResponses = [
userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" },
url: "http://localhost:3000/page2",
},
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -86,6 +88,7 @@ const mockResponses = [
userAgent: { browser: "Safari", os: "iOS", device: "Mobile" },
url: "http://localhost:3000/page3",
},
notes: [],
tags: [],
} as unknown as TResponse,
] as unknown as TResponse[];
@@ -82,6 +82,7 @@ export const ResponseCardModal = ({
survey={survey}
response={responses[currentIndex]}
user={user}
pageType="response"
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
@@ -117,6 +117,17 @@ const mockResponses: TResponse[] = [
singleUseId: null,
ttc: {},
tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }],
notes: [
{
id: "note1",
text: "Note 1",
createdAt: new Date(),
updatedAt: new Date(),
isResolved: false,
isEdited: false,
user: { id: "user1", name: "User 1" },
},
],
variables: { var1: "Response Var Value" },
language: "en",
contact: null,
@@ -133,6 +144,7 @@ const mockResponses: TResponse[] = [
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "de",
contact: null,
@@ -222,17 +234,12 @@ describe("ResponseDataView", () => {
status: "Completed",
responseId: "response1",
tags: mockResponses[0].tags,
notes: mockResponses[0].notes,
variables: { var1: "Response Var Value" },
verifiedEmail: "test@example.com",
language: "en",
person: null,
contactAttributes: null,
meta: {
url: "http://localhost",
userAgent: {
browser: "test-agent",
},
},
},
{
responseData: {
@@ -242,17 +249,12 @@ describe("ResponseDataView", () => {
status: "Not Completed",
responseId: "response2",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "de",
person: null,
contactAttributes: null,
meta: {
url: "http://localhost",
userAgent: {
browser: "test-agent-2",
},
},
},
];
@@ -90,6 +90,7 @@ export const mapResponsesToTableData = (
: t("environments.surveys.responses.not_completed"),
responseId: response.id,
tags: response.tags,
notes: response.notes,
variables: survey.variables.reduce(
(acc, curr) => {
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
@@ -100,7 +101,6 @@ export const mapResponsesToTableData = (
language: response.language,
person: response.contact,
contactAttributes: response.contactAttributes,
meta: response.meta,
}));
};
@@ -107,6 +107,7 @@ const mockResponses: TResponse[] = [
finished: true,
data: {},
meta: { userAgent: {} },
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -117,6 +118,7 @@ const mockResponses: TResponse[] = [
finished: true,
data: {},
meta: { userAgent: {} },
notes: [],
tags: [],
} as unknown as TResponse,
];
@@ -5,7 +5,7 @@ import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseTableData } from "@formbricks/types/responses";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyQuestion,
@@ -102,12 +102,6 @@ vi.mock("lucide-react", () => ({
EyeOffIcon: () => <span>EyeOff</span>,
MailIcon: () => <span>Mail</span>,
TagIcon: () => <span>Tag</span>,
MousePointerClickIcon: () => <span>MousePointerClick</span>,
AirplayIcon: () => <span>Airplay</span>,
ArrowUpFromDotIcon: () => <span>ArrowUpFromDot</span>,
FlagIcon: () => <span>Flag</span>,
GlobeIcon: () => <span>Globe</span>,
SmartphoneIcon: () => <span>Smartphone</span>,
}));
// Mock new dependencies
@@ -229,6 +223,14 @@ const mockResponseData = {
var1: "Segment A",
var2: 100,
},
notes: [
{
id: "note1",
text: "This is a note",
updatedAt: new Date(),
user: { name: "User" } as unknown as TResponseNoteUser,
} as TResponseNote,
],
status: "completed",
tags: [{ id: "tag1", name: "Important" } as unknown as TTag],
language: "default",
@@ -294,6 +296,14 @@ describe("generateResponseTableColumns", () => {
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hf1Col).toBeUndefined();
});
test("should generate Notes column", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesCol = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesCol).toBeDefined();
(notesCol?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
});
});
describe("ResponseTableColumns", () => {
@@ -433,6 +443,36 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(cellResult).toBeUndefined();
});
test("notesColumn renders when notes is an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with notes
const mockRow = {
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
} as any;
// Call the cell function
notesColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
});
test("notesColumn returns undefined when notes is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with no notes
const mockRow = {
original: { notes: null },
} as any;
// Call the cell function
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
@@ -2,6 +2,7 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -18,14 +19,43 @@ import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
COLUMNS_ICON_MAP,
METADATA_FIELDS,
getAddressFieldLabel,
getContactInfoFieldLabel,
getMetadataFieldLabel,
getMetadataValue,
} from "../lib/utils";
const getAddressFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "addressLine1":
return t("environments.surveys.responses.address_line_1");
case "addressLine2":
return t("environments.surveys.responses.address_line_2");
case "city":
return t("environments.surveys.responses.city");
case "state":
return t("environments.surveys.responses.state_region");
case "zip":
return t("environments.surveys.responses.zip_post_code");
case "country":
return t("environments.surveys.responses.country");
default:
break;
}
};
const getContactInfoFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "firstName":
return t("environments.surveys.responses.first_name");
case "lastName":
return t("environments.surveys.responses.last_name");
case "email":
return t("environments.surveys.responses.email");
case "phone":
return t("environments.surveys.responses.phone");
case "company":
return t("environments.surveys.responses.company");
default:
break;
}
};
const getQuestionColumnsData = (
question: TSurveyQuestion,
@@ -226,33 +256,6 @@ const getQuestionColumnsData = (
}
};
const getMetadataColumnsData = (t: TFnType): ColumnDef<TResponseTableData>[] => {
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label];
metadataColumns.push({
accessorKey: label,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
</div>
),
cell: ({ row }) => {
const value = getMetadataValue(row.original.meta, label);
if (value) {
return <div className="truncate text-slate-900">{value}</div>;
}
return null;
},
});
});
return metadataColumns;
};
export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
@@ -334,6 +337,18 @@ export const generateResponseTableColumns = (
},
};
const notesColumn: ColumnDef<TResponseTableData> = {
accessorKey: "notes",
header: t("common.notes"),
cell: ({ row }) => {
const notes = row.original.notes;
if (Array.isArray(notes)) {
const notesArray = notes.map((note) => note.text);
return processResponseData(notesArray);
}
},
};
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
return {
accessorKey: variable.id,
@@ -374,8 +389,6 @@ export const generateResponseTableColumns = (
})
: [];
const metadataColumns = getMetadataColumnsData(t);
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
accessorKey: "verifiedEmail",
header: () => (
@@ -397,8 +410,8 @@ export const generateResponseTableColumns = (
...questionColumns,
...variableColumns,
...hiddenFieldColumns,
...metadataColumns,
tagsColumn,
notesColumn,
];
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
@@ -1,204 +0,0 @@
import "@testing-library/jest-dom/vitest";
import {
AirplayIcon,
ArrowUpFromDotIcon,
FlagIcon,
GlobeIcon,
MousePointerClickIcon,
SmartphoneIcon,
} from "lucide-react";
import { describe, expect, test, vi } from "vitest";
import {
COLUMNS_ICON_MAP,
getAddressFieldLabel,
getContactInfoFieldLabel,
getMetadataFieldLabel,
getMetadataValue,
} from "./utils";
describe("utils", () => {
const mockT = vi.fn((key: string) => {
const translations: Record<string, string> = {
"environments.surveys.responses.address_line_1": "Address Line 1",
"environments.surveys.responses.address_line_2": "Address Line 2",
"environments.surveys.responses.city": "City",
"environments.surveys.responses.state_region": "State/Region",
"environments.surveys.responses.zip_post_code": "ZIP/Post Code",
"environments.surveys.responses.country": "Country",
"environments.surveys.responses.first_name": "First Name",
"environments.surveys.responses.last_name": "Last Name",
"environments.surveys.responses.email": "Email",
"environments.surveys.responses.phone": "Phone",
"environments.surveys.responses.company": "Company",
"common.action": "Action",
"environments.surveys.responses.os": "OS",
"environments.surveys.responses.device": "Device",
"environments.surveys.responses.browser": "Browser",
"common.url": "URL",
"environments.surveys.responses.source": "Source",
};
return translations[key] || key;
});
describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => {
const result = getAddressFieldLabel("addressLine1", mockT);
expect(result).toBe("Address Line 1");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_1");
});
test("returns correct label for addressLine2", () => {
const result = getAddressFieldLabel("addressLine2", mockT);
expect(result).toBe("Address Line 2");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_2");
});
test("returns correct label for city", () => {
const result = getAddressFieldLabel("city", mockT);
expect(result).toBe("City");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.city");
});
test("returns correct label for state", () => {
const result = getAddressFieldLabel("state", mockT);
expect(result).toBe("State/Region");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.state_region");
});
test("returns correct label for zip", () => {
const result = getAddressFieldLabel("zip", mockT);
expect(result).toBe("ZIP/Post Code");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.zip_post_code");
});
test("returns correct label for country", () => {
const result = getAddressFieldLabel("country", mockT);
expect(result).toBe("Country");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
});
test("returns undefined for unknown field", () => {
const result = getAddressFieldLabel("unknown", mockT);
expect(result).toBeUndefined();
expect(mockT).not.toHaveBeenCalled();
});
});
describe("getContactInfoFieldLabel", () => {
test("returns correct label for firstName", () => {
const result = getContactInfoFieldLabel("firstName", mockT);
expect(result).toBe("First Name");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.first_name");
});
test("returns correct label for lastName", () => {
const result = getContactInfoFieldLabel("lastName", mockT);
expect(result).toBe("Last Name");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.last_name");
});
test("returns correct label for email", () => {
const result = getContactInfoFieldLabel("email", mockT);
expect(result).toBe("Email");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.email");
});
test("returns correct label for phone", () => {
const result = getContactInfoFieldLabel("phone", mockT);
expect(result).toBe("Phone");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.phone");
});
test("returns correct label for company", () => {
const result = getContactInfoFieldLabel("company", mockT);
expect(result).toBe("Company");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.company");
});
test("returns undefined for unknown field", () => {
const result = getContactInfoFieldLabel("unknown", mockT);
expect(result).toBeUndefined();
expect(mockT).not.toHaveBeenCalled();
});
});
describe("getMetadataFieldLabel", () => {
test("returns correct label for action", () => {
const result = getMetadataFieldLabel("action", mockT);
expect(result).toBe("Action");
expect(mockT).toHaveBeenCalledWith("common.action");
});
test("returns correct label for country", () => {
const result = getMetadataFieldLabel("country", mockT);
expect(result).toBe("Country");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
});
test("returns correct label for os", () => {
const result = getMetadataFieldLabel("os", mockT);
expect(result).toBe("OS");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.os");
});
test("returns correct label for device", () => {
const result = getMetadataFieldLabel("device", mockT);
expect(result).toBe("Device");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.device");
});
test("returns correct label for browser", () => {
const result = getMetadataFieldLabel("browser", mockT);
expect(result).toBe("Browser");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.browser");
});
test("returns correct label for url", () => {
const result = getMetadataFieldLabel("url", mockT);
expect(result).toBe("URL");
expect(mockT).toHaveBeenCalledWith("common.url");
});
test("returns correct label for source", () => {
const result = getMetadataFieldLabel("source", mockT);
expect(result).toBe("Source");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.source");
});
test("returns capitalized label for unknown field", () => {
const result = getMetadataFieldLabel("customField", mockT);
expect(result).toBe("Customfield");
expect(mockT).not.toHaveBeenCalled();
});
test("returns capitalized label for field with underscores", () => {
const result = getMetadataFieldLabel("custom_field", mockT);
expect(result).toBe("Custom_field");
expect(mockT).not.toHaveBeenCalled();
});
});
describe("COLUMNS_ICON_MAP", () => {
test("contains correct icon mappings", () => {
expect(COLUMNS_ICON_MAP.action).toBe(MousePointerClickIcon);
expect(COLUMNS_ICON_MAP.country).toBe(FlagIcon);
expect(COLUMNS_ICON_MAP.browser).toBe(GlobeIcon);
expect(COLUMNS_ICON_MAP.os).toBe(AirplayIcon);
expect(COLUMNS_ICON_MAP.device).toBe(SmartphoneIcon);
expect(COLUMNS_ICON_MAP.source).toBe(ArrowUpFromDotIcon);
expect(COLUMNS_ICON_MAP.url).toBe(GlobeIcon);
});
});
describe("getMetadataValue", () => {
test("returns correct value for action", () => {
const result = getMetadataValue({ action: "action_column" }, "action");
expect(result).toBe("action_column");
});
test("returns correct value for userAgent", () => {
const result = getMetadataValue({ userAgent: { browser: "browser_column" } }, "browser");
expect(result).toBe("browser_column");
});
});
});
@@ -1,88 +0,0 @@
import { TFnType } from "@tolgee/react";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
FlagIcon,
GlobeIcon,
MousePointerClickIcon,
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
export const getAddressFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "addressLine1":
return t("environments.surveys.responses.address_line_1");
case "addressLine2":
return t("environments.surveys.responses.address_line_2");
case "city":
return t("environments.surveys.responses.city");
case "state":
return t("environments.surveys.responses.state_region");
case "zip":
return t("environments.surveys.responses.zip_post_code");
case "country":
return t("environments.surveys.responses.country");
default:
break;
}
};
export const getContactInfoFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "firstName":
return t("environments.surveys.responses.first_name");
case "lastName":
return t("environments.surveys.responses.last_name");
case "email":
return t("environments.surveys.responses.email");
case "phone":
return t("environments.surveys.responses.phone");
case "company":
return t("environments.surveys.responses.company");
default:
break;
}
};
export const getMetadataFieldLabel = (label: string, t: TFnType) => {
switch (label) {
case "action":
return t("common.action");
case "country":
return t("environments.surveys.responses.country");
case "os":
return t("environments.surveys.responses.os");
case "device":
return t("environments.surveys.responses.device");
case "browser":
return t("environments.surveys.responses.browser");
case "url":
return t("common.url");
case "source":
return t("environments.surveys.responses.source");
default:
return capitalize(label);
}
};
export const COLUMNS_ICON_MAP = {
action: MousePointerClickIcon,
country: FlagIcon,
browser: GlobeIcon,
os: AirplayIcon,
device: SmartphoneIcon,
source: ArrowUpFromDotIcon,
url: GlobeIcon,
};
const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
if (userAgentFields.includes(label)) {
return meta.userAgent?.[label];
}
return meta[label];
};
@@ -172,15 +172,6 @@ vi.mock("./shareEmbedModal/success-view", () => ({
),
}));
// Mock LinkSettingsTab
vi.mock("./shareEmbedModal/link-settings-tab", () => ({
LinkSettingsTab: ({ survey, isReadOnly, locale }: any) => (
<div data-testid="link-settings-tab">
LinkSettings for {survey.id} - ReadOnly: {isReadOnly.toString()} - Locale: {locale}
</div>
),
}));
// Mock lucide-react icons
vi.mock("lucide-react", async (importOriginal) => {
const actual = (await importOriginal()) as any;
@@ -193,7 +184,6 @@ vi.mock("lucide-react", async (importOriginal) => {
SmartphoneIcon: () => <svg data-testid="smartphone-icon" />,
SquareStack: () => <svg data-testid="square-stack-icon" />,
UserIcon: () => <svg data-testid="user-icon" />,
Settings: () => <svg data-testid="settings-icon" />,
};
});
@@ -237,7 +227,6 @@ const mockSurvey: TSurvey = {
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
metadata: {},
};
const mockAppSurvey: TSurvey = {
@@ -281,7 +270,6 @@ const defaultProps = {
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
isReadOnly: false,
};
describe("ShareSurveyModal", () => {
@@ -404,6 +392,7 @@ describe("ShareSurveyModal", () => {
});
test("resets to start view when modal is closed and reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
@@ -479,40 +468,4 @@ describe("ShareSurveyModal", () => {
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
});
test("includes link settings tab in share tabs for link surveys", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
const shareView = screen.getByTestId("share-view");
expect(shareView).toBeInTheDocument();
// Check that tabs are rendered and include link settings
const tabsContainer = screen.getByTestId("tabs");
expect(tabsContainer).toBeInTheDocument();
// Look for the link-settings tab button
expect(screen.getByTestId("tab-link-settings")).toBeInTheDocument();
});
test("does not include link settings for app surveys", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
// App surveys should render TabContainer, not ShareView with link settings
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
expect(screen.queryByTestId("tab-link-settings")).not.toBeInTheDocument();
});
test("handles locale prop correctly for link settings", () => {
const customProps = {
...defaultProps,
modalView: "share" as const,
};
render(<ShareSurveyModal {...customProps} />);
// Verify that ShareView is rendered (which would contain LinkSettingsTab)
expect(screen.getByTestId("share-view")).toBeInTheDocument();
});
});
@@ -4,32 +4,18 @@ import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surv
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
import {
LinkTabsType,
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useTranslate } from "@tolgee/react";
import {
Code2Icon,
LinkIcon,
MailIcon,
QrCodeIcon,
Settings,
Share2Icon,
SquareStack,
UserIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -69,20 +55,17 @@ export const ShareSurveyModal = ({
const { email } = user;
const { t } = useTranslate();
const linkTabs: {
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
id: ShareViewType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
componentType: React.ComponentType<any>;
componentProps: any;
}[] = useMemo(
() => [
{
id: ShareViaType.ANON_LINKS,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.ANON_LINKS,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon,
title: t("environments.surveys.share.anonymous_links.nav_title"),
@@ -98,8 +81,7 @@ export const ShareSurveyModal = ({
},
},
{
id: ShareViaType.PERSONAL_LINKS,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.PERSONAL_LINKS,
label: t("environments.surveys.share.personal_links.nav_title"),
icon: UserIcon,
title: t("environments.surveys.share.personal_links.nav_title"),
@@ -112,55 +94,45 @@ export const ShareSurveyModal = ({
isContactsEnabled,
isFormbricksCloud,
},
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.WEBSITE_EMBED,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.WEBSITE_EMBED,
label: t("environments.surveys.share.embed_on_website.nav_title"),
icon: Code2Icon,
title: t("environments.surveys.share.embed_on_website.nav_title"),
description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab,
componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.EMAIL,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.EMAIL,
label: t("environments.surveys.share.send_email.nav_title"),
icon: MailIcon,
title: t("environments.surveys.share.send_email.nav_title"),
description: t("environments.surveys.share.send_email.description"),
componentType: EmailTab,
componentProps: { surveyId: survey.id, email },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.SOCIAL_MEDIA,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.SOCIAL_MEDIA,
label: t("environments.surveys.share.social_media.title"),
icon: Share2Icon,
title: t("environments.surveys.share.social_media.title"),
description: t("environments.surveys.share.social_media.description"),
componentType: SocialMediaTab,
componentProps: { surveyUrl, surveyTitle: survey.name },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.QR_CODE,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.QR_CODE,
label: t("environments.surveys.summary.qr_code"),
icon: QrCodeIcon,
title: t("environments.surveys.summary.qr_code"),
description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab,
componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.DYNAMIC_POPUP,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.DYNAMIC_POPUP,
label: t("environments.surveys.share.dynamic_popup.nav_title"),
icon: SquareStack,
title: t("environments.surveys.share.dynamic_popup.nav_title"),
@@ -168,24 +140,14 @@ export const ShareSurveyModal = ({
componentType: DynamicPopupTab,
componentProps: { environmentId, surveyId: survey.id },
},
{
id: ShareSettingsType.LINK_SETTINGS,
type: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.link_settings.title"),
icon: Settings,
title: t("environments.surveys.share.link_settings.title"),
description: t("environments.surveys.share.link_settings.description"),
componentType: LinkSettingsTab,
componentProps: { isReadOnly, locale: user.locale },
},
],
[
t,
survey,
publicDomain,
setSurveyUrl,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
@@ -194,14 +156,9 @@ export const ShareSurveyModal = ({
]
);
const getDefaultActiveId = useCallback(() => {
if (survey.type !== "link") {
return ShareViaType.APP;
}
return ShareViaType.ANON_LINKS;
}, [survey.type]);
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
const [activeId, setActiveId] = useState(
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
);
useEffect(() => {
if (open) {
@@ -209,19 +166,11 @@ export const ShareSurveyModal = ({
}
}, [open, modalView]);
// Ensure active tab is not disabled - if it is, switch to default
useEffect(() => {
const activeTab = linkTabs.find((tab) => tab.id === activeId);
if (activeTab?.disabled) {
setActiveId(getDefaultActiveId());
}
}, [activeId, linkTabs, getDefaultActiveId]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
setShowView("start");
setActiveId(getDefaultActiveId());
setActiveId(ShareViewType.ANON_LINKS);
}
};
@@ -229,7 +178,7 @@ export const ShareSurveyModal = ({
setShowView(view);
};
const handleEmbedViewWithTab = (tabId: ShareViaType | ShareSettingsType) => {
const handleEmbedViewWithTab = (tabId: ShareViewType) => {
setShowView("share");
setActiveId(tabId);
};
@@ -1,428 +0,0 @@
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LinkSettingsTab } from "./link-settings-tab";
// Mock dependencies
vi.mock("@/lib/i18n/utils", () => ({
createI18nString: vi.fn(),
extractLanguageCodes: vi.fn(),
getEnabledLanguages: vi.fn(),
}));
vi.mock("@/modules/survey/editor/actions", () => ({
updateSurveyAction: vi.fn(),
}));
vi.mock("@formbricks/i18n-utils/src/utils", () => ({
getLanguageLabel: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context", () => ({
useSurvey: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: vi.fn(({ onFileUpload, fileUrl, disabled, id }) => (
<div data-testid={id}>
<input
data-testid="file-input"
type="text"
value={fileUrl || ""}
onChange={(e) => onFileUpload(e.target.value ? [e.target.value] : undefined)}
disabled={disabled}
/>
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, disabled, type, ...props }) => (
<button data-testid="save-button" disabled={disabled} type={type} {...props}>
{children}
</button>
)),
}));
const mockSurvey: TSurvey = {
id: "test-survey-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "link",
environmentId: "test-env-id",
status: "inProgress",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
questions: [],
endings: [],
hiddenFields: { enabled: false },
displayPercentage: null,
autoComplete: null,
segment: null,
languages: [
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
{ language: { id: "lang2", code: "en" }, default: false, enabled: true } as TSurveyLanguage,
],
showLanguageSwitch: false,
singleUse: { enabled: false, isEncrypted: false },
projectOverwrites: null,
surveyClosedMessage: null,
delay: 0,
isVerifyEmailEnabled: false,
createdBy: null,
variables: [],
followUps: [],
runOnDate: null,
closeOnDate: null,
styling: null,
pin: null,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
metadata: {
title: { default: "Test Title", en: "Test Title EN" },
description: { default: "Test Description", en: "Test Description EN" },
ogImage: "https://example.com/image.png",
},
};
const mockSingleLanguageSurvey: TSurvey = {
...mockSurvey,
languages: [
{ language: { id: "lang1", code: "default" }, default: true, enabled: true },
] as TSurveyLanguage[],
};
describe("LinkSettingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSurvey).mockReturnValue({
survey: mockSurvey,
});
vi.mocked(getEnabledLanguages).mockReturnValue([
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
{ language: { id: "lang2", code: "en" }, default: false, enabled: true } as TSurveyLanguage,
]);
vi.mocked(extractLanguageCodes).mockReturnValue(["default", "en"]);
vi.mocked(createI18nString).mockImplementation((text, languages) => {
const result = {};
languages.forEach((lang) => {
result[lang] = typeof text === "string" ? text : "";
});
return result;
});
vi.mocked(getLanguageLabel).mockImplementation((code) => {
const labels = {
default: "Default",
en: "English",
};
return labels[code] || code;
});
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
});
afterEach(() => {
cleanup();
});
test("renders form fields correctly", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
expect(screen.getByText("common.language")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.link_settings.link_title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.link_settings.link_description")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.link_settings.preview_image")).toBeInTheDocument();
expect(screen.getByTestId("save-button")).toBeInTheDocument();
});
test("initializes form with existing metadata", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
const descriptionInput = screen.getByDisplayValue("Test Description");
expect(titleInput).toBeInTheDocument();
expect(descriptionInput).toBeInTheDocument();
});
test("initializes form with empty values when metadata is undefined", () => {
const mockSurveyWithoutMetadata: TSurvey = {
...mockSurvey,
metadata: {},
};
vi.mocked(useSurvey).mockReturnValue({
survey: mockSurveyWithoutMetadata,
});
vi.mocked(createI18nString).mockReturnValue({ default: "", en: "" });
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
expect(vi.mocked(createI18nString)).toHaveBeenCalledWith("", ["default", "en"]);
});
test("does not show language selector for single language surveys", () => {
vi.mocked(useSurvey).mockReturnValue({
survey: mockSingleLanguageSurvey,
});
vi.mocked(getEnabledLanguages).mockReturnValue([
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
]);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
expect(screen.queryByText("common.language")).not.toBeInTheDocument();
});
test("shows language selector for multi-language surveys", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
expect(screen.getByText("common.language")).toBeInTheDocument();
});
test("handles language change correctly", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
// Since the Select component is complex to test in JSDOM, let's test that
// the language selector is rendered and has the expected options
const languageSelect = screen.getByRole("combobox");
expect(languageSelect).toBeInTheDocument();
// Check that the language options are available in the hidden select
const hiddenSelect = screen.getByDisplayValue("Default");
expect(hiddenSelect).toBeInTheDocument();
// Check for English option in the select
const englishOption = screen
.getByDisplayValue("Default")
.closest("select")
?.querySelector('option[value="en"]');
expect(englishOption).toBeInTheDocument();
});
test("handles title input change", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
await user.type(titleInput, "New Title");
expect(titleInput).toHaveValue("New Title");
});
test("handles description input change", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const descriptionInput = screen.getByDisplayValue("Test Description");
await user.clear(descriptionInput);
await user.type(descriptionInput, "New Description");
expect(descriptionInput).toHaveValue("New Description");
});
test("handles file upload", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
expect(fileInput).toHaveValue("https://example.com/new-image.png");
});
test("handles file removal", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "" } });
expect(fileInput).toHaveValue("");
});
test("disables form when isReadOnly is true", () => {
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
const descriptionInput = screen.getByDisplayValue("Test Description");
const saveButton = screen.getByTestId("save-button");
expect(titleInput).toBeDisabled();
expect(descriptionInput).toBeDisabled();
expect(saveButton).toBeDisabled();
});
test("submits form successfully", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
await user.type(titleInput, "Updated Title");
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
expect(callArgs.metadata.title?.default).toBe("Updated Title");
});
test("handles submission error", async () => {
const user = userEvent.setup();
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
await user.type(titleInput, "Updated Title");
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
});
test("does not submit when form is saving", async () => {
const user = userEvent.setup();
let resolvePromise: (value: any) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
vi.mocked(updateSurveyAction).mockReturnValue(pendingPromise as any);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
// Make form dirty first
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
await user.type(titleInput, "Modified Title");
const saveButton = screen.getByTestId("save-button");
// First click should trigger submission
await user.click(saveButton);
// Wait a bit to ensure the first submission started
await new Promise((resolve) => setTimeout(resolve, 10));
// Second click should be prevented because form is saving
await user.click(saveButton);
// Should only be called once
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalledTimes(1);
// Clean up by resolving the promise
resolvePromise!({ data: mockSurvey });
});
test("does not submit when isReadOnly is true", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
expect(vi.mocked(updateSurveyAction)).not.toHaveBeenCalled();
});
test("handles ogImage correctly in form submission", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
expect(callArgs.metadata.ogImage).toBe("https://example.com/new-image.png");
});
test("handles empty ogImage correctly in form submission", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "" } });
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
expect(callArgs.metadata.ogImage).toBeUndefined();
});
test("merges form data with existing metadata correctly", async () => {
const user = userEvent.setup();
const surveyWithPartialMetadata: TSurvey = {
...mockSurvey,
metadata: {
title: { default: "Existing Title" },
description: { default: "Existing Description" },
},
};
vi.mocked(useSurvey).mockReturnValue({
survey: surveyWithPartialMetadata,
});
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
const titleInput = screen.getByDisplayValue("Existing Title");
await user.clear(titleInput);
await user.type(titleInput, "New Title");
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
await waitFor(() => {
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
});
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
expect(callArgs.metadata.title?.default).toBe("New Title");
expect(callArgs.metadata.description?.default).toBe("Existing Description");
});
});
@@ -1,257 +0,0 @@
"use client";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import {
FormControl,
FormDescription,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
interface LinkSettingsTabProps {
isReadOnly: boolean;
locale: string;
}
interface LinkSettingsFormData {
title: TI18nString;
description: TI18nString;
ogImage?: string;
}
export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) => {
const { t } = useTranslate();
const { survey } = useSurvey();
const enabledLanguages = getEnabledLanguages(survey.languages);
const hasMultipleLanguages = enabledLanguages.length > 1;
// Set default language - use 'default' if no multi-language is set up
const defaultLanguageCode = hasMultipleLanguages
? survey.languages.find((lang) => lang.default)?.language.code || "default"
: "default";
const languageCodes = useMemo(() => extractLanguageCodes(survey.languages), [survey.languages]);
const [selectedLanguageCode, setSelectedLanguageCode] = useState(defaultLanguageCode);
const [isSaving, setIsSaving] = useState(false);
// Current language code for metadata storage
const currentLangCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
// Initialize form with current values - memoize to prevent re-initialization
const initialFormData = useMemo(() => {
const metadata = survey.metadata || {
title: createI18nString("", languageCodes),
description: createI18nString("", languageCodes),
ogImage: undefined,
};
return {
title: metadata.title || createI18nString("", languageCodes),
description: metadata.description || createI18nString("", languageCodes),
ogImage: metadata.ogImage || "",
};
}, [survey.metadata, languageCodes]);
const form = useForm<LinkSettingsFormData>({
defaultValues: initialFormData,
});
const {
handleSubmit,
setValue,
reset,
formState: { isDirty },
} = form;
const handleFileUpload = (urls: string[] | undefined) => {
if (urls && urls.length > 0) {
setValue("ogImage", urls[0], { shouldDirty: true });
} else {
// Handle removal of the image
setValue("ogImage", "", { shouldDirty: true });
}
};
const onSubmit = async (data: LinkSettingsFormData) => {
if (isSaving || isReadOnly) return;
setIsSaving(true);
// Get current linkMetadata
const currentSurveyMetadata = survey.metadata || {
title: createI18nString("", languageCodes),
description: createI18nString("", languageCodes),
ogImage: undefined,
};
// Merge form data with existing linkMetadata
const updatedTitle: TI18nString = {
...currentSurveyMetadata.title,
...data.title,
};
const updatedDescription: TI18nString = {
...currentSurveyMetadata.description,
...data.description,
};
const updatedSurveyMetadata: TSurveyMetadata = {
title: updatedTitle,
description: updatedDescription,
ogImage: data.ogImage || undefined,
};
const updatedSurvey: TSurvey = {
...survey,
metadata: updatedSurveyMetadata,
};
const result = await updateSurveyAction(updatedSurvey);
if (result?.data) {
toast.success(t("environments.surveys.edit.settings_saved_successfully"));
reset(data);
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
}
setIsSaving(false);
};
const inputFields: {
name: "title" | "description";
label: string;
description: string;
placeholder: string;
}[] = [
{
name: "title",
label: t("environments.surveys.share.link_settings.link_title"),
description: t("environments.surveys.share.link_settings.link_title_description"),
placeholder: survey.name,
},
{
name: "description",
label: t("environments.surveys.share.link_settings.link_description"),
description: t("environments.surveys.share.link_settings.link_description_description"),
placeholder: "Please complete this survey.",
},
];
return (
<div className="px-1">
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Language Selection - only show if survey has multiple languages */}
{hasMultipleLanguages && (
<FormItem>
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<Select value={selectedLanguageCode} onValueChange={setSelectedLanguageCode}>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledLanguages.map((lang) => (
<SelectItem key={lang.language.code} value={lang.language.code} className="bg-white">
{getLanguageLabel(lang.language.code, locale)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("environments.surveys.share.link_settings.language_help_text")}
</FormDescription>
</FormItem>
)}
{inputFields.map((inputField) => {
return (
<FormField
key={inputField.name}
control={form.control}
name={inputField.name}
render={({ field }) => (
<FormItem>
<FormLabel>{inputField.label}</FormLabel>
<FormControl>
<Input
value={field.value[currentLangCode] || ""}
className="bg-white"
onChange={(e) => {
const updatedValue = {
...field.value,
[currentLangCode]: e.target.value,
};
field.onChange(updatedValue);
}}
placeholder={inputField.placeholder}
disabled={isReadOnly}
/>
</FormControl>
<FormDescription>{inputField.description}</FormDescription>
<FormError />
</FormItem>
)}
/>
);
})}
{/* Preview Image */}
<FormField
control={form.control}
name="ogImage"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.link_settings.preview_image")}</FormLabel>
<FormControl>
<FileInput
id={`og-image-upload-${survey.id}`}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={survey.environmentId}
onFileUpload={handleFileUpload}
fileUrl={field.value}
maxSizeInMB={5}
disabled={isReadOnly}
/>
</FormControl>
<FormDescription>
{t("environments.surveys.share.link_settings.preview_image_description")}
</FormDescription>
<FormError />
</FormItem>
)}
/>
{/* Save Button */}
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
{isSaving ? t("common.saving") : t("common.save")}
</Button>
</form>
</FormProvider>
</div>
);
};
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LinkTabsType, ShareSettingsType, ShareViaType } from "../../types/share";
import { ShareViewType } from "../../types/share";
import { ShareView } from "./share-view";
// Mock sidebar components
@@ -161,7 +161,6 @@ vi.mock("lucide-react", () => ({
Share2Icon: () => <div data-testid="share2-icon">Share2Icon</div>,
SquareStack: () => <div data-testid="square-stack-icon">SquareStack</div>,
UserIcon: () => <div data-testid="user-icon">UserIcon</div>,
Settings: () => <div data-testid="settings-icon">Settings</div>,
}));
// Mock tooltip and typography components
@@ -265,25 +264,9 @@ const mockSurvey = {
styling: null,
} as any;
// Mock LinkSettingsTab
const MockLinkSettingsTab = ({
survey,
isReadOnly,
locale,
}: {
survey: any;
isReadOnly: boolean;
locale: string;
}) => (
<div data-testid="link-settings-tab">
LinkSettingsTab Content for {survey.id} - ReadOnly: {isReadOnly.toString()} - Locale: {locale}
</div>
);
const mockTabs = [
{
id: ShareViaType.EMAIL,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.EMAIL,
label: "Email",
icon: () => <div data-testid="email-tab-icon" />,
componentType: MockEmailTab,
@@ -292,8 +275,7 @@ const mockTabs = [
description: "Send survey via email",
},
{
id: ShareViaType.WEBSITE_EMBED,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.WEBSITE_EMBED,
label: "Website Embed",
icon: () => <div data-testid="website-embed-tab-icon" />,
componentType: MockWebsiteEmbedTab,
@@ -302,8 +284,7 @@ const mockTabs = [
description: "Embed survey on your website",
},
{
id: ShareViaType.DYNAMIC_POPUP,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.DYNAMIC_POPUP,
label: "Dynamic Popup",
icon: () => <div data-testid="dynamic-popup-tab-icon" />,
componentType: MockDynamicPopupTab,
@@ -312,8 +293,7 @@ const mockTabs = [
description: "Show survey as popup",
},
{
id: ShareViaType.ANON_LINKS,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.ANON_LINKS,
label: "Anonymous Links",
icon: () => <div data-testid="anonymous-links-tab-icon" />,
componentType: MockAnonymousLinksTab,
@@ -328,8 +308,7 @@ const mockTabs = [
description: "Share anonymous links",
},
{
id: ShareViaType.QR_CODE,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.QR_CODE,
label: "QR Code",
icon: () => <div data-testid="qr-code-tab-icon" />,
componentType: MockQRCodeTab,
@@ -338,8 +317,7 @@ const mockTabs = [
description: "Generate QR code",
},
{
id: ShareViaType.PERSONAL_LINKS,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.PERSONAL_LINKS,
label: "Personal Links",
icon: () => <div data-testid="personal-links-tab-icon" />,
componentType: MockPersonalLinksTab,
@@ -348,8 +326,7 @@ const mockTabs = [
description: "Create personal links",
},
{
id: ShareViaType.SOCIAL_MEDIA,
type: LinkTabsType.SHARE_VIA,
id: ShareViewType.SOCIAL_MEDIA,
label: "Social Media",
icon: () => <div data-testid="social-media-tab-icon" />,
componentType: MockSocialMediaTab,
@@ -357,21 +334,11 @@ const mockTabs = [
title: "Social Media",
description: "Share on social media",
},
{
id: ShareSettingsType.LINK_SETTINGS,
type: LinkTabsType.SHARE_SETTING,
label: "Link Settings",
icon: () => <div data-testid="link-settings-tab-icon" />,
componentType: MockLinkSettingsTab,
componentProps: { survey: mockSurvey, isReadOnly: false, locale: "en-US" },
title: "Link Settings",
description: "Configure link settings",
},
];
const defaultProps = {
tabs: mockTabs,
activeId: ShareViaType.EMAIL,
activeId: ShareViewType.EMAIL,
setActiveId: vi.fn(),
};
@@ -403,51 +370,43 @@ describe("ShareView", () => {
});
test("renders desktop tabs", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
// Desktop sidebar should be rendered
const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title");
expect(sidebarLabel).toBeInTheDocument();
});
test("renders share settings section", () => {
render(<ShareView {...defaultProps} />);
// Share settings section should be rendered
const shareSettingsLabel = screen.getByText("environments.surveys.share.share_settings_title");
expect(shareSettingsLabel).toBeInTheDocument();
});
test("calls setActiveId when a tab is clicked (desktop)", async () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
const websiteEmbedTabButton = screen.getByLabelText("Website Embed");
await userEvent.click(websiteEmbedTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViaType.WEBSITE_EMBED);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
});
test("renders EmailTab when activeId is EMAIL", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
expect(screen.getByText("EmailTab Content for survey1 with test@example.com")).toBeInTheDocument();
});
test("renders WebsiteEmbedTab when activeId is WEBSITE_EMBED", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.WEBSITE_EMBED} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument();
expect(screen.getByText("WebsiteEmbedTab Content for http://example.com/survey1")).toBeInTheDocument();
});
test("renders DynamicPopupTab when activeId is DYNAMIC_POPUP", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.DYNAMIC_POPUP} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.DYNAMIC_POPUP} />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument();
expect(screen.getByText("DynamicPopupTab Content for survey1 in env1")).toBeInTheDocument();
});
test("renders AnonymousLinksTab when activeId is ANON_LINKS", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.ANON_LINKS} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.ANON_LINKS} />);
expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument();
expect(
screen.getByText("AnonymousLinksTab Content for survey1 at http://example.com/survey1")
@@ -455,24 +414,16 @@ describe("ShareView", () => {
});
test("renders QRCodeTab when activeId is QR_CODE", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.QR_CODE} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.QR_CODE} />);
expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument();
});
test("renders LinkSettingsTab when activeId is LINK_SETTINGS", () => {
render(<ShareView {...defaultProps} activeId={ShareSettingsType.LINK_SETTINGS} />);
expect(screen.getByTestId("link-settings-tab")).toBeInTheDocument();
expect(
screen.getByText("LinkSettingsTab Content for survey1 - ReadOnly: false - Locale: en-US")
).toBeInTheDocument();
});
test("renders nothing when activeId doesn't match any tab", () => {
// Create a special case with no matching tab
const propsWithNoMatchingTab = {
...defaultProps,
tabs: mockTabs.slice(0, 3), // Only include first 3 tabs
activeId: ShareViaType.SOCIAL_MEDIA, // Use a tab not in the subset
activeId: ShareViewType.SOCIAL_MEDIA, // Use a tab not in the subset
};
render(<ShareView {...propsWithNoMatchingTab} />);
@@ -485,17 +436,16 @@ describe("ShareView", () => {
expect(screen.queryByTestId("qr-code-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("personal-links-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("social-media-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("link-settings-tab")).not.toBeInTheDocument();
});
test("renders PersonalLinksTab when activeId is PERSONAL_LINKS", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.PERSONAL_LINKS} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.PERSONAL_LINKS} />);
expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument();
expect(screen.getByText("PersonalLinksTab Content for survey1 in env1")).toBeInTheDocument();
});
test("renders SocialMediaTab when activeId is SOCIAL_MEDIA", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.SOCIAL_MEDIA} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.SOCIAL_MEDIA} />);
expect(screen.getByTestId("social-media-tab")).toBeInTheDocument();
expect(
screen.getByText("SocialMediaTab Content for Test Survey at http://example.com/survey1")
@@ -503,7 +453,7 @@ describe("ShareView", () => {
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
// Get responsive buttons - these are Button components containing icons
const responsiveButtons = screen.getAllByTestId("website-embed-tab-icon");
@@ -517,12 +467,12 @@ describe("ShareView", () => {
if (responsiveButton) {
await userEvent.click(responsiveButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViaType.WEBSITE_EMBED);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
}
});
test("applies active styles to the active tab (desktop)", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
const emailTabButton = screen.getByLabelText("Email");
expect(emailTabButton).toHaveClass("bg-slate-100");
@@ -535,7 +485,7 @@ describe("ShareView", () => {
});
test("applies active styles to the active tab (responsive)", () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
// Get responsive buttons - these are Button components with ghost variant
const responsiveButtons = screen.getAllByTestId("email-tab-icon");
@@ -587,7 +537,6 @@ describe("ShareView", () => {
"qr-code-tab-icon",
"personal-links-tab-icon",
"social-media-tab-icon",
"link-settings-tab-icon",
];
expectedTestIds.forEach((testId) => {
@@ -599,28 +548,4 @@ describe("ShareView", () => {
expect(responsiveButton).toBeTruthy();
});
});
test("separates share via and share settings tabs correctly", () => {
render(<ShareView {...defaultProps} />);
// Check that share via tabs are rendered
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Website Embed")).toBeInTheDocument();
expect(screen.getByLabelText("Anonymous Links")).toBeInTheDocument();
// Check that share settings tabs are rendered
expect(screen.getByLabelText("Link Settings")).toBeInTheDocument();
// Check that both sections have their titles
expect(screen.getByText("environments.surveys.share.share_view_title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.share_settings_title")).toBeInTheDocument();
});
test("calls setActiveId when a share settings tab is clicked", async () => {
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
const linkSettingsTabButton = screen.getByLabelText("Link Settings");
await userEvent.click(linkSettingsTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareSettingsType.LINK_SETTINGS);
});
});
@@ -1,11 +1,7 @@
"use client";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import {
LinkTabsType,
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import {
@@ -26,18 +22,16 @@ import { useEffect, useState } from "react";
interface ShareViewProps {
tabs: Array<{
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
id: ShareViewType;
label: string;
icon: React.ElementType;
componentType: React.ComponentType<any>;
componentProps: any;
title: string;
description?: string;
disabled?: boolean;
}>;
activeId: ShareViaType | ShareSettingsType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
activeId: ShareViewType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViewType>>;
}
export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
@@ -69,21 +63,6 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
);
};
const shareSettingsTabs = tabs.filter((tab) => tab.type === LinkTabsType.SHARE_SETTING);
const shareViaTabs = tabs.filter((tab) => tab.type === LinkTabsType.SHARE_VIA);
const sideBarGroups = [
{
id: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.share_view_title"),
tabs: shareViaTabs,
},
{
id: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.share_settings_title"),
tabs: shareSettingsTabs,
},
];
return (
<div className="h-full">
<div className={`flex h-full lg:grid lg:grid-cols-4`}>
@@ -97,35 +76,32 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
}>
<Sidebar className="relative h-full p-0" variant="inset" collapsible="icon">
<SidebarContent className="h-full rounded-l-lg border-r border-slate-200 bg-white p-4">
{sideBarGroups.map((group) => (
<SidebarGroup className="p-0" key={group.id}>
<SidebarGroupLabel>
<Small className="text-xs text-slate-500">{group.label}</Small>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-1">
{group.tabs.map((tab) => (
<SidebarMenuItem key={tab.id}>
<SidebarMenuButton
onClick={() => setActiveId(tab.id)}
className={cn(
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
tab.id === activeId && !tab.disabled
? "bg-slate-100 font-medium text-slate-900"
: "text-slate-700"
)}
tooltip={tab.label}
isActive={tab.id === activeId}
disabled={tab.disabled}>
<tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
<SidebarGroup className="p-0">
<SidebarGroupLabel>
<Small className="text-xs text-slate-500">
{t("environments.surveys.share.share_view_title")}
</Small>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-1">
{tabs.map((tab) => (
<SidebarMenuItem key={tab.id}>
<SidebarMenuButton
onClick={() => setActiveId(tab.id)}
className={cn(
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
tab.id === activeId ? "bg-slate-100 font-medium text-slate-900" : "text-slate-700"
)}
tooltip={tab.label}
isActive={tab.id === activeId}>
<tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
@@ -138,10 +114,9 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
<Button
variant="ghost"
onClick={() => setActiveId(tab.id)}
disabled={tab.disabled}
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId && !tab.disabled
tab.id === activeId
? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
@@ -1,4 +1,4 @@
export enum ShareViaType {
export enum ShareViewType {
ANON_LINKS = "anon-links",
PERSONAL_LINKS = "personal-links",
EMAIL = "email",
@@ -9,12 +9,3 @@ export enum ShareViaType {
SOCIAL_MEDIA = "social-media",
QR_CODE = "qr-code",
}
export enum ShareSettingsType {
LINK_SETTINGS = "link-settings",
}
export enum LinkTabsType {
SHARE_VIA = "share_via",
SHARE_SETTING = "share_setting",
}
+12
View File
@@ -0,0 +1,12 @@
const Loading = () => {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex h-1/2 w-1/4 flex-col">
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-slate-200 text-slate-900"></div>
</div>
</div>
);
};
export default Loading;
+59
View File
@@ -0,0 +1,59 @@
import { getShortUrl } from "@/lib/shortUrl/service";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url";
export const generateMetadata = async (props): Promise<Metadata> => {
const params = await props.params;
if (!params.shortUrlId) {
notFound();
}
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
notFound();
}
try {
const shortUrl = await getShortUrl(params.shortUrlId);
if (!shortUrl) {
notFound();
}
const surveyId = shortUrl.url.substring(shortUrl.url.lastIndexOf("/") + 1);
return getMetadataForLinkSurvey(surveyId);
} catch (error) {
notFound();
}
};
const Page = async (props) => {
const params = await props.params;
if (!params.shortUrlId) {
notFound();
}
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
// return not found if unable to parse short url id
notFound();
}
let shortUrl: TShortUrl | null = null;
try {
shortUrl = await getShortUrl(params.shortUrlId);
} catch (error) {
logger.error(error, "Could not fetch short url");
notFound();
}
if (shortUrl) {
redirect(shortUrl.url);
}
notFound();
};
export default Page;
@@ -253,6 +253,7 @@ export const mockResponse: TResponse = {
contactAttributes: {},
meta: {},
finished: true,
notes: [],
singleUseId: null,
tags: [],
displayId: null,
@@ -90,6 +90,7 @@ const mockPipelineInput = {
personAttributes: {},
singleUseId: null,
personId: "person1",
notes: [],
tags: [],
variables: {
[variableId]: "Variable Value",
+1 -2
View File
@@ -1,11 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;
@@ -5,7 +5,6 @@ import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getActionClasses } from "@/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
@@ -22,180 +21,168 @@ import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { NextRequest, userAgent } from "next/server";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
const validateInput = (
environmentId: string,
userId: string
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
if (!inputValidation.success) {
return {
isValid: false,
error: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
return { isValid: true, data: inputValidation.data };
};
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
logger.error({ environmentId }, "Organization does not exist");
// fail closed if the organization does not exist
return true;
}
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: { responses: monthlyResponseLimit, miu: null },
},
});
} catch (error) {
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
}
}
return isLimitReached;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string; userId: string }> };
}) => {
const params = await props.params;
try {
const { device } = userAgent(req);
export const GET = async (
request: NextRequest,
props: {
params: Promise<{
environmentId: string;
userId: string;
}>;
}
): Promise<Response> => {
const params = await props.params;
try {
const { device } = userAgent(request);
// validate using zod
const validation = validateInput(params.environmentId, params.userId);
if (!validation.isValid) {
return { response: validation.error };
}
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse({
environmentId: params.environmentId,
userId: params.userId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, userId } = validation.data;
const { environmentId, userId } = inputValidation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const project = await getProjectByEnvironmentId(environmentId);
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
if (!project) {
throw new Error("Project not found");
}
if (!environment.appSetupCompleted) {
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
}
if (!environment.appSetupCompleted) {
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
}
// check organization subscriptions and response limits
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
// check organization subscriptions
const organization = await getOrganizationByEnvironmentId(environmentId);
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
},
},
},
value: userId,
if (!organization) {
throw new Error("Organization does not exist");
}
// check if response limit is reached
let isAppSurveyResponseLimitReached = false;
if (IS_FORMBRICKS_CLOUD) {
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
isAppSurveyResponseLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isAppSurveyResponseLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: monthlyResponseLimit,
miu: null,
},
},
environment: { connect: { id: environmentId } },
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
});
} catch (error) {
logger.error({ error, url: request.url }, `Error sending plan limits reached event to Posthog`);
}
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return {
response: responses.successResponse({ ...state }, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
return {
response: responses.internalServerErrorResponse(
"Unable to handle the request: " + error.message,
true
),
};
}
},
});
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
},
},
},
value: userId,
},
},
environment: { connect: { id: environmentId } },
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
if (!project) {
throw new Error("Project not found");
}
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
logger.error(
{ error, url: request.url },
"Error in GET /api/v1/client/[environmentId]/app/sync/[userId]"
);
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
}
};
@@ -1,9 +1,7 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -25,55 +23,40 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
if (inputValidation.data.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
if (inputValidation.data.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
} else {
logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
}
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
};
} else {
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
}
},
});
}
};
@@ -1,6 +1,5 @@
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -17,69 +16,60 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string }> };
}) => {
const params = await props.params;
export const GET = async (
request: NextRequest,
props: {
params: Promise<{
environmentId: string;
}>;
}
): Promise<Response> => {
const params = await props.params;
try {
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
return {
response: responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
),
};
} catch (err) {
if (err instanceof ResourceNotFoundError) {
logger.warn(
{
environmentId: params.environmentId,
resourceType: err.resourceType,
resourceId: err.resourceId,
},
"Resource not found in environment endpoint"
);
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId),
};
}
logger.error(
{
error: err,
url: req.url,
environmentId: params.environmentId,
},
"Error in GET /api/v1/client/[environmentId]/environment"
);
return {
response: responses.internalServerErrorResponse(err.message, true),
};
try {
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (!params.environmentId || typeof params.environmentId !== "string") {
return responses.badRequestResponse("Environment ID is required", undefined, true);
}
},
});
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
return responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
logger.warn(
{
environmentId: params.environmentId,
resourceType: err.resourceType,
resourceId: err.resourceId,
},
"Resource not found in environment endpoint"
);
return responses.notFoundResponse(err.resourceType, err.resourceId);
}
logger.error(
{
error: err,
url: request.url,
environmentId: params.environmentId,
},
"Error in GET /api/v1/client/[environmentId]/environment"
);
return responses.internalServerErrorResponse(err.message, true);
}
};
@@ -1,12 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
@@ -29,135 +27,108 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
}) => {
const params = await props.params;
const { responseId } = params;
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
const { responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
if (!responseId) {
return responses.badRequestResponse("Response ID is missing", undefined, true);
}
const responseUpdate = await request.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (response.finished) {
return responses.badRequestResponse("Response is already finished", undefined, true);
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
const responseUpdate = await req.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
};
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
}
if (response.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
};
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
};
}
}
// send response update to pipeline
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
}
return {
response: responses.successResponse({}, true),
};
},
});
}
return responses.successResponse({}, true);
};
@@ -98,6 +98,7 @@ const mockResponsePrisma = {
language: null,
displayId: null,
tags: [],
notes: [],
};
describe("createResponse", () => {
@@ -53,6 +53,22 @@ export const responseSelection = {
},
},
},
notes: {
select: {
id: true,
createdAt: true,
updatedAt: true,
text: true,
user: {
select: {
id: true,
name: true,
},
},
isResolved: true,
isEdited: true,
},
},
} satisfies Prisma.ResponseSelect;
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
@@ -1,13 +1,11 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
@@ -31,150 +29,121 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await req.json();
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error.message },
true
),
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await request.json();
} catch (error) {
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
);
}
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
if (!environmentIdValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
),
};
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
response = await createResponse({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(error.message);
}
}
if (!responseInputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
),
};
}
const userAgent = req.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", responseInputData.surveyId, true),
};
}
if (survey.environmentId !== environmentId) {
return {
response: responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
),
};
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
response = await createResponse({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
response: responses.internalServerErrorResponse(error.message),
};
}
}
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
if (responseInput.finished) {
sendToPipeline({
event: "responseCreated",
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});
return {
response: responses.successResponse({ id: response.id }, true),
};
},
});
return responses.successResponse({ id: response.id }, true);
};
@@ -2,7 +2,6 @@
// body -> should be a valid file object (buffer)
// method -> PUT (to be the same as the signedUrl method)
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
@@ -29,150 +28,115 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
if (!ENCRYPTION_KEY) {
return {
response: responses.internalServerErrorResponse("Encryption key is not set"),
};
}
const params = await props.params;
const environmentId = params.environmentId;
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const params = await context.params;
const environmentId = params.environmentId;
const accessType = "private"; // private files are accessible only by authorized users
const accessType = "private"; // private files are accessible only by authorized users
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const surveyId = jsonInput.surveyId as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const surveyId = jsonInput.surveyId as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
if (!fileType) {
return {
response: responses.badRequestResponse("contentType is required"),
};
}
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return {
response: responses.badRequestResponse("fileName is required"),
};
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
if (!surveyId) {
return {
response: responses.badRequestResponse("surveyId is required"),
};
}
if (!surveyId) {
return responses.badRequestResponse("surveyId is required");
}
if (!signedSignature) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedSignature) {
return responses.unauthorizedResponse();
}
if (!signedUuid) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedUuid) {
return responses.unauthorizedResponse();
}
if (!signedTimestamp) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedTimestamp) {
return responses.unauthorizedResponse();
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId),
};
}
if (!survey) {
return responses.notFoundResponse("Survey", surveyId);
}
if (!organization) {
return {
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
};
}
if (!organization) {
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
}
const fileName = decodeURIComponent(encodedFileName);
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file", {
fileName,
fileType,
}),
};
}
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return responses.unauthorizedResponse();
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return responses.badRequestResponse("fileBuffer is required");
}
try {
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(
fileName,
fileBuffer,
accessType,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
UPLOADS_DIR,
isBiggerFileUploadAllowed
);
if (!validated) {
return {
response: responses.unauthorizedResponse(),
};
return responses.successResponse({
message: "File uploaded successfully",
});
} catch (err) {
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return {
response: responses.badRequestResponse("fileBuffer is required"),
};
}
try {
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(
fileName,
fileBuffer,
accessType,
environmentId,
UPLOADS_DIR,
isBiggerFileUploadAllowed
);
return {
response: responses.successResponse({
message: "File uploaded successfully",
}),
};
} catch (err) {
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
if (err.name === "FileTooLargeError") {
return {
response: responses.badRequestResponse(err.message),
};
}
return {
response: responses.internalServerErrorResponse("File upload failed"),
};
}
},
});
return responses.internalServerErrorResponse("File upload failed");
}
};
@@ -1,6 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
@@ -31,62 +30,46 @@ export const OPTIONS = async (): Promise<Response> => {
// use this to let users upload files to a survey for example
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const environmentId = params.environmentId;
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
const params = await context.params;
const environmentId = params.environmentId;
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
});
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
});
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
true
),
};
}
if (!inputValidation.success) {
return responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
true
);
}
const { fileName, fileType, surveyId } = inputValidation.data;
const { fileName, fileType, surveyId } = inputValidation.data;
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(
fileValidation.error ?? "Invalid file",
{ fileName, fileType },
true
),
};
}
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId),
};
}
if (!survey) {
return responses.notFoundResponse("Survey", surveyId);
}
if (!organization) {
return {
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
};
}
if (!organization) {
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
return {
response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed),
};
},
});
return await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
};
@@ -1,9 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import * as z from "zod";
import { logger } from "@formbricks/logger";
@@ -20,83 +21,65 @@ const getEmail = async (token: string) => {
return z.string().parse(res_?.email);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const session = await getServerSession(authOptions);
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (!session) {
return responses.notAuthenticatedResponse();
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
const formData = {
grant_type: "authorization_code",
code,
redirect_uri,
client_id,
code_verifier,
};
try {
const key = await fetchAirtableAuthToken(formData);
if (!key) {
return responses.notFoundResponse("airtable auth token", key);
}
const email = await getEmail(key.access_token);
if (!code) {
return {
response: responses.badRequestResponse("`code` is missing"),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
"base64"
);
if (!client_id)
return {
response: responses.internalServerErrorResponse("Airtable client id is missing"),
};
const formData = {
grant_type: "authorization_code",
code,
redirect_uri,
client_id,
code_verifier,
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
email,
},
};
try {
const key = await fetchAirtableAuthToken(formData);
if (!key) {
return {
response: responses.notFoundResponse("airtable auth token", key),
};
}
const email = await getEmail(key.access_token);
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
email,
},
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
return {
response: responses.internalServerErrorResponse(error),
};
}
},
});
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`);
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
responses.internalServerErrorResponse(error);
}
responses.badRequestResponse("unknown error occurred");
};
@@ -1,66 +1,54 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import crypto from "crypto";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
if (!client_id)
return {
response: responses.internalServerErrorResponse("Airtable client id is missing"),
};
const codeVerifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
"base64"
);
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
const codeChallengeMethod = "S256";
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
.digest("base64") // base64 encode, needs to be transformed to base64url
.replace(/=/g, "") // remove =
.replace(/\+/g, "-") // replace + with -
.replace(/\//g, "_"); // replace / with _ now base64url encoded
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
const codeChallengeMethod = "S256";
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
.digest("base64") // base64 encode, needs to be transformed to base64url
.replace(/=/g, "") // remove =
.replace(/\+/g, "-") // replace + with -
.replace(/\//g, "_"); // replace / with _ now base64url encoded
authUrl.searchParams.append("client_id", client_id);
authUrl.searchParams.append("redirect_uri", redirect_uri);
authUrl.searchParams.append("state", environmentId);
authUrl.searchParams.append("scope", scope);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
authUrl.searchParams.append("code_challenge", codeChallenge);
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
return {
response: responses.successResponse({ authUrl: authUrl.toString() }),
};
},
});
authUrl.searchParams.append("client_id", client_id);
authUrl.searchParams.append("redirect_uri", redirect_uri);
authUrl.searchParams.append("state", environmentId);
authUrl.searchParams.append("scope", scope);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
authUrl.searchParams.append("code_challenge", codeChallenge);
return responses.successResponse({ authUrl: authUrl.toString() });
};
@@ -1,55 +1,43 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const url = req.url;
const environmentId = req.headers.get("environmentId");
const queryParams = new URLSearchParams(url.split("?")[1]);
const baseId = z.string().safeParse(queryParams.get("baseId"));
export const GET = async (req: NextRequest) => {
const url = req.url;
const environmentId = req.headers.get("environmentId");
const queryParams = new URLSearchParams(url.split("?")[1]);
const session = await getServerSession(authOptions);
const baseId = z.string().safeParse(queryParams.get("baseId"));
if (!baseId.success) {
return {
response: responses.badRequestResponse("Base Id is Required"),
};
}
if (!baseId.success) {
return responses.badRequestResponse("Base Id is Required");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment || !environmentId) {
return responses.unauthorizedResponse();
}
if (!integration) {
return {
response: responses.notFoundResponse("Integration not found", environmentId),
};
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const tables = await getTables(integration.config.key, baseId.data);
return {
response: responses.successResponse(tables),
};
},
});
if (!integration) {
return responses.notFoundResponse("Integration not found", environmentId);
}
const tables = await getTables(integration.config.key, baseId.data);
return responses.successResponse(tables);
};
@@ -1,5 +1,4 @@
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
ENCRYPTION_KEY,
NOTION_OAUTH_CLIENT_ID,
@@ -12,93 +11,70 @@ import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integrati
import { NextRequest } from "next/server";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
export const GET = withV1ApiWrapper({
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
}
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),
};
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id)
return {
response: responses.internalServerErrorResponse("Notion client id is missing"),
};
if (!redirect_uri)
return {
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
};
if (!client_secret)
return {
response: responses.internalServerErrorResponse("Notion client secret is missing"),
};
if (code) {
// encode in base 64
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
if (code) {
// encode in base 64
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
const response = await fetch("https://api.notion.com/v1/oauth/token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Basic ${encoded}`,
},
body: JSON.stringify({
grant_type: "authorization_code",
code: code,
redirect_uri: redirect_uri,
}),
});
const response = await fetch("https://api.notion.com/v1/oauth/token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Basic ${encoded}`,
},
body: JSON.stringify({
grant_type: "authorization_code",
code: code,
redirect_uri: redirect_uri,
}),
});
const tokenData = await response.json();
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY);
tokenData.access_token = encryptedAccessToken;
const tokenData = await response.json();
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!);
tokenData.access_token = encryptedAccessToken;
const notionIntegration: TIntegrationNotionInput = {
type: "notion" as "notion",
config: {
key: tokenData,
data: [],
},
};
const existingIntegration = await getIntegrationByType(environmentId, "notion");
if (existingIntegration) {
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
}
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
),
};
}
return {
response: responses.badRequestResponse("Missing code or error parameter"),
const notionIntegration: TIntegrationNotionInput = {
type: "notion" as "notion",
config: {
key: tokenData,
data: [],
},
};
},
});
const existingIntegration = await getIntegrationByType(environmentId, "notion");
if (existingIntegration) {
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
}
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`);
}
} else if (error) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
);
}
};
@@ -1,5 +1,4 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
@@ -7,54 +6,35 @@ import {
NOTION_REDIRECT_URI,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const auth_url = NOTION_AUTH_URL;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id)
return {
response: responses.internalServerErrorResponse("Notion client id is missing"),
};
if (!redirect_uri)
return {
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
};
if (!client_secret)
return {
response: responses.internalServerErrorResponse("Notion client secret is missing"),
};
if (!auth_url)
return {
response: responses.internalServerErrorResponse("Notion auth url is missing"),
};
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
return {
response: responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` }),
};
},
});
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const auth_url = NOTION_AUTH_URL;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
if (!auth_url) return responses.internalServerErrorResponse("Notion auth url is missing");
return responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` });
};
@@ -1,5 +1,4 @@
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { NextRequest } from "next/server";
@@ -9,103 +8,79 @@ import {
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
export const GET = withV1ApiWrapper({
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
const formData = {
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
};
const formBody: string[] = [];
for (const property in formData) {
const encodedKey = encodeURIComponent(property);
const encodedValue = encodeURIComponent(formData[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
const bodyString = formBody.join("&");
if (code) {
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: bodyString,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const data = await response.json();
if (!data.ok) {
return responses.badRequestResponse(data.error);
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),
};
}
if (!SLACK_CLIENT_ID)
return {
response: responses.internalServerErrorResponse("Slack client id is missing"),
};
if (!SLACK_CLIENT_SECRET)
return {
response: responses.internalServerErrorResponse("Slack client secret is missing"),
};
const formData = {
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
const slackCredentials: TIntegrationSlackCredential = {
app_id: data.app_id,
authed_user: data.authed_user,
token_type: data.token_type,
access_token: data.access_token,
bot_user_id: data.bot_user_id,
team: data.team,
};
const formBody: string[] = [];
for (const property in formData) {
const encodedKey = encodeURIComponent(property);
const encodedValue = encodeURIComponent(formData[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
const bodyString = formBody.join("&");
if (code) {
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: bodyString,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const data = await response.json();
const slackIntegration = await getIntegrationByType(environmentId, "slack");
if (!data.ok) {
return {
response: responses.badRequestResponse(data.error),
};
}
const slackCredentials: TIntegrationSlackCredential = {
app_id: data.app_id,
authed_user: data.authed_user,
token_type: data.token_type,
access_token: data.access_token,
bot_user_id: data.bot_user_id,
team: data.team,
};
const slackIntegration = await getIntegrationByType(environmentId, "slack");
const slackConfiguration: TIntegrationSlackConfig = {
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
key: slackCredentials,
};
const integration = {
type: "slack" as "slack",
environment: environmentId,
config: slackConfiguration,
};
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
),
};
}
return {
response: responses.badRequestResponse("Missing code or error parameter"),
const slackConfiguration: TIntegrationSlackConfig = {
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
key: slackCredentials,
};
},
});
const integration = {
type: "slack" as "slack",
environment: environmentId,
config: slackConfiguration,
};
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`);
}
} else if (error) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`);
}
};
+21 -38
View File
@@ -1,47 +1,30 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
if (!SLACK_CLIENT_ID)
return {
response: responses.internalServerErrorResponse("Slack client id is missing"),
};
if (!SLACK_CLIENT_SECRET)
return {
response: responses.internalServerErrorResponse("Slack client secret is missing"),
};
if (!SLACK_AUTH_URL)
return {
response: responses.internalServerErrorResponse("Slack auth url is missing"),
};
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
return {
response: responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` }),
};
},
});
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
if (!SLACK_AUTH_URL) return responses.internalServerErrorResponse("Slack auth url is missing");
return responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` });
};
@@ -1,10 +1,9 @@
import { handleErrorResponse } from "@/app/api/v1/auth";
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -28,49 +27,36 @@ const fetchAndAuthorizeActionClass = async (
return actionClass;
};
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ actionClassId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
export const GET = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
if (actionClass) {
return responses.successResponse(actionClass);
}
return responses.notFoundResponse("Action Class", params.actionClassId);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
try {
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
if (actionClass) {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.successResponse(actionClass),
response: responses.notAuthenticatedResponse(),
};
}
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
auditLog.userId = authentication.apiKeyId;
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ actionClassId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
try {
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return {
@@ -78,12 +64,13 @@ export const PUT = withV1ApiWrapper({
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
let actionClassUpdate;
try {
actionClassUpdate = await req.json();
actionClassUpdate = await request.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON");
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -118,25 +105,24 @@ export const PUT = withV1ApiWrapper({
};
}
},
action: "updated",
targetType: "actionClass",
});
"updated",
"actionClass"
);
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ actionClassId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.actionClassId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return {
@@ -145,6 +131,7 @@ export const DELETE = withV1ApiWrapper({
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
const deletedActionClass = await deleteActionClass(params.actionClassId);
return {
@@ -156,6 +143,6 @@ export const DELETE = withV1ApiWrapper({
};
}
},
action: "deleted",
targetType: "actionClass",
});
"deleted",
"actionClass"
);
@@ -1,53 +1,51 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { createActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./lib/action-classes";
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
return responses.successResponse(actionClasses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
return {
response: responses.successResponse(actionClasses),
};
} catch (error) {
if (error instanceof DatabaseError) {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.badRequestResponse(error.message),
response: responses.notAuthenticatedResponse(),
};
}
throw error;
}
},
});
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
let actionClassInput;
try {
actionClassInput = await req.json();
actionClassInput = await request.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -87,6 +85,6 @@ export const POST = withV1ApiWrapper({
throw error;
}
},
action: "created",
targetType: "actionClass",
});
"created",
"actionClass"
);
+10 -21
View File
@@ -1,8 +1,5 @@
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
@@ -12,11 +9,9 @@ export const GET = async () => {
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (apiKey) {
const hashedApiKey = hashApiKey(apiKey);
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashedApiKey,
hashedKey: hashApiKey(apiKey),
},
select: {
apiKeyEnvironments: {
@@ -44,13 +39,9 @@ export const GET = async () => {
});
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
return new Response("Not authenticated", {
status: 401,
});
}
if (
@@ -69,18 +60,16 @@ export const GET = async () => {
},
});
} else {
return responses.badRequestResponse("You can't use this method with this API key");
return new Response("You can't use this method with this API key", {
status: 400,
});
}
} else {
const sessionUser = await getSessionUser();
if (!sessionUser) {
return responses.notAuthenticatedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.api.v1, sessionUser.id);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
return new Response("Not authenticated", {
status: 401,
});
}
const user = await prisma.user.findUnique({
@@ -1,24 +1,19 @@
import { handleErrorResponse } from "@/app/api/v1/auth";
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: TApiKeyAuthentication,
authentication: any,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
if (!authentication) {
return { error: responses.notAuthenticatedResponse() };
}
const response = await getResponse(responseId);
if (!response) {
return { error: responses.notFoundResponse("Response", responseId) };
@@ -36,47 +31,38 @@ async function fetchAndAuthorizeResponse(
return { response, survey };
}
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ responseId: string }> };
authentication: TApiKeyAuthentication;
}) => {
const params = await props.params;
try {
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
if (result.error) {
return {
response: result.error,
};
}
export const GET = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
return {
response: responses.successResponse(result.response),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
if (result.error) return result.error;
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ responseId: string }> };
auditLog: TApiAuditLog;
authentication: TApiKeyAuthentication;
}) => {
return responses.successResponse(result.response);
} catch (error) {
return handleErrorResponse(error);
}
};
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) {
return {
@@ -95,25 +81,24 @@ export const DELETE = withV1ApiWrapper({
};
}
},
action: "deleted",
targetType: "response",
});
"deleted",
"response"
);
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
auditLog: TApiAuditLog;
authentication: TApiKeyAuthentication;
}) => {
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) {
return {
@@ -124,9 +109,9 @@ export const PUT = withV1ApiWrapper({
let responseUpdate;
try {
responseUpdate = await req.json();
responseUpdate = await request.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON");
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -159,6 +144,6 @@ export const PUT = withV1ApiWrapper({
};
}
},
action: "updated",
targetType: "response",
});
"updated",
"response"
);
@@ -65,7 +65,8 @@ const mockResponsePrisma = {
displayId,
contact: null, // Prisma relation
tags: [], // Prisma relation
} as unknown as ResponsePrisma & { contact: any; tags: any[] }; // Adjust type as needed
notes: [], // Prisma relation
} as unknown as ResponsePrisma & { contact: any; tags: any[]; notes: any[] }; // Adjust type as needed
const mockResponse: TResponse = {
id: responseId,
@@ -84,6 +85,7 @@ const mockResponse: TResponse = {
displayId,
contact: null, // Transformed structure
tags: [], // Transformed structure
notes: [], // Transformed structure
};
const mockEnvironmentIds = [environmentId, "env-2"];
@@ -57,6 +57,22 @@ export const responseSelection = {
},
},
},
notes: {
select: {
id: true,
createdAt: true,
updatedAt: true,
text: true,
user: {
select: {
id: true,
name: true,
},
},
isResolved: true,
isEdited: true,
},
},
} satisfies Prisma.ResponseSelect;
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
@@ -1,6 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -11,56 +12,42 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const searchParams = req.nextUrl.searchParams;
const surveyId = searchParams.get("surveyId");
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
export const GET = async (request: NextRequest) => {
const searchParams = request.nextUrl.searchParams;
const surveyId = searchParams.get("surveyId");
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
try {
let allResponses: TResponse[] = [];
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let allResponses: TResponse[] = [];
if (surveyId) {
const survey = await getSurvey(surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId, true),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
}
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
} else {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
if (surveyId) {
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId, true);
}
return {
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
throw error;
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
} else {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
}
},
});
return responses.successResponse(allResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
const validateInput = async (request: Request) => {
let jsonInput;
@@ -105,18 +92,19 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
return { survey };
};
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
const inputResult = await validateInput(req);
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const inputResult = await validateInput(request);
if (inputResult.error) {
return {
response: inputResult.error,
@@ -157,14 +145,16 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(response, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in POST /api/v1/management/responses");
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
response: responses.internalServerErrorResponse(error.message),
};
@@ -178,6 +168,6 @@ export const POST = withV1ApiWrapper({
throw error;
}
},
action: "created",
targetType: "response",
});
"created",
"response"
);
@@ -1,16 +1,24 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { describe, expect, test, vi } from "vitest";
import { NextRequest } from "next/server";
import { describe, expect, test } from "vitest";
import { vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkAuth, checkForRequiredFields } from "./utils";
import { checkForRequiredFields } from "./utils";
import { checkAuth } from "./utils";
// Create mock response objects
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 });
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
@@ -72,22 +80,19 @@ describe("checkForRequiredFields", () => {
describe("checkAuth", () => {
const environmentId = "env-123";
const mockRequest = new NextRequest("http://localhost:3000/api/test");
test("returns notAuthenticatedResponse when authentication is null", async () => {
const result = await checkAuth(null, environmentId);
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns notAuthenticatedResponse when authentication is undefined", async () => {
const result = await checkAuth(undefined as any, environmentId);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns unauthorizedResponse when API key authentication lacks POST permission", async () => {
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
@@ -107,10 +112,12 @@ describe("checkAuth", () => {
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(false);
const result = await checkAuth(mockAuthentication, environmentId);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
@@ -120,7 +127,7 @@ describe("checkAuth", () => {
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when API key authentication has POST permission", async () => {
test("returns undefined when no session and authentication has POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
@@ -140,10 +147,12 @@ describe("checkAuth", () => {
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(true);
const result = await checkAuth(mockAuthentication, environmentId);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
@@ -162,7 +171,7 @@ describe("checkAuth", () => {
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
const result = await checkAuth(mockSession, environmentId);
const result = await checkAuth(mockSession, environmentId, mockRequest);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
@@ -179,18 +188,25 @@ describe("checkAuth", () => {
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
const result = await checkAuth(mockSession, environmentId);
const result = await checkAuth(mockSession, environmentId, mockRequest);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(result).toBeUndefined();
});
test("returns notAuthenticatedResponse when authentication object is neither session nor API key", async () => {
const invalidAuth = { someProperty: "value" } as any;
test("does not call authenticateRequest when session exists", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
const result = await checkAuth(invalidAuth, environmentId);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
await checkAuth(mockSession, environmentId, mockRequest);
expect(authenticateRequest).not.toHaveBeenCalled();
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
});
});
@@ -1,7 +1,9 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
export const checkForRequiredFields = (
environmentId: string,
@@ -21,21 +23,19 @@ export const checkForRequiredFields = (
}
};
export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
if (!authentication) {
return responses.notAuthenticatedResponse();
}
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
if (!session) {
//check whether its using API key
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
if ("user" in authentication) {
const isUserAuthorized = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
} else if ("hashedApiKey" in authentication) {
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
} else {
return responses.notAuthenticatedResponse();
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
}
};
@@ -3,107 +3,88 @@
// method -> PUT (to be the same as the signedUrl method)
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
if (!ENCRYPTION_KEY) {
return {
response: responses.internalServerErrorResponse("Encryption key is not set"),
};
export const POST = async (req: NextRequest): Promise<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const accessType = "public"; // public files are accessible by anyone
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
if (requiredFieldResponse) return requiredFieldResponse;
if (!signedSignature || !signedUuid || !signedTimestamp) {
return responses.unauthorizedResponse();
}
const session = await getServerSession(authOptions);
const authResponse = await checkAuth(session, environmentId, req);
if (authResponse) return authResponse;
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file");
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return responses.unauthorizedResponse();
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return responses.badRequestResponse("fileBuffer is required");
}
try {
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
return responses.successResponse({
message: "File uploaded successfully",
});
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
}
const accessType = "public"; // public files are accessible by anyone
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
};
}
if (!signedSignature || !signedUuid || !signedTimestamp) {
return {
response: responses.unauthorizedResponse(),
};
}
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) return { response: authResponse };
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file"),
};
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return {
response: responses.unauthorizedResponse(),
};
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return {
response: responses.badRequestResponse("fileBuffer is required"),
};
}
try {
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
return {
response: responses.successResponse({
message: "File uploaded successfully",
}),
};
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return {
response: responses.badRequestResponse(err.message),
};
}
return {
response: responses.internalServerErrorResponse("File upload failed"),
};
}
},
});
return responses.internalServerErrorResponse("File upload failed");
}
};
+36 -52
View File
@@ -1,7 +1,8 @@
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
@@ -12,57 +13,40 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
let storageInput;
export const POST = async (request: NextRequest): Promise<Response> => {
let storageInput;
try {
storageInput = await req.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
try {
storageInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) return requiredFieldResponse;
const session = await getServerSession(authOptions);
const authResponse = await checkAuth(session, environmentId, request);
if (authResponse) return authResponse;
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file type");
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
);
}
}
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
};
}
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) {
return {
response: authResponse,
};
}
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file type"),
};
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return {
response: responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
),
};
}
}
return {
response: await getSignedUrlForPublicFile(fileName, environmentId, fileType),
};
},
});
return await getSignedUrlForPublicFile(fileName, environmentId, fileType);
};
@@ -1,13 +1,12 @@
import { handleErrorResponse } from "@/app/api/v1/auth";
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
@@ -28,47 +27,36 @@ const fetchAndAuthorizeSurvey = async (
return { survey };
};
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ surveyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (result.error) return result.error;
return responses.successResponse(result.survey);
} catch (error) {
return handleErrorResponse(error);
}
};
try {
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (result.error) {
return {
response: result.error,
};
}
return {
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ surveyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) {
return {
@@ -87,25 +75,23 @@ export const DELETE = withV1ApiWrapper({
};
}
},
action: "deleted",
targetType: "survey",
});
"deleted",
"survey"
);
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ surveyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) {
return {
@@ -120,12 +106,13 @@ export const PUT = withV1ApiWrapper({
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
let surveyUpdate;
try {
surveyUpdate = await req.json();
surveyUpdate = await request.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -159,16 +146,18 @@ export const PUT = withV1ApiWrapper({
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
},
action: "updated",
targetType: "survey",
});
"updated",
"survey"
);
@@ -1,77 +1,55 @@
import { handleErrorResponse } from "@/app/api/v1/auth";
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ surveyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const params = await props.params;
const survey = await getSurvey(params.surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (survey.type !== "link") {
return {
response: responses.badRequestResponse("Single use links are only available for link surveys"),
};
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return {
response: responses.badRequestResponse("Single use links are not enabled for this survey"),
};
}
const searchParams = req.nextUrl.searchParams;
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
if (limit < 1) {
return {
response: responses.badRequestResponse("Limit cannot be less than 1"),
};
}
if (limit > 5000) {
return {
response: responses.badRequestResponse("Limit cannot be more than 5000"),
};
}
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return {
response: responses.successResponse(surveyLinks),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
export const GET = async (
request: NextRequest,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const survey = await getSurvey(params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
},
});
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
if (survey.type !== "link") {
return responses.badRequestResponse("Single use links are only available for link surveys");
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return responses.badRequestResponse("Single use links are not enabled for this survey");
}
const searchParams = request.nextUrl.searchParams;
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
if (limit < 1) {
return responses.badRequestResponse("Limit cannot be less than 1");
}
if (limit > 5000) {
return responses.badRequestResponse("Limit cannot be more than 5000");
}
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return responses.successResponse(surveyLinks);
} catch (error) {
return handleErrorResponse(error);
}
};
+38 -47
View File
@@ -1,64 +1,55 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const searchParams = new URL(request.url).searchParams;
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
return responses.successResponse(surveys);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
const searchParams = new URL(req.url).searchParams;
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
return {
response: responses.successResponse(surveys),
};
} catch (error) {
if (error instanceof DatabaseError) {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.badRequestResponse(error.message),
response: responses.notAuthenticatedResponse(),
};
}
throw error;
}
},
});
auditLog.userId = authentication.apiKeyId;
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
let surveyInput;
try {
surveyInput = await req.json();
surveyInput = await request.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON");
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -89,6 +80,7 @@ export const POST = withV1ApiWrapper({
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
const surveyData = { ...inputValidation.data, environmentId };
@@ -102,19 +94,18 @@ export const POST = withV1ApiWrapper({
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
auditLog.targetId = survey.id;
auditLog.newObject = survey;
return {
response: responses.successResponse(survey),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.internalServerErrorResponse(error.message),
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
},
action: "created",
targetType: "survey",
});
"created",
"survey"
);
@@ -1,52 +1,53 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { headers } from "next/headers";
import { logger } from "@formbricks/logger";
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ webhookId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return {
response: responses.notFoundResponse("Webhook", params.webhookId),
};
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
}
return {
response: responses.successResponse(webhook),
};
},
});
// add webhook to database
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
return responses.successResponse(webhook);
};
export const DELETE = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ webhookId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ webhookId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.webhookId;
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return {
response: responses.notAuthenticatedResponse(),
};
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
// check if webhook exists
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
@@ -70,12 +71,12 @@ export const DELETE = withV1ApiWrapper({
};
} catch (e) {
auditLog.status = "failure";
logger.error({ error: e, url: req.url }, "Error deleting webhook");
logger.error({ error: e, url: request.url }, "Error deleting webhook");
return {
response: responses.notFoundResponse("Webhook", params.webhookId),
};
}
},
action: "deleted",
targetType: "webhook",
});
"deleted",
"webhook"
);
+34 -35
View File
@@ -1,44 +1,43 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
try {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
return {
response: responses.successResponse(webhooks),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.internalServerErrorResponse(error.message),
};
}
throw error;
export const GET = async (request: Request) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
try {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
return responses.successResponse(webhooks);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.internalServerErrorResponse(error.message);
}
},
});
throw error;
}
};
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const webhookInput = await req.json();
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.organizationId = authentication.organizationId;
auditLog.userId = authentication.apiKeyId;
const webhookInput = await request.json();
const inputValidation = ZWebhookInput.safeParse(webhookInput);
if (!inputValidation.success) {
@@ -86,6 +85,6 @@ export const POST = withV1ApiWrapper({
throw error;
}
},
action: "created",
targetType: "webhook",
});
"created",
"webhook"
);
@@ -114,6 +114,7 @@ const mockResponsePrisma = {
language: "en",
displayId: null,
tags: [],
notes: [],
};
const expectedResponse: TResponse = {
+75 -341
View File
@@ -1,12 +1,11 @@
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import * as Sentry from "@sentry/nextjs";
import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { responses } from "./response";
import { ApiAuditLog } from "./with-api-logging";
// Mocks
// This top-level mock is crucial for the SUT (withApiLogging.ts)
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
@@ -30,6 +29,7 @@ vi.mock("@formbricks/logger", () => {
return {
logger: {
withContext: mockWithContextInstance,
// These are for direct calls like logger.error(), logger.warn()
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
@@ -37,70 +37,35 @@ vi.mock("@formbricks/logger", () => {
};
});
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/middleware/endpoint-validator", async () => {
const original = await vi.importActual("@/app/middleware/endpoint-validator");
return {
...original,
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
isIntegrationRoute: vi.fn().mockReturnValue(false),
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
};
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
syncUserIdentification: { windowMs: 60000, max: 50 },
},
},
}));
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
// Parse the URL to get the pathname
const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url);
return {
method,
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
} as unknown as Request;
}
const mockApiAuthentication = {
hashedApiKey: "test-api-key",
apiKeyId: "api-key-1",
// Minimal valid ApiAuditLog
const baseAudit: ApiAuditLog = {
action: "created",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
} as TAuthenticationApiKey;
status: "failure",
userType: "api",
};
describe("withV1ApiWrapper", () => {
describe("withApiLogging", () => {
beforeEach(() => {
vi.resetModules();
vi.resetModules(); // Reset SUT and other potentially cached modules
// vi.doMock for constants if a specific test needs to override it
// The top-level mocks for audit-logs, sentry, logger should be re-applied implicitly
// or are already in place due to vi.mock hoisting.
// Restore the mock for constants to its default for most tests
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
IS_PRODUCTION: true,
@@ -109,45 +74,34 @@ describe("withV1ApiWrapper", () => {
REDIS_URL: "redis://localhost:6379",
}));
vi.clearAllMocks();
vi.clearAllMocks(); // Clear call counts etc. for all vi.fn()
});
test("logs and audits on error response with API key authentication", async () => {
test("logs and audits on error response", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.internalServerErrorResponse("fail"),
};
});
const req = createMockRequest({
url: "https://api.test/v1/management/surveys",
headers: new Map([["x-request-id", "abc-123"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
const req = createMockRequest({ headers: new Map([["x-request-id", "abc-123"]]) });
const { withApiLogging } = await import("./with-api-logging"); // SUT dynamically imported
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "abc-123",
@@ -156,7 +110,7 @@ describe("withV1ApiWrapper", () => {
action: "created",
status: "failure",
targetType: "survey",
userId: "api-key-1",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
@@ -171,36 +125,28 @@ describe("withV1ApiWrapper", () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.badRequestResponse("bad req"),
};
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
@@ -208,7 +154,7 @@ describe("withV1ApiWrapper", () => {
action: "created",
status: "failure",
targetType: "survey",
userId: "api-key-1",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
@@ -219,34 +165,21 @@ describe("withV1ApiWrapper", () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
throw new Error("fail!");
});
const req = createMockRequest({
url: "https://api.test/v1/management/surveys",
headers: new Map([["x-request-id", "err-1"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
const req = createMockRequest({ headers: new Map([["x-request-id", "err-1"]]) });
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
const body = await res.json();
expect(body).toEqual({
@@ -256,6 +189,8 @@ describe("withV1ApiWrapper", () => {
});
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "err-1",
@@ -264,7 +199,7 @@ describe("withV1ApiWrapper", () => {
action: "created",
status: "failure",
targetType: "survey",
userId: "api-key-1",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
@@ -275,39 +210,31 @@ describe("withV1ApiWrapper", () => {
);
});
test("does not log on success response but still audits", async () => {
test("does not log/audit on success response", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.successResponse({ ok: true }),
};
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(logger.withContext).not.toHaveBeenCalled();
expect(mockContextualLoggerError).not.toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
@@ -315,7 +242,7 @@ describe("withV1ApiWrapper", () => {
action: "created",
status: "success",
targetType: "survey",
userId: "api-key-1",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
@@ -324,6 +251,7 @@ describe("withV1ApiWrapper", () => {
});
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
// For this specific test, we override the AUDIT_LOG_ENABLED constant
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
@@ -335,209 +263,15 @@ describe("withV1ApiWrapper", () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { withV1ApiWrapper } = await import("./with-api-logging");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const { withApiLogging } = await import("./with-api-logging");
const handler = vi.fn().mockResolvedValue({
response: responses.internalServerErrorResponse("fail"),
audit: { ...baseAudit },
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
const req = createMockRequest();
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
test("handles client-side API routes without authentication", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "/api/v1/client/displays" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(handler).toHaveBeenCalledWith({
req,
props: undefined,
auditLog: undefined,
authentication: null,
});
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
});
test("handles sync user identification rate limiting", async () => {
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
const {
isClientSideApiRoute,
isManagementApiRoute,
isIntegrationRoute,
isSyncWithUserIdentificationEndpoint,
} = await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
userId: "user-123",
environmentId: "env-123",
});
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
const rateLimitError = new Error("Sync rate limit exceeded");
rateLimitError.message = "Sync rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(applyRateLimit).toHaveBeenCalledWith(
expect.objectContaining({ windowMs: 60000, max: 50 }),
"user-123"
);
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
await wrapped(req, undefined);
expect(handler).toHaveBeenCalledWith({
req,
props: undefined,
auditLog: undefined,
authentication: mockApiAuthentication,
});
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
});
describe("buildAuditLogBaseObject", () => {
test("creates audit log base object with correct structure", async () => {
const { buildAuditLogBaseObject } = await import("./with-api-logging");
const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys");
expect(result).toEqual({
action: "created",
targetType: "survey",
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl: "https://api.test/v1/management/surveys",
});
});
});
+65 -309
View File
@@ -1,327 +1,83 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import {
AuthenticationMethod,
isClientSideApiRoute,
isIntegrationRoute,
isManagementApiRoute,
isSyncWithUserIdentificationEndpoint,
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
export type TApiKeyAuthentication = TAuthenticationApiKey | null;
export type TSessionAuthentication = Session | null;
// Interface for handler function parameters
export interface THandlerParams<TProps = unknown> {
req?: NextRequest;
props?: TProps;
auditLog?: TApiAuditLog;
authentication?: TApiKeyAuthentication | TSessionAuthentication;
}
// Interface for wrapper function parameters
export interface TWithV1ApiWrapperParams<TResult extends { response: Response }, TProps = unknown> {
handler: (params: THandlerParams<TProps>) => Promise<TResult>;
action?: TAuditAction;
targetType?: TAuditTarget;
}
enum ApiV1RouteTypeEnum {
Client = "client",
General = "general",
Integration = "integration",
}
export type ApiAuditLog = Parameters<typeof queueAuditEvent>[0];
/**
* Apply client-side API rate limiting (IP-based or sync-specific)
* withApiLogging wraps an V1 API handler to provide unified error/audit/system logging.
* - Handler must return { response }.
* - If not a successResponse, calls audit log, system log, and Sentry as needed.
* - System and Sentry logs are always called for non-success responses.
*/
const applyClientRateLimit = async (url: string): Promise<void> => {
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
if (syncEndpoint) {
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
} else {
await applyIPRateLimit(rateLimitConfigs.api.client);
}
};
export const withApiLogging = <TResult extends { response: Response }>(
handler: (req: Request, props?: any, auditLog?: ApiAuditLog) => Promise<TResult>,
action: TAuditAction,
targetType: TAuditTarget
) => {
return async function (req: Request, props: any): Promise<Response> {
const auditLog = buildAuditLogBaseObject(action, targetType, req.url);
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
url: string,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum
): Promise<Response | null> => {
try {
if (authentication) {
if ("user" in authentication) {
// Session-based authentication for integration routes
await applyRateLimit(rateLimitConfigs.api.v1, authentication.user.id);
} else if ("hashedApiKey" in authentication) {
// API key authentication for general routes
await applyRateLimit(rateLimitConfigs.api.v1, authentication.hashedApiKey);
} else {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
}
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(url);
}
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
return null;
};
/**
* Execute handler with error handling
*/
const executeHandler = async <TResult extends { response: Response }, TProps>(
handler: (params: THandlerParams<TProps>) => Promise<TResult>,
req: NextRequest,
props: TProps,
auditLog: TApiAuditLog | undefined,
authentication: TApiV1Authentication
): Promise<{ result: TResult; error?: unknown }> => {
try {
const result = await handler({ req, props, auditLog, authentication });
return { result };
} catch (err) {
const result = {
response: responses.internalServerErrorResponse("An unexpected error occurred."),
} as TResult;
return { result, error: err };
}
};
/**
* Set up audit log with authentication details
*/
const setupAuditLog = (
authentication: TApiV1Authentication,
auditLog: TApiAuditLog | undefined,
routeType: ApiV1RouteTypeEnum
): void => {
if (
authentication &&
auditLog &&
routeType === ApiV1RouteTypeEnum.General &&
"apiKeyId" in authentication
) {
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
}
if (authentication && auditLog && "user" in authentication) {
auditLog.userId = authentication.user.id;
}
};
/**
* Handle authentication based on method
*/
const handleAuthentication = async (
authenticationMethod: AuthenticationMethod,
req: NextRequest
): Promise<TApiV1Authentication> => {
switch (authenticationMethod) {
case AuthenticationMethod.ApiKey:
return await authenticateRequest(req);
case AuthenticationMethod.Session:
return await getServerSession(authOptions);
case AuthenticationMethod.Both: {
const session = await getServerSession(authOptions);
return session ?? (await authenticateRequest(req));
}
case AuthenticationMethod.None:
return null;
}
};
/**
* Log error details to system logger and Sentry
*/
const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => {
const logContext = {
correlationId,
method: req.method,
path: req.url,
status: res.status,
...(error && { error }),
};
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, { extra: { error, correlationId } });
}
};
/**
* Handle response processing and logging
*/
const processResponse = async (
res: Response,
req: NextRequest,
auditLog?: TApiAuditLog,
error?: any
): Promise<void> => {
const correlationId = req.headers.get("x-request-id") ?? "";
// Handle audit logging
if (auditLog) {
if (res.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = correlationId;
}
}
// Handle error logging
if (!res.ok) {
logErrorDetails(res, req, correlationId, error);
}
// Queue audit event if enabled and audit log exists
if (AUDIT_LOG_ENABLED && auditLog) {
queueAuditEvent(auditLog);
}
};
const getRouteType = (
req: NextRequest
): { routeType: ApiV1RouteTypeEnum; isRateLimited: boolean; authenticationMethod: AuthenticationMethod } => {
const pathname = req.nextUrl.pathname;
const { isClientSideApi, isRateLimited } = isClientSideApiRoute(pathname);
const { isManagementApi, authenticationMethod } = isManagementApiRoute(pathname);
const isIntegration = isIntegrationRoute(pathname);
if (isClientSideApi)
return {
routeType: ApiV1RouteTypeEnum.Client,
isRateLimited,
authenticationMethod: AuthenticationMethod.None,
};
if (isManagementApi)
return { routeType: ApiV1RouteTypeEnum.General, isRateLimited: true, authenticationMethod };
if (isIntegration)
return {
routeType: ApiV1RouteTypeEnum.Integration,
isRateLimited: true,
authenticationMethod: AuthenticationMethod.Session,
};
throw new Error(`Unknown route type: ${pathname}`);
};
/**
* withV1ApiWrapper wraps a V1 API handler to provide unified authentication, rate limiting, and optional audit/system logging.
*
* Features:
* - Performs authentication once and passes result to handler
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
* - Includes additional sync user identification rate limiting for client-side sync endpoints
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
* - System and Sentry logs are always called for non-success responses
* - Uses function overloads to provide type safety without requiring type guards
*
* @param params - Configuration object for the wrapper
* @param params.handler - The API handler function that processes the request, receives an object with:
* - req: The incoming HTTP request object
* - props: Optional route parameters (e.g., { params: { id: string } })
* - auditLog: Optional audit log object for tracking API actions (only present when action/targetType provided)
* - authentication: Authentication result (type determined by route - API key for general, session for integration)
* @param params.action - Optional audit action type (e.g., "created", "updated", "deleted"). Required for audit logging
* @param params.targetType - Optional audit target type (e.g., "webhook", "survey", "response"). Required for audit logging
* @returns Wrapped handler function that returns the final HTTP response
*
*/
export const withV1ApiWrapper: {
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication?: TApiKeyAuthentication }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication?: TSessionAuthentication }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication?: TApiV1Authentication }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
} = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
const auditLog = saveAuditLog ? buildAuditLogBaseObject(action, targetType, req.url) : undefined;
let routeType: ApiV1RouteTypeEnum;
let isRateLimited: boolean;
let authenticationMethod: AuthenticationMethod;
// === Route Classification ===
let result: { response: Response };
let error: any = undefined;
try {
({ routeType, isRateLimited, authenticationMethod } = getRouteType(req));
} catch (error) {
logger.error({ error }, "Error getting route type");
return responses.internalServerErrorResponse("An unexpected error occurred.");
result = await handler(req, props, auditLog);
} catch (err) {
error = err;
result = {
response: responses.internalServerErrorResponse("An unexpected error occurred."),
};
}
// === Authentication ===
const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
return responses.notAuthenticatedResponse();
}
// === Audit Log Enhancement ===
setupAuditLog(authentication, auditLog, routeType);
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(req.nextUrl.pathname, authentication, routeType);
if (rateLimitResponse) return rateLimitResponse;
}
// === Handler Execution ===
const { result, error } = await executeHandler(handler, req, props, auditLog, authentication);
const res = result.response;
// Try to parse the response as JSON to check if it's a success or error
let isSuccess = false;
let parsed: any = undefined;
try {
parsed = await res.clone().json();
isSuccess = parsed && typeof parsed === "object" && "data" in parsed;
} catch {
isSuccess = false;
}
// === Response Processing & Logging ===
await processResponse(res, req, auditLog, error);
const correlationId = req.headers.get("x-request-id") ?? "";
if (!isSuccess) {
if (auditLog) {
auditLog.eventId = correlationId;
}
// System log
const logContext: any = {
correlationId,
method: req.method,
path: req.url,
status: res.status,
};
if (error) {
logContext.error = error;
}
logger.withContext(logContext).error("API Error Details");
// Sentry log
if (SENTRY_DSN && IS_PRODUCTION && res.status === 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, {
extra: {
error,
correlationId,
},
});
}
} else {
auditLog.status = "success";
}
if (AUDIT_LOG_ENABLED && auditLog) {
queueAuditEvent(auditLog);
}
return res;
};
@@ -331,7 +87,7 @@ export const buildAuditLogBaseObject = (
action: TAuditAction,
targetType: TAuditTarget,
apiUrl: string
): TApiAuditLog => {
): ApiAuditLog => {
return {
action,
targetType,
-1
View File
@@ -3703,6 +3703,5 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
showLanguageSwitch: false,
followUps: [],
isBackButtonHidden: false,
metadata: {},
} as TSurvey;
};
+39
View File
@@ -0,0 +1,39 @@
import * as constants from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
describe("bucket middleware rate limiters", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
const mockedRateLimit = rateLimit as unknown as Mock;
mockedRateLimit.mockImplementation((config) => config);
});
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
const { clientSideApiEndpointsLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
expect(clientSideApiEndpointsLimiter).toEqual({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
});
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
const { syncUserIdentificationLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
expect(syncUserIdentificationLimiter).toEqual({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { CLIENT_SIDE_API_RATE_LIMIT, SYNC_USER_IDENTIFICATION_RATE_LIMIT } from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const clientSideApiEndpointsLimiter = rateLimit({
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
export const syncUserIdentificationLimiter = rateLimit({
interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
@@ -1,10 +1,8 @@
import { describe, expect, test } from "vitest";
import {
AuthenticationMethod,
isAdminDomainRoute,
isAuthProtectedRoute,
isClientSideApiRoute,
isIntegrationRoute,
isManagementApiRoute,
isPublicDomainRoute,
isRouteAllowedForDomain,
@@ -12,230 +10,34 @@ import {
} from "./endpoint-validator";
describe("endpoint-validator", () => {
describe("AuthenticationMethod enum", () => {
test("should have correct values", () => {
expect(AuthenticationMethod.ApiKey).toBe("apiKey");
expect(AuthenticationMethod.Session).toBe("session");
expect(AuthenticationMethod.Both).toBe("both");
expect(AuthenticationMethod.None).toBe("none");
});
});
describe("isClientSideApiRoute", () => {
test("should return correct object for client-side API routes with rate limiting", () => {
expect(isClientSideApiRoute("/api/v1/client/storage")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v1/client/other")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v2/client/other")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v3/client/test")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
});
test("should return correct object for OG route (client-side but not rate limited)", () => {
expect(isClientSideApiRoute("/api/v1/client/og")).toEqual({
isClientSideApi: true,
isRateLimited: false,
});
expect(isClientSideApiRoute("/api/v1/client/og/image")).toEqual({
isClientSideApi: true,
isRateLimited: false,
});
test("should return true for client-side API routes", () => {
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
expect(isClientSideApiRoute("/api/v2/client/other")).toBe(true);
});
test("should return false for non-client-side API routes", () => {
expect(isClientSideApiRoute("/api/v1/management/something")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v1/js/actions")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/something")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/auth/login")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v1/integrations/webhook")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
});
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
expect(isClientSideApiRoute("/api/something")).toBe(false);
expect(isClientSideApiRoute("/auth/login")).toBe(false);
test("should handle edge cases", () => {
expect(isClientSideApiRoute("/api/v1/client")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v1/client/")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/client/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
// exception for open graph image generation route, it should not be rate limited
expect(isClientSideApiRoute("/api/v1/client/og")).toBe(false);
});
});
describe("isManagementApiRoute", () => {
test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v2/management/other")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/management/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v3/management/users")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
test("should return true for management API routes", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toBe(true);
expect(isManagementApiRoute("/api/v2/management/other")).toBe(true);
});
test("should return correct object for storage management routes with both authentication methods", () => {
expect(isManagementApiRoute("/api/v1/management/storage")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v1/management/storage/files")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v1/management/storage/upload")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for webhooks routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/webhooks")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/webhooks/123")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/webhooks/webhook-id/config")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should return correct object for non-v1 storage management routes (only v1 supports both auth methods)", () => {
expect(isManagementApiRoute("/api/v2/management/storage")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v3/management/storage/upload")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should return correct object for non-v1 webhooks routes (falls back to management regex)", () => {
expect(isManagementApiRoute("/api/v2/webhooks")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v3/webhooks/123")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v2/management/webhooks")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should return correct object for non-management API routes", () => {
expect(isManagementApiRoute("/api/v1/client/something")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/something")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/auth/login")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/integrations/webhook")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should handle edge cases", () => {
expect(isManagementApiRoute("/api/v1/management")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/management/")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/management/test")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should handle webhooks edge cases", () => {
expect(isManagementApiRoute("/api/v1/webhook")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/webhooks")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/webhooks/api/v1")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
});
describe("isIntegrationRoute", () => {
test("should return true for integration API routes", () => {
expect(isIntegrationRoute("/api/v1/integrations/webhook")).toBe(true);
expect(isIntegrationRoute("/api/v2/integrations/slack")).toBe(true);
expect(isIntegrationRoute("/api/v1/integrations/zapier")).toBe(true);
expect(isIntegrationRoute("/api/v3/integrations/other")).toBe(true);
});
test("should return false for non-integration API routes", () => {
expect(isIntegrationRoute("/api/v1/client/something")).toBe(false);
expect(isIntegrationRoute("/api/v1/management/something")).toBe(false);
expect(isIntegrationRoute("/api/something")).toBe(false);
expect(isIntegrationRoute("/auth/login")).toBe(false);
expect(isIntegrationRoute("/integrations/webhook")).toBe(false);
});
test("should handle edge cases", () => {
expect(isIntegrationRoute("/api/v1/integrations")).toBe(false);
expect(isIntegrationRoute("/api/v1/integrations/")).toBe(true);
expect(isIntegrationRoute("/api/integrations/test")).toBe(false);
test("should return false for non-management API routes", () => {
expect(isManagementApiRoute("/api/v1/client/something")).toBe(false);
expect(isManagementApiRoute("/api/something")).toBe(false);
expect(isManagementApiRoute("/auth/login")).toBe(false);
});
});
@@ -243,30 +45,15 @@ describe("endpoint-validator", () => {
test("should return true for protected routes", () => {
expect(isAuthProtectedRoute("/environments")).toBe(true);
expect(isAuthProtectedRoute("/environments/something")).toBe(true);
expect(isAuthProtectedRoute("/environments/123/surveys")).toBe(true);
expect(isAuthProtectedRoute("/setup/organization")).toBe(true);
expect(isAuthProtectedRoute("/setup/organization/create")).toBe(true);
expect(isAuthProtectedRoute("/organizations")).toBe(true);
expect(isAuthProtectedRoute("/organizations/something")).toBe(true);
expect(isAuthProtectedRoute("/organizations/123/settings")).toBe(true);
});
test("should return false for non-protected routes", () => {
expect(isAuthProtectedRoute("/auth/login")).toBe(false);
expect(isAuthProtectedRoute("/auth/signup")).toBe(false);
expect(isAuthProtectedRoute("/api/something")).toBe(false);
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
test("should handle edge cases", () => {
expect(isAuthProtectedRoute("/environment")).toBe(false); // partial match should not work
expect(isAuthProtectedRoute("/organization")).toBe(false); // partial match should not work
expect(isAuthProtectedRoute("/setup/team")).toBe(false); // not in protected routes
expect(isAuthProtectedRoute("/setup")).toBe(false); // partial match should not work
});
});
@@ -283,42 +70,12 @@ describe("endpoint-validator", () => {
environmentId: "abc-123",
userId: "xyz-789",
});
const result3 = isSyncWithUserIdentificationEndpoint(
"/api/v1/client/env_123_test/app/sync/user_456_test"
);
expect(result3).toEqual({
environmentId: "env_123_test",
userId: "user_456_test",
});
});
test("should handle optional trailing slash", () => {
// Test both with and without trailing slash
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
expect(result2).toEqual({
environmentId: "env123",
userId: "user456",
});
});
test("should return false for invalid sync URLs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
});
test("should handle empty or malformed IDs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
});
});
@@ -327,57 +84,21 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/health")).toBe(true);
});
test("should return true for public storage routes", () => {
expect(isPublicDomainRoute("/storage/env123/public/file.jpg")).toBe(true);
expect(isPublicDomainRoute("/storage/abc-456/public/document.pdf")).toBe(true);
expect(isPublicDomainRoute("/storage/env123/public/folder/image.png")).toBe(true);
});
test("should return false for private storage routes", () => {
expect(isPublicDomainRoute("/storage/env123/private/file.jpg")).toBe(false);
expect(isPublicDomainRoute("/storage/env123")).toBe(false);
expect(isPublicDomainRoute("/storage")).toBe(false);
});
// Static assets are not handled by domain routing - middleware doesn't run on them
test("should return true for survey routes", () => {
expect(isPublicDomainRoute("/s/survey123")).toBe(true);
expect(isPublicDomainRoute("/s/survey-id-with-dashes")).toBe(true);
expect(isPublicDomainRoute("/s/survey_id_with_underscores")).toBe(true);
expect(isPublicDomainRoute("/s/abc123def456")).toBe(true);
});
test("should return false for malformed survey routes", () => {
expect(isPublicDomainRoute("/s/")).toBe(false);
expect(isPublicDomainRoute("/s")).toBe(false);
expect(isPublicDomainRoute("/survey/123")).toBe(false);
});
test("should return true for contact survey routes", () => {
expect(isPublicDomainRoute("/c/jwt-token")).toBe(true);
expect(isPublicDomainRoute("/c/very-long-jwt-token-123")).toBe(true);
expect(isPublicDomainRoute("/c/token.with.dots")).toBe(true);
});
test("should return false for malformed contact survey routes", () => {
expect(isPublicDomainRoute("/c/")).toBe(false);
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/env/actions")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/env/responses")).toBe(true);
});
test("should return false for non-client API routes", () => {
expect(isPublicDomainRoute("/api/v3/client/something")).toBe(false); // only v1 and v2 supported
expect(isPublicDomainRoute("/api/client/something")).toBe(false);
expect(isPublicDomainRoute("/api/v1/management/users")).toBe(false);
expect(isPublicDomainRoute("/api/v1/integrations/webhook")).toBe(false);
});
test("should return false for admin-only routes", () => {
@@ -395,11 +116,7 @@ describe("endpoint-validator", () => {
describe("isAdminDomainRoute", () => {
test("should return true for health endpoint (backward compatibility)", () => {
expect(isAdminDomainRoute("/health")).toBe(true);
});
test("should return true for public storage routes (backward compatibility)", () => {
expect(isAdminDomainRoute("/storage/env123/public/file.jpg")).toBe(true);
expect(isAdminDomainRoute("/storage/abc-456/public/document.pdf")).toBe(true);
expect(isAdminDomainRoute("/health")).toBe(true);
});
// Static assets are not handled by domain routing - middleware doesn't run on them
@@ -418,261 +135,73 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/product/features")).toBe(true);
expect(isAdminDomainRoute("/api/v1/management/users")).toBe(true);
expect(isAdminDomainRoute("/api/v2/management/surveys")).toBe(true);
expect(isAdminDomainRoute("/api/v1/integrations/webhook")).toBe(true);
expect(isAdminDomainRoute("/pipeline/jobs")).toBe(true);
expect(isAdminDomainRoute("/cron/tasks")).toBe(true);
expect(isAdminDomainRoute("/random/route")).toBe(true);
});
test("should return false for public-only routes", () => {
expect(isAdminDomainRoute("/s/survey123")).toBe(false);
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
test("should handle edge cases", () => {
expect(isAdminDomainRoute("")).toBe(true);
expect(isAdminDomainRoute("/unknown/path")).toBe(true); // unknown routes default to admin
});
});
describe("isRouteAllowedForDomain", () => {
describe("public domain routing", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
expect(isRouteAllowedForDomain("/storage/env123/public/file.jpg", true)).toBe(true);
// Static assets not tested - middleware doesn't run on them
});
test("should block admin routes on public domain", () => {
expect(isRouteAllowedForDomain("/", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/integrations/webhook", true)).toBe(false);
expect(isRouteAllowedForDomain("/organizations/123", true)).toBe(false);
expect(isRouteAllowedForDomain("/setup/organization", true)).toBe(false);
});
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
// Static assets not tested - middleware doesn't run on them
});
describe("admin domain routing", () => {
test("should allow admin routes on admin domain", () => {
expect(isRouteAllowedForDomain("/", false)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/integrations/webhook", false)).toBe(true);
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
expect(isRouteAllowedForDomain("/storage/env123/public/file.jpg", false)).toBe(true);
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
expect(isRouteAllowedForDomain("/unknown/route", false)).toBe(true);
});
test("should block public-only routes on admin domain when PUBLIC_URL is configured", () => {
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
test("should block admin routes on public domain", () => {
expect(isRouteAllowedForDomain("/", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
});
describe("edge cases", () => {
test("should handle empty paths", () => {
expect(isRouteAllowedForDomain("", true)).toBe(false);
expect(isRouteAllowedForDomain("", false)).toBe(true);
});
test("should block public routes on admin domain when PUBLIC_URL is configured", () => {
// Admin routes should be allowed
expect(isRouteAllowedForDomain("/", false)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
// Public routes should be blocked on admin domain
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
});
});
describe("comprehensive integration tests", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
});
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isIntegrationRoute("/api/v1/integrations/webhook/")).toBe(true);
});
describe("edge cases", () => {
test("should handle empty paths", () => {
expect(isPublicDomainRoute("")).toBe(false);
expect(isAdminDomainRoute("")).toBe(true);
expect(isAdminDomainRoute("")).toBe(true);
});
describe("nested route handling", () => {
test("should handle nested survey routes", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/env789/surveys/123")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/env123/actions")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
});
test("should handle deeply nested admin routes", () => {
expect(isAuthProtectedRoute("/environments/123/surveys/456/settings")).toBe(true);
expect(isAuthProtectedRoute("/organizations/789/members/123/roles")).toBe(true);
expect(isAuthProtectedRoute("/setup/organization/team/invites")).toBe(true);
});
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
});
describe("version handling", () => {
test("should handle different API versions correctly", () => {
// Client API - only v1 and v2 supported in public routes
expect(isPublicDomainRoute("/api/v1/client/test")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/test")).toBe(true);
expect(isPublicDomainRoute("/api/v3/client/test")).toBe(false);
// Management API - all versions supported
expect(isManagementApiRoute("/api/v1/management/test")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v2/management/test")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v3/management/test")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
// Integration API - all versions supported
expect(isIntegrationRoute("/api/v1/integrations/test")).toBe(true);
expect(isIntegrationRoute("/api/v2/integrations/test")).toBe(true);
expect(isIntegrationRoute("/api/v3/integrations/test")).toBe(true);
});
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
});
describe("special characters in routes", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
).toEqual({
environmentId: "env-123_test",
userId: "user-456_test",
});
});
test("should handle nested survey routes", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
});
describe("security considerations", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
});
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
});
describe("performance considerations", () => {
test("should handle very long URLs efficiently", () => {
const longSurveyId = "a".repeat(1000);
const longPath = `s/${longSurveyId}`;
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
const longEnvironmentId = "env" + "a".repeat(1000);
const longUserId = "user" + "b".repeat(1000);
expect(
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
).toEqual({
environmentId: longEnvironmentId,
userId: longUserId,
});
});
test("should handle empty and minimal inputs", () => {
expect(isPublicDomainRoute("")).toBe(false);
expect(isClientSideApiRoute("")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isManagementApiRoute("")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isIntegrationRoute("")).toBe(false);
expect(isAuthProtectedRoute("")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
});
});
describe("case sensitivity", () => {
test("should be case sensitive for route patterns", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isManagementApiRoute("/API/V1/MANAGEMENT/test")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isIntegrationRoute("/API/V1/INTEGRATIONS/test")).toBe(false);
expect(isAuthProtectedRoute("/ENVIRONMENTS/123")).toBe(false);
});
test("should handle nested client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
});
});
});
+6 -23
View File
@@ -4,35 +4,18 @@ import {
matchesAnyPattern,
} from "./route-config";
export enum AuthenticationMethod {
ApiKey = "apiKey",
Session = "session",
Both = "both",
None = "none",
}
export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => {
export const isClientSideApiRoute = (url: string): boolean => {
// Open Graph image generation route is a client side API route but it should not be rate limited
if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false };
if (url.includes("/api/v1/client/og")) return false;
if (url.includes("/api/v1/js/actions")) return true;
if (url.includes("/api/v1/client/storage")) return true;
const regex = /^\/api\/v\d+\/client\//;
return { isClientSideApi: regex.test(url), isRateLimited: true };
return regex.test(url);
};
export const isManagementApiRoute = (
url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.ApiKey };
export const isManagementApiRoute = (url: string): boolean => {
const regex = /^\/api\/v\d+\/management\//;
return { isManagementApi: regex.test(url), authenticationMethod: AuthenticationMethod.ApiKey };
};
export const isIntegrationRoute = (url: string): boolean => {
const regex = /^\/api\/v\d+\/integrations\//;
return regex.test(url);
};
+15 -9
View File
@@ -153,6 +153,20 @@ export const SURVEY_BG_COLORS = [
"#CDFAD5",
];
// Rate Limiting
export const CLIENT_SIDE_API_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 100,
};
export const MANAGEMENT_API_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 100,
};
export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 5,
};
export const DEBUG = env.DEBUG === "1";
// Enterprise License constant
@@ -174,15 +188,7 @@ export const STRIPE_API_VERSION = "2024-06-20";
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
export const DEFAULT_LOCALE = "en-US";
export const AVAILABLE_LOCALES: TUserLocale[] = [
"en-US",
"de-DE",
"pt-BR",
"fr-FR",
"zh-Hant-TW",
"pt-PT",
"ro-RO",
];
export const AVAILABLE_LOCALES: TUserLocale[] = ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"];
// Billing constants
-18
View File
@@ -140,7 +140,6 @@ export const appLanguages = [
"fr-FR": "Anglais (États-Unis)",
"zh-Hant-TW": "英文 (美國)",
"pt-PT": "Inglês (EUA)",
"ro-RO": "Engleză (SUA)",
},
},
{
@@ -152,7 +151,6 @@ export const appLanguages = [
"fr-FR": "Allemand",
"zh-Hant-TW": "德語",
"pt-PT": "Alemão",
"ro-RO": "Germană",
},
},
{
@@ -164,7 +162,6 @@ export const appLanguages = [
"fr-FR": "Portugais (Brésil)",
"zh-Hant-TW": "葡萄牙語 (巴西)",
"pt-PT": "Português (Brasil)",
"ro-RO": "Portugheză (Brazilia)",
},
},
{
@@ -176,7 +173,6 @@ export const appLanguages = [
"fr-FR": "Français",
"zh-Hant-TW": "法語",
"pt-PT": "Francês",
"ro-RO": "Franceză",
},
},
{
@@ -188,7 +184,6 @@ export const appLanguages = [
"fr-FR": "Chinois (Traditionnel)",
"zh-Hant-TW": "繁體中文",
"pt-PT": "Chinês (Tradicional)",
"ro-RO": "Chineză (Tradicională)",
},
},
{
@@ -200,19 +195,6 @@ export const appLanguages = [
"fr-FR": "Portugais (Portugal)",
"zh-Hant-TW": "葡萄牙語 (葡萄牙)",
"pt-PT": "Português (Portugal)",
"ro-RO": "Portugheză (Portugalia)",
},
},
{
code: "ro-RO",
label: {
"en-US": "Romanian",
"de-DE": "Rumänisch",
"pt-BR": "Romeno",
"fr-FR": "Roumain",
"zh-Hant-TW": "羅馬尼亞語",
"pt-PT": "Romeno",
"ro-RO": "Română",
},
},
];
+28 -865
View File
@@ -1,5 +1,4 @@
import * as crypto from "@/lib/crypto";
import jwt from "jsonwebtoken";
import { env } from "@/lib/env";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
@@ -15,69 +14,12 @@ import {
verifyTokenForLinkSurvey,
} from "./jwt";
const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
const DIFFERENT_SECRET = "different-secret";
// Error message constants
const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
// Helper function to test error cases for missing secrets/keys
const testMissingSecretsError = async (
testFn: (...args: any[]) => any,
args: any[],
options: {
testNextAuthSecret?: boolean;
testEncryptionKey?: boolean;
isAsync?: boolean;
} = {}
) => {
const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
if (testNextAuthSecret) {
const constants = await import("@/lib/constants");
const originalSecret = (constants as any).NEXTAUTH_SECRET;
(constants as any).NEXTAUTH_SECRET = undefined;
if (isAsync) {
await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
} else {
expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
}
// Restore
(constants as any).NEXTAUTH_SECRET = originalSecret;
}
if (testEncryptionKey) {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
if (isAsync) {
await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
} else {
expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
}
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
}
};
// Mock environment variables
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "0".repeat(32),
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
NEXTAUTH_SECRET: "test-nextauth-secret",
},
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
NEXTAUTH_SECRET: "test-nextauth-secret",
ENCRYPTION_KEY: "0".repeat(32),
} as typeof env,
}));
// Mock prisma
@@ -89,65 +31,22 @@ vi.mock("@formbricks/database", () => ({
},
}));
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
describe("JWT Functions - Comprehensive Security Tests", () => {
describe("JWT Functions", () => {
const mockUser = {
id: "test-user-id",
email: "test@example.com",
};
let mockSymmetricEncrypt: any;
let mockSymmetricDecrypt: any;
beforeEach(() => {
vi.clearAllMocks();
// Setup default crypto mocks
mockSymmetricEncrypt = vi
.spyOn(crypto, "symmetricEncrypt")
.mockImplementation((text: string) => `encrypted_${text}`);
mockSymmetricDecrypt = vi
.spyOn(crypto, "symmetricDecrypt")
.mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
});
describe("createToken", () => {
test("should create a valid token with encrypted user ID", () => {
const token = createToken(mockUser.id);
test("should create a valid token", () => {
const token = createToken(mockUser.id, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
});
test("should accept custom options", () => {
const customOptions = { expiresIn: "1h" };
const token = createToken(mockUser.id, customOptions);
expect(token).toBeDefined();
// Verify the token contains the expected expiration
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 1 hour (3600 seconds)
expect(decoded.exp - decoded.iat).toBe(3600);
});
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
await testMissingSecretsError(createToken, [mockUser.id], {
testNextAuthSecret: true,
testEncryptionKey: false,
});
});
});
@@ -157,18 +56,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should include surveyId in payload", () => {
const surveyId = "test-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const decoded = jwt.decode(token) as any;
expect(decoded.surveyId).toBe(surveyId);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
});
});
@@ -177,30 +64,24 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
const token = createEmailToken(mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createEmailToken, [mockUser.email]);
test("should throw error if NEXTAUTH_SECRET is not set", () => {
const originalSecret = env.NEXTAUTH_SECRET;
try {
(env as any).NEXTAUTH_SECRET = undefined;
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
} finally {
(env as any).NEXTAUTH_SECRET = originalSecret;
}
});
});
describe("createEmailChangeToken", () => {
test("should create a valid email change token with 1 day expiration", () => {
const token = createEmailChangeToken(mockUser.id, mockUser.email);
expect(token).toBeDefined();
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 1 day (86400 seconds)
expect(decoded.exp - decoded.iat).toBe(86400);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
});
});
@@ -210,50 +91,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
const token = createInviteToken(inviteId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should accept custom options", () => {
const inviteId = "test-invite-id";
const customOptions = { expiresIn: "24h" };
const token = createInviteToken(inviteId, mockUser.email, customOptions);
expect(token).toBeDefined();
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 24 hours (86400 seconds)
expect(decoded.exp - decoded.iat).toBe(86400);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
});
});
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
});
test("should fall back to original email if decryption fails", () => {
mockSymmetricDecrypt.mockImplementationOnce(() => {
throw new Error("Decryption failed");
});
// Create token manually with unencrypted email for legacy compatibility
const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
const extractedEmail = getEmailFromEmailToken(legacyToken);
expect(extractedEmail).toBe(mockUser.email);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
await testMissingSecretsError(getEmailFromEmailToken, [token]);
});
});
@@ -269,194 +106,23 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
expect(result).toBeNull();
});
test("should return null if NEXTAUTH_SECRET is not set", async () => {
const constants = await import("@/lib/constants");
const originalSecret = (constants as any).NEXTAUTH_SECRET;
(constants as any).NEXTAUTH_SECRET = undefined;
const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
expect(result).toBeNull();
// Restore
(constants as any).NEXTAUTH_SECRET = originalSecret;
});
test("should return null if surveyId doesn't match", () => {
const surveyId = "test-survey-id";
const differentSurveyId = "different-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const result = verifyTokenForLinkSurvey(token, differentSurveyId);
expect(result).toBeNull();
});
test("should return null if email is missing from payload", () => {
const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
expect(result).toBeNull();
});
test("should fall back to original email if decryption fails", () => {
mockSymmetricDecrypt.mockImplementationOnce(() => {
throw new Error("Decryption failed");
});
// Create legacy token with unencrypted email
const legacyToken = jwt.sign(
{
email: mockUser.email,
surveyId: "test-survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
expect(result).toBe(mockUser.email);
});
test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
// Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
const token = jwt.sign(
{
email: mockUser.email,
surveyId: "survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(token, "survey-id");
expect(result).toBe(mockUser.email);
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
});
test("should verify legacy survey tokens with surveyId-based secret", async () => {
const surveyId = "test-survey-id";
// Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
expect(result).toBe(mockUser.email);
});
test("should reject survey tokens that fail both new and legacy verification", async () => {
const surveyId = "test-survey-id";
const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
expect(result).toBeNull();
// Verify error logging
const { logger } = await import("@formbricks/logger");
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
});
test("should reject legacy survey tokens for wrong survey", () => {
const correctSurveyId = "correct-survey-id";
const wrongSurveyId = "wrong-survey-id";
// Create legacy token for one survey
const legacyToken = jwt.sign(
{ email: `encrypted_${mockUser.email}` },
TEST_NEXTAUTH_SECRET + correctSurveyId
);
// Try to verify with different survey ID
const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
expect(result).toBeNull();
});
});
describe("verifyToken", () => {
test("should verify valid token", async () => {
const token = createToken(mockUser.id);
const token = createToken(mockUser.id, mockUser.email);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
id: mockUser.id,
email: mockUser.email,
});
});
test("should throw error if user not found", async () => {
(prisma.user.findUnique as any).mockResolvedValue(null);
const token = createToken(mockUser.id);
const token = createToken(mockUser.id, mockUser.email);
await expect(verifyToken(token)).rejects.toThrow("User not found");
});
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
await testMissingSecretsError(verifyToken, ["any-token"], {
testNextAuthSecret: true,
testEncryptionKey: false,
isAsync: true,
});
});
test("should throw error for invalid token signature", async () => {
const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
});
test("should throw error if token payload is missing id", async () => {
const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
});
test("should return raw id from payload", async () => {
// Create token with unencrypted id
const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email,
});
});
test("should verify legacy tokens with email-based secret", async () => {
// Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
const verified = await verifyToken(legacyToken);
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
});
});
test("should prioritize new tokens over legacy tokens", async () => {
// Create both new and legacy tokens for the same user
const newToken = createToken(mockUser.id);
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
// New token should verify without triggering legacy path
const verifiedNew = await verifyToken(newToken);
expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
// Legacy token should trigger legacy path
const verifiedLegacy = await verifyToken(legacyToken);
expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
});
test("should reject tokens that fail both new and legacy verification", async () => {
const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
// Verify both methods were attempted
const { logger } = await import("@formbricks/logger");
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Token verification failed with new method"
);
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Token verification failed with legacy method"
);
});
});
describe("verifyInviteToken", () => {
@@ -473,53 +139,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
test("should throw error for invalid token", () => {
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(verifyInviteToken, ["any-token"]);
});
test("should throw error if inviteId is missing", () => {
const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
});
test("should throw error if email is missing", () => {
const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
});
test("should fall back to original values if decryption fails", () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Decryption failed");
});
const inviteId = "test-invite-id";
const legacyToken = jwt.sign(
{
inviteId,
email: mockUser.email,
},
TEST_NEXTAUTH_SECRET
);
const verified = verifyInviteToken(legacyToken);
expect(verified).toEqual({
inviteId,
email: mockUser.email,
});
});
test("should throw error for token with wrong signature", () => {
const invalidToken = jwt.sign(
{
inviteId: "test-invite-id",
email: mockUser.email,
},
DIFFERENT_SECRET
);
expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
});
});
describe("verifyEmailChangeToken", () => {
@@ -531,478 +150,22 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(result).toEqual({ id: userId, email });
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
});
test("should throw error if token is invalid or missing fields", async () => {
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should throw error if id is missing", async () => {
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should throw error if email is missing", async () => {
const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
// Create a token with missing fields
const jwt = await import("jsonwebtoken");
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should return original id/email if decryption fails", async () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Decryption failed");
});
// Create a token with non-encrypted id/email
const jwt = await import("jsonwebtoken");
const payload = { id: "plain-id", email: "plain@example.com" };
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
const result = await verifyEmailChangeToken(token);
expect(result).toEqual(payload);
});
test("should throw error for token with wrong signature", async () => {
const invalidToken = jwt.sign(
{
id: "test-id",
email: "test@example.com",
},
DIFFERENT_SECRET
);
await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
});
});
// SECURITY SCENARIO TESTS
describe("Security Scenarios", () => {
describe("Algorithm Confusion Attack Prevention", () => {
test("should reject 'none' algorithm tokens in verifyToken", async () => {
// Create malicious token with "none" algorithm
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
})
).toString("base64url") +
".";
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
});
test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
email: "encrypted_attacker@evil.com",
surveyId: "test-survey-id",
})
).toString("base64url") +
".";
const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
expect(result).toBeNull();
});
test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
inviteId: "encrypted_malicious-invite",
email: "encrypted_attacker@evil.com",
})
).toString("base64url") +
".";
expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
});
test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
email: "encrypted_attacker@evil.com",
})
).toString("base64url") +
".";
await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
});
test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
// Create malicious token with RS256 algorithm header but HS256 signature
const maliciousHeader = Buffer.from(
JSON.stringify({
alg: "RS256",
typ: "JWT",
})
).toString("base64url");
const maliciousPayload = Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
})
).toString("base64url");
// Create signature using HMAC (as if it were HS256)
const crypto = require("crypto");
const signature = crypto
.createHmac("sha256", TEST_NEXTAUTH_SECRET)
.update(`${maliciousHeader}.${maliciousPayload}`)
.digest("base64url");
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
});
test("should only accept HS256 algorithm", async () => {
// Test that other valid algorithms are rejected
const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
for (const alg of otherAlgorithms) {
const maliciousHeader = Buffer.from(
JSON.stringify({
alg,
typ: "JWT",
})
).toString("base64url");
const maliciousPayload = Buffer.from(
JSON.stringify({
id: "encrypted_test-id",
})
).toString("base64url");
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
}
});
});
describe("Token Tampering", () => {
test("should reject tokens with modified payload", async () => {
const token = createToken(mockUser.id);
const [header, payload, signature] = token.split(".");
// Modify the payload
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
decodedPayload.id = "malicious-id";
const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
});
test("should reject tokens with modified signature", async () => {
const token = createToken(mockUser.id);
const [header, payload] = token.split(".");
const tamperedToken = `${header}.${payload}.tamperedsignature`;
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
});
test("should reject malformed tokens", async () => {
const malformedTokens = [
"not.a.jwt",
"only.two.parts",
"too.many.parts.here.invalid",
"",
"invalid-base64",
];
for (const malformedToken of malformedTokens) {
await expect(verifyToken(malformedToken)).rejects.toThrow();
}
});
});
describe("Cross-Survey Token Reuse", () => {
test("should reject survey tokens used for different surveys", () => {
const surveyId1 = "survey-1";
const surveyId2 = "survey-2";
const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
const result = verifyTokenForLinkSurvey(token, surveyId2);
expect(result).toBeNull();
});
});
describe("Expired Tokens", () => {
test("should reject expired tokens", async () => {
const expiredToken = jwt.sign(
{
id: "encrypted_test-id",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
TEST_NEXTAUTH_SECRET
);
await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
});
test("should reject expired email change tokens", async () => {
const expiredToken = jwt.sign(
{
id: "encrypted_test-id",
email: "encrypted_test@example.com",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
TEST_NEXTAUTH_SECRET
);
await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
});
});
describe("Encryption Key Attacks", () => {
test("should fail gracefully with wrong encryption key", async () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Authentication tag verification failed");
});
// Mock findUnique to only return user for correct decrypted ID, not ciphertext
(prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
if (where.id === mockUser.id) {
return Promise.resolve(mockUser);
}
return Promise.resolve(null); // Return null for ciphertext IDs
});
const token = createToken(mockUser.id);
// Should fail because ciphertext passed as userId won't match any user in DB
await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
});
test("should handle encryption key not set gracefully", async () => {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
const token = jwt.sign(
{
email: "test@example.com",
surveyId: "test-survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(token, "test-survey-id");
expect(result).toBe("test@example.com");
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
});
});
describe("SQL Injection Attempts", () => {
test("should safely handle malicious user IDs", async () => {
const maliciousIds = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
"admin'/*",
"<script>alert('xss')</script>",
"../../etc/passwd",
];
for (const maliciousId of maliciousIds) {
mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
// The function should look up the user safely
await verifyToken(token);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: maliciousId },
});
}
});
});
describe("Token Reuse and Replay Attacks", () => {
test("should allow legitimate token reuse within validity period", async () => {
const token = createToken(mockUser.id);
// First use
const result1 = await verifyToken(token);
expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
// Second use (should still work)
const result2 = await verifyToken(token);
expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
});
});
describe("Legacy Token Compatibility", () => {
test("should handle legacy unencrypted tokens gracefully", async () => {
// Legacy token with plain text data
const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
const result = await verifyToken(legacyToken);
expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
expect(result.email).toBe(mockUser.email);
});
test("should handle mixed encrypted/unencrypted fields", async () => {
mockSymmetricDecrypt
.mockImplementationOnce(() => mockUser.id) // id decrypts successfully
.mockImplementationOnce(() => {
throw new Error("Email not encrypted");
}); // email fails
const token = jwt.sign(
{
id: "encrypted_test-id",
email: "plain-email@example.com",
},
TEST_NEXTAUTH_SECRET
);
const result = await verifyEmailChangeToken(token);
expect(result.id).toBe(mockUser.id);
expect(result.email).toBe("plain-email@example.com");
});
test("should verify old format user tokens with email-based secrets", async () => {
// Simulate old token format with per-user secret
const oldFormatToken = jwt.sign(
{ id: `encrypted_${mockUser.id}` },
TEST_NEXTAUTH_SECRET + mockUser.email
);
const result = await verifyToken(oldFormatToken);
expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
expect(result.email).toBe(mockUser.email);
});
test("should verify old format survey tokens with survey-based secrets", () => {
const surveyId = "legacy-survey-id";
// Simulate old survey token format
const oldFormatSurveyToken = jwt.sign(
{ email: `encrypted_${mockUser.email}` },
TEST_NEXTAUTH_SECRET + surveyId
);
const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
expect(result).toBe(mockUser.email);
});
test("should gracefully handle database errors during legacy verification", async () => {
// Create token that will fail new method
const legacyToken = jwt.sign(
{ id: `encrypted_${mockUser.id}` },
TEST_NEXTAUTH_SECRET + mockUser.email
);
// Make database lookup fail
(prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
});
});
describe("Edge Cases and Error Handling", () => {
test("should handle database connection errors gracefully", async () => {
(prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
const token = createToken(mockUser.id);
await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
});
test("should handle crypto module errors", () => {
mockSymmetricEncrypt.mockImplementation(() => {
throw new Error("Crypto module error");
});
expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
});
test("should validate email format in tokens", () => {
const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
invalidEmails.forEach((invalidEmail) => {
expect(() => createEmailToken(invalidEmail)).not.toThrow();
// Note: JWT functions don't validate email format, they just encrypt/decrypt
// Email validation should happen at a higher level
});
});
test("should handle extremely long inputs", () => {
const longString = "a".repeat(10000);
expect(() => createToken(longString)).not.toThrow();
expect(() => createEmailToken(longString)).not.toThrow();
});
test("should handle special characters in user data", () => {
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
expect(() => createToken(specialChars)).not.toThrow();
expect(() => createEmailToken(specialChars)).not.toThrow();
});
});
describe("Performance and Resource Exhaustion", () => {
test("should handle rapid token creation without memory leaks", () => {
const tokens: string[] = [];
for (let i = 0; i < 1000; i++) {
tokens.push(createToken(`user-${i}`));
}
expect(tokens.length).toBe(1000);
expect(tokens.every((token) => typeof token === "string")).toBe(true);
});
test("should handle rapid token verification", async () => {
const token = createToken(mockUser.id);
const verifications: Promise<any>[] = [];
for (let i = 0; i < 100; i++) {
verifications.push(verifyToken(token));
}
const results = await Promise.all(verifications);
expect(results.length).toBe(100);
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
});
});
});
});
+86 -210
View File
@@ -1,64 +1,43 @@
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
import jwt, { JwtPayload } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
// Helper function to decrypt with fallback to plain text
const decryptWithFallback = (encryptedText: string, key: string): string => {
try {
return symmetricDecrypt(encryptedText, key);
} catch {
return encryptedText; // Return as-is if decryption fails (legacy format)
}
};
export const createToken = (userId: string, options = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
export const createToken = (userId: string, userEmail: string, options = {}): string => {
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
};
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
if (!NEXTAUTH_SECRET) {
if (!env.NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as {
id: string;
email: string;
};
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
if (!payload?.id || !payload?.email) {
throw new Error("Token is invalid or missing required fields");
}
// Decrypt both fields with fallback
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
let decryptedId: string;
let decryptedEmail: string;
try {
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
} catch {
decryptedId = payload.id;
}
try {
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
} catch {
decryptedEmail = payload.email;
}
return {
id: decryptedId,
@@ -67,230 +46,127 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
};
export const createEmailChangeToken = (userId: string, email: string): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
const payload = {
id: encryptedUserId,
email: encryptedEmail,
};
return jwt.sign(payload, NEXTAUTH_SECRET, {
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
expiresIn: "1d",
});
};
export const createEmailToken = (email: string): string => {
if (!NEXTAUTH_SECRET) {
if (!env.NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
};
export const getEmailFromEmailToken = (token: string): string => {
if (!NEXTAUTH_SECRET) {
if (!env.NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
try {
// Try to decrypt first (for newer tokens)
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
// If decryption fails, return the original email (for older tokens)
return payload.email;
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
};
return decryptWithFallback(payload.email, ENCRYPTION_KEY);
};
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
if (!NEXTAUTH_SECRET) {
if (!env.NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
};
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
if (!NEXTAUTH_SECRET) {
return null;
}
try {
let payload: JwtPayload & { email: string; surveyId?: string };
// Try primary method first (consistent secret)
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
try {
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
surveyId: string;
};
} catch (primaryError) {
logger.error(primaryError, "Token verification failed with primary method");
// Fallback to legacy method (surveyId-based secret)
try {
payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
};
} catch (legacyError) {
logger.error(legacyError, "Token verification failed with legacy method");
throw new Error("Invalid token");
// Try to decrypt first (for newer tokens)
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
// If decryption fails, return the original email (for older tokens)
return email;
}
// Verify the surveyId matches if present in payload (new format)
if (payload.surveyId && payload.surveyId !== surveyId) {
return null;
}
const { email } = payload;
if (!email) {
return null;
}
// Decrypt email with fallback to plain text
if (!ENCRYPTION_KEY) {
return email; // Return as-is if encryption key not set
}
return decryptWithFallback(email, ENCRYPTION_KEY);
} catch (error) {
logger.error(error, "Survey link token verification failed");
} catch (err) {
return null;
}
};
// Helper function to get user email for legacy verification
const getUserEmailForLegacyVerification = async (
token: string,
userId?: string
): Promise<{ userId: string; userEmail: string }> => {
if (!userId) {
const decoded = jwt.decode(token);
export const verifyToken = async (token: string): Promise<JwtPayload> => {
// First decode to get the ID
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
// Validate decoded token structure before using it
if (
!decoded ||
typeof decoded !== "object" ||
!decoded.id ||
typeof decoded.id !== "string" ||
decoded.id.trim() === ""
) {
logger.error("Invalid token: missing or invalid user ID");
throw new Error("Invalid token");
}
userId = decoded.id;
if (!payload) {
throw new Error("Token is invalid");
}
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
// Validate decrypted ID before database query
if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
logger.error("Invalid token: missing or invalid user ID");
throw new Error("Invalid token");
const { id } = payload;
if (!id) {
throw new Error("Token missing required field: id");
}
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
let decryptedId: string;
try {
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
} catch {
decryptedId = id;
}
// If no email provided, look up the user
const foundUser = await prisma.user.findUnique({
where: { id: decryptedId },
});
if (!foundUser) {
const errorMessage = "User not found";
logger.error(errorMessage);
throw new Error(errorMessage);
throw new Error("User not found");
}
return { userId: decryptedId, userEmail: foundUser.email };
};
const userEmail = foundUser.email;
export const verifyToken = async (token: string): Promise<JwtPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
let payload: JwtPayload & { id: string };
let userData: { userId: string; userEmail: string } | null = null;
// Try new method first, with smart fallback to legacy
try {
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
};
} catch (newMethodError) {
logger.error(newMethodError, "Token verification failed with new method");
// Get user email for legacy verification
userData = await getUserEmailForLegacyVerification(token);
// Try legacy verification with email-based secret
try {
payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
algorithms: ["HS256"],
}) as JwtPayload & {
id: string;
};
} catch (legacyMethodError) {
logger.error(legacyMethodError, "Token verification failed with legacy method");
throw new Error("Invalid token");
}
}
if (!payload?.id) {
throw new Error("Invalid token");
}
// Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
return { id: userData.userId, email: userData.userEmail };
return { id: decryptedId, email: userEmail };
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
try {
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
inviteId: string;
email: string;
};
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
const { inviteId, email } = payload;
if (!encryptedInviteId || !encryptedEmail) {
throw new Error("Invalid token");
let decryptedInviteId: string;
let decryptedEmail: string;
try {
// Try to decrypt first (for newer tokens)
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
} catch {
// If decryption fails, use original values (for older tokens)
decryptedInviteId = inviteId;
decryptedEmail = email;
}
// Decrypt both fields with fallback to original values
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
return {
inviteId: decryptedInviteId,
email: decryptedEmail,
+21 -2
View File
@@ -18,6 +18,7 @@ import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/type
import { TTag } from "@formbricks/types/tags";
import { ITEMS_PER_PAGE, WEBAPP_URL } from "../constants";
import { deleteDisplay } from "../display/service";
import { getResponseNotes } from "../responseNote/service";
import { deleteFile, putFile } from "../storage/service";
import { getSurvey } from "../survey/service";
import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion";
@@ -71,6 +72,22 @@ export const responseSelection = {
},
},
},
notes: {
select: {
id: true,
createdAt: true,
updatedAt: true,
text: true,
user: {
select: {
id: true,
name: true,
},
},
isResolved: true,
isEdited: true,
},
},
} satisfies Prisma.ResponseSelect;
export const getResponseContact = (
@@ -110,6 +127,7 @@ export const getResponsesByContactId = reactCache(
await Promise.all(
responsePrisma.map(async (response) => {
const responseNotes = await getResponseNotes(response.id);
const responseContact: TResponseContact = {
id: response.contact?.id as string,
userId: response.contact?.attributes.find((attribute) => attribute.attributeKey.key === "userId")
@@ -119,7 +137,7 @@ export const getResponsesByContactId = reactCache(
responses.push({
...response,
contact: responseContact,
notes: responseNotes,
tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
});
})
@@ -532,10 +550,11 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
select: responseSelection,
});
const responseNotes = await getResponseNotes(responsePrisma.id);
const response: TResponse = {
...responsePrisma,
contact: getResponseContact(responsePrisma),
notes: responseNotes,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -5,12 +5,16 @@ import { TDisplay } from "@formbricks/types/displays";
import { TResponse, TResponseFilterCriteria, TResponseUpdateInput } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { responseNoteSelect } from "../../../responseNote/service";
import { responseSelection } from "../../service";
import { constantsForTests } from "../constants";
type ResponseMock = Prisma.ResponseGetPayload<{
include: typeof responseSelection;
}>;
type ResponseNoteMock = Prisma.ResponseNoteGetPayload<{
include: typeof responseNoteSelect;
}>;
export const mockEnvironmentId = "ars2tjk8hsi8oqk1uac00mo7";
export const mockContactId = "lhwy39ga2zy8by1ol1bnaiso";
@@ -30,6 +34,25 @@ export const mockMeta = {
},
};
export const mockResponseNote: ResponseNoteMock = {
id: "clnndevho0mqrqp0fm2ozul8p",
createdAt: new Date(),
updatedAt: new Date(),
text: constantsForTests.text,
isEdited: constantsForTests.boolean,
isResolved: constantsForTests.boolean,
responseId: mockResponseId,
userId: mockUserId,
response: {
id: mockResponseId,
surveyId: mockSurveyId,
},
user: {
id: mockContactId,
name: constantsForTests.fullName,
},
};
export const mockContact = {
id: mockContactId,
userId: mockUserId,
@@ -71,6 +94,7 @@ export const mockResponse: ResponseMock = {
createdAt: new Date(),
finished: constantsForTests.boolean,
meta: mockMeta,
notes: [mockResponseNote],
tags: mockTags,
personId: mockContactId,
updatedAt: new Date(),
@@ -118,6 +142,7 @@ export const mockResponses: ResponseMock[] = [
},
language: null,
tags: getMockTags(["tag1", "tag3"]),
notes: [],
endingId: null,
displayId: null,
},
@@ -144,6 +169,7 @@ export const mockResponses: ResponseMock[] = [
person: null,
language: null,
tags: getMockTags(["tag1", "tag2"]),
notes: [],
},
{
id: "clsk7b15p001fk8iu04qpvo2f",
@@ -166,6 +192,7 @@ export const mockResponses: ResponseMock[] = [
personId: mockContactId,
person: null,
tags: getMockTags(["tag2", "tag3"]),
notes: [],
language: null,
},
{
@@ -189,6 +216,7 @@ export const mockResponses: ResponseMock[] = [
personId: mockContactId,
person: null,
tags: getMockTags(["tag1", "tag4"]),
notes: [],
language: null,
},
{
@@ -212,6 +240,7 @@ export const mockResponses: ResponseMock[] = [
personId: mockContactId,
person: null,
tags: getMockTags(["tag4", "tag5"]),
notes: [],
language: null,
},
];
@@ -5,6 +5,7 @@ import {
mockEnvironmentId,
mockResponse,
mockResponseData,
mockResponseNote,
mockSingleUseId,
mockSurveyId,
mockSurveySummaryOutput,
@@ -55,6 +56,7 @@ beforeEach(() => {
// mocking the person findFirst call as it is used in the transformPrismaPerson function
prisma.contact.findFirst.mockResolvedValue(mockContact);
prisma.responseNote.findMany.mockResolvedValue([mockResponseNote]);
prisma.response.findUnique.mockResolvedValue(mockResponse);
+2
View File
@@ -367,6 +367,7 @@ describe("Response Utils", () => {
finished: true,
createdAt: new Date(),
updatedAt: new Date(),
notes: [],
tags: [],
},
];
@@ -417,6 +418,7 @@ describe("Response Utils", () => {
finished: true,
createdAt: new Date(),
updatedAt: new Date(),
notes: [],
tags: [],
},
];
+1
View File
@@ -613,6 +613,7 @@ export const getResponsesJson = (
"Survey ID": response.surveyId,
"Formbricks ID (internal)": response.contact?.id || "",
"User ID": response.contact?.userId || "",
Notes: response.notes.map((note) => `${note.user.name}: ${note.text}`).join("\n"),
Tags: response.tags.map((tag) => tag.name).join(", "),
});
+159
View File
@@ -0,0 +1,159 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseNote } from "@formbricks/types/responses";
import { validateInputs } from "../utils/validate";
export const responseNoteSelect = {
id: true,
createdAt: true,
updatedAt: true,
text: true,
isEdited: true,
isResolved: true,
user: {
select: {
id: true,
name: true,
},
},
response: {
select: {
id: true,
surveyId: true,
},
},
};
export const createResponseNote = async (
responseId: string,
userId: string,
text: string
): Promise<TResponseNote> => {
validateInputs([responseId, ZId], [userId, ZId], [text, ZString]);
try {
const responseNote = await prisma.responseNote.create({
data: {
responseId: responseId,
userId: userId,
text: text,
},
select: responseNoteSelect,
});
return responseNote;
} catch (error) {
logger.error(error, "Error creating response note");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getResponseNote = reactCache(
async (responseNoteId: string): Promise<(TResponseNote & { responseId: string }) | null> => {
try {
const responseNote = await prisma.responseNote.findUnique({
where: {
id: responseNoteId,
},
select: {
...responseNoteSelect,
responseId: true,
},
});
return responseNote;
} catch (error) {
logger.error(error, "Error getting response note");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getResponseNotes = reactCache(async (responseId: string): Promise<TResponseNote[]> => {
try {
validateInputs([responseId, ZId]);
const responseNotes = await prisma.responseNote.findMany({
where: {
responseId,
},
select: responseNoteSelect,
});
if (!responseNotes) {
throw new ResourceNotFoundError("Response Notes by ResponseId", responseId);
}
return responseNotes;
} catch (error) {
logger.error(error, "Error getting response notes");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
export const updateResponseNote = async (responseNoteId: string, text: string): Promise<TResponseNote> => {
validateInputs([responseNoteId, ZString], [text, ZString]);
try {
const updatedResponseNote = await prisma.responseNote.update({
where: {
id: responseNoteId,
},
data: {
text: text,
updatedAt: new Date(),
isEdited: true,
},
select: responseNoteSelect,
});
return updatedResponseNote;
} catch (error) {
logger.error(error, "Error updating response note");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const resolveResponseNote = async (responseNoteId: string): Promise<TResponseNote> => {
validateInputs([responseNoteId, ZString]);
try {
const responseNote = await prisma.responseNote.update({
where: {
id: responseNoteId,
},
data: {
updatedAt: new Date(),
isResolved: true,
},
select: responseNoteSelect,
});
return responseNote;
} catch (error) {
logger.error(error, "Error resolving response note");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
+6
View File
@@ -278,6 +278,7 @@ describe("Response Processing", () => {
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
@@ -319,6 +320,7 @@ describe("Response Processing", () => {
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
@@ -390,6 +392,7 @@ describe("Response Processing", () => {
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
@@ -419,6 +422,7 @@ describe("Response Processing", () => {
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
@@ -449,6 +453,7 @@ describe("Response Processing", () => {
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
@@ -483,6 +488,7 @@ describe("Response Processing", () => {
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
+44
View File
@@ -0,0 +1,44 @@
// DEPRECATED
// The ShortUrl feature is deprecated and only available for backward compatibility.
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url";
import { validateInputs } from "../utils/validate";
// Get the full url from short url and return it
export const getShortUrl = reactCache(async (id: string): Promise<TShortUrl | null> => {
validateInputs([id, ZShortUrlId]);
try {
return await prisma.shortUrl.findUnique({
where: {
id,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
export const getShortUrlByUrl = reactCache(async (url: string): Promise<TShortUrl | null> => {
validateInputs([url, z.string().url()]);
try {
return await prisma.shortUrl.findUnique({
where: {
url,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
-114
View File
@@ -1,7 +1,4 @@
import { S3Client } from "@aws-sdk/client-s3";
import { readFile } from "fs/promises";
import { lookup } from "mime-types";
import path from "path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock AWS SDK
@@ -10,19 +7,6 @@ const mockS3Client = {
send: mockSend,
};
vi.mock("fs/promises", () => ({
readFile: vi.fn(),
access: vi.fn(),
mkdir: vi.fn(),
rmdir: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock("mime-types", () => ({
lookup: vi.fn(),
}));
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn(() => mockS3Client),
HeadBucketCommand: vi.fn(),
@@ -210,103 +194,5 @@ describe("Storage Service", () => {
expect(result.signedUrl).toBe("https://test-bucket.s3.test-region.amazonaws.com");
expect(result.presignedFields).toEqual({ key: "test-key", policy: "test-policy" });
});
test("use local storage for private files when S3 is not configured", async () => {
vi.resetModules();
vi.doMock("../constants", () => ({
S3_ACCESS_KEY: "test-access-key",
S3_SECRET_KEY: "test-secret-key",
S3_REGION: "test-region",
S3_BUCKET_NAME: "test-bucket",
S3_ENDPOINT_URL: "http://test-endpoint",
S3_FORCE_PATH_STYLE: true,
isS3Configured: () => false,
IS_FORMBRICKS_CLOUD: false,
MAX_SIZES: {
standard: 5 * 1024 * 1024,
big: 10 * 1024 * 1024,
},
WEBAPP_URL: "http://test-webapp",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!",
UPLOADS_DIR: "/tmp/uploads",
}));
vi.mock("../getPublicUrl", () => ({
getPublicDomain: () => "https://public-domain.com",
}));
const freshModule = await import("./service");
const freshGetUploadSignedUrl = freshModule.getUploadSignedUrl as typeof getUploadSignedUrl;
const result = await freshGetUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private");
expect(result.fileUrl).toContain("http://test-webapp");
expect(result.fileUrl).toMatch(
/http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/
);
expect(result.fileUrl).not.toContain("test-bucket");
expect(result.fileUrl).not.toContain("test-endpoint");
});
});
describe("getLocalFile", () => {
let getLocalFile: any;
beforeEach(async () => {
const serviceModule = await import("./service");
getLocalFile = serviceModule.getLocalFile;
});
test("should return file buffer and metadata", async () => {
vi.mocked(readFile).mockResolvedValue(Buffer.from("test"));
vi.mocked(lookup).mockReturnValue("image/jpeg");
const result = await getLocalFile("/tmp/uploads/test/test.jpg");
expect(result.fileBuffer).toBeInstanceOf(Buffer);
expect(result.metaData).toEqual({ contentType: "image/jpeg" });
});
test("should throw error when file does not exist", async () => {
vi.mocked(readFile).mockRejectedValue(new Error("File not found"));
await expect(getLocalFile("/tmp/uploads/test/test.jpg")).rejects.toThrow("File not found");
});
test("should throw error when file path attempts traversal outside uploads dir", async () => {
const traversalOutside = path.join("/tmp/uploads", "../outside.txt");
await expect(getLocalFile(traversalOutside)).rejects.toThrow(
"Invalid file path: Path must be within uploads folder"
);
});
test("should reject path traversal using '../secret' with security error", async () => {
await expect(getLocalFile("../secret")).rejects.toThrow(
"Invalid file path: Path must be within uploads folder"
);
});
test("should reject Windows-style traversal '..\\\\secret' with security error", async () => {
await expect(getLocalFile("..\\secret")).rejects.toThrow(
"Invalid file path: Path must be within uploads folder"
);
});
test("should reject nested traversal 'subdir/../../etc/passwd' with security error", async () => {
await expect(getLocalFile("subdir/../../etc/passwd")).rejects.toThrow(
"Invalid file path: Path must be within uploads folder"
);
});
test("should throw EISDIR when provided path is a directory inside uploads", async () => {
// Simulate Node throwing EISDIR when attempting to read a directory
const eisdirError: any = new Error("EISDIR: illegal operation on a directory, read");
eisdirError.code = "EISDIR";
vi.mocked(readFile).mockRejectedValueOnce(eisdirError);
await expect(getLocalFile("/tmp/uploads/some-dir")).rejects.toMatchObject({
code: "EISDIR",
message: expect.stringContaining("EISDIR"),
});
});
});
});
+5 -23
View File
@@ -72,28 +72,12 @@ export const testS3BucketAccess = async () => {
}
};
// Helper function to validate file paths are within the uploads directory
const validateAndResolvePath = (filePath: string): string => {
// Resolve and normalize the path to prevent directory traversal attacks
const resolvedPath = path.resolve(filePath);
const uploadsPath = path.resolve(UPLOADS_DIR);
// Ensure the resolved path is within the uploads directory
if (!resolvedPath.startsWith(uploadsPath)) {
throw new Error("Invalid file path: Path must be within uploads folder");
}
return resolvedPath;
};
const ensureDirectoryExists = async (dirPath: string) => {
const safePath = validateAndResolvePath(dirPath);
try {
await access(safePath);
await access(dirPath);
} catch (error: any) {
if (error.code === "ENOENT") {
await mkdir(safePath, { recursive: true });
await mkdir(dirPath, { recursive: true });
} else {
throw error;
}
@@ -142,7 +126,7 @@ export const getS3File = async (fileKey: string): Promise<string> => {
export const getLocalFile = async (filePath: string): Promise<TGetFileResponse> => {
try {
const safeFilePath = validateAndResolvePath(filePath);
const safeFilePath = path.resolve(process.cwd(), filePath);
const file = await readFile(safeFilePath);
let contentType = "";
@@ -279,7 +263,6 @@ export const putFileToLocalStorage = async (
await ensureDirectoryExists(`${rootDir}/${environmentId}/${accessType}`);
const uploadPath = `${rootDir}/${environmentId}/${accessType}/${fileName}`;
const safeUploadPath = validateAndResolvePath(uploadPath);
const buffer = Buffer.from(fileBuffer as unknown as WithImplicitCoercion<string>);
const bufferBytes = buffer.byteLength;
@@ -297,7 +280,7 @@ export const putFileToLocalStorage = async (
throw err;
}
await writeFile(safeUploadPath, buffer as unknown as any);
await writeFile(uploadPath, buffer as unknown as any);
} catch (err) {
throw err;
}
@@ -363,8 +346,7 @@ export const deleteFile = async (
export const deleteLocalFile = async (filePath: string) => {
try {
const safeFilePath = validateAndResolvePath(filePath);
await unlink(safeFilePath);
await unlink(filePath);
} catch (err: any) {
throw err;
}

Some files were not shown because too many files have changed in this diff Show More