feat: Initial release of Formbricks Vanity Server

This commit is contained in:
Marco Gallegos
2025-12-13 13:08:31 -06:00
commit cae1a4647b
24 changed files with 3081 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.env
.git
.gitignore
README.md
DEVELOPMENT_PLAN.md

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
PORT=3011
FORMBRICKS_ENV_ID=your_environment_id_here
FORMBRICKS_SDK_URL=https://your-formbricks-instance.com
FORMBRICKS_API_KEY=your_api_key_here
BASE_DOMAIN=https://your-vanity-server-domain.com
ADMIN_API_TOKEN=your_admin_token_here
SQLITE_DB_PATH=

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Dependencies
/node_modules
# Environment variables
.env
# Logs
server.log
data/*.db

179
COOLIFY.md Normal file
View File

@@ -0,0 +1,179 @@
# Formbricks Vanity Server - Coolify Deployment Guide
## Configuración para Coolify en VPS
### Información del Deployment
- **Dominio**: `your-vanity-server.com` (configurable)
- **Formbricks Instance**: `your-formbricks-instance.com` (configurable)
- **Puerto**: 3011
## Pasos para Desplegar en Coolify
### 1. Publicar Imagen en Docker Hub
```bash
# Login a Docker Hub
docker login
# Construir la imagen
docker build -t your-dockerhub-username/formbricks-vanity-server:latest .
# Publicar a Docker Hub
docker push your-dockerhub-username/formbricks-vanity-server:latest
```
### 2. Configurar en Coolify
1. **Crear Nuevo Recurso**
- Ve a tu proyecto en Coolify
- Click en "Add New Resource"
- Selecciona "Docker Compose"
2. **Configurar Docker Compose**
- Pega el contenido del archivo `docker-compose.yml`
- O usa la imagen directamente desde Docker Hub
3. **Variables de Entorno**
En Coolify, configura estas variables de entorno:
```
FORMBRICKS_API_KEY=fbk_6QpdF1eC0E9umr9HjWUBaTxO_ispeHZYd-dI_EK9m2Q
ADMIN_API_TOKEN=9HiRr6K0Hfp2I4RgoLLsXr
FORMBRICKS_ENV_ID=cmbgr9ipo000ls201jpy12fbi,cmbgr9ipk000gs201rcukyfr7
```
> ⚠️ **Importante**: No incluyas `FORMBRICKS_SDK_URL` ni `BASE_DOMAIN` en las variables de entorno de Coolify, ya que están hardcodeadas en el docker-compose.yml
4. **Configurar Dominio**
- En Coolify, ve a "Domains"
- Agrega: `forms.soul23.cloud`
- Coolify configurará automáticamente SSL con Let's Encrypt
5. **Configurar Red**
- Asegúrate de que el servicio esté en la red `coolify`
- Esto ya está configurado en el `docker-compose.yml`
6. **Volumen para Persistencia**
- El volumen `formbricks_data` se crea automáticamente
- Los datos de SQLite se guardarán en `/app/data`
### 3. Desplegar
1. Click en "Deploy" en Coolify
2. Espera a que la imagen se descargue y el contenedor inicie
3. Verifica los logs en Coolify
### 4. Verificar Deployment
Una vez desplegado, verifica:
- **Admin UI**: `https://forms.soul23.cloud/admin`
- **Ejemplo de encuesta**: `https://forms.soul23.cloud/socias/Contratos`
- Debe redirigir a: `https://feedback.soul23.cloud/s/k40zfrs2r62ifbgavpumemlc`
## Configuración Inicial
### 1. Acceder al Admin UI
```
URL: https://forms.soul23.cloud/admin
Token: 9HiRr6K0Hfp2I4RgoLLsXr
```
### 2. Configurar Aliases
1. Ve al Admin UI
2. Configura los aliases para tus proyectos:
- `socias` → Environment `cmbgr9ipo000ls201jpy12fbi`
- `vanity` → Environment `cmbgr6u7s0009s201i45xtbtv`
### 3. Usar las Encuestas
Tus encuestas estarán disponibles en:
- `https://forms.soul23.cloud/{alias}/{nombre-encuesta}`
Ejemplos:
- `https://forms.soul23.cloud/socias/Contratos` (redirige a Formbricks)
- `https://forms.soul23.cloud/vanity/test` (embebida)
## Actualizar la Aplicación
Para actualizar a una nueva versión:
```bash
# 1. Construir nueva imagen
docker build -t your-dockerhub-username/formbricks-vanity-server:v1.1.0 .
# 2. Publicar
docker push your-dockerhub-username/formbricks-vanity-server:v1.1.0
# 3. En Coolify, actualiza la imagen en docker-compose.yml
# 4. Click en "Redeploy"
```
## Troubleshooting
### Ver Logs en Coolify
1. Ve a tu servicio en Coolify
2. Click en "Logs"
3. Verifica que el servidor inicie correctamente
### Problemas Comunes
**Error de conexión a Formbricks**
- Verifica que `FORMBRICKS_API_KEY` sea correcta
- Verifica que `feedback.soul23.cloud` sea accesible desde el VPS
**Base de datos no persiste**
- Verifica que el volumen `formbricks_data` esté montado
- En Coolify, ve a "Volumes" y verifica que exista
**SSL no funciona**
- Coolify maneja SSL automáticamente
- Verifica que el dominio `forms.soul23.cloud` apunte a tu VPS
- Espera unos minutos para que Let's Encrypt emita el certificado
## Backup de la Base de Datos
Para hacer backup de la base de datos SQLite:
```bash
# Conectarse al contenedor
docker exec -it formbricks-vanity sh
# Copiar la base de datos
cp /app/data/survey_mappings.db /tmp/backup.db
# Salir del contenedor
exit
# Copiar desde el contenedor al host
docker cp formbricks-vanity:/tmp/backup.db ./backup-$(date +%Y%m%d).db
```
## Monitoreo
Coolify proporciona métricas automáticas. Puedes ver:
- CPU usage
- Memory usage
- Network traffic
- Container status
## Recursos Adicionales
- **Documentación de Coolify**: https://coolify.io/docs
- **Docker Hub**: Publica tu imagen para fácil deployment
- **Health Checks**: Coolify monitorea automáticamente la salud del contenedor

138
DOCKER.md Normal file
View File

@@ -0,0 +1,138 @@
# Formbricks Vanity Server - Docker Deployment Guide
## Quick Start
### 1. Build the Docker Image
```bash
docker build -t your-dockerhub-username/formbricks-vanity-server:latest .
```
### 2. Run the Container
```bash
docker run -d \
-p 3011:3011 \
-e FORMBRICKS_SDK_URL=https://your-formbricks-instance.com \
-e FORMBRICKS_API_KEY=your_api_key_here \
-e ADMIN_API_TOKEN=your_admin_token_here \
-v $(pwd)/data:/app/data \
--name formbricks-vanity \
your-dockerhub-username/formbricks-vanity-server:latest
```
### 3. Using Docker Compose (Recommended)
Create a `docker-compose.yml` file:
```yaml
version: "3.8"
services:
formbricks-vanity:
image: your-dockerhub-username/formbricks-vanity-server:latest
container_name: formbricks-vanity
ports:
- "3011:3011"
environment:
- PORT=3011
- FORMBRICKS_SDK_URL=https://your-formbricks-instance.com
- FORMBRICKS_API_KEY=your_api_key_here
- ADMIN_API_TOKEN=your_admin_token_here
- FORMBRICKS_ENV_ID=your_environment_id
- BASE_DOMAIN=https://your-formbricks-instance.com
volumes:
- ./data:/app/data
restart: unless-stopped
```
Then run:
```bash
docker-compose up -d
```
## Environment Variables
| Variable | Required | Description |
| -------------------- | -------- | ----------------------------------------------------- |
| `PORT` | No | Server port (default: 3011) |
| `FORMBRICKS_SDK_URL` | Yes | Your Formbricks instance URL |
| `FORMBRICKS_API_KEY` | Yes | Formbricks API key |
| `ADMIN_API_TOKEN` | Yes | Token for admin UI access |
| `FORMBRICKS_ENV_ID` | No | Environment ID (optional, for backward compatibility) |
| `BASE_DOMAIN` | No | Base domain for the application |
| `SQLITE_DB_PATH` | No | Custom SQLite database path |
## Publishing to Docker Hub
### 1. Login to Docker Hub
```bash
docker login
```
### 2. Tag Your Image
```bash
docker tag formbricks-vanity-server:latest your-dockerhub-username/formbricks-vanity-server:latest
docker tag formbricks-vanity-server:latest your-dockerhub-username/formbricks-vanity-server:v1.0.0
```
### 3. Push to Docker Hub
```bash
docker push your-dockerhub-username/formbricks-vanity-server:latest
docker push your-dockerhub-username/formbricks-vanity-server:v1.0.0
```
## Data Persistence
The SQLite database is stored in `/app/data` inside the container. Make sure to mount a volume to persist data:
```bash
-v $(pwd)/data:/app/data
```
## Accessing the Application
- **Surveys**: `http://localhost:3011/{alias}/{survey_name}`
- **Admin UI**: `http://localhost:3011/admin`
## Health Check
Add a health check to your docker-compose.yml:
```yaml
healthcheck:
test:
[
"CMD",
"wget",
"--quiet",
"--tries=1",
"--spider",
"http://localhost:3011/admin",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
## Troubleshooting
### Container won't start
- Check logs: `docker logs formbricks-vanity`
- Verify environment variables are set correctly
- Ensure the data directory has proper permissions
### Database issues
- The database is created automatically on first run
- If you need to reset, stop the container and delete the `data` directory
### Port conflicts
- Change the host port mapping: `-p 8080:3011` (maps host port 8080 to container port 3011)

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Use an official Node.js runtime as a parent image
FROM node:20-alpine
# Set the working directory in the container
WORKDIR /app
# Copy package.json and package-lock.json to leverage Docker cache
COPY package*.json ./
# Install app dependencies
RUN npm install --only=production
# Bundle app source
COPY . .
# Your app binds to port 3011
EXPOSE 3011
# Define the command to run your app
CMD ["node", "src/server.js"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Marco
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

189
README.md Normal file
View File

@@ -0,0 +1,189 @@
<div align="center">
![Formbricks Vanity Server](./assets/banner.png)
# Formbricks Vanity Server
**Servidor de URLs personalizadas para encuestas de Formbricks con gestión inteligente de redirecciones**
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/)
[![Coolify](https://img.shields.io/badge/Coolify-Compatible-6C47FF?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMiAxMkwxMiAyMkwyMiAxMkwxMiAyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+)](https://coolify.io/)
[![Node.js](https://img.shields.io/badge/Node.js-20-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[Características](#características) •
[Instalación](#instalación-rápida) •
[Deployment](#deployment) •
[Documentación](#documentación)
</div>
---
## 🎯 Características
- **🔗 URLs Personalizadas**: Crea URLs amigables para tus encuestas de Formbricks
- **🎨 Admin UI**: Interfaz visual para gestionar aliases de proyectos
- **🔀 Redirección Inteligente**:
- Encuestas tipo "link" → Redirigen automáticamente a Formbricks
- Encuestas tipo "app" → Se embeben en tu servidor
- **🏢 Multi-Proyecto**: Soporte para múltiples ambientes de Formbricks
- **🐳 Docker Ready**: Listo para desplegar en Coolify, Docker, o cualquier plataforma de contenedores
- **💾 Persistencia**: Base de datos SQLite con sincronización automática desde Formbricks API
## 🚀 Instalación Rápida
### Opción 1: Docker (Recomendado)
```bash
docker run -d \
-p 3011:3011 \
-e FORMBRICKS_SDK_URL=https://your-formbricks-instance.com \
-e FORMBRICKS_API_KEY=your_api_key \
-e ADMIN_API_TOKEN=your_admin_token \
-e BASE_DOMAIN=https://your-vanity-server.com \
-v ./data:/app/data \
--name formbricks-vanity \
marcogll/soul23_form_mgr:latest
```
### Opción 2: Desarrollo Local
```bash
# Clonar repositorio
git clone https://github.com/your-username/formbricks-vanity-server.git
cd formbricks-vanity-server
# Instalar dependencias
npm install
# Configurar variables de entorno
cp .env.example .env
# Editar .env con tus credenciales
# Iniciar servidor
npm start
```
## 📦 Deployment
### Coolify (VPS)
Deployment en un solo click con Coolify. Ver [COOLIFY.md](./COOLIFY.md) para instrucciones detalladas.
**Configuración:**
- **Dominio**: `forms.soul23.cloud`
- **Puerto**: 3011
- **SSL**: Automático con Let's Encrypt
### Docker Hub
```bash
# Construir imagen
docker build -t your-username/formbricks-vanity-server:latest .
# Publicar a Docker Hub
docker push your-username/formbricks-vanity-server:latest
```
## 🎮 Uso
### 1. Configurar Admin UI
Accede a `https://your-vanity-server.com/admin` (o `http://localhost:3011/admin` en local)
1. Ingresa tu Admin Token
2. Configura aliases para tus proyectos:
- `socias` → Environment de Formbricks
- `vanity` → Otro environment
### 2. Acceder a Encuestas
Tus encuestas estarán disponibles en:
```
https://your-vanity-server.com/{alias}/{nombre-encuesta}
```
**Ejemplos:**
- `https://your-vanity-server.com/socias/Contratos` → Redirige a Formbricks
- `https://your-vanity-server.com/vanity/test` → Embebida en el servidor
## ⚙️ Configuración
### Variables de Entorno
| Variable | Requerida | Descripción |
| -------------------- | --------- | ----------------------------------- |
| `FORMBRICKS_SDK_URL` | ✅ | URL de tu instancia de Formbricks |
| `FORMBRICKS_API_KEY` | ✅ | API Key de Formbricks |
| `ADMIN_API_TOKEN` | ✅ | Token para acceder al Admin UI |
| `PORT` | ❌ | Puerto del servidor (default: 3011) |
| `FORMBRICKS_ENV_ID` | ❌ | ID de ambiente (opcional) |
Ver [.env.example](./.env.example) para más detalles.
## 📚 Documentación
- **[COOLIFY.md](./COOLIFY.md)** - Guía completa de deployment en Coolify
- **[DOCKER.md](./DOCKER.md)** - Guía general de Docker y Docker Compose
- **[.env.example](./.env.example)** - Plantilla de variables de entorno
## 🛠️ Tecnologías
<div align="center">
| Tecnología | Uso |
| ---------------------------------------------------------------------------------------- | ---------------- |
| ![Node.js](https://img.shields.io/badge/Node.js-20-339933?logo=node.js&logoColor=white) | Runtime |
| ![Express](https://img.shields.io/badge/Express-4.x-000000?logo=express&logoColor=white) | Framework Web |
| ![SQLite](https://img.shields.io/badge/SQLite-3-003B57?logo=sqlite&logoColor=white) | Base de Datos |
| ![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white) | Containerización |
</div>
## 🏗️ Arquitectura
```
┌─────────────────┐
│ forms.soul23 │ ← URLs Personalizadas
│ .cloud │
└────────┬────────┘
├─ /socias/Contratos ──→ 302 Redirect ──→ feedback.soul23.cloud/s/{id}
│ (Encuestas tipo "link")
└─ /vanity/test ──→ Embedded Survey
(Encuestas tipo "app")
```
## 🤝 Contribuir
Las contribuciones son bienvenidas. Por favor:
1. Fork el proyecto
2. Crea una rama para tu feature (`git checkout -b feature/AmazingFeature`)
3. Commit tus cambios (`git commit -m 'Add some AmazingFeature'`)
4. Push a la rama (`git push origin feature/AmazingFeature`)
5. Abre un Pull Request
## 📝 Licencia
Este proyecto está bajo la Licencia MIT. Ver [LICENSE](LICENSE) para más detalles.
## 🙏 Agradecimientos
- [Formbricks](https://formbricks.com/) - Plataforma de encuestas open-source
- [Coolify](https://coolify.io/) - Plataforma de deployment self-hosted
---
<div align="center">
**Hecho con ❤️ para la comunidad de Formbricks**
[⬆ Volver arriba](#formbricks-vanity-server)
</div>

BIN
assets/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

BIN
assets/banner_link.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
data/survey_mappings.db-shm Normal file

Binary file not shown.

BIN
data/survey_mappings.db-wal Normal file

Binary file not shown.

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
version: "3.8"
services:
formbricks-vanity:
image: your-dockerhub-username/formbricks-vanity-server:latest
container_name: formbricks-vanity
ports:
- "3011:3011"
environment:
- PORT=3011
- FORMBRICKS_SDK_URL=${FORMBRICKS_SDK_URL}
- FORMBRICKS_API_KEY=${FORMBRICKS_API_KEY}
- ADMIN_API_TOKEN=${ADMIN_API_TOKEN}
- FORMBRICKS_ENV_ID=${FORMBRICKS_ENV_ID}
- BASE_DOMAIN=${BASE_DOMAIN}
- SQLITE_DB_PATH=
volumes:
- formbricks_data:/app/data
restart: unless-stopped
networks:
- coolify
volumes:
formbricks_data:
driver: local
networks:
coolify:
external: true

1469
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"axios": "^1.13.2",
"better-sqlite3": "^8.7.0",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
"express": "^5.2.1"
}
}

42
src/db.js Normal file
View File

@@ -0,0 +1,42 @@
const fs = require("fs");
const path = require("path");
const Database = require("better-sqlite3");
const defaultDbPath = path.join(__dirname, "..", "data", "survey_mappings.db");
const dbPath = process.env.SQLITE_DB_PATH || defaultDbPath;
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS survey_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL,
partner TEXT NOT NULL,
survey_name TEXT NOT NULL,
survey_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project, partner, survey_name)
);
CREATE TABLE IF NOT EXISTS environment_aliases (
environment_id TEXT PRIMARY KEY,
alias TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS surveys (
id TEXT PRIMARY KEY,
environment_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'link',
custom_slug TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
module.exports = db;

View File

@@ -0,0 +1,15 @@
function ensureAdminToken(req, res, next) {
const expectedToken = process.env.ADMIN_API_TOKEN;
if (!expectedToken) {
return res.status(503).json({ error: 'Admin token is not configured.' });
}
const providedToken = req.header('x-admin-token');
if (providedToken !== expectedToken) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
}
module.exports = ensureAdminToken;

64
src/routes/admin.js Normal file
View File

@@ -0,0 +1,64 @@
const express = require("express");
const router = express.Router();
const ensureAdminToken = require("../middleware/adminAuth");
const {
getAllEnvironments,
updateEnvironmentAlias,
getSurveysByEnvironment,
updateSurveySlug,
} = require("../services/formbricks");
router.use(ensureAdminToken);
router.get("/environments", (req, res) => {
try {
const environments = getAllEnvironments();
res.json({ environments });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.put("/environments/:id/alias", (req, res) => {
const { id } = req.params;
const { alias } = req.body;
if (!alias) {
return res.status(400).json({ error: "Alias is required" });
}
try {
updateEnvironmentAlias(id, alias);
res.json({ status: "ok" });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
router.get("/environments/:id/surveys", (req, res) => {
const { id } = req.params;
try {
const surveys = getSurveysByEnvironment(id);
res.json({ surveys });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.put("/surveys/:id/slug", (req, res) => {
const { id } = req.params;
const { slug } = req.body;
try {
const success = updateSurveySlug(id, slug);
if (success) {
res.json({ status: "ok" });
} else {
res.status(404).json({ error: "Survey not found" });
}
} catch (error) {
res.status(400).json({ error: error.message });
}
});
module.exports = router;

36
src/routes/surveys.js Normal file
View File

@@ -0,0 +1,36 @@
const express = require("express");
const router = express.Router();
const { getSurveyIdByAlias } = require("../services/formbricks");
router.get("/:root/:survey", (req, res) => {
const { root, survey } = req.params;
console.log(`Requesting survey: root=${root}, survey=${survey}`);
const result = getSurveyIdByAlias(root, survey);
if (result) {
const { surveyId, environmentId, type } = result;
console.log(
`Found surveyId: ${surveyId}, environmentId: ${environmentId}, type: ${type}`
);
// Redirect link surveys to Formbricks
if (type === "link") {
const redirectUrl = `${process.env.FORMBRICKS_SDK_URL}/s/${surveyId}`;
console.log(`Redirecting to: ${redirectUrl}`);
return res.redirect(redirectUrl);
}
// Embed app surveys
res.render("survey", {
title: `${root} - ${survey}`,
surveyId: surveyId,
formbricksSdkUrl: process.env.FORMBRICKS_SDK_URL,
formbricksEnvId: environmentId,
});
} else {
console.log("Survey not found");
res.status(404).send("Survey not found");
}
});
module.exports = router;

65
src/server.js Normal file
View File

@@ -0,0 +1,65 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const surveyRoutes = require('./routes/surveys');
const adminRoutes = require('./routes/admin');
const { refreshSurveyCache } = require('./services/formbricks');
const app = express();
const PORT = process.env.PORT || 3011;
// Template Engine Setup
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Root landing page
app.get('/', (req, res) => {
res.render('index', {
title: 'Formbricks Survey Portal'
});
});
// Admin UI
app.get('/admin', (req, res) => {
res.render('admin', {
title: 'Admin - Formbricks Vanity'
});
});
// Admin API routes
app.use('/api/mappings', adminRoutes);
// Main survey routes (catch-all for vanity URLs)
app.use('/', surveyRoutes);
// Handle 404 for any other route
app.use((req, res, next) => {
res.status(404).send('Sorry, that page does not exist.');
});
// Global error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
// Initialize the survey cache at startup
refreshSurveyCache()
.then(() => {
app.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}`);
});
})
.catch(error => {
console.error('Failed to initialize Formbricks survey cache. Please check API key and connection.', error);
process.exit(1); // Exit if we can't load the initial surveys
});

174
src/services/formbricks.js Normal file
View File

@@ -0,0 +1,174 @@
const axios = require("axios");
const db = require("../db");
// --- Prepared Statements ---
const upsertEnvironmentStmt = db.prepare(`
INSERT INTO environment_aliases (environment_id)
VALUES (?)
ON CONFLICT(environment_id) DO NOTHING
`);
const upsertSurveyStmt = db.prepare(`
INSERT INTO surveys (id, environment_id, name, type, updated_at)
VALUES (@id, @environmentId, @name, @type, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
environment_id = excluded.environment_id,
name = excluded.name,
type = excluded.type,
updated_at = datetime('now')
`);
const selectAliasStmt = db.prepare(`
SELECT alias FROM environment_aliases WHERE environment_id = ?
`);
const selectEnvByAliasStmt = db.prepare(`
SELECT environment_id FROM environment_aliases WHERE alias = ?
`);
const selectSurveyStmt = db.prepare(`
SELECT id, type, custom_slug FROM surveys WHERE environment_id = ? AND (name = ? OR custom_slug = ?)
`);
const selectAllEnvsStmt = db.prepare(`
SELECT * FROM environment_aliases ORDER BY created_at DESC
`);
const updateAliasStmt = db.prepare(`
UPDATE environment_aliases SET alias = ?, updated_at = datetime('now') WHERE environment_id = ?
`);
const selectSurveysByEnvStmt = db.prepare(`
SELECT id, name, type, custom_slug FROM surveys WHERE environment_id = ? ORDER BY name ASC
`);
// --- Helper Functions ---
async function fetchSurveysFromAPI() {
if (!process.env.FORMBRICKS_API_KEY) {
throw new Error(
"FORMBRICKS_API_KEY is not defined in environment variables."
);
}
if (!process.env.FORMBRICKS_SDK_URL) {
throw new Error(
"FORMBRICKS_SDK_URL is not defined in environment variables."
);
}
try {
const response = await axios.get(
`${process.env.FORMBRICKS_SDK_URL}/api/v1/management/surveys`,
{
headers: {
"x-api-key": process.env.FORMBRICKS_API_KEY,
},
}
);
return Array.isArray(response.data?.data) ? response.data.data : [];
} catch (error) {
console.error(
"Failed to fetch surveys from Formbricks API:",
error.message
);
throw new Error("Could not fetch surveys from Formbricks API.");
}
}
async function refreshSurveyCache() {
try {
console.log("Fetching surveys from Formbricks API...");
const surveys = await fetchSurveysFromAPI();
let synced = 0;
const dbTransaction = db.transaction((surveys) => {
for (const survey of surveys) {
if (!survey?.id || !survey?.environmentId) {
continue;
}
// Ensure environment exists
upsertEnvironmentStmt.run(survey.environmentId);
// Upsert survey
upsertSurveyStmt.run({
id: survey.id,
environmentId: survey.environmentId,
name: survey.name || survey.id,
type: survey.type || "link",
});
synced += 1;
}
});
dbTransaction(surveys);
console.log(`Successfully synced ${synced} surveys into the database.`);
} catch (error) {
console.error("Failed to refresh survey cache:", error.message);
}
}
// --- Exported Functions ---
function getAllEnvironments() {
return selectAllEnvsStmt.all();
}
function updateEnvironmentAlias(environmentId, alias) {
try {
const result = updateAliasStmt.run(alias, environmentId);
return result.changes > 0;
} catch (error) {
if (error.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new Error("Alias already in use");
}
throw error;
}
}
function getSurveysByEnvironment(environmentId) {
return selectSurveysByEnvStmt.all(environmentId);
}
function getSurveyIdByAlias(rootAlias, surveyName) {
const env = selectEnvByAliasStmt.get(rootAlias);
if (!env) return null;
const survey = selectSurveyStmt.get(
env.environment_id,
surveyName,
surveyName
);
return survey
? {
surveyId: survey.id,
environmentId: env.environment_id,
type: survey.type,
}
: null;
}
function updateSurveySlug(surveyId, customSlug) {
try {
const stmt = db.prepare(`
UPDATE surveys SET custom_slug = ?, updated_at = datetime('now') WHERE id = ?
`);
const result = stmt.run(customSlug || null, surveyId);
return result.changes > 0;
} catch (error) {
throw error;
}
}
module.exports = {
refreshSurveyCache,
fetchSurveysFromAPI,
getAllEnvironments,
updateEnvironmentAlias,
getSurveysByEnvironment,
getSurveyIdByAlias,
updateSurveySlug,
};

463
src/views/admin.ejs Normal file
View File

@@ -0,0 +1,463 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
/* Catppuccin Mocha Palette */
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
--ctp-surface0: #313244;
--ctp-surface1: #45475a;
--ctp-surface2: #585b70;
--ctp-overlay0: #6c7086;
--ctp-overlay1: #7f849c;
--ctp-overlay2: #9399b2;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-lavender: #b4befe;
--ctp-blue: #89b4fa;
--ctp-sapphire: #74c7ec;
--ctp-sky: #89dceb;
--ctp-teal: #94e2d5;
--ctp-green: #a6e3a1;
--ctp-yellow: #f9e2af;
--ctp-peach: #fab387;
--ctp-maroon: #eba0ac;
--ctp-red: #f38ba8;
--ctp-mauve: #cba6f7;
--ctp-pink: #f5c2e7;
--ctp-flamingo: #f2cdcd;
--ctp-rosewater: #f5e0dc;
/* Application Colors */
--bg-color: var(--ctp-base);
--card-bg: var(--ctp-mantle);
--text-primary: var(--ctp-text);
--text-secondary: var(--ctp-subtext0);
--accent: var(--ctp-mauve);
--accent-hover: var(--ctp-lavender);
--border: var(--ctp-surface0);
--danger: var(--ctp-red);
--success: var(--ctp-green);
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
margin: 0;
padding: 2rem;
line-height: 1.5;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.card {
background-color: var(--card-bg);
border-radius: 0.75rem;
border: 1px solid var(--border);
padding: 1.5rem;
margin-bottom: 2rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
input, select {
background-color: var(--ctp-surface0);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-family: inherit;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(203, 166, 247, 0.1);
}
button {
background-color: var(--accent);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: var(--accent-hover);
}
button.danger {
background-color: transparent;
color: var(--danger);
border: 1px solid var(--danger);
}
button.danger:hover {
background-color: rgba(239, 68, 68, 0.1);
}
button.secondary {
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
button.secondary:hover {
background-color: rgba(255, 255, 255, 0.05);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
th {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.875rem;
}
.hidden {
display: none;
}
.form-group {
margin-bottom: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 1rem;
align-items: end;
}
#auth-section {
max-width: 400px;
margin: 4rem auto;
text-align: center;
}
.env-section {
margin-bottom: 2rem;
border-bottom: 1px solid var(--border);
padding-bottom: 2rem;
}
.env-section:last-child {
border-bottom: none;
}
.survey-list {
margin-top: 1rem;
padding-left: 1rem;
border-left: 2px solid var(--border);
}
.survey-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
color: var(--text-secondary);
gap: 1rem;
}
.survey-link {
color: var(--accent);
text-decoration: none;
flex: 1;
}
.survey-link:hover {
text-decoration: underline;
}
.copy-btn {
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.copy-btn:hover {
background-color: rgba(255, 255, 255, 0.05);
color: var(--accent);
}
.copy-btn.copied {
background-color: var(--success);
color: white;
border-color: var(--success);
}
</style>
</head>
<body>
<div id="auth-section" class="card">
<h2>Admin Authentication</h2>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">Enter your Admin Token to manage surveys.</p>
<div class="form-group">
<input type="password" id="admin-token" placeholder="Admin Token">
</div>
<button onclick="authenticate()">Login</button>
</div>
<div id="app-section" class="container hidden">
<header>
<h1>Environment Management</h1>
<button onclick="logout()" class="danger">Logout</button>
</header>
<div class="card">
<div class="card-header">
<h2>Environments & Aliases</h2>
<button onclick="loadData()">Refresh</button>
</div>
<div id="environments-container">
<!-- Environments will be inserted here -->
</div>
</div>
</div>
<script>
const API_BASE = '/api/mappings';
let token = localStorage.getItem('admin_token');
if (token) {
showApp();
}
function authenticate() {
const input = document.getElementById('admin-token').value;
if (input) {
token = input;
localStorage.setItem('admin_token', token);
showApp();
}
}
function logout() {
token = null;
localStorage.removeItem('admin_token');
document.getElementById('auth-section').classList.remove('hidden');
document.getElementById('app-section').classList.add('hidden');
}
function showApp() {
document.getElementById('auth-section').classList.add('hidden');
document.getElementById('app-section').classList.remove('hidden');
loadData();
}
async function fetchWithAuth(url, options = {}) {
const headers = {
'Content-Type': 'application/json',
'x-admin-token': token,
...options.headers
};
const response = await fetch(url, { ...options, headers });
if (response.status === 403) {
alert('Invalid Token');
logout();
return null;
}
return response;
}
async function loadData() {
const res = await fetchWithAuth(`${API_BASE}/environments`);
if (!res) return;
const data = await res.json();
renderEnvironments(data.environments);
}
async function renderEnvironments(environments) {
const container = document.getElementById('environments-container');
container.innerHTML = '';
for (const env of environments) {
const div = document.createElement('div');
div.className = 'env-section';
// Fetch surveys for this environment
const surveyRes = await fetchWithAuth(`${API_BASE}/environments/${env.environment_id}/surveys`);
const surveyData = await surveyRes.json();
const surveys = surveyData.surveys || [];
const aliasValue = env.alias || '';
const hasAlias = !!aliasValue;
let surveysHtml = '';
if (surveys.length > 0) {
surveysHtml = '<div class="survey-list">';
surveys.forEach(s => {
const displaySlug = s.custom_slug || s.name;
const url = hasAlias ? `/${aliasValue}/${displaySlug}` : '#';
const fullUrl = hasAlias ? `${window.location.origin}/${aliasValue}/${displaySlug}` : '';
const link = hasAlias
? `<a href="${url}" target="_blank" class="survey-link">${url}</a>`
: `<span style="color: var(--text-secondary);">(Set alias to generate URL)</span>`;
const copyBtn = hasAlias
? `<button class="copy-btn" onclick="copyToClipboard('${fullUrl}', this)">📋 Copy</button>`
: '';
surveysHtml += `
<div class="survey-item" style="flex-direction: column; align-items: stretch; gap: 0.5rem; padding: 1rem 0; border-bottom: 1px solid var(--border);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${s.name}</span>
<span style="font-size: 0.75rem; color: var(--text-secondary);">Type: ${s.type}</span>
</div>
<div style="display: grid; grid-template-columns: 1fr auto auto; gap: 0.5rem; align-items: center;">
<input
type="text"
id="slug-${s.id}"
value="${displaySlug}"
placeholder="custom-slug"
style="font-size: 0.875rem; padding: 0.375rem 0.5rem;"
/>
<button onclick="saveSlug('${s.id}')" class="secondary" style="font-size: 0.875rem;">Save Slug</button>
${copyBtn}
</div>
${hasAlias ? `<div style="font-size: 0.875rem;">${link}</div>` : ''}
</div>
`;
});
surveysHtml += '</div>';
} else {
surveysHtml = '<p style="color: var(--text-secondary); margin-top: 0.5rem;">No surveys found.</p>';
}
div.innerHTML = `
<div style="margin-bottom: 1rem;">
<h3 style="margin: 0 0 0.5rem 0;">Environment ID: <span style="font-family: monospace; font-weight: 400; color: var(--text-secondary);">${env.environment_id}</span></h3>
<div class="form-row">
<div>
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary); font-size: 0.875rem;">Root Path Alias</label>
<input type="text" id="alias-${env.environment_id}" value="${aliasValue}" placeholder="e.g. marketing">
</div>
<button onclick="saveAlias('${env.environment_id}')">Save Alias</button>
</div>
</div>
${surveysHtml}
`;
container.appendChild(div);
}
}
async function saveAlias(envId) {
const input = document.getElementById(`alias-${envId}`);
const alias = input.value.trim();
if (!alias) {
alert('Alias cannot be empty');
return;
}
const res = await fetchWithAuth(`${API_BASE}/environments/${envId}/alias`, {
method: 'PUT',
body: JSON.stringify({ alias })
});
if (res.ok) {
loadData(); // Refresh to update URLs
} else {
const err = await res.json();
alert(err.error || 'Failed to update alias');
}
}
async function copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
const originalText = button.textContent;
button.textContent = '✓ Copied!';
button.classList.add('copied');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard');
}
}
async function saveSlug(surveyId) {
const input = document.getElementById(`slug-${surveyId}`);
const slug = input.value.trim();
if (!slug) {
alert('Slug cannot be empty');
return;
}
const res = await fetchWithAuth(`${API_BASE}/surveys/${surveyId}/slug`, {
method: 'PUT',
body: JSON.stringify({ slug })
});
if (res.ok) {
loadData(); // Refresh to update URLs
} else {
const err = await res.json();
alert(err.error || 'Failed to update slug');
}
}
</script>
</body>
</html>

