Add public URL sharing with express-pouchdb sync

- Add express-pouchdb for self-hosted PouchDB sync server
- Public databases (/db/public/:db) accessible without auth
- Add auth middleware for public/private database access
- Simplify share button to copy current URL to clipboard
- Move feature config from static JSON to dynamic API endpoint
- Add PouchDB sync to PouchData class for real-time collaboration
- Fix Express 5 compatibility by patching req.query
- Skip express.json() for /pouchdb routes (stream handling)
- Remove unused PouchdbPersistenceManager and old share system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-12-29 11:49:56 -06:00
parent 74a2d179b9
commit a772372b2b
20 changed files with 2418 additions and 666 deletions

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ dist-ssr
# Local Netlify folder
.netlify
/data/

1966
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "immersive",
"private": true,
"version": "0.0.8-34",
"version": "0.0.8-42",
"type": "module",
"license": "MIT",
"engines": {
@ -46,13 +46,17 @@
"dotenv": "^17.2.3",
"events": "^3.3.0",
"express": "^5.2.1",
"express-pouchdb": "^4.2.0",
"hash-wasm": "4.11.0",
"hls.js": "^1.1.4",
"js-crypto-aes": "1.0.6",
"leveldown": "^6.1.1",
"loglevel": "^1.9.1",
"meaningful-string": "^1.4.0",
"peer-lite": "2.0.2",
"pouchdb": "^8.0.1",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-adapter-memory": "^9.0.0",
"pouchdb-find": "^8.0.1",
"query-string": "^8.1.0",
"react-router-dom": "^6.26.1",
@ -73,4 +77,4 @@
"vite-plugin-cp": "^1.0.0",
"vitest": "^1.4.0"
}
}
}

View File

@ -1,89 +0,0 @@
# Feature Configuration Testing
This directory contains static JSON files for testing different user tiers locally.
## Available Configurations
### Default: `features` (none tier)
- **What you see**: Only the home page
- **All pages and features**: Disabled
- **Use case**: Unauthenticated users or when API is unavailable
### Free Tier: `features-free.json`
- **Pages**: All marketing pages + VR Experience
- **Features**: Basic diagram creation, management, immersive mode
- **Limits**: 6 diagrams max, 100MB storage
- **No access to**: Templates, private/encrypted designs, collaboration
### Basic Tier: `features-basic.json`
- **Pages**: All pages available
- **Features**: Free features + templates + private designs
- **Limits**: 25 diagrams max, 500MB storage
- **No access to**: Encrypted designs, collaboration
### Pro Tier: `features-pro.json`
- **Pages**: All pages available
- **Features**: Everything unlocked
- **Limits**: Unlimited (indicated by -1)
## How to Test Locally
### Method 1: Copy the file you want to test
```bash
# Test free tier
cp public/api/user/features-free.json public/api/user/features
# Test basic tier
cp public/api/user/features-basic.json public/api/user/features
# Test pro tier
cp public/api/user/features-pro.json public/api/user/features
# Test none/default (locked down)
cp public/api/user/features-none.json public/api/user/features
```
### Method 2: Symlink (easier for switching)
```bash
# Remove the default file
rm public/api/user/features
# Create a symlink to the tier you want to test
ln -s features-free.json public/api/user/features
# or
ln -s features-basic.json public/api/user/features
# or
ln -s features-pro.json public/api/user/features
```
## What Changes Between Tiers
| Feature | None | Free | Basic | Pro |
|---------|------|------|-------|-----|
| Pages (Examples, Docs, Pricing) | ❌ | ✅ | ✅ | ✅ |
| VR Experience | ❌ | ✅ | ✅ | ✅ |
| Create Diagram | ❌ | ✅ | ✅ | ✅ |
| Create From Template | ❌ | ❌ | ✅ | ✅ |
| Private Designs | ❌ | ❌ | ✅ | ✅ |
| Encrypted Designs | ❌ | ❌ | ❌ | ✅ |
| Share/Collaborate | ❌ | ❌ | ❌ | ✅ |
| Max Diagrams | 0 | 6 | 25 | ∞ |
| Storage | 0 | 100MB | 500MB | ∞ |
## Backend Implementation (Future)
When you're ready to implement the backend, create an endpoint at:
```
GET https://www.deepdiagram.com/api/user/features
```
The endpoint should:
1. Validate the Auth0 JWT token from `Authorization: Bearer <token>` header
2. Query the user's subscription tier from your database
3. Return JSON matching one of these structures based on their tier
4. Handle errors gracefully (401 for invalid token, 403 for unauthorized)
The frontend will automatically fall back to the static `features` file if:
- User is not authenticated
- API returns an error
- Network request fails

