/**
* Lark Chart Block Worker
* Cloudflare Workers entry point for serving the Chart Block widget
*/
export interface Env {
ENVIRONMENT: string;
LARK_APP_ID?: string;
LARK_APP_SECRET?: string;
}
// Allowed Lark domains for CORS
const ALLOWED_ORIGINS = [
'https://www.larksuite.com',
'https://www.feishu.cn',
'https://open.larksuite.com',
'https://open.feishu.cn',
'https://*.larksuite.com',
'https://*.feishu.cn',
];
/**
* Check if origin is allowed
*/
function isOriginAllowed(origin: string | null): boolean {
if (!origin) return false;
return ALLOWED_ORIGINS.some(allowed => {
if (allowed.includes('*')) {
// Extract the base domain pattern (e.g., '.larksuite.com')
const pattern = allowed.replace('https://*.', '.');
// Prevent subdomain bypass: check that origin ends with pattern AND starts with https://
// This ensures something like https://evil.com.larksuite.com is rejected
return origin.startsWith('https://') &&
origin.endsWith(pattern) &&
!origin.substring(8).includes('.', origin.substring(8).indexOf(pattern));
}
return origin === allowed;
});
}
/**
* Get CORS headers based on request origin
*/
function getCorsHeaders(request: Request, env: Env): Record<string, string> {
const origin = request.headers.get('Origin');
// For development, allow all origins
const isDev = env.ENVIRONMENT === 'development' || env.ENVIRONMENT === 'dev';
if (isDev) {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
}
// Production: strict origin checking
if (origin && isOriginAllowed(origin)) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
};
}
// No CORS headers if origin not allowed
return {};
}
/**
* Security headers to prevent XSS and other attacks
* Note: X-Frame-Options removed to allow embedding in Lark dashboards
* Content-Security-Policy frame-ancestors controls iframe embedding instead
*/
const SECURITY_HEADERS = {
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://lf-cdn.bytednsdoc.com https://unpkg.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://open.larksuite.com https://open.feishu.cn https://*.larksuite.com https://*.feishu.cn; frame-ancestors 'self' https://*.larksuite.com https://*.feishu.cn https://*.larkoffice.com https://lark-dashboard-preview.pages.dev;",
'X-Content-Type-Options': 'nosniff',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
};
/**
* Sanitize HTML to prevent XSS
*/
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
};
return text.replace(/[&<>"'/]/g, (char) => map[char]);
}
/**
* Validate appToken and tableId to prevent path traversal attacks
*/
function validateResourceId(id: string, type: 'appToken' | 'tableId'): boolean {
if (!id || typeof id !== 'string') {
return false;
}
// Check for path traversal patterns
if (id.includes('..') || id.includes('/') || id.includes('\\')) {
return false;
}
// Validate format: alphanumeric and underscores only
const validPattern = /^[a-zA-Z0-9_]+$/;
if (!validPattern.test(id)) {
return false;
}
// Validate length (typical Lark tokens are 20-30 chars)
if (id.length < 10 || id.length > 50) {
return false;
}
return true;
}
// HTML template for the chart block widget
const getBlockHtml = (env: Env) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lark Chart Block</title>
<script src="https://lf-cdn.bytednsdoc.com/obj/static/block/js/tt.js"></script>
<script src="https://unpkg.com/@visactor/vchart@1.11.0/build/index.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
#chart-container { width: 100%; height: 400px; }
.loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #666; }
.error { color: #ff4d4f; text-align: center; padding: 20px; background: #fff1f0; border-radius: 4px; margin: 16px; }
.config-panel { padding: 16px; background: #f5f5f5; border-radius: 8px; margin-bottom: 16px; }
.config-panel select, .config-panel input { margin: 4px 0; padding: 8px; width: 100%; border: 1px solid #d9d9d9; border-radius: 4px; }
.config-panel button { margin-top: 8px; padding: 8px 16px; background: #3370FF; color: white; border: none; border-radius: 4px; cursor: pointer; transition: background 0.3s; }
.config-panel button:hover { background: #2456d6; }
.config-panel button:active { background: #1a45b3; }
#cancel-btn { background: #8c8c8c; margin-left: 8px; }
#cancel-btn:hover { background: #737373; }
</style>
</head>
<body>
<div id="app">
<div id="config-panel" class="config-panel" style="display: none;">
<h3>Chart Configuration</h3>
<label>Chart Type:</label>
<select id="chart-type">
<option value="bar">Bar Chart</option>
<option value="line">Line Chart</option>
<option value="pie">Pie Chart</option>
<option value="area">Area Chart</option>
<option value="scatter">Scatter Plot</option>
<option value="funnel">Funnel Chart</option>
<option value="radar">Radar Chart</option>
</select>
<label>Chart Title:</label>
<input type="text" id="chart-title" placeholder="Enter chart title" maxlength="200">
<label>App Token:</label>
<input type="text" id="app-token" placeholder="Enter Lark Base app token" maxlength="50">
<label>Table ID:</label>
<input type="text" id="table-id" placeholder="Enter table ID" maxlength="50">
<label>View ID (optional):</label>
<input type="text" id="view-id" placeholder="Enter view ID (optional)" maxlength="50">
<label>X-Axis Field:</label>
<input type="text" id="x-axis-field" placeholder="Enter X-axis field name" maxlength="100">
<label>Y-Axis Field:</label>
<input type="text" id="y-axis-field" placeholder="Enter Y-axis field name" maxlength="100">
<button id="create-btn">Create Chart</button>
<button id="cancel-btn">Cancel</button>
</div>
<div id="chart-container">
<div class="loading">Loading chart...</div>
</div>
</div>
<script>
// Utility: Sanitize HTML to prevent XSS
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/'
};
return String(text).replace(/[&<>"'/]/g, function(char) {
return map[char];
});
}
// Utility: Validate resource IDs to prevent path traversal
function validateResourceId(id, type) {
if (!id || typeof id !== 'string') {
return false;
}
// Check for path traversal patterns
if (id.includes('..') || id.includes('/') || id.includes('\\\\')) {
return false;
}
// Validate format: alphanumeric and underscores only
const validPattern = /^[a-zA-Z0-9_]+$/;
if (!validPattern.test(id)) {
return false;
}
// Validate length (typical Lark tokens are 20-30 chars)
if (id.length < 10 || id.length > 50) {
return false;
}
return true;
}
// Chart Block Creator
const ChartBlockCreator = {
data: {
chartConfig: null,
chartData: [],
isLoading: false,
error: null,
mode: 'create',
chartInstance: null
},
setData(updates) {
Object.assign(this.data, updates);
this.render();
},
render() {
const container = document.getElementById('chart-container');
const configPanel = document.getElementById('config-panel');
if (this.data.mode === 'create') {
configPanel.style.display = 'block';
} else {
configPanel.style.display = 'none';
}
if (this.data.isLoading) {
container.innerHTML = '<div class="loading">Loading chart data...</div>';
} else if (this.data.error) {
// Sanitize error message before displaying
const safeError = escapeHtml(this.data.error);
container.innerHTML = '<div class="error">' + safeError + '</div>';
}
},
async loadChartData(config) {
this.setData({ isLoading: true, error: null });
try {
const { dataSource } = config;
// Validate configuration
if (!dataSource || !dataSource.appToken || !dataSource.tableId) {
throw new Error('Invalid data source configuration');
}
// Validate appToken and tableId to prevent path traversal
if (!validateResourceId(dataSource.appToken, 'appToken')) {
throw new Error('Invalid app token format');
}
if (!validateResourceId(dataSource.tableId, 'tableId')) {
throw new Error('Invalid table ID format');
}
const url = '/open-apis/bitable/v1/apps/' + dataSource.appToken +
'/tables/' + dataSource.tableId + '/records?page_size=500';
tt.request({
url: url,
method: 'GET',
success: (res) => {
try {
const records = res.data.items || [];
// Limit records to prevent DoS
if (records.length > 5000) {
console.warn('Too many records, limiting to 5000');
records.splice(5000);
}
const chartData = this.transformData(records, dataSource.fields);
this.setData({ chartData, isLoading: false });
this.renderChart(config, chartData);
} catch (error) {
console.error('Failed to process data:', error);
this.setData({ isLoading: false, error: 'Failed to process chart data' });
}
},
fail: (error) => {
console.error('Failed to load data:', error);
this.setData({ isLoading: false, error: 'Failed to load chart data' });
}
});
} catch (error) {
console.error('Load error:', error);
this.setData({ isLoading: false, error: error.message });
}
},
transformData(records, fields) {
const { xAxis, yAxis } = fields;
const yAxisFields = Array.isArray(yAxis) ? yAxis : [yAxis];
return records.map(record => {
const fieldValues = record.fields || {};
const dataPoint = { x: this.extractFieldValue(fieldValues[xAxis]) };
yAxisFields.forEach((yField, index) => {
const key = yAxisFields.length > 1 ? 'y' + (index + 1) : 'y';
dataPoint[key] = this.extractFieldValue(fieldValues[yField]) || 0;
});
return dataPoint;
});
},
extractFieldValue(fieldValue) {
if (fieldValue === null || fieldValue === undefined) {
return null;
}
// Sanitize string values
if (typeof fieldValue === 'string') {
return fieldValue.substring(0, 1000); // Limit length
}
if (typeof fieldValue === 'number' || typeof fieldValue === 'boolean') {
return fieldValue;
}
if (Array.isArray(fieldValue)) {
if (fieldValue.length === 0) return null;
return this.extractFieldValue(fieldValue[0]);
}
if (typeof fieldValue === 'object') {
if (fieldValue.text) return String(fieldValue.text).substring(0, 1000);
if (fieldValue.name) return String(fieldValue.name).substring(0, 1000);
if (fieldValue.value !== undefined) return fieldValue.value;
}
return null;
},
renderChart(config, data) {
const container = document.getElementById('chart-container');
container.innerHTML = '';
if (this.data.chartInstance) {
try {
this.data.chartInstance.release();
} catch (error) {
console.warn('Failed to release chart:', error);
}
}
if (!data || data.length === 0) {
container.innerHTML = '<div class="error">No data available for chart</div>';
return;
}
const { chartType, title, options = {} } = config;
const spec = this.buildVChartSpec(chartType, data, title, options);
try {
const chart = new VChart(spec, { dom: container });
chart.renderSync();
this.data.chartInstance = chart;
} catch (error) {
console.error('Failed to render chart:', error);
this.setData({ error: 'Failed to render chart' });
}
},
buildVChartSpec(chartType, data, title, options) {
const colors = options.colors || ['#3370FF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE'];
const baseSpec = {
type: chartType,
data: [{ values: data }],
title: title ? { visible: true, text: String(title).substring(0, 200) } : { visible: false },
legends: { visible: options.showLegend !== false },
label: { visible: options.showDataLabels },
animationAppear: options.animation !== false,
color: colors
};
switch (chartType) {
case 'bar':
case 'line':
case 'area':
return { ...baseSpec, xField: 'x', yField: 'y' };
case 'pie':
return { ...baseSpec, type: 'pie', valueField: 'y', categoryField: 'x', outerRadius: 0.8 };
case 'scatter':
return { ...baseSpec, xField: 'x', yField: 'y' };
case 'funnel':
return { ...baseSpec, type: 'funnel', categoryField: 'x', valueField: 'y' };
case 'radar':
return { ...baseSpec, type: 'radar', categoryField: 'x', valueField: 'y' };
default:
return baseSpec;
}
}
};
// Initialize Creator
Creator({
data: ChartBlockCreator.data,
onLoad(options) {
console.log('Block loaded', options);
const { blockInfo } = options;
const { mode, setting } = blockInfo;
ChartBlockCreator.setData({ mode });
if (mode === 'setting' && setting?.chartConfig) {
ChartBlockCreator.setData({ chartConfig: setting.chartConfig });
ChartBlockCreator.loadChartData(setting.chartConfig);
}
},
onReady() {
console.log('Block ready');
if (ChartBlockCreator.data.chartConfig) {
ChartBlockCreator.renderChart(
ChartBlockCreator.data.chartConfig,
ChartBlockCreator.data.chartData
);
}
},
methods: {
createBlock(chartConfig) {
const sourceMeta = {
chartConfig,
version: '1.0.0',
createdAt: Date.now()
};
tt.setBlockInfo({ sourceMeta });
ChartBlockCreator.setData({ chartConfig, mode: 'setting' });
ChartBlockCreator.loadChartData(chartConfig);
},
cancelBlock() {
tt.cancel();
}
}
});
// UI Event handlers with input validation
document.getElementById('create-btn').addEventListener('click', () => {
const chartType = document.getElementById('chart-type').value;
const titleInput = document.getElementById('chart-title');
const title = titleInput.value.trim().substring(0, 200);
// Get user input for data source configuration
const appToken = document.getElementById('app-token').value.trim();
const tableId = document.getElementById('table-id').value.trim();
const viewId = document.getElementById('view-id').value.trim();
const xAxisField = document.getElementById('x-axis-field').value.trim();
const yAxisField = document.getElementById('y-axis-field').value.trim();
// Validate required fields
if (!appToken || !tableId || !xAxisField || !yAxisField) {
alert('Please fill in all required fields: App Token, Table ID, X-Axis Field, and Y-Axis Field');
return;
}
// Validate input formats
if (!validateResourceId(appToken, 'appToken')) {
alert('Invalid App Token format. Use alphanumeric characters and underscores only.');
return;
}
if (!validateResourceId(tableId, 'tableId')) {
alert('Invalid Table ID format. Use alphanumeric characters and underscores only.');
return;
}
// Chart configuration from user inputs
const config = {
chartType,
title: title || 'Chart',
dataSource: {
appToken: appToken,
tableId: tableId,
viewId: viewId || undefined,
fields: {
xAxis: xAxisField,
yAxis: yAxisField
}
},
options: {
showLegend: true,
animation: true,
colors: ['#3370FF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE']
}
};
ChartBlockCreator.methods.createBlock(config);
});
document.getElementById('cancel-btn').addEventListener('click', () => {
ChartBlockCreator.methods.cancelBlock();
});
</script>
</body>
</html>
`;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
// Get CORS headers based on request
const corsHeaders = getCorsHeaders(request, env);
// Handle preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: corsHeaders,
});
}
// Serve the block widget
if (path === '/' || path === '/index.html' || path === '/block') {
return new Response(getBlockHtml(env), {
headers: {
'Content-Type': 'text/html;charset=UTF-8',
...corsHeaders,
...SECURITY_HEADERS,
'Cache-Control': 'public, max-age=3600',
},
});
}
// Health check endpoint
if (path === '/health') {
return new Response(JSON.stringify({
status: 'ok',
environment: env.ENVIRONMENT,
timestamp: new Date().toISOString(),
version: '1.0.0',
}), {
headers: {
'Content-Type': 'application/json',
...corsHeaders,
'Cache-Control': 'no-cache',
},
});
}
// Block manifest
if (path === '/manifest.json' || path === '/block.config.json') {
return new Response(JSON.stringify({
name: 'Chart Block',
version: '1.0.0',
description: 'Interactive chart widget for Lark Base dashboards',
capabilities: ['data-binding', 'bitable-integration'],
supportedChartTypes: ['bar', 'line', 'pie', 'area', 'scatter', 'funnel', 'radar'],
security: {
xssProtection: true,
inputValidation: true,
sanitization: true,
},
}), {
headers: {
'Content-Type': 'application/json',
...corsHeaders,
'Cache-Control': 'public, max-age=3600',
},
});
}
// 404 for unknown paths
return new Response(JSON.stringify({ error: 'Not Found' }), {
status: 404,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
});
},
};