103
src/views/index.ejs Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title %></title>
<style>
:root {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color: #0b1b2b;
background: #f4f6fb;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.panel {
max-width: 900px;
width: 100%;
background: #fff;
border-radius: 24px;
box-shadow: 0 15px 40px rgba(15, 23, 42, 0.2);
padding: 3rem;
}
h1 {
margin-top: 0;
font-size: 2.5rem;
}
p {
line-height: 1.6;
color: #3f4b5b;
}
.grid {
margin-top: 2rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.card {
padding: 1.25rem;
border-radius: 16px;
border: 1px solid #e0e6ef;
background: #f9fbff;
}
.pill {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.85rem;
background: #e5edff;
color: #3855ff;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="panel">
<div class="pill">Formbricks Vanity</div>
<h1>Surveys under your own brand</h1>
<p>
This service proxies Formbricks surveys behind clean, semantic URLs and renders the
official experience within your own domain. No manual mapping files, no redirect
chains — just friendly slugs that everyone on your team can memorise.
</p>
<div class="grid">
<div class="card">
<h2>Friendly URLs</h2>
<p>
Every survey is addressable via <code>/:project/:partner/:survey</code>. The slug
maps transparently to the Formbricks survey ID.
</p>
</div>
<div class="card">
<h2>Automatic Sync</h2>
<p>
The server fetches your Formbricks catalog at startup and keeps the SQLite-backed
registry in sync without manual edits.
</p>
</div>
<div class="card">
<h2>Admin controls</h2>
<p>
A protected API lets you inspect and manage the current mappings, so your ops team
can add or edit surveys without touching the CLI.
</p>
</div>
</div>
</div>
</body>
</html>

40
src/views/survey.ejs Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
<style>
body, html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background-color: #f0f2f5;
}
</style>
<script type="text/javascript">
(function() {
var script = document.createElement("script");
script.type = "text/javascript";
script.async = true;
script.src = "<%= formbricksSdkUrl %>/js/formbricks.umd.cjs";
script.onload = function() {
if (window.formbricks) {
window.formbricks.init({
environmentId: "<%= formbricksEnvId %>",
apiHost: "<%= formbricksSdkUrl %>"
});
window.formbricks.display("<%= surveyId %>");
}
};
var firstScript = document.getElementsByTagName("script")[0];
firstScript.parentNode.insertBefore(script, firstScript);
})();
</script>
</head>
<body>
</body>
</html>