View File

@ -1,26 +0,0 @@
{
"tier": "basic",
"pages": {
"examples": "coming-soon",
"documentation": "coming-soon",
"pricing": "coming-soon",
"vrExperience": "on"
},
"features": {
"createDiagram": "on",
"createFromTemplate": "coming-soon",
"manageDiagrams": "on",
"shareCollaborate": "coming-soon",
"privateDesigns": "coming-soon",
"encryptedDesigns": "pro",
"editData": "coming-soon",
"config": "on",
"enterImmersive": "on",
"launchMetaQuest": "on"
},
"limits": {
"maxDiagrams": 10,
"maxCollaborators": 0,
"storageQuotaMB": 200
}
}

View File

@ -1,26 +0,0 @@
{
"tier": "basic",
"pages": {
"examples": "on",
"documentation": "on",
"pricing": "on",
"vrExperience": "on"
},
"features": {
"createDiagram": "on",
"createFromTemplate": "on",
"manageDiagrams": "on",
"shareCollaborate": "coming-soon",
"privateDesigns": "on",
"encryptedDesigns": "pro",
"editData": "on",
"config": "on",
"enterImmersive": "on",
"launchMetaQuest": "on"
},
"limits": {
"maxDiagrams": 25,
"maxCollaborators": 0,
"storageQuotaMB": 500
}
}

View File

@ -1,26 +0,0 @@
{
"tier": "free",
"pages": {
"examples": "on",
"documentation": "on",
"pricing": "on",
"vrExperience": "on"
},
"features": {
"createDiagram": "on",
"createFromTemplate": "coming-soon",
"manageDiagrams": "on",
"shareCollaborate": "coming-soon",
"privateDesigns": "coming-soon",
"encryptedDesigns": "pro",
"editData": "on",
"config": "on",
"enterImmersive": "on",
"launchMetaQuest": "on"
},
"limits": {
"maxDiagrams": 6,
"maxCollaborators": 0,
"storageQuotaMB": 100
}
}

View File

@ -1,26 +0,0 @@
{
"tier": "none",
"pages": {
"examples": "off",
"documentation": "off",
"pricing": "off",
"vrExperience": "off"
},
"features": {
"createDiagram": "off",
"createFromTemplate": "off",
"manageDiagrams": "off",
"shareCollaborate": "off",
"privateDesigns": "off",
"encryptedDesigns": "off",
"editData": "off",
"config": "off",
"enterImmersive": "off",
"launchMetaQuest": "off"
},
"limits": {
"maxDiagrams": 0,
"maxCollaborators": 0,
"storageQuotaMB": 0
}
}

View File

@ -1,26 +0,0 @@
{
"tier": "pro",
"pages": {
"examples": "on",
"documentation": "on",
"pricing": "on",
"vrExperience": "on"
},
"features": {
"createDiagram": "on",
"createFromTemplate": "on",
"manageDiagrams": "on",
"shareCollaborate": "on",
"privateDesigns": "on",
"encryptedDesigns": "on",
"editData": "on",
"config": "on",
"enterImmersive": "on",
"launchMetaQuest": "on"
},
"limits": {
"maxDiagrams": -1,
"maxCollaborators": -1,
"storageQuotaMB": -1
}
}

View File

