Compare commits

...

9 Commits

Author SHA1 Message Date
Zimeng Xiong 4bc66ab014 MVP passwords 2025-11-28 10:19:44 -08:00
Zimeng Xiong 971046d568 Update README 2025-11-24 15:04:52 -08:00
Zimeng Xiong 602350d2e6 Merge pull request #9 from ZimengXiong/pre-release
v0.1.6 Add export button, store library in database
2025-11-24 15:01:02 -08:00
Zimeng Xiong f20d48fea2 fix migration issues 2025-11-24 14:53:17 -08:00
Zimeng Xiong c53dc010de Merge branch '8-export-drawing' into pre-release 2025-11-24 14:43:58 -08:00
Zimeng Xiong 03e778a06f add export functionality via exportUtils 2025-11-24 14:39:38 -08:00
Zimeng Xiong fa73708d97 allow importing of libraries via URL, update db schema 2025-11-24 14:32:48 -08:00
Zimeng Xiong ee8204532d Update README.md 2025-11-23 10:23:24 -08:00
Zimeng Xiong a347403a26 Fix caution message formatting in README 2025-11-23 10:15:51 -08:00
37 changed files with 5476 additions and 46 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88"> <img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
# ExcaliDash v0.1.5 # ExcaliDash v0.1.6
![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash) ![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash)
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com)
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features. A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
@@ -74,7 +75,7 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
# Installation # Installation
> [!CAUTION] > [!CAUTION]
> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization) have been made, they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF. > NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF.
> [!CAUTION] > [!CAUTION]
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron). > ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
+1 -1
View File
@@ -1 +1 @@
0.1.5 0.1.6
+5 -2
View File
@@ -8,7 +8,10 @@ COPY package*.json ./
COPY tsconfig.json ./ COPY tsconfig.json ./
# Install dependencies # Install dependencies
RUN npm ci # Install build deps required for compiling native modules like better-sqlite3
RUN apk add --no-cache python3 make g++ build-base sqlite-dev && \
npm ci
ENV PYTHON=/usr/bin/python3
# Copy prisma schema # Copy prisma schema
COPY prisma ./prisma/ COPY prisma ./prisma/
@@ -26,7 +29,7 @@ RUN npx tsc
FROM node:20-alpine FROM node:20-alpine
# Install OpenSSL for Prisma and su-exec, create non-root user # Install OpenSSL for Prisma and su-exec, create non-root user
RUN apk add --no-cache openssl su-exec && \ RUN apk add --no-cache openssl su-exec sqlite-libs && \
addgroup -g 1001 -S nodejs && \ addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 adduser -S nodejs -u 1001
+6
View File
@@ -5,6 +5,12 @@ set -e
if [ ! -f "/app/prisma/schema.prisma" ]; then if [ ! -f "/app/prisma/schema.prisma" ]; then
echo "Mount is empty. Hydrating /app/prisma..." echo "Mount is empty. Hydrating /app/prisma..."
cp -R /app/prisma_template/. /app/prisma/ cp -R /app/prisma_template/. /app/prisma/
else
# Volume exists but may be missing new migrations from an upgrade
# Always sync schema and migrations from template to ensure upgrades work
echo "Syncing schema and migrations from template..."
cp /app/prisma_template/schema.prisma /app/prisma/schema.prisma
cp -R /app/prisma_template/migrations/. /app/prisma/migrations/
fi fi
# 2. Fix permissions unconditionally (Running as root) # 2. Fix permissions unconditionally (Running as root)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "0.1.5", "version": "0.1.6",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "Library" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
"items" TEXT NOT NULL DEFAULT '[]',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "PrivateVault" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'vault',
"passwordHash" TEXT NOT NULL,
"salt" TEXT NOT NULL,
"hint" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Drawing" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"elements" TEXT NOT NULL,
"appState" TEXT NOT NULL,
"files" TEXT NOT NULL DEFAULT '{}',
"preview" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"collectionId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"isPrivate" BOOLEAN NOT NULL DEFAULT false,
"encryptedData" TEXT,
"iv" TEXT,
CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version") SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version" FROM "Drawing";
DROP TABLE "Drawing";
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
Binary file not shown.
Binary file not shown.
+22
View File
@@ -32,4 +32,26 @@ model Drawing {
collection Collection? @relation(fields: [collectionId], references: [id]) collection Collection? @relation(fields: [collectionId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Privacy/Encryption fields
isPrivate Boolean @default(false)
encryptedData String? // Encrypted blob containing elements, appState, files when isPrivate=true
iv String? // Initialization vector for AES-GCM decryption
}
// Singleton model for storing vault password hash and settings
model PrivateVault {
id String @id @default("vault") // Singleton pattern
passwordHash String // bcrypt hash for password verification
salt String // Salt for client-side key derivation (hex encoded)
hint String? // Optional password hint
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Library {
id String @id @default("default") // Singleton pattern - use "default" ID
items String @default("[]") // Stored as JSON string array of library items
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
File diff suppressed because one or more lines are too long
+22 -1
View File
@@ -136,6 +136,25 @@ exports.Prisma.DrawingScalarFieldEnum = {
version: 'version', version: 'version',
collectionId: 'collectionId', collectionId: 'collectionId',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt',
isPrivate: 'isPrivate',
encryptedData: 'encryptedData',
iv: 'iv'
};
exports.Prisma.PrivateVaultScalarFieldEnum = {
id: 'id',
passwordHash: 'passwordHash',
salt: 'salt',
hint: 'hint',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.LibraryScalarFieldEnum = {
id: 'id',
items: 'items',
createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
@@ -152,7 +171,9 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
Collection: 'Collection', Collection: 'Collection',
Drawing: 'Drawing' Drawing: 'Drawing',
PrivateVault: 'PrivateVault',
Library: 'Library'
}; };
/** /**
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "prisma-client-6afe3d9baa793154c8d01c79f8418d91423e5ccaec794547bf848a451459cf53", "name": "prisma-client-2894d2d911743eb6e6a7a673efae7513c10b6ce609edeb9caf76ed42f4728682",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",
@@ -32,4 +32,26 @@ model Drawing {
collection Collection? @relation(fields: [collectionId], references: [id]) collection Collection? @relation(fields: [collectionId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Privacy/Encryption fields
isPrivate Boolean @default(false)
encryptedData String? // Encrypted blob containing elements, appState, files when isPrivate=true
iv String? // Initialization vector for AES-GCM decryption
}
// Singleton model for storing vault password hash and settings
model PrivateVault {
id String @id @default("vault") // Singleton pattern
passwordHash String // bcrypt hash for password verification
salt String // Salt for client-side key derivation (hex encoded)
hint String? // Optional password hint
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Library {
id String @id @default("default") // Singleton pattern - use "default" ID
items String @default("[]") // Stored as JSON string array of library items
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
+22 -1
View File
@@ -136,6 +136,25 @@ exports.Prisma.DrawingScalarFieldEnum = {
version: 'version', version: 'version',
collectionId: 'collectionId', collectionId: 'collectionId',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt',
isPrivate: 'isPrivate',
encryptedData: 'encryptedData',
iv: 'iv'
};
exports.Prisma.PrivateVaultScalarFieldEnum = {
id: 'id',
passwordHash: 'passwordHash',
salt: 'salt',
hint: 'hint',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.LibraryScalarFieldEnum = {
id: 'id',
items: 'items',
createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
@@ -152,7 +171,9 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
Collection: 'Collection', Collection: 'Collection',
Drawing: 'Drawing' Drawing: 'Drawing',
PrivateVault: 'PrivateVault',
Library: 'Library'
}; };
/** /**
+403 -1
View File
@@ -10,6 +10,7 @@ import { Worker } from "worker_threads";
import multer from "multer"; import multer from "multer";
import archiver from "archiver"; import archiver from "archiver";
import { z } from "zod"; import { z } from "zod";
import * as crypto from "crypto";
// @ts-ignore // @ts-ignore
import { PrismaClient } from "./generated/client"; import { PrismaClient } from "./generated/client";
import { import {
@@ -481,13 +482,350 @@ app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" }); res.status(200).json({ status: "ok" });
}); });
// --- Private Vault ---
// Hash password using scrypt (similar to bcrypt but built-in to Node.js)
const hashPasswordServer = (
password: string,
salt?: string
): Promise<{ hash: string; salt: string }> => {
return new Promise((resolve, reject) => {
const useSalt = salt || crypto.randomBytes(16).toString("hex");
crypto.scrypt(password, useSalt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve({ hash: derivedKey.toString("hex"), salt: useSalt });
});
});
};
const verifyPasswordServer = (
password: string,
hash: string,
salt: string
): Promise<boolean> => {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(derivedKey.toString("hex") === hash);
});
});
};
// GET /vault/status - Check if vault is set up
app.get("/vault/status", async (req, res) => {
try {
const vault = await prisma.privateVault.findUnique({
where: { id: "vault" },
});
const privateDrawingsCount = await prisma.drawing.count({
where: { isPrivate: true },
});
if (!vault) {
return res.json({
isSetup: false,
privateDrawingsCount,
});
}
res.json({
isSetup: true,
salt: vault.salt,
hint: vault.hint,
privateDrawingsCount,
});
} catch (error) {
console.error("Failed to get vault status:", error);
res.status(500).json({ error: "Failed to get vault status" });
}
});
// POST /vault/setup - Create vault with password
app.post("/vault/setup", async (req, res) => {
try {
const { passwordHash, salt, hint } = req.body;
if (!passwordHash || !salt) {
return res
.status(400)
.json({ error: "Password hash and salt are required" });
}
// Check if vault already exists
const existing = await prisma.privateVault.findUnique({
where: { id: "vault" },
});
if (existing) {
return res.status(400).json({ error: "Vault already exists" });
}
// Hash the client-side hash again for extra security
const { hash: serverHash, salt: serverSalt } = await hashPasswordServer(
passwordHash
);
await prisma.privateVault.create({
data: {
id: "vault",
passwordHash: `${serverHash}:${serverSalt}`, // Store both server hash and server salt
salt, // Client-side salt for key derivation
hint: hint || null,
},
});
res.json({ success: true });
} catch (error) {
console.error("Failed to setup vault:", error);
res.status(500).json({ error: "Failed to setup vault" });
}
});
// POST /vault/verify - Verify password and return salt for decryption
app.post("/vault/verify", async (req, res) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: "Password is required" });
}
const vault = await prisma.privateVault.findUnique({
where: { id: "vault" },
});
if (!vault) {
return res.status(404).json({ error: "Vault not set up" });
}
// Parse stored hash format: "serverHash:serverSalt"
const [storedHash, serverSalt] = vault.passwordHash.split(":");
// Client sends SHA-256 hash of password, we verify against our scrypt hash
const isValid = await verifyPasswordServer(
password,
storedHash,
serverSalt
);
if (!isValid) {
return res
.status(401)
.json({ success: false, error: "Invalid password" });
}
res.json({
success: true,
salt: vault.salt, // Return client-side salt for key derivation
});
} catch (error) {
console.error("Failed to verify password:", error);
res.status(500).json({ error: "Failed to verify password" });
}
});
// PUT /vault/password - Change vault password
app.put("/vault/password", async (req, res) => {
try {
const { passwordHash, salt } = req.body;
if (!passwordHash || !salt) {
return res
.status(400)
.json({ error: "Password hash and salt are required" });
}
const vault = await prisma.privateVault.findUnique({
where: { id: "vault" },
});
if (!vault) {
return res.status(404).json({ error: "Vault not set up" });
}
// Hash the new client-side hash
const { hash: serverHash, salt: serverSalt } = await hashPasswordServer(
passwordHash
);
await prisma.privateVault.update({
where: { id: "vault" },
data: {
passwordHash: `${serverHash}:${serverSalt}`,
salt,
},
});
res.json({ success: true });
} catch (error) {
console.error("Failed to change password:", error);
res.status(500).json({ error: "Failed to change password" });
}
});
// GET /vault/hint - Get password hint
app.get("/vault/hint", async (req, res) => {
try {
const vault = await prisma.privateVault.findUnique({
where: { id: "vault" },
});
if (!vault) {
return res.status(404).json({ error: "Vault not set up" });
}
res.json({ hint: vault.hint });
} catch (error) {
console.error("Failed to get hint:", error);
res.status(500).json({ error: "Failed to get hint" });
}
});
// PUT /vault/hint - Update password hint
app.put("/vault/hint", async (req, res) => {
try {
const { hint } = req.body;
const vault = await prisma.privateVault.findUnique({
where: { id: "vault" },
});
if (!vault) {
return res.status(404).json({ error: "Vault not set up" });
}
await prisma.privateVault.update({
where: { id: "vault" },
data: { hint },
});
res.json({ success: true });
} catch (error) {
console.error("Failed to update hint:", error);
res.status(500).json({ error: "Failed to update hint" });
}
});
// GET /drawings/private - Get all private drawings
app.get("/drawings/private", async (req, res) => {
try {
const drawings = await prisma.drawing.findMany({
where: { isPrivate: true },
orderBy: { updatedAt: "desc" },
});
res.json(drawings);
} catch (error) {
console.error("Failed to get private drawings:", error);
res.status(500).json({ error: "Failed to get private drawings" });
}
});
// PUT /drawings/:id/lock - Move drawing to private vault
app.put("/drawings/:id/lock", async (req, res) => {
try {
const { id } = req.params;
const { encryptedData, iv } = req.body;
if (!encryptedData || !iv) {
return res
.status(400)
.json({ error: "Encrypted data and IV are required" });
}
const drawing = await prisma.drawing.findUnique({
where: { id },
});
if (!drawing) {
return res.status(404).json({ error: "Drawing not found" });
}
// Generate a locked preview
const lockedPreview = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
<rect width="200" height="150" fill="#f1f5f9"/>
<rect x="75" y="45" width="50" height="40" rx="4" fill="#94a3b8"/>
<rect x="85" y="30" width="30" height="25" rx="15" fill="none" stroke="#94a3b8" stroke-width="6"/>
<circle cx="100" cy="65" r="4" fill="#f1f5f9"/>
<rect x="98" y="65" width="4" height="10" fill="#f1f5f9"/>
<text x="100" y="110" font-family="system-ui" font-size="12" fill="#64748b" text-anchor="middle">Private Drawing</text>
</svg>`;
await prisma.drawing.update({
where: { id },
data: {
isPrivate: true,
encryptedData,
iv,
elements: "[]", // Clear plaintext data
appState: "{}",
files: "{}",
preview: lockedPreview,
collectionId: null, // Remove from any collection
},
});
res.json({ success: true });
} catch (error) {
console.error("Failed to lock drawing:", error);
res.status(500).json({ error: "Failed to lock drawing" });
}
});
// PUT /drawings/:id/unlock - Remove drawing from private vault
app.put("/drawings/:id/unlock", async (req, res) => {
try {
const { id } = req.params;
const { elements, appState, files, preview } = req.body;
if (!elements || !appState) {
return res
.status(400)
.json({ error: "Elements and appState are required" });
}
const drawing = await prisma.drawing.findUnique({
where: { id },
});
if (!drawing) {
return res.status(404).json({ error: "Drawing not found" });
}
if (!drawing.isPrivate) {
return res.status(400).json({ error: "Drawing is not private" });
}
await prisma.drawing.update({
where: { id },
data: {
isPrivate: false,
encryptedData: null,
iv: null,
elements: JSON.stringify(elements),
appState: JSON.stringify(appState),
files: JSON.stringify(files || {}),
preview: preview || null,
},
});
res.json({ success: true });
} catch (error) {
console.error("Failed to unlock drawing:", error);
res.status(500).json({ error: "Failed to unlock drawing" });
}
});
// --- Drawings --- // --- Drawings ---
// GET /drawings // GET /drawings
app.get("/drawings", async (req, res) => { app.get("/drawings", async (req, res) => {
try { try {
const { search, collectionId } = req.query; const { search, collectionId } = req.query;
const where: any = {}; const where: any = {
isPrivate: false, // Exclude private drawings from regular listings
};
if (search) { if (search) {
where.name = { contains: String(search) }; where.name = { contains: String(search) };
@@ -534,6 +872,18 @@ app.get("/drawings/:id", async (req, res) => {
return res.status(404).json({ error: "Drawing not found" }); return res.status(404).json({ error: "Drawing not found" });
} }
// For private drawings, return encrypted data instead of parsed content
if (drawing.isPrivate) {
console.log("[API] Returning private drawing", { id });
return res.json({
...drawing,
elements: [], // Empty for private drawings
appState: {},
files: {},
// encryptedData, iv, and isPrivate are already included
});
}
console.log("[API] Returning drawing", { console.log("[API] Returning drawing", {
id, id,
elementCount: (() => { elementCount: (() => {
@@ -801,6 +1151,58 @@ app.delete("/collections/:id", async (req, res) => {
} }
}); });
// --- Library ---
// GET /library - Fetch stored library items
app.get("/library", async (req, res) => {
try {
const library = await prisma.library.findUnique({
where: { id: "default" },
});
if (!library) {
// Return empty array if no library exists yet
return res.json({ items: [] });
}
res.json({
items: JSON.parse(library.items),
});
} catch (error) {
console.error("Failed to fetch library:", error);
res.status(500).json({ error: "Failed to fetch library" });
}
});
// PUT /library - Update/create library items
app.put("/library", async (req, res) => {
try {
const { items } = req.body;
if (!Array.isArray(items)) {
return res.status(400).json({ error: "Items must be an array" });
}
const library = await prisma.library.upsert({
where: { id: "default" },
update: {
items: JSON.stringify(items),
},
create: {
id: "default",
items: JSON.stringify(items),
},
});
res.json({
items: JSON.parse(library.items),
});
} catch (error) {
console.error("Failed to update library:", error);
res.status(500).json({ error: "Failed to update library" });
}
});
// --- Export/Import Endpoints --- // --- Export/Import Endpoints ---
// GET /export - Export SQLite database (supports .sqlite and .db extensions) // GET /export - Export SQLite database (supports .sqlite and .db extensions)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.1.5", "version": "0.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+5
View File
@@ -2,19 +2,24 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { Editor } from './pages/Editor'; import { Editor } from './pages/Editor';
import { Settings } from './pages/Settings'; import { Settings } from './pages/Settings';
import { PrivateDrawings } from './pages/PrivateDrawings';
import { ThemeProvider } from './context/ThemeContext'; import { ThemeProvider } from './context/ThemeContext';
import { VaultProvider } from './context/VaultContext';
function App() { function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<VaultProvider>
<Router> <Router>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/collections" element={<Dashboard />} /> <Route path="/collections" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/private" element={<PrivateDrawings />} />
<Route path="/editor/:id" element={<Editor />} /> <Route path="/editor/:id" element={<Editor />} />
</Routes> </Routes>
</Router> </Router>
</VaultProvider>
</ThemeProvider> </ThemeProvider>
); );
} }
+106 -1
View File
@@ -1,5 +1,10 @@
import axios from "axios"; import axios from "axios";
import type { Drawing, Collection } from "../types"; import type {
Drawing,
Collection,
VaultStatus,
VaultVerifyResult,
} from "../types";
export const API_URL = import.meta.env.VITE_API_URL || "/api"; export const API_URL = import.meta.env.VITE_API_URL || "/api";
@@ -84,3 +89,103 @@ export const deleteCollection = async (id: string) => {
const response = await api.delete<{ success: true }>(`/collections/${id}`); const response = await api.delete<{ success: true }>(`/collections/${id}`);
return response.data; return response.data;
}; };
// --- Library ---
export const getLibrary = async () => {
const response = await api.get<{ items: any[] }>("/library");
return response.data.items;
};
export const updateLibrary = async (items: any[]) => {
const response = await api.put<{ items: any[] }>("/library", { items });
return response.data.items;
};
// --- Private Vault ---
export const getVaultStatus = async (): Promise<VaultStatus> => {
const response = await api.get<VaultStatus>("/vault/status");
return response.data;
};
export const setupVault = async (
passwordHash: string,
salt: string,
hint?: string
): Promise<void> => {
await api.post("/vault/setup", { passwordHash, salt, hint });
};
export const verifyVaultPassword = async (
password: string
): Promise<VaultVerifyResult> => {
const response = await api.post<VaultVerifyResult>("/vault/verify", {
password,
});
return response.data;
};
export const updateVaultHint = async (hint: string): Promise<void> => {
await api.put("/vault/hint", { hint });
};
export const getVaultHint = async (): Promise<string | null> => {
const response = await api.get<{ hint: string | null }>("/vault/hint");
return response.data.hint;
};
export const changeVaultPassword = async (
newPasswordHash: string,
newSalt: string,
_oldKey: CryptoKey,
_newKey: CryptoKey
): Promise<void> => {
// Note: The actual re-encryption of drawings happens client-side
// This endpoint just updates the password hash and salt
await api.put("/vault/password", {
passwordHash: newPasswordHash,
salt: newSalt,
});
};
// --- Private Drawings ---
export const getPrivateDrawings = async (): Promise<Drawing[]> => {
const response = await api.get<Drawing[]>("/drawings/private");
return response.data.map(deserializeDrawing);
};
export const lockDrawing = async (
id: string,
encryptedData: string,
iv: string
): Promise<void> => {
await api.put(`/drawings/${id}/lock`, { encryptedData, iv });
};
export const lockDrawingWithPreview = async (
id: string,
encryptedData: string,
iv: string,
preview?: string
): Promise<void> => {
const body: any = { encryptedData, iv };
if (preview !== undefined) body.preview = preview;
await api.put(`/drawings/${id}/lock`, body);
};
export const unlockDrawing = async (
id: string,
elements: any[],
appState: any,
files: any,
preview?: string
): Promise<void> => {
await api.put(`/drawings/${id}/unlock`, {
elements,
appState,
files,
preview,
});
};
@@ -0,0 +1,222 @@
import React, { useState } from 'react';
import { X, Key, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { validatePasswordStrength } from '../utils/crypto';
interface ChangeVaultPasswordProps {
isOpen: boolean;
onClose: () => void;
onChangePassword: (oldPassword: string, newPassword: string) => Promise<void>;
}
export const ChangeVaultPassword: React.FC<ChangeVaultPasswordProps> = ({
isOpen,
onClose,
onChangePassword,
}) => {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showOld, setShowOld] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const strength = validatePasswordStrength(newPassword);
const passwordsMatch = confirmPassword === newPassword;
const canSubmit = oldPassword.length > 0 && newPassword.length > 0 && passwordsMatch && strength.isValid;
const handleClose = () => {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setShowOld(false);
setShowNew(false);
setShowConfirm(false);
setError(null);
onClose();
};
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setIsLoading(true);
setError(null);
try {
await onChangePassword(oldPassword, newPassword);
handleClose();
} catch (err: any) {
setError(err instanceof Error ? err.message : 'Failed to change password');
} finally {
setIsLoading(false);
}
};
const getStrengthColor = (score: number) => {
if (score <= 1) return 'bg-red-500';
if (score === 2) return 'bg-orange-500';
if (score === 3) return 'bg-yellow-500';
return 'bg-green-500';
};
const getStrengthText = (score: number) => {
if (score <= 1) return 'Weak';
if (score === 2) return 'Fair';
if (score === 3) return 'Good';
return 'Strong';
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,0.2)] w-full max-w-md mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b-2 border-black dark:border-neutral-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-xl flex items-center justify-center">
<Key size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Change Vault Password</h2>
<p className="text-sm text-slate-500 dark:text-neutral-400">Update your vault password</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<X size={20} className="text-slate-500 dark:text-neutral-400" />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle size={16} className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
</div>
)}
{/* Old Password */}
<div className="space-y-2">
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">Current Password</label>
<div className="relative">
<input
type={showOld ? 'text' : 'password'}
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Enter your current password"
autoFocus
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500"
/>
<button
type="button"
onClick={() => setShowOld(!showOld)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
>
{showOld ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{/* New Password */}
<div className="space-y-2">
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">New Password</label>
<div className="relative">
<input
type={showNew ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter your new password"
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500"
/>
<button
type="button"
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
>
{showNew ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{/* Strength Indicator */}
{newPassword.length > 0 && (
<div className="space-y-2">
<div className="flex gap-1">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className={`h-1.5 flex-1 rounded-full transition-colors ${
i <= strength.score ? getStrengthColor(strength.score) : 'bg-slate-200 dark:bg-neutral-700'
}`}
/>
))}
</div>
<p className={`text-xs font-medium ${
strength.score <= 1 ? 'text-red-600 dark:text-red-400' :
strength.score === 2 ? 'text-orange-600 dark:text-orange-400' :
strength.score === 3 ? 'text-yellow-600 dark:text-yellow-400' :
'text-green-600 dark:text-green-400'
}`}>
{getStrengthText(strength.score)}
</p>
{strength.feedback.length > 0 && (
<ul className="text-xs text-slate-500 dark:text-neutral-400 space-y-1">
{strength.feedback.map((fb, i) => (
<li key={i}> {fb}</li>
))}
</ul>
)}
</div>
)}
</div>
{/* Confirm Password */}
<div className="space-y-2">
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">Confirm New Password</label>
<div className="relative">
<input
type={showConfirm ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
className={`w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500 ${
confirmPassword.length > 0 && !passwordsMatch ? 'border-red-500' : 'border-black dark:border-neutral-700'
}`}
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
>
{showConfirm ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={handleClose}
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg font-bold text-slate-700 dark:text-neutral-300 hover:bg-slate-200 dark:hover:bg-neutral-700 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!canSubmit || isLoading}
className="flex-1 px-4 py-3 bg-indigo-500 border-2 border-black dark:border-indigo-600 rounded-lg font-bold text-white hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(79,70,229,0.5)]"
>
{isLoading ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
</div>
</div>
);
};
+28 -1
View File
@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy } from 'lucide-react'; import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download, Lock } from 'lucide-react';
import type { Drawing, Collection } from '../types'; import type { Drawing, Collection } from '../types';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx'; import clsx from 'clsx';
import { exportToSvg } from "@excalidraw/excalidraw"; import { exportToSvg } from "@excalidraw/excalidraw";
import { exportDrawingToFile } from '../utils/exportUtils';
import * as api from '../api'; import * as api from '../api';
@@ -23,6 +24,8 @@ interface DrawingCardProps {
onDragStart?: (e: React.DragEvent, id: string) => void; onDragStart?: (e: React.DragEvent, id: string) => void;
onMouseDown?: (e: React.MouseEvent, id: string) => void; onMouseDown?: (e: React.MouseEvent, id: string) => void;
onPreviewGenerated?: (id: string, preview: string) => void; onPreviewGenerated?: (id: string, preview: string) => void;
onMoveToVault?: (id: string) => void;
isVaultSetup?: boolean;
} }
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => { const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -43,6 +46,8 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
onDragStart, onDragStart,
onMouseDown, onMouseDown,
onPreviewGenerated, onPreviewGenerated,
onMoveToVault,
isVaultSetup = false,
}) => { }) => {
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false); const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
@@ -325,6 +330,28 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
<Copy size={14} /> Duplicate <Copy size={14} /> Duplicate
</button> </button>
<button
onClick={() => {
exportDrawingToFile(drawing);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
>
<Download size={14} /> Export
</button>
{isVaultSetup && onMoveToVault && (
<button
onClick={() => {
onMoveToVault(drawing.id);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/30 flex items-center gap-2"
>
<Lock size={14} /> Move to Private Vault
</button>
)}
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div> <div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
<button <button

Some files were not shown because too many files have changed in this diff Show More