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:
parent
74a2d179b9
commit
a772372b2b
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,3 +25,4 @@ dist-ssr
|
|||||||
|
|
||||||
# Local Netlify folder
|
# Local Netlify folder
|
||||||
.netlify
|
.netlify
|
||||||
|
/data/
|
||||||
|
|||||||
1966
package-lock.json
generated
1966
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "immersive",
|
"name": "immersive",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.8-34",
|
"version": "0.0.8-42",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -46,13 +46,17 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-pouchdb": "^4.2.0",
|
||||||
"hash-wasm": "4.11.0",
|
"hash-wasm": "4.11.0",
|
||||||
"hls.js": "^1.1.4",
|
"hls.js": "^1.1.4",
|
||||||
"js-crypto-aes": "1.0.6",
|
"js-crypto-aes": "1.0.6",
|
||||||
|
"leveldown": "^6.1.1",
|
||||||
"loglevel": "^1.9.1",
|
"loglevel": "^1.9.1",
|
||||||
"meaningful-string": "^1.4.0",
|
"meaningful-string": "^1.4.0",
|
||||||
"peer-lite": "2.0.2",
|
"peer-lite": "2.0.2",
|
||||||
"pouchdb": "^8.0.1",
|
"pouchdb": "^8.0.1",
|
||||||
|
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||||
|
"pouchdb-adapter-memory": "^9.0.0",
|
||||||
"pouchdb-find": "^8.0.1",
|
"pouchdb-find": "^8.0.1",
|
||||||
"query-string": "^8.1.0",
|
"query-string": "^8.1.0",
|
||||||
"react-router-dom": "^6.26.1",
|
"react-router-dom": "^6.26.1",
|
||||||
@ -73,4 +77,4 @@
|
|||||||
"vite-plugin-cp": "^1.0.0",
|
"vite-plugin-cp": "^1.0.0",
|
||||||
"vitest": "^1.4.0"
|
"vitest": "^1.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
53
server.js
53
server.js
@ -3,6 +3,8 @@ import ViteExpress from "vite-express";
|
|||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import apiRoutes from "./server/api/index.js";
|
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
|
// Load .env.local first, then fall back to .env
|
||||||
dotenv.config({ path: '.env.local' });
|
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
|
// API routes
|
||||||
app.use("/api", apiRoutes);
|
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)
|
// Check if running in API-only mode (split deployment)
|
||||||
const apiOnly = process.env.API_ONLY === "true";
|
const apiOnly = process.env.API_ONLY === "true";
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,16 @@ import { Router } from "express";
|
|||||||
import claudeRouter from "./claude.js";
|
import claudeRouter from "./claude.js";
|
||||||
import ollamaRouter from "./ollama.js";
|
import ollamaRouter from "./ollama.js";
|
||||||
import sessionRouter from "./session.js";
|
import sessionRouter from "./session.js";
|
||||||
|
import userRouter from "./user.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Session management
|
// Session management
|
||||||
router.use("/session", sessionRouter);
|
router.use("/session", sessionRouter);
|
||||||
|
|
||||||
|
// User features
|
||||||
|
router.use("/user", userRouter);
|
||||||
|
|
||||||
// Claude API proxy
|
// Claude API proxy
|
||||||
router.use("/claude", claudeRouter);
|
router.use("/claude", claudeRouter);
|
||||||
|
|
||||||
|
|||||||
155
server/api/user.js
Normal file
155
server/api/user.js
Normal 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;
|
||||||
78
server/middleware/dbAuth.js
Normal file
78
server/middleware/dbAuth.js
Normal 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;
|
||||||
62
server/services/databaseService.js
Normal file
62
server/services/databaseService.js
Normal 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 };
|
||||||
@ -5,18 +5,67 @@ import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserver
|
|||||||
import log, {Logger} from "loglevel";
|
import log, {Logger} from "loglevel";
|
||||||
import PouchDB from 'pouchdb';
|
import PouchDB from 'pouchdb';
|
||||||
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
|
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
|
||||||
|
import {isPublicPath, getRemoteDbPath} from "../../util/functions/getPath";
|
||||||
|
|
||||||
export class PouchData {
|
export class PouchData {
|
||||||
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
||||||
public readonly onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
public readonly onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
||||||
private _db: PouchDB;
|
private _db: PouchDB;
|
||||||
|
private _remote: PouchDB;
|
||||||
private _diagramManager: DiagramManager;
|
private _diagramManager: DiagramManager;
|
||||||
private _logger: Logger = log.getLogger('PouchData');
|
private _logger: Logger = log.getLogger('PouchData');
|
||||||
private _dbName: string;
|
private _dbName: string;
|
||||||
|
private _syncHandler: any;
|
||||||
|
|
||||||
constructor(dbname: string) {
|
constructor(dbname: string) {
|
||||||
this._db = new PouchDB(dbname);
|
this._db = new PouchDB(dbname);
|
||||||
this._dbName = 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) {
|
public setDiagramManager(diagramManager: DiagramManager) {
|
||||||
this._diagramManager = diagramManager;
|
this._diagramManager = diagramManager;
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -40,6 +40,7 @@ export default function VrExperience() {
|
|||||||
const createFromTemplateState = useFeatureState('createFromTemplate');
|
const createFromTemplateState = useFeatureState('createFromTemplate');
|
||||||
const manageDiagramsState = useFeatureState('manageDiagrams');
|
const manageDiagramsState = useFeatureState('manageDiagrams');
|
||||||
const shareCollaborateState = useFeatureState('shareCollaborate');
|
const shareCollaborateState = useFeatureState('shareCollaborate');
|
||||||
|
console.log('[Share] shareCollaborateState:', shareCollaborateState);
|
||||||
const editDataState = useFeatureState('editData');
|
const editDataState = useFeatureState('editData');
|
||||||
const configState = useFeatureState('config');
|
const configState = useFeatureState('config');
|
||||||
const enterImmersiveState = useFeatureState('enterImmersive');
|
const enterImmersiveState = useFeatureState('enterImmersive');
|
||||||
@ -121,6 +122,30 @@ export default function VrExperience() {
|
|||||||
const [showVRPrompt, setShowVRPrompt] = useState(false);
|
const [showVRPrompt, setShowVRPrompt] = useState(false);
|
||||||
const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop
|
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(() => {
|
useEffect(() => {
|
||||||
const canvas = document.getElementById('vrCanvas');
|
const canvas = document.getElementById('vrCanvas');
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
@ -131,6 +156,7 @@ export default function VrExperience() {
|
|||||||
logger.debug('destroying vrApp');
|
logger.debug('destroying vrApp');
|
||||||
vrApp.dispose();
|
vrApp.dispose();
|
||||||
}
|
}
|
||||||
|
console.log('[Share] Initializing VrApp with dbName:', dbName);
|
||||||
vrApp = new VrApp(canvas as HTMLCanvasElement, dbName);
|
vrApp = new VrApp(canvas as HTMLCanvasElement, dbName);
|
||||||
closeManage();
|
closeManage();
|
||||||
|
|
||||||
@ -329,9 +355,9 @@ export default function VrExperience() {
|
|||||||
|
|
||||||
{shouldShow(shareCollaborateState) && (
|
{shouldShow(shareCollaborateState) && (
|
||||||
<VrMenuItem
|
<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"
|
label="Share"
|
||||||
onClick={getClickHandler(shareCollaborateState, null)}
|
onClick={getClickHandler(shareCollaborateState, handleShare)}
|
||||||
availableIcon={getFeatureIndicator(shareCollaborateState)}/>
|
availableIcon={getFeatureIndicator(shareCollaborateState)}/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
|
|||||||
createDiagram: 'basic', // Guests can create diagrams
|
createDiagram: 'basic', // Guests can create diagrams
|
||||||
createFromTemplate: 'coming-soon', // Coming soon for guests
|
createFromTemplate: 'coming-soon', // Coming soon for guests
|
||||||
manageDiagrams: 'basic', // Guests can manage their local diagrams
|
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
|
privateDesigns: 'coming-soon', // Coming soon for guests
|
||||||
encryptedDesigns: 'pro', // No encryption for guests
|
encryptedDesigns: 'pro', // No encryption for guests
|
||||||
editData: 'coming-soon', // Guests can edit data
|
editData: 'coming-soon', // Guests can edit data
|
||||||
@ -83,7 +83,7 @@ export const BASIC_FEATURE_CONFIG: FeatureConfig = {
|
|||||||
createDiagram: 'on',
|
createDiagram: 'on',
|
||||||
createFromTemplate: 'coming-soon',
|
createFromTemplate: 'coming-soon',
|
||||||
manageDiagrams: 'on',
|
manageDiagrams: 'on',
|
||||||
shareCollaborate: 'coming-soon',
|
shareCollaborate: 'on',
|
||||||
privateDesigns: 'coming-soon',
|
privateDesigns: 'coming-soon',
|
||||||
encryptedDesigns: 'pro',
|
encryptedDesigns: 'pro',
|
||||||
editData: 'coming-soon',
|
editData: 'coming-soon',
|
||||||
|
|||||||
@ -1,10 +1,51 @@
|
|||||||
export function getPath(): string {
|
export function getPath(): string {
|
||||||
const path = window.location.pathname.split('/');
|
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]) {
|
if (path.length == 3 && path[1]) {
|
||||||
return path[2];
|
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) {
|
export function getParameter(name: string) {
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import {PouchData} from "./integration/database/pouchData";
|
|||||||
const webGpu = false;
|
const webGpu = false;
|
||||||
|
|
||||||
log.setLevel('debug', false);
|
log.setLevel('debug', false);
|
||||||
log.getLogger('PouchdbPersistenceManager').setLevel('debug', false);
|
|
||||||
export default class VrApp {
|
export default class VrApp {
|
||||||
//preTasks = [havokModule];
|
//preTasks = [havokModule];
|
||||||
private logger: Logger = log.getLogger('App');
|
private logger: Logger = log.getLogger('App');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user