MVP passwords
This commit is contained in:
+5
-2
@@ -8,7 +8,10 @@ COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# 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 ./prisma/
|
||||
@@ -26,7 +29,7 @@ RUN npx tsc
|
||||
FROM node:20-alpine
|
||||
|
||||
# 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 && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -32,6 +32,21 @@ model Drawing {
|
||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
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 {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -136,6 +136,18 @@ exports.Prisma.DrawingScalarFieldEnum = {
|
||||
version: 'version',
|
||||
collectionId: 'collectionId',
|
||||
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'
|
||||
};
|
||||
|
||||
@@ -160,6 +172,7 @@ exports.Prisma.NullsOrder = {
|
||||
exports.Prisma.ModelName = {
|
||||
Collection: 'Collection',
|
||||
Drawing: 'Drawing',
|
||||
PrivateVault: 'PrivateVault',
|
||||
Library: 'Library'
|
||||
};
|
||||
|
||||
|
||||
+1281
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-8eed3ee5004eaec649fc60571177778f25acb4a3cdc2c238bbb8e70dd820d0ff",
|
||||
"name": "prisma-client-2894d2d911743eb6e6a7a673efae7513c10b6ce609edeb9caf76ed42f4728682",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
@@ -32,6 +32,21 @@ model Drawing {
|
||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
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 {
|
||||
|
||||
@@ -136,6 +136,18 @@ exports.Prisma.DrawingScalarFieldEnum = {
|
||||
version: 'version',
|
||||
collectionId: 'collectionId',
|
||||
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'
|
||||
};
|
||||
|
||||
@@ -160,6 +172,7 @@ exports.Prisma.NullsOrder = {
|
||||
exports.Prisma.ModelName = {
|
||||
Collection: 'Collection',
|
||||
Drawing: 'Drawing',
|
||||
PrivateVault: 'PrivateVault',
|
||||
Library: 'Library'
|
||||
};
|
||||
|
||||
|
||||
+351
-1
@@ -10,6 +10,7 @@ import { Worker } from "worker_threads";
|
||||
import multer from "multer";
|
||||
import archiver from "archiver";
|
||||
import { z } from "zod";
|
||||
import * as crypto from "crypto";
|
||||
// @ts-ignore
|
||||
import { PrismaClient } from "./generated/client";
|
||||
import {
|
||||
@@ -481,13 +482,350 @@ app.get("/health", (req, res) => {
|
||||
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 ---
|
||||
|
||||
// GET /drawings
|
||||
app.get("/drawings", async (req, res) => {
|
||||
try {
|
||||
const { search, collectionId } = req.query;
|
||||
const where: any = {};
|
||||
const where: any = {
|
||||
isPrivate: false, // Exclude private drawings from regular listings
|
||||
};
|
||||
|
||||
if (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" });
|
||||
}
|
||||
|
||||
// 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", {
|
||||
id,
|
||||
elementCount: (() => {
|
||||
|
||||
Reference in New Issue
Block a user