fix: allows local ip images (#7189)

Co-authored-by: pandeymangg <pandeyman@Anshumans-MacBook-Air.local>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
This commit is contained in:
Anshuman Pandey
2026-02-10 20:29:27 +04:00
committed by GitHub
parent 04c2b030f1
commit ff10ca7d6a
43 changed files with 1071 additions and 273 deletions
@@ -0,0 +1,356 @@
/**
* Data Migration: Convert absolute storage URLs to relative paths
*
* This migration converts URLs like:
* http://localhost:3000/storage/env123/public/image.png
* https://app.formbricks.com/storage/env123/public/image.png
*
* To relative paths:
* /storage/env123/public/image.png
*
* This is needed because:
* 1. Next.js 16+ blocks image optimization for private IPs
* 2. Relative paths work with the new streaming endpoint
* 3. Self-hosted users can change their domain without breaking images
*
* Tables affected:
* - Survey: welcomeCard, questions, blocks, endings, styling, metadata
* - Project: styling, logo
* - Organization: whitelabel
* - Response: data (file upload responses)
*/
import { Prisma } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
import type {
MigrationStats,
OrganizationRecord,
ProjectRecord,
ResponseRecord,
SurveyRecord,
} from "./types";
import {
containsAbsoluteStorageUrl,
getUrlConversionCount,
resetUrlConversionCount,
transformJsonUrls,
} from "./utils";
const BATCH_SIZE = 500;
export const migrateStorageUrlsToRelative: MigrationScript = {
type: "data",
id: "cm6xq8k2n0001l508storage01",
name: "20260204174943_migrate_storage_urls_to_relative",
run: async ({ tx }) => {
const stats: MigrationStats = {
surveysProcessed: 0,
surveysUpdated: 0,
projectsProcessed: 0,
projectsUpdated: 0,
organizationsProcessed: 0,
organizationsUpdated: 0,
responsesProcessed: 0,
responsesUpdated: 0,
urlsConverted: 0,
errors: 0,
};
resetUrlConversionCount();
// ==================== MIGRATE SURVEYS ====================
logger.info("Starting Survey migration...");
// Use '%http%/storage/%' to match absolute URLs anywhere in the JSON text
// This won't match already-migrated relative paths like /storage/... (no 'http' before it)
const surveyQuery = Prisma.sql`
SELECT id, "welcomeCard", questions, blocks, endings, styling, metadata
FROM "Survey"
WHERE "welcomeCard"::text LIKE '%http%/storage/%'
OR questions::text LIKE '%http%/storage/%'
OR blocks::text LIKE '%http%/storage/%'
OR endings::text LIKE '%http%/storage/%'
OR styling::text LIKE '%http%/storage/%'
OR metadata::text LIKE '%http%/storage/%'
`;
const surveysToMigrate: SurveyRecord[] = await tx.$queryRaw(surveyQuery);
logger.info(`Found ${surveysToMigrate.length} surveys with storage URLs`);
const surveyUpdates: { id: string; data: Partial<SurveyRecord> }[] = [];
for (const survey of surveysToMigrate) {
stats.surveysProcessed++;
const updates: Partial<SurveyRecord> = {};
let hasChanges = false;
// Transform each JSON column if it contains absolute storage URLs
if (containsAbsoluteStorageUrl(survey.welcomeCard)) {
updates.welcomeCard = transformJsonUrls(JSON.parse(JSON.stringify(survey.welcomeCard)));
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.questions)) {
updates.questions = transformJsonUrls(JSON.parse(JSON.stringify(survey.questions)));
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.blocks)) {
updates.blocks = transformJsonUrls(JSON.parse(JSON.stringify(survey.blocks))) as unknown[];
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.endings)) {
updates.endings = transformJsonUrls(JSON.parse(JSON.stringify(survey.endings))) as unknown[];
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.styling)) {
updates.styling = transformJsonUrls(JSON.parse(JSON.stringify(survey.styling)));
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.metadata)) {
updates.metadata = transformJsonUrls(JSON.parse(JSON.stringify(survey.metadata)));
hasChanges = true;
}
if (hasChanges) {
surveyUpdates.push({ id: survey.id, data: updates });
stats.surveysUpdated++;
}
}
// Batch update surveys
for (let i = 0; i < surveyUpdates.length; i += BATCH_SIZE) {
const batch = surveyUpdates.slice(i, i + BATCH_SIZE);
for (const update of batch) {
const setClauses: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (update.data.welcomeCard !== undefined) {
setClauses.push(`"welcomeCard" = $${paramIndex}::jsonb`);
values.push(JSON.stringify(update.data.welcomeCard));
paramIndex++;
}
if (update.data.questions !== undefined) {
setClauses.push(`questions = $${paramIndex}::jsonb`);
values.push(JSON.stringify(update.data.questions));
paramIndex++;
}
if (update.data.blocks !== undefined) {
setClauses.push(
`blocks = (SELECT array_agg(elem) FROM jsonb_array_elements($${paramIndex}::jsonb) AS elem)`
);
values.push(JSON.stringify(update.data.blocks));
paramIndex++;
}
if (update.data.endings !== undefined) {
setClauses.push(
`endings = (SELECT array_agg(elem) FROM jsonb_array_elements($${paramIndex}::jsonb) AS elem)`
);
values.push(JSON.stringify(update.data.endings));
paramIndex++;
}
if (update.data.styling !== undefined) {
setClauses.push(`styling = $${paramIndex}::jsonb`);
values.push(JSON.stringify(update.data.styling));
paramIndex++;
}
if (update.data.metadata !== undefined) {
setClauses.push(`metadata = $${paramIndex}::jsonb`);
values.push(JSON.stringify(update.data.metadata));
paramIndex++;
}
values.push(update.id);
if (setClauses.length > 0) {
await tx.$executeRawUnsafe(
`UPDATE "Survey" SET ${setClauses.join(", ")}, updated_at = NOW() WHERE id = $${paramIndex}`,
...values
);
}
}
logger.info(
`Survey progress: ${Math.min(i + BATCH_SIZE, surveyUpdates.length)}/${surveyUpdates.length}`
);
}
logger.info(`Surveys migration complete: ${stats.surveysUpdated}/${stats.surveysProcessed} updated`);
// ==================== MIGRATE PROJECTS ====================
logger.info("Starting Project migration...");
// Use '%http%/storage/%' to match absolute URLs anywhere in the JSON text
const projectQuery = Prisma.sql`
SELECT id, styling, logo
FROM "Project"
WHERE styling::text LIKE '%http%/storage/%'
OR logo::text LIKE '%http%/storage/%'
`;
const projectsToMigrate: ProjectRecord[] = await tx.$queryRaw(projectQuery);
logger.info(`Found ${projectsToMigrate.length} projects with storage URLs`);
for (const project of projectsToMigrate) {
stats.projectsProcessed++;
const updates: Partial<ProjectRecord> = {};
let hasChanges = false;
if (containsAbsoluteStorageUrl(project.styling)) {
updates.styling = transformJsonUrls(JSON.parse(JSON.stringify(project.styling)));
hasChanges = true;
}
if (containsAbsoluteStorageUrl(project.logo)) {
updates.logo = transformJsonUrls(JSON.parse(JSON.stringify(project.logo)));
hasChanges = true;
}
if (hasChanges) {
const setClauses: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (updates.styling !== undefined) {
setClauses.push(`styling = $${paramIndex}::jsonb`);
values.push(JSON.stringify(updates.styling));
paramIndex++;
}
if (updates.logo !== undefined) {
setClauses.push(`logo = $${paramIndex}::jsonb`);
values.push(JSON.stringify(updates.logo));
paramIndex++;
}
values.push(project.id);
await tx.$executeRawUnsafe(
`UPDATE "Project" SET ${setClauses.join(", ")}, updated_at = NOW() WHERE id = $${paramIndex}`,
...values
);
stats.projectsUpdated++;
}
}
logger.info(`Projects migration complete: ${stats.projectsUpdated}/${stats.projectsProcessed} updated`);
// ==================== MIGRATE ORGANIZATIONS ====================
logger.info("Starting Organization migration...");
// Use '%http%/storage/%' to match absolute URLs anywhere in the JSON text
const orgQuery = Prisma.sql`
SELECT id, whitelabel
FROM "Organization"
WHERE whitelabel::text LIKE '%http%/storage/%'
`;
const orgsToMigrate: OrganizationRecord[] = await tx.$queryRaw(orgQuery);
logger.info(`Found ${orgsToMigrate.length} organizations with storage URLs`);
for (const org of orgsToMigrate) {
stats.organizationsProcessed++;
if (containsAbsoluteStorageUrl(org.whitelabel)) {
const updatedWhitelabel = transformJsonUrls(JSON.parse(JSON.stringify(org.whitelabel)));
await tx.$executeRawUnsafe(
`UPDATE "Organization" SET whitelabel = $1::jsonb, updated_at = NOW() WHERE id = $2`,
JSON.stringify(updatedWhitelabel),
org.id
);
stats.organizationsUpdated++;
}
}
logger.info(
`Organizations migration complete: ${stats.organizationsUpdated}/${stats.organizationsProcessed} updated`
);
// ==================== MIGRATE RESPONSES ====================
logger.info("Starting Response migration...");
// Responses can be numerous, so we process in batches using cursor pagination
let lastId: string | null = null;
let hasMore = true;
while (hasMore) {
// Use '%http%/storage/%' to match absolute URLs anywhere in the JSON text
const responseQuery = lastId
? Prisma.sql`
SELECT id, data
FROM "Response"
WHERE data::text LIKE '%http%/storage/%'
AND id > ${lastId}
ORDER BY id
LIMIT ${BATCH_SIZE}
`
: Prisma.sql`
SELECT id, data
FROM "Response"
WHERE data::text LIKE '%http%/storage/%'
ORDER BY id
LIMIT ${BATCH_SIZE}
`;
const responseBatch: ResponseRecord[] = await tx.$queryRaw(responseQuery);
if (responseBatch.length === 0) {
hasMore = false;
break;
}
for (const response of responseBatch) {
stats.responsesProcessed++;
if (containsAbsoluteStorageUrl(response.data)) {
const updatedData = transformJsonUrls(JSON.parse(JSON.stringify(response.data)));
await tx.$executeRawUnsafe(
`UPDATE "Response" SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
JSON.stringify(updatedData),
response.id
);
stats.responsesUpdated++;
}
lastId = response.id;
}
logger.info(
`Response progress: ${stats.responsesProcessed} processed, ${stats.responsesUpdated} updated`
);
if (responseBatch.length < BATCH_SIZE) {
hasMore = false;
}
}
logger.info(
`Responses migration complete: ${stats.responsesUpdated}/${stats.responsesProcessed} updated`
);
// ==================== FINAL STATS ====================
stats.urlsConverted = getUrlConversionCount();
logger.info("=== Migration Complete ===");
logger.info(`Surveys: ${stats.surveysUpdated}/${stats.surveysProcessed} updated`);
logger.info(`Projects: ${stats.projectsUpdated}/${stats.projectsProcessed} updated`);
logger.info(`Organizations: ${stats.organizationsUpdated}/${stats.organizationsProcessed} updated`);
logger.info(`Responses: ${stats.responsesUpdated}/${stats.responsesProcessed} updated`);
logger.info(`Total URLs converted: ${stats.urlsConverted}`);
if (stats.errors > 0) {
logger.warn(`Errors encountered: ${stats.errors}`);
}
},
};
@@ -0,0 +1,42 @@
/**
* Types for the storage URL migration
*/
export interface SurveyRecord {
id: string;
welcomeCard: unknown;
questions: unknown;
blocks: unknown[];
endings: unknown[];
styling: unknown;
metadata: unknown;
}
export interface ProjectRecord {
id: string;
styling: unknown;
logo: unknown;
}
export interface OrganizationRecord {
id: string;
whitelabel: unknown;
}
export interface ResponseRecord {
id: string;
data: unknown;
}
export interface MigrationStats {
surveysProcessed: number;
surveysUpdated: number;
projectsProcessed: number;
projectsUpdated: number;
organizationsProcessed: number;
organizationsUpdated: number;
responsesProcessed: number;
responsesUpdated: number;
urlsConverted: number;
errors: number;
}
@@ -0,0 +1,98 @@
/**
* Utility functions for converting absolute storage URLs to relative paths
*/
// Regex to match absolute storage URLs: http(s)://anything/storage/...
const ABSOLUTE_STORAGE_URL_REGEX = /^https?:\/\/[^/]+\/storage\//;
/**
* Convert an absolute storage URL to a relative path
* @param url The URL to convert
* @returns The relative path if it's an absolute storage URL, otherwise the original value
*/
export function convertStorageUrlToRelative(url: string): string {
if (!url || typeof url !== "string") {
return url;
}
// Check if it's an absolute storage URL
if (ABSOLUTE_STORAGE_URL_REGEX.test(url)) {
const storageIndex = url.indexOf("/storage/");
if (storageIndex !== -1) {
return url.substring(storageIndex); // Returns /storage/...
}
}
return url; // Return unchanged if not an absolute storage URL
}
/**
* Track statistics for URL conversions
*/
let urlConversionCount = 0;
export function resetUrlConversionCount(): void {
urlConversionCount = 0;
}
export function getUrlConversionCount(): number {
return urlConversionCount;
}
/**
* Recursively transform all string values in an object, converting absolute storage URLs to relative paths
* @param obj The object to transform
* @returns The transformed object (mutated in place for arrays/objects)
*/
export function transformJsonUrls(obj: unknown): unknown {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj === "string") {
const converted = convertStorageUrlToRelative(obj);
if (converted !== obj) {
urlConversionCount++;
}
return converted;
}
if (Array.isArray(obj)) {
return obj.map((item) => transformJsonUrls(item));
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = transformJsonUrls(value);
}
return result;
}
return obj;
}
/**
* Check if an object contains any absolute storage URLs
* @param obj The object to check
* @returns true if the object contains absolute storage URLs
*/
export function containsAbsoluteStorageUrl(obj: unknown): boolean {
if (obj === null || obj === undefined) {
return false;
}
if (typeof obj === "string") {
return ABSOLUTE_STORAGE_URL_REGEX.test(obj);
}
if (Array.isArray(obj)) {
return obj.some((item) => containsAbsoluteStorageUrl(item));
}
if (typeof obj === "object") {
return Object.values(obj as Record<string, unknown>).some((value) => containsAbsoluteStorageUrl(value));
}
return false;
}