@ -3,6 +3,8 @@ import ViteExpress from "vite-express";
import cors from "cors";
import dotenv from "dotenv";
import apiRoutes from "./server/api/index.js";
import { pouchApp, PouchDB } from "./server/services/databaseService.js";
import { dbAuthMiddleware } from "./server/middleware/dbAuth.js";
// Load .env.local first, then fall back to .env
dotenv.config({ path: '.env.local' });
@ -20,11 +22,60 @@ if (allowedOrigins.length > 0) {
}));
}
app.use(express.json());
// Parse JSON for all routes EXCEPT /pouchdb (express-pouchdb handles its own body parsing)
app.use((req, res, next) => {
if (req.path.startsWith('/pouchdb')) {
return next();
}
express.json()(req, res, next);
});
// API routes
app.use("/api", apiRoutes);
// Test endpoint to verify PouchDB is working
app.get("/pouchdb-test/:dbname", async (req, res) => {
try {
const dbName = req.params.dbname;
console.log(`[Test] Creating database: ${dbName}`);
const db = new PouchDB(dbName);
const info = await db.info();
console.log(`[Test] Database info:`, info);
// Try to add a test doc
const result = await db.put({ _id: 'test-doc', hello: 'world' });
console.log(`[Test] Added doc:`, result);
// Read it back
const doc = await db.get('test-doc');
console.log(`[Test] Got doc:`, doc);
res.json({ success: true, info, doc });
} catch (err) {
console.error(`[Test] Error:`, err);
res.status(500).json({ error: err.message, stack: err.stack });
}
});
// PouchDB database sync endpoint with auth middleware
// Public databases (/pouchdb/public-*) are accessible without auth
// Private databases (/pouchdb/private-*) require authentication
// Patch req.query for Express 5 compatibility with express-pouchdb
app.use("/pouchdb", dbAuthMiddleware, (req, res, next) => {
// Express 5 makes req.query read-only, but express-pouchdb needs to write to it
// Redefine as writable property
Object.defineProperty(req, 'query', {
value: { ...req.query },
writable: true,
configurable: true
});
next();
}, pouchApp, (err, req, res, next) => {
// Error handler for express-pouchdb
console.error('[PouchDB Error]', err);
res.status(500).json({ error: err.message, stack: err.stack });
});
// Check if running in API-only mode (split deployment)
const apiOnly = process.env.API_ONLY === "true";

View File

@ -2,12 +2,16 @@ import { Router } from "express";
import claudeRouter from "./claude.js";
import ollamaRouter from "./ollama.js";
import sessionRouter from "./session.js";
import userRouter from "./user.js";
const router = Router();
// Session management
router.use("/session", sessionRouter);
// User features
router.use("/user", userRouter);
// Claude API proxy
router.use("/claude", claudeRouter);

155
server/api/user.js Normal file
View File

@ -0,0 +1,155 @@
import { Router } from "express";
const router = Router();
// Feature configurations by tier
const FEATURE_CONFIGS = {
none: {
tier: 'none',
pages: {
examples: 'off',
documentation: 'off',
pricing: 'off',
vrExperience: 'off',
},
features: {
createDiagram: 'off',
createFromTemplate: 'off',
manageDiagrams: 'off',
shareCollaborate: 'off',
privateDesigns: 'off',
encryptedDesigns: 'off',
editData: 'off',
config: 'off',
enterImmersive: 'off',
launchMetaQuest: 'off',
},
limits: {
maxDiagrams: 0,
maxCollaborators: 0,
storageQuotaMB: 0,
},
},
free: {
tier: 'free',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'coming-soon',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'coming-soon',
encryptedDesigns: 'pro',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: 6,
maxCollaborators: 0,
storageQuotaMB: 100,
},
},
basic: {
tier: 'basic',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'on',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'on',
encryptedDesigns: 'pro',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: 25,
maxCollaborators: 0,
storageQuotaMB: 500,
},
},
pro: {
tier: 'pro',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'on',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'on',
encryptedDesigns: 'on',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: -1,
maxCollaborators: -1,
storageQuotaMB: -1,
},
},
};
// Default tier for authenticated users without a specific tier
const DEFAULT_TIER = 'basic';
/**
* GET /api/user/features
* Returns feature configuration for the current user
*
* Query params:
* - tier: Override tier for testing (e.g., ?tier=pro)
*/
router.get("/features", (req, res) => {
// Allow tier override via query param for testing
const tierOverride = req.query.tier;
// TODO: In production, determine tier from JWT token or user database
// For now, use query param override or default to 'basic'
const tier = tierOverride && FEATURE_CONFIGS[tierOverride]
? tierOverride
: DEFAULT_TIER;
const config = FEATURE_CONFIGS[tier];
console.log(`[User] Returning feature config for tier: ${tier}`);
res.json(config);
});
/**
* GET /api/user/features/:tier
* Returns feature configuration for a specific tier (for testing/admin)
*/
router.get("/features/:tier", (req, res) => {
const { tier } = req.params;
const config = FEATURE_CONFIGS[tier];
if (!config) {
return res.status(404).json({ error: `Unknown tier: ${tier}` });
}
console.log(`[User] Returning feature config for tier: ${tier}`);
res.json(config);
});
export default router;

View File

