Files
insertr/lib/src/core/api-client.js
Joakim 1fa607c47c Fix site_id isolation for demo sites
- Auto-derive site_id from demo directory paths (demos/demo-site -> 'demo', demos/simple/test-simple -> 'simple')
- Add validation requiring explicit site_id for non-demo paths with helpful error messages
- Remove JavaScript 'demo' fallback and add proper error messaging for missing site_id
- Ensure each demo site uses isolated content namespace to prevent content mixing

Resolves issue where /sites/simple and /sites/demo both used site_id=demo
2025-09-16 22:23:41 +02:00

307 lines
11 KiB
JavaScript

/**
* ApiClient - Handle communication with content API
*/
export class ApiClient {
constructor(options = {}) {
// Smart server detection based on environment
const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const defaultEndpoint = isDevelopment
? 'http://localhost:8080/api/content' // Development: separate API server
: '/api/content'; // Production: same-origin API
this.baseUrl = options.apiEndpoint || defaultEndpoint;
this.siteId = options.siteId || this.handleMissingSiteId();
// Log API configuration in development
if (isDevelopment && !options.apiEndpoint) {
console.log(`🔌 API Client: Using development server at ${this.baseUrl}`);
}
}
async getContent(contentId) {
try {
const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`);
return response.ok ? await response.json() : null;
} catch (error) {
console.warn('Failed to fetch content:', contentId, error);
return null;
}
}
async createContent(content, type, htmlMarkup) {
try {
const payload = {
html_markup: htmlMarkup, // Always send HTML markup - server extracts ID or generates new one
value: content,
type: type,
file_path: this.getCurrentFilePath() // Always include file path for consistent ID generation
};
const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
console.log(`✅ Content created: ${result.id} (${result.type})`);
return result;
} else {
console.warn(`⚠️ Create failed (${response.status}): server will generate ID`);
return null;
}
} catch (error) {
if (error.name === 'TypeError' && error.message.includes('fetch')) {
console.warn(`🔌 API Server not reachable at ${this.baseUrl}`);
console.warn('💡 Start full-stack development: just dev');
} else {
console.error('Failed to create content:', error);
}
return false;
}
}
async getContentVersions(contentId) {
try {
const response = await fetch(`${this.baseUrl}/${contentId}/versions?site_id=${this.siteId}`);
if (response.ok) {
const result = await response.json();
return result.versions || [];
} else {
console.warn(`⚠️ Failed to fetch versions (${response.status}): ${contentId}`);
return [];
}
} catch (error) {
console.error('Failed to fetch version history:', contentId, error);
return [];
}
}
async rollbackContent(contentId, versionId) {
try {
const response = await fetch(`${this.baseUrl}/${contentId}/rollback?site_id=${this.siteId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify({
version_id: versionId
})
});
if (response.ok) {
console.log(`✅ Content rolled back: ${contentId} to version ${versionId}`);
return await response.json();
} else {
console.warn(`⚠️ Rollback failed (${response.status}): ${contentId}`);
return false;
}
} catch (error) {
console.error('Failed to rollback content:', contentId, error);
return false;
}
}
/**
* Get authentication token for API requests
* @returns {string} JWT token or mock token for development
*/
getAuthToken() {
// Check if we have a real JWT token from OAuth
const realToken = this.getStoredToken();
if (realToken && !this.isTokenExpired(realToken)) {
return realToken;
}
// Development/mock token for when no real auth is present
return this.getMockToken();
}
/**
* Get current user information from token
* @returns {string} User identifier
*/
getCurrentUser() {
const token = this.getAuthToken();
// If it's a mock token, return mock user
if (token.startsWith('mock-')) {
return 'anonymous';
}
// Parse real JWT token for user info
try {
const payload = this.parseJWT(token);
return payload.sub || payload.user_id || payload.email || 'anonymous';
} catch (error) {
console.warn('Failed to parse JWT token:', error);
return 'anonymous';
}
}
/**
* Get stored JWT token from localStorage/sessionStorage
* @returns {string|null} Stored JWT token
*/
getStoredToken() {
// Try localStorage first (persistent), then sessionStorage (session-only)
return localStorage.getItem('insertr_auth_token') ||
sessionStorage.getItem('insertr_auth_token') ||
null;
}
/**
* Store JWT token for future requests
* @param {string} token - JWT token from OAuth provider
* @param {boolean} persistent - Whether to use localStorage (true) or sessionStorage (false)
*/
setStoredToken(token, persistent = true) {
const storage = persistent ? localStorage : sessionStorage;
storage.setItem('insertr_auth_token', token);
// Clear the other storage to avoid conflicts
const otherStorage = persistent ? sessionStorage : localStorage;
otherStorage.removeItem('insertr_auth_token');
}
/**
* Clear stored authentication token
*/
clearStoredToken() {
localStorage.removeItem('insertr_auth_token');
sessionStorage.removeItem('insertr_auth_token');
}
/**
* Generate mock JWT token for development/testing
* @returns {string} Mock JWT token
*/
getMockToken() {
// Create a mock JWT-like token for development
// Format: mock-{user}-{timestamp}-{random}
const user = 'anonymous';
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 9);
return `mock-${user}-${timestamp}-${random}`;
}
/**
* Parse JWT token payload
* @param {string} token - JWT token
* @returns {object} Parsed payload
*/
parseJWT(token) {
if (token.startsWith('mock-')) {
// Return mock payload for development tokens
return {
sub: 'anonymous',
user_id: 'anonymous',
email: 'anonymous@localhost',
iss: 'insertr-dev',
exp: Date.now() + 24 * 60 * 60 * 1000 // 24 hours from now
};
}
try {
// Parse real JWT token
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
return payload;
} catch (error) {
throw new Error(`Failed to parse JWT token: ${error.message}`);
}
}
/**
* Check if JWT token is expired
* @param {string} token - JWT token
* @returns {boolean} True if token is expired
*/
isTokenExpired(token) {
try {
const payload = this.parseJWT(token);
const now = Math.floor(Date.now() / 1000);
return payload.exp && payload.exp < now;
} catch (error) {
// If we can't parse the token, consider it expired
return true;
}
}
/**
* Initialize OAuth flow with provider (Google, GitHub, etc.)
* @param {string} provider - OAuth provider ('google', 'github', etc.)
* @returns {Promise<boolean>} Success status
*/
async initiateOAuth(provider = 'google') {
// This will be implemented when we add real OAuth integration
console.log(`🔐 OAuth flow with ${provider} not yet implemented`);
console.log('💡 For now, using mock authentication in development');
// Store a mock token for development
const mockToken = this.getMockToken();
this.setStoredToken(mockToken, true);
return true;
}
/**
* Handle OAuth callback after user returns from provider
* @param {URLSearchParams} urlParams - URL parameters from OAuth callback
* @returns {Promise<boolean>} Success status
*/
async handleOAuthCallback(urlParams) {
// This will be implemented when we add real OAuth integration
const code = urlParams.get('code');
const state = urlParams.get('state');
if (code) {
console.log('🔐 OAuth callback received, exchanging code for token...');
// TODO: Exchange authorization code for JWT token
// const token = await this.exchangeCodeForToken(code, state);
// this.setStoredToken(token, true);
return true;
}
return false;
}
/**
* Handle missing site_id configuration
* @returns {string} Site ID or throws error
*/
handleMissingSiteId() {
console.error('❌ No site_id configured for Insertr.');
console.log('💡 Add data-site-id attribute to your script tag:');
console.log(' <script src="insertr.js" data-site-id="mysite"></script>');
console.log('');
console.log('🚀 For demos under demos/, site_id is auto-derived from directory name.');
console.log('🏭 For production sites, you must explicitly set your site_id.');
// Return a placeholder that will cause API calls to fail gracefully
return '__MISSING_SITE_ID__';
}
/**
* Get current file path from URL for consistent ID generation
* @returns {string} File path like "index.html", "about.html"
*/
getCurrentFilePath() {
const path = window.location.pathname;
if (path === '/' || path === '') {
return 'index.html';
}
// Remove leading slash: "/about.html" → "about.html"
return path.replace(/^\//, '');
}
}