@ -0,0 +1,78 @@
/**
* Database authentication middleware.
* Allows public databases to be accessed without auth.
* Private databases require authentication.
*/
/**
* Middleware to handle database authentication based on path.
*
* Database naming patterns:
* / - Root endpoint, always allowed (server info)
* /public-{dbname} - No auth required, anyone can read/write
* /private-{dbname} - Auth required
* /{dbname} - Treated as private by default
*/
export function dbAuthMiddleware(req, res, next) {
// Extract the database name (first segment after /pouchdb/)
const pathParts = req.path.split('/').filter(Boolean);
const dbName = pathParts[0] || '';
// Allow root endpoint (server info check)
if (req.path === '/' || req.path === '') {
console.log(`[DB Auth] Root access: ${req.method} ${req.path}`);
return next();
}
// Check if this is a public database (name starts with 'public-')
const isPublic = dbName.startsWith('public-');
if (isPublic) {
// No auth required for public databases
console.log(`[DB Auth] Public access: ${req.method} ${req.path}`);
return next();
}
// For private databases, check for auth header
const auth = req.headers.authorization;
if (!auth) {
console.log(`[DB Auth] Unauthorized access attempt: ${req.method} ${req.path}`);
return res.status(401).json({
error: 'unauthorized',
reason: 'Authentication required for private databases'
});
}
// Parse Basic auth header
if (auth.startsWith('Basic ')) {
try {
const credentials = Buffer.from(auth.slice(6), 'base64').toString();
const [username, password] = credentials.split(':');
// For now, accept any credentials for private databases
// TODO: Implement proper user verification
req.dbUser = { name: username };
console.log(`[DB Auth] Authenticated: ${username} accessing ${req.path}`);
return next();
} catch (err) {
console.log(`[DB Auth] Invalid auth header: ${err.message}`);
}
}
// TODO: Add JWT/Bearer token support for Auth0 integration
if (auth.startsWith('Bearer ')) {
// For now, accept bearer tokens without verification
// TODO: Verify JWT with Auth0
req.dbUser = { name: 'bearer-user' };
console.log(`[DB Auth] Bearer token access: ${req.path}`);
return next();
}
return res.status(401).json({
error: 'unauthorized',
reason: 'Invalid authentication'
});
}
export default dbAuthMiddleware;

View File

@ -0,0 +1,62 @@
/**
* Database service using express-pouchdb for self-hosted database sync.
* Provides PouchDB HTTP API compatible with client-side PouchDB replication.
*/
import PouchDB from 'pouchdb';
import PouchDBAdapterMemory from 'pouchdb-adapter-memory';
import expressPouchdb from 'express-pouchdb';
import path from 'path';
import { fileURLToPath } from 'url';
// Register memory adapter (works in Node.js without leveldown issues)
PouchDB.plugin(PouchDBAdapterMemory);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Data directory for persistent storage (used for logs)
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
// Use memory adapter for now - data persists while server is running
// TODO: Switch to leveldb once version conflicts are resolved
const memPouchDB = PouchDB.defaults({
adapter: 'memory'
});
// Create express-pouchdb middleware
// Using 'minimumForPouchDB' mode for lightweight operation
// Include routes needed for PouchDB replication
const pouchApp = expressPouchdb(memPouchDB, {
mode: 'minimumForPouchDB',
overrideMode: {
include: [
'routes/root', // GET / - server info
'routes/db', // PUT/GET/DELETE /:db
'routes/all-dbs', // GET /_all_dbs
'routes/changes', // GET /:db/_changes
'routes/bulk-docs', // POST /:db/_bulk_docs
'routes/bulk-get', // POST /:db/_bulk_get
'routes/all-docs', // GET /:db/_all_docs
'routes/revs-diff', // POST /:db/_revs_diff
'routes/documents' // GET/PUT/DELETE /:db/:docid
]
},
logPath: path.join(DATA_DIR, 'logs', 'pouchdb.log')
});
console.log(`[Database] Initialized express-pouchdb with data dir: ${DATA_DIR}`);
// Test that PouchDB can create databases
(async () => {
try {
const testDb = new memPouchDB('_test_db');
const info = await testDb.info();
console.log('[Database] Test DB created successfully:', info);
await testDb.destroy();
console.log('[Database] Test DB destroyed');
} catch (err) {
console.error('[Database] Failed to create test database:', err);
}
})();
export { memPouchDB as PouchDB, pouchApp };

View File

@ -5,18 +5,67 @@ import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserver
import log, {Logger} from "loglevel";
import PouchDB from 'pouchdb';
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
import {isPublicPath, getRemoteDbPath} from "../../util/functions/getPath";
export class PouchData {
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
public readonly onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
private _db: PouchDB;
private _remote: PouchDB;
private _diagramManager: DiagramManager;
private _logger: Logger = log.getLogger('PouchData');
private _dbName: string;
private _syncHandler: any;
constructor(dbname: string) {
this._db = new PouchDB(dbname);
this._dbName = dbname;
// Start sync for public databases
this.initSync();
}
/**
* Initialize sync with remote express-pouchdb for public databases
*/
private initSync() {
const remoteDbPath = getRemoteDbPath();
const isPublic = isPublicPath();
if (!remoteDbPath || !isPublic) {
this._logger.debug('[Sync] Not a public path, skipping remote sync');
return;
}
const remoteUrl = `${window.location.origin}/pouchdb/${remoteDbPath}`;
this._logger.info(`[Sync] Connecting to remote: ${remoteUrl}`);
this._remote = new PouchDB(remoteUrl);
// Start live bidirectional sync
this._syncHandler = this._db.sync(this._remote, { live: true, retry: true })
.on('change', (info) => {
this._logger.debug('[Sync] Change:', info.direction, info.change.docs.length, 'docs');
// Process incoming changes
if (info.direction === 'pull' && info.change && info.change.docs) {
info.change.docs.forEach((doc) => {
if (doc._deleted) {
this.onDBEntityRemoveObservable.notifyObservers(doc);
} else if (doc.id && doc.id !== 'metadata') {
this.onDBEntityUpdateObservable.notifyObservers(doc);
}
});
}
})
.on('paused', (info) => {
this._logger.debug('[Sync] Paused - up to date');
})
.on('active', () => {
this._logger.debug('[Sync] Active - syncing');
})
.on('error', (err) => {
this._logger.error('[Sync] Error:', err);
});
}
public setDiagramManager(diagramManager: DiagramManager) {
this._diagramManager = diagramManager;

View File

@ -1,409 +0,0 @@
import PouchDB from 'pouchdb';
import {DiagramEntity, DiagramEntityType, DiagramEventType} from "../../diagram/types/diagramEntity";
import {Observable} from "@babylonjs/core";
import axios from "axios";
import {DiagramManager} from "../../diagram/diagramManager";
import log, {Logger} from "loglevel";
import {ascii_to_hex} from "../functions/hexFunctions";
import {getPath} from "../../util/functions/getPath";
import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserverMask";
import {syncDoc} from "../functions/syncDoc";
import {checkDb} from "../functions/checkDb";
import {UserModelType} from "../../users/userTypes";
import {getMe} from "../../util/me";
import {Encryption} from "../encryption";
import {Presence} from "../presence";
type PasswordEvent = {
detail: string;
}
type PasswordEvent2 = {
password: string;
id: string;
encrypted: boolean;
}
export class PouchdbPersistenceManager {
private _logger: Logger = log.getLogger('PouchdbPersistenceManager');
onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
private db: PouchDB;
private remote: PouchDB;
private user: string;
private _encryption = new Encryption();
private _encKey = null;
private _diagramManager: DiagramManager;
private _salt: string;
private _failCount: number = 0;
constructor() {
document.addEventListener('passwordset', (evt) => {
this._encKey = ((evt as unknown) as PasswordEvent).detail || null;
if (this._encKey && typeof (this._encKey) == 'string') {
this.initialize().then(() => {
this._logger.debug('Initialized');
});
}
this._logger.debug(evt);
});
document.addEventListener('dbcreated', (evt: CustomEvent) => {
const detail = ((evt.detail as unknown) as PasswordEvent2);
const password = detail.password;
const id = detail.id;
if (detail.encrypted) {
this._encKey = password;
} else {
this._encKey = null;
}
//this._encKey = password;
this.db = new PouchDB(detail.id, {auto_compaction: true});
this.setupMetadata(id).then(() => {
document.location.href = '/db/' + id;
}).catch((err) => {
console.log(err);
})
});
}
public setDiagramManager(diagramManager: DiagramManager) {
this._diagramManager = diagramManager;
diagramManager.onDiagramEventObservable.add((evt) => {
this._logger.debug(evt);
if (!evt?.entity) {
this._logger.warn('no entity');
return;
}
if (!evt?.entity?.id) {
this._logger.warn('no entity id');
return;
}
switch (evt.type) {
case DiagramEventType.REMOVE:
this.remove(evt.entity.id);
break;
case DiagramEventType.ADD:
case DiagramEventType.MODIFY:
case DiagramEventType.DROP:
this.upsert(evt.entity);
break;
default:
this._logger.warn('unknown diagram event type', evt);
}
}, DiagramEventObserverMask.TO_DB);
this.onDBEntityUpdateObservable.add((evt) => {
this._logger.debug(evt);
if (evt.id != 'metadata' && evt?.type == DiagramEntityType.USER) {
diagramManager.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: evt
}, DiagramEventObserverMask.FROM_DB);
} else {
}
});
this.onDBEntityRemoveObservable.add((entity) => {
this._logger.debug(entity);
diagramManager.onDiagramEventObservable.notifyObservers(
{type: DiagramEventType.REMOVE, entity: entity}, DiagramEventObserverMask.FROM_DB);
});
}
public async remove(id: string) {
if (!id) {
return;
}
try {
const doc = await this.db.get(id);
this.db.remove(doc);
} catch (err) {
this._logger.error(err);
}
}
public async upsert(entity: DiagramEntity) {
if (!entity) {
return;
}
if (entity.template == '#image-template' && !entity.image) {
this._logger.error('no image data', entity);
return;
}
if (this._encKey && !this._encryption.ready) {
await this._encryption.setPassword(this._encKey);
}
try {
const doc = await this.db.get(entity.id, {conflicts: true, include_docs: true});
if (doc && doc._conflicts) {
this._logger.warn('CONFLICTS!', doc._conflicts);
}
if (this._encKey) {
if (!doc.encrypted) {
this._logger.warn("current local doc is not encrypted, encrypting");
}
await this._encryption.encryptObject(entity);
const newDoc = {
_id: doc._id,
_rev: doc._rev,
encrypted: this._encryption.getEncrypted()
}
this.db.put(newDoc)
} else {
if (doc) {
if (doc.encrypted) {
this._logger.error("current local doc is encrypted, but encryption key is missing... saving in plaintext");
}
const newDoc = {_id: doc._id, _rev: doc._rev, ...entity};
this.db.put(newDoc);
} else {
this.db.put({_id: entity.id, ...entity});
}
}
} catch (err) {
if (err.status == 404) {
try {
if (this._encKey) {
if (!this._encryption.ready) {
this._logger.error('Encryption not ready, there is a potential problem when this happens, we will generate a new salt which may cause data loss and/or slowness');
await this._encryption.setPassword(this._encKey);
}
await this._encryption.encryptObject(entity);
const newDoc = {
_id: entity.id,
encrypted: this._encryption.getEncrypted()
}
this.db.put(newDoc);
} else {
this._logger.info('no encryption key, saving in plaintext');
const newEntity = {_id: entity.id, ...entity};
this.db.put(newEntity);
}
} catch (err2) {
this._logger.error("Unable to save document");
this._logger.error(err2);
}
} else {
this._logger.error("Unknown error with document get from db");
this._logger.error(err);
}
}
}
public async initialize() {
if (!await this.initLocal()) {
return;
}
await this.sendLocalDataToScene();
}
private async setupMetadata(current: string): Promise<boolean> {
try {
const doc = await this.db.get('metadata');
if (doc.encrypted) {
if (!this._salt && doc.encrypted.salt) {
this._logger.warn('Missing Salt');
this._salt = doc.encrypted.salt;
}
if (!this._encKey) {
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
document.dispatchEvent(promptPassword);
return false;
}
if (!this._encryption.ready) {
this._logger.warn("Encryption not ready, setting password");
await this._encryption.setPassword(this._encKey, doc.encrypted.salt);
}
const decrypted = await this._encryption.decryptToObject(doc.encrypted.encrypted, doc.encrypted.iv);
if (decrypted.friendly) {
this._logger.info("Storing Document friendly name in local storage, decrypted");
localStorage.setItem(current, decrypted.friendly);
}
} else {
if (doc && doc.friendly) {
this._logger.info("Storing Document friendly name in local storage");
localStorage.setItem(current, doc.friendly);
this._encKey = null;
}
if (doc && doc.camera) {
}
}
} catch (err) {
if (err.status == 404) {
this._logger.debug('no metadata found');
const friendly = localStorage.getItem(current);
if (friendly) {
if (this._encKey) {
if (!this._encryption.ready) {
await this._encryption.setPassword(this._encKey);
}
await this._encryption.encryptObject({friendly: friendly});
await this.db.put({_id: 'metadata', id: 'metadata', encrypted: this._encryption.getEncrypted()})
} else {
this._logger.debug('local friendly name found ', friendly, ' setting metadata');
const newDoc = {_id: 'metadata', id: 'metadata', friendly: friendly};
await this.db.put(newDoc);
}
} else {
this._logger.warn('no friendly name found');
}
}
}
return true;
}
private async initLocal(): Promise<boolean> {
try {
let sync = false;
let current = getPath();
if (current && current != 'localdb') {
sync = true;
} else {
current = 'localdb';
}
this.db = new PouchDB(current, {auto_compaction: true});
if (sync) {
if (await this.setupMetadata(current)) {
await this.beginSync(current);
}
}
return true;
} catch (err) {
this._logger.error(err);
this._logger.error('cannot initialize pouchdb for sync');
return false;
}
}
private async sendLocalDataToScene() {
let salt = null;
const clear = localStorage.getItem('clearLocal');
try {
const all = await this.db.allDocs({include_docs: true});
for (const dbEntity of all.rows) {
this._logger.debug(dbEntity.doc);
if (clear) {
this.remove(dbEntity.id);
} else {
if (dbEntity.doc.encrypted) {
if (!salt || salt != dbEntity.doc.encrypted.salt) {
await this._encryption.setPassword(this._encKey, dbEntity.doc.encrypted.salt);
salt = dbEntity.doc.encrypted.salt;
}
const decrypted = await this._encryption.decryptToObject(dbEntity.doc.encrypted.encrypted, dbEntity.doc.encrypted.iv);
if (decrypted.id != 'metadata') {
this.onDBEntityUpdateObservable.notifyObservers(decrypted, DiagramEventObserverMask.FROM_DB);
}
} else {
if (dbEntity.id != 'metadata') {
this.onDBEntityUpdateObservable.notifyObservers(dbEntity.doc, DiagramEventObserverMask.FROM_DB);
}
}
}
if (clear) {
localStorage.removeItem('clearLocal');
}
}
} catch (err) {
switch (err.message) {
case 'WebCrypto_DecryptionFailure: ':
case 'Invalid data type!':
this._failCount++;
if (this._failCount < 5) {
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
document.dispatchEvent(promptPassword);
} else {
this._logger.error('Too many decryption failures, Ignoring... This may compromise your data security');
window.alert('Too many decryption failures, Ignoring... This may compromise your data security');
}
}
this._logger.error(err);
}
}
private async beginSync(localName: string) {
try {
const userHex = ascii_to_hex(localName);
const remoteDbName = 'userdb-' + userHex;
const remoteUserName = localName;
const password = this._encKey || localName;
if (await checkDb(localName, remoteDbName, password) == false) {
return;
}
const userEndpoint: string = import.meta.env.VITE_USER_ENDPOINT
this._logger.debug(userEndpoint);
this._logger.debug(remoteDbName);
const target = await axios.get(userEndpoint);
if (target.status != 200) {
this._logger.warn(target.statusText);
return;
}
if (target.data && target.data.userCtx) {
if (!target.data.userCtx.name || target.data.userCtx.name != remoteUserName) {
try {
const buildTarget = await axios.post(userEndpoint,
{username: remoteUserName, password: password});
if (buildTarget.status != 200) {
this._logger.error(buildTarget.statusText);
return;
} else {
this.user = buildTarget.data.userCtx;
this._logger.debug(this.user);
}
} catch (err) {
if (err.response && err.response.status == 401) {
this._logger.warn(err);
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
document.dispatchEvent(promptPassword);
}
// } else {
this._logger.error(err);
}
}
}
const remoteEndpoint: string = import.meta.env.VITE_SYNCDB_ENDPOINT;
this._logger.debug(remoteEndpoint + remoteDbName);
this.remote = new PouchDB(remoteEndpoint + remoteDbName,
{auth: {username: remoteUserName, password: password}, skip_setup: true});
const dbInfo = await this.remote.info();
this._logger.debug(dbInfo);
const presence: Presence = new Presence(getMe(), remoteDbName);
this._diagramManager.onUserEventObservable.add((user: UserModelType) => {
//this._logger.debug(user);
presence.sendUser(user);
}, -1, false, this);
this.db.sync(this.remote, {live: true, retry: true})
.on('change', (info) => {
syncDoc(info, this.onDBEntityRemoveObservable, this.onDBEntityUpdateObservable, this._encryption, this._encKey);
})
.on('active', (info) => {
this._logger.debug('sync active', info)
})
.on('paused', (info) => {
this._logger.debug('sync paused', info)
})
.on('error', (err) => {
this._logger.error('sync error', err)
});
} catch (err) {
this._logger.error(err);
}
}
}

View File

@ -40,6 +40,7 @@ export default function VrExperience() {
const createFromTemplateState = useFeatureState('createFromTemplate');
const manageDiagramsState = useFeatureState('manageDiagrams');
const shareCollaborateState = useFeatureState('shareCollaborate');
console.log('[Share] shareCollaborateState:', shareCollaborateState);
const editDataState = useFeatureState('editData');
const configState = useFeatureState('config');
const enterImmersiveState = useFeatureState('enterImmersive');
@ -121,6 +122,30 @@ export default function VrExperience() {
const [showVRPrompt, setShowVRPrompt] = useState(false);
const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop
// Handle share: simply copy current URL to clipboard
const handleShare = async () => {
const shareUrl = window.location.href;
logger.info(`Sharing URL: ${shareUrl}`);
// Try to copy URL to clipboard with fallback
let copied = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
copied = true;
}
} catch (clipboardError) {
logger.warn('Clipboard API failed:', clipboardError);
}
if (copied) {
alert(`URL copied to clipboard!\n\n${shareUrl}`);
} else {
// Fallback: show URL in prompt so user can copy manually
prompt('Share URL (copy manually):', shareUrl);
}
};
useEffect(() => {
const canvas = document.getElementById('vrCanvas');
if (!canvas) {
@ -131,6 +156,7 @@ export default function VrExperience() {
logger.debug('destroying vrApp');
vrApp.dispose();
}
console.log('[Share] Initializing VrApp with dbName:', dbName);
vrApp = new VrApp(canvas as HTMLCanvasElement, dbName);
closeManage();
@ -329,9 +355,9 @@ export default function VrExperience() {
{shouldShow(shareCollaborateState) && (
<VrMenuItem
tip="Share your model with others and collaborate in real time with others. This is a paid feature."
tip="Share your model with others. Creates a shareable link that copies to clipboard."
label="Share"
onClick={getClickHandler(shareCollaborateState, null)}
onClick={getClickHandler(shareCollaborateState, handleShare)}
availableIcon={getFeatureIndicator(shareCollaborateState)}/>
)}

View File

@ -56,7 +56,7 @@ export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
createDiagram: 'basic', // Guests can create diagrams
createFromTemplate: 'coming-soon', // Coming soon for guests
manageDiagrams: 'basic', // Guests can manage their local diagrams
shareCollaborate: 'coming-soon', // Coming soon for guests
shareCollaborate: 'on', // Share diagrams via link
privateDesigns: 'coming-soon', // Coming soon for guests
encryptedDesigns: 'pro', // No encryption for guests
editData: 'coming-soon', // Guests can edit data
@ -83,7 +83,7 @@ export const BASIC_FEATURE_CONFIG: FeatureConfig = {
createDiagram: 'on',
createFromTemplate: 'coming-soon',
manageDiagrams: 'on',
shareCollaborate: 'coming-soon',
shareCollaborate: 'on',
privateDesigns: 'coming-soon',
encryptedDesigns: 'pro',
editData: 'coming-soon',

View File

@ -1,10 +1,51 @@
export function getPath(): string {
const path = window.location.pathname.split('/');
// Handle /db/public/:db or /db/private/:db patterns
if (path.length >= 4 && path[1] === 'db') {
return path[3];
}
// Legacy pattern /db/:db
if (path.length == 3 && path[1]) {
return path[2];
} else {
return null;
}
return null;
}
/**
* Check if the current path is a public database
* Public paths: /db/public/:db
* Private paths: /db/private/:db
*/
export function isPublicPath(): boolean {
const path = window.location.pathname.split('/');
return path.length >= 3 && path[1] === 'db' && path[2] === 'public';
}
/**
* Get the database type from the current path
*/
export function getDbType(): 'public' | 'private' | null {
const path = window.location.pathname.split('/');
if (path.length >= 3 && path[1] === 'db') {
if (path[2] === 'public') return 'public';
if (path[2] === 'private') return 'private';
}
return null;
}
/**
* Get the full database path for PouchDB sync
* Returns: public-{dbname} or private-{dbname}
* Uses dash separator instead of slash for express-pouchdb compatibility
*/
export function getRemoteDbPath(): string | null {
const path = window.location.pathname.split('/');
if (path.length >= 4 && path[1] === 'db') {
const type = path[2]; // 'public' or 'private'
const dbName = path[3];
return `${type}-${dbName}`;
}
return null;
}
export function getParameter(name: string) {

View File

@ -23,7 +23,6 @@ import {PouchData} from "./integration/database/pouchData";
const webGpu = false;
log.setLevel('debug', false);
log.getLogger('PouchdbPersistenceManager').setLevel('debug', false);
export default class VrApp {
//preTasks = [havokModule];
private logger: Logger = log.getLogger('App');