Cleaning e2e encryption stuff
This commit is contained in:
+8
-13
@@ -23,7 +23,6 @@ export async function serveCommand(
|
||||
key?: string;
|
||||
reindex?: boolean;
|
||||
syncOnly?: boolean;
|
||||
clientEncryption?: boolean;
|
||||
},
|
||||
folder?: string,
|
||||
) {
|
||||
@@ -32,15 +31,6 @@ export async function serveCommand(
|
||||
const port = options.port ||
|
||||
(Deno.env.get("SB_PORT") && +Deno.env.get("SB_PORT")!) || 3000;
|
||||
|
||||
const clientEncryption = options.clientEncryption ||
|
||||
!!Deno.env.get("SB_CLIENT_ENCRYPTION");
|
||||
|
||||
if (clientEncryption) {
|
||||
console.log(
|
||||
"Running in client encryption mode, this will implicitly enable sync-only mode",
|
||||
);
|
||||
}
|
||||
|
||||
const syncOnly = options.syncOnly || !!Deno.env.get("SB_SYNC_ONLY");
|
||||
|
||||
const readOnly = !!Deno.env.get("SB_READ_ONLY");
|
||||
@@ -92,7 +82,6 @@ export async function serveCommand(
|
||||
syncOnly,
|
||||
readOnly,
|
||||
shellBackend: backendConfig,
|
||||
clientEncryption,
|
||||
enableSpaceScript,
|
||||
pagesPath: folder,
|
||||
});
|
||||
@@ -125,14 +114,20 @@ export async function serveCommand(
|
||||
const manifestDescription = Deno.env.get("SB_DESCRIPTION");
|
||||
|
||||
if (manifestName || manifestDescription) {
|
||||
const manifestData = JSON.parse(clientAssets.readTextFileSync('.client/manifest.json'));
|
||||
const manifestData = JSON.parse(
|
||||
clientAssets.readTextFileSync(".client/manifest.json"),
|
||||
);
|
||||
if (manifestName) {
|
||||
manifestData.name = manifestData.short_name = manifestName;
|
||||
}
|
||||
if (manifestDescription) {
|
||||
manifestData.description = manifestDescription;
|
||||
}
|
||||
clientAssets.writeTextFileSync('.client/manifest.json', 'application/json', JSON.stringify(manifestData));
|
||||
clientAssets.writeTextFileSync(
|
||||
".client/manifest.json",
|
||||
"application/json",
|
||||
JSON.stringify(manifestData),
|
||||
);
|
||||
}
|
||||
const httpServer = new HttpServer({
|
||||
hostname,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { MemoryKvPrimitives } from "$lib/data/memory_kv_primitives.ts";
|
||||
import { assert, assertEquals } from "$std/testing/asserts.ts";
|
||||
import { ChunkedKvStoreSpacePrimitives } from "./chunked_datastore_space_primitives.ts";
|
||||
import { EncryptedSpacePrimitives } from "./encrypted_space_primitives.ts";
|
||||
import { testSpacePrimitives } from "./space_primitives.test.ts";
|
||||
|
||||
Deno.test("Encrypted Space Primitives", async () => {
|
||||
// Using an in-memory store for testing
|
||||
const memoryKv = new MemoryKvPrimitives();
|
||||
const spacePrimitives = new EncryptedSpacePrimitives(
|
||||
new ChunkedKvStoreSpacePrimitives(
|
||||
memoryKv,
|
||||
1024 * 1024,
|
||||
),
|
||||
);
|
||||
assertEquals(false, await spacePrimitives.init());
|
||||
await spacePrimitives.setup("password");
|
||||
assertEquals(await spacePrimitives.fetchFileList(), []);
|
||||
await testSpacePrimitives(spacePrimitives);
|
||||
|
||||
// Let's try an incorrect password
|
||||
try {
|
||||
await spacePrimitives.login("wronk");
|
||||
assert(false);
|
||||
} catch (e: any) {
|
||||
assertEquals(e.message, "Incorrect password");
|
||||
}
|
||||
|
||||
// Now let's update the password
|
||||
await spacePrimitives.updatePassword("password", "password2");
|
||||
|
||||
try {
|
||||
await spacePrimitives.updatePassword("password", "password2");
|
||||
assert(false);
|
||||
} catch (e: any) {
|
||||
assertEquals(e.message, "Incorrect password");
|
||||
}
|
||||
|
||||
await spacePrimitives.writeFile(
|
||||
"test.txt",
|
||||
new TextEncoder().encode("Hello World"),
|
||||
);
|
||||
|
||||
// Let's do this again with the new password
|
||||
|
||||
const spacePrimitives2 = new EncryptedSpacePrimitives(
|
||||
new ChunkedKvStoreSpacePrimitives(
|
||||
memoryKv,
|
||||
1024 * 1024,
|
||||
),
|
||||
);
|
||||
assertEquals(true, await spacePrimitives2.init());
|
||||
await spacePrimitives2.login("password2");
|
||||
assertEquals(
|
||||
new TextDecoder().decode(
|
||||
(await spacePrimitives2.readFile("test.txt")).data,
|
||||
),
|
||||
"Hello World",
|
||||
);
|
||||
await spacePrimitives2.deleteFile("test.txt");
|
||||
await testSpacePrimitives(spacePrimitives2);
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
import { FileMeta } from "../../plug-api/types.ts";
|
||||
import {
|
||||
base32Decode,
|
||||
base32Encode,
|
||||
decryptAES,
|
||||
deriveKeyFromPassword,
|
||||
encryptAES,
|
||||
exportKey,
|
||||
generateSalt,
|
||||
importKey,
|
||||
} from "../../lib/crypto.ts";
|
||||
import { plugPrefix } from "./constants.ts";
|
||||
import { SpacePrimitives } from "./space_primitives.ts";
|
||||
|
||||
export const encryptedFileExt = ".crypt";
|
||||
export const keyPath = "KEY";
|
||||
export const saltFile = "salt.crypt";
|
||||
|
||||
/**
|
||||
* This class adds an (AES) based encryption layer on top of another SpacePrimitives implementation.
|
||||
* It encrypts all file names and file contents.
|
||||
* It uses a key file (default named _KEY) to store the encryption key, this file is encrypted with a key derived from the user's password.
|
||||
* The reason to keep the actualy encryption key in a file is to allow the user to change their password without having to re-encrypt all files.
|
||||
* Important note: FileMeta's size will reflect the underlying encrypted size, not the original size
|
||||
*/
|
||||
export class EncryptedSpacePrimitives implements SpacePrimitives {
|
||||
private masterKey?: CryptoKey;
|
||||
private encryptedKeyFileName?: string;
|
||||
spaceSalt?: Uint8Array;
|
||||
|
||||
constructor(
|
||||
private wrapped: SpacePrimitives,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the space is initialized by loading the salt file.
|
||||
* @returns true if the space was initialized, false if it was not initialized yet
|
||||
*/
|
||||
async init(salt?: Uint8Array | undefined | null): Promise<boolean> {
|
||||
if (salt) {
|
||||
this.spaceSalt = salt;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
this.spaceSalt = (await this.wrapped.readFile(saltFile)).data;
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (e.message === "Not found") {
|
||||
console.warn("Space not initialized");
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a fresh space with a new salt and master encryption key derived from a password
|
||||
* @param password
|
||||
*/
|
||||
async setup(password: string): Promise<void> {
|
||||
if (this.spaceSalt) {
|
||||
throw new Error("Space already initialized");
|
||||
}
|
||||
this.spaceSalt = generateSalt();
|
||||
await this.wrapped.writeFile(saltFile, this.spaceSalt);
|
||||
await this.createKey(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the encryption key from the master key based on the user's password
|
||||
* @param password the user's password
|
||||
*/
|
||||
async login(password: string): Promise<void> {
|
||||
if (!this.spaceSalt) {
|
||||
throw new Error("Space not initialized");
|
||||
}
|
||||
// First derive an encryption key solely used for encrypting the key file from the user's password
|
||||
const keyEncryptionKey = await deriveKeyFromPassword(
|
||||
this.spaceSalt!,
|
||||
password,
|
||||
);
|
||||
const encryptedKeyFileName = await this.encryptPath(
|
||||
keyEncryptionKey,
|
||||
keyPath,
|
||||
);
|
||||
|
||||
try {
|
||||
this.masterKey = await importKey(
|
||||
await decryptAES(
|
||||
keyEncryptionKey,
|
||||
(await this.wrapped.readFile(
|
||||
encryptedKeyFileName,
|
||||
)).data,
|
||||
),
|
||||
);
|
||||
this.encryptedKeyFileName = encryptedKeyFileName;
|
||||
} catch (e: any) {
|
||||
if (e.message === "Not found") {
|
||||
throw new Error("Incorrect password");
|
||||
}
|
||||
console.trace();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private generateKey(): Promise<CryptoKey> {
|
||||
return window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256,
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
private async createKey(password: string): Promise<void> {
|
||||
const keyEncryptionKey = await deriveKeyFromPassword(
|
||||
this.spaceSalt!,
|
||||
password,
|
||||
);
|
||||
this.encryptedKeyFileName = await this.encryptPath(
|
||||
keyEncryptionKey,
|
||||
keyPath,
|
||||
);
|
||||
this.masterKey = await this.generateKey();
|
||||
// And write it
|
||||
await this.wrapped.writeFile(
|
||||
this.encryptedKeyFileName,
|
||||
await encryptAES(
|
||||
keyEncryptionKey,
|
||||
await exportKey(this.masterKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updatePassword(oldPassword: string, newPasword: string): Promise<void> {
|
||||
if (!this.masterKey) {
|
||||
throw new Error("No key loaded");
|
||||
}
|
||||
const oldPasswordKeyFileName = await this.encryptPath(
|
||||
await deriveKeyFromPassword(this.spaceSalt!, oldPassword),
|
||||
keyPath,
|
||||
);
|
||||
|
||||
// Check if the old password is correct
|
||||
try {
|
||||
await this.wrapped.getFileMeta(oldPasswordKeyFileName);
|
||||
} catch (e: any) {
|
||||
if (e.message === "Not found") {
|
||||
throw new Error("Incorrect password");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// First derive an encryption key solely used for encrypting the key file from the user's password
|
||||
const keyEncryptionKey = await deriveKeyFromPassword(
|
||||
this.spaceSalt!,
|
||||
newPasword,
|
||||
);
|
||||
|
||||
this.encryptedKeyFileName = await this.encryptPath(
|
||||
keyEncryptionKey,
|
||||
keyPath,
|
||||
);
|
||||
// And write it
|
||||
await this.wrapped.writeFile(
|
||||
this.encryptedKeyFileName,
|
||||
await encryptAES(
|
||||
keyEncryptionKey,
|
||||
await exportKey(this.masterKey),
|
||||
),
|
||||
);
|
||||
|
||||
// Then delete the old key file based on the old password
|
||||
await this.wrapped.deleteFile(oldPasswordKeyFileName);
|
||||
}
|
||||
|
||||
isUnencryptedPath(name: string) {
|
||||
return name.startsWith(plugPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Left pads a string with zeros to a length of 32, encrypts it using AES-GCM and returns the base32 encoded ciphertext
|
||||
* @param key
|
||||
* @param path
|
||||
* @returns
|
||||
*/
|
||||
async encryptPath(
|
||||
key: CryptoKey,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
if (!this.spaceSalt) {
|
||||
throw new Error("Space not initialized");
|
||||
}
|
||||
if (this.isUnencryptedPath(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
path = path.padEnd(32, "\0");
|
||||
const encodedMessage = new TextEncoder().encode(path);
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: this.spaceSalt,
|
||||
},
|
||||
key,
|
||||
encodedMessage,
|
||||
);
|
||||
const encodedPath = base32Encode(new Uint8Array(ciphertext));
|
||||
// console.log(new TextDecoder().decode(ciphertext));
|
||||
return encodedPath.slice(0, 3) + "/" + encodedPath.slice(3) +
|
||||
encryptedFileExt;
|
||||
}
|
||||
|
||||
private async decryptPath(
|
||||
key: CryptoKey,
|
||||
encryptedPath: string,
|
||||
): Promise<string> {
|
||||
if (!this.spaceSalt) {
|
||||
throw new Error("Space not initialized");
|
||||
}
|
||||
if (this.isUnencryptedPath(encryptedPath)) {
|
||||
return encryptedPath;
|
||||
}
|
||||
|
||||
if (!encryptedPath.endsWith(encryptedFileExt)) {
|
||||
throw new Error("Invalid encrypted path");
|
||||
}
|
||||
// Remove the extension and slashes
|
||||
encryptedPath = encryptedPath.slice(0, -encryptedFileExt.length).replaceAll(
|
||||
"/",
|
||||
"",
|
||||
);
|
||||
|
||||
// console.log("To decrypt", encryptedPath);
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: this.spaceSalt,
|
||||
},
|
||||
key,
|
||||
base32Decode(encryptedPath),
|
||||
);
|
||||
// Decode the buffer and remove the padding
|
||||
return removePadding(new TextDecoder().decode(decrypted), "\0");
|
||||
}
|
||||
|
||||
async fetchFileList(): Promise<FileMeta[]> {
|
||||
const files = await this.wrapped.fetchFileList();
|
||||
// console.log(files);
|
||||
return Promise.all(
|
||||
files.filter((fileMeta) =>
|
||||
fileMeta.name !== this.encryptedKeyFileName &&
|
||||
fileMeta.name !== saltFile
|
||||
)
|
||||
.map(async (fileMeta) => {
|
||||
return {
|
||||
...fileMeta,
|
||||
name: await this.decryptPath(this.masterKey!, fileMeta.name),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getFileMeta(name: string): Promise<FileMeta> {
|
||||
if (this.isUnencryptedPath(name)) {
|
||||
return this.wrapped.getFileMeta(name);
|
||||
}
|
||||
const fileMeta = await this.wrapped.getFileMeta(
|
||||
await this.encryptPath(this.masterKey!, name),
|
||||
);
|
||||
return {
|
||||
...fileMeta,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(name: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
|
||||
if (this.isUnencryptedPath(name)) {
|
||||
return this.wrapped.readFile(name);
|
||||
}
|
||||
const { data, meta } = await this.wrapped.readFile(
|
||||
await this.encryptPath(this.masterKey!, name),
|
||||
);
|
||||
return {
|
||||
data: await decryptAES(this.masterKey!, data),
|
||||
meta: {
|
||||
...meta,
|
||||
name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
selfUpdate?: boolean | undefined,
|
||||
meta?: FileMeta | undefined,
|
||||
): Promise<FileMeta> {
|
||||
if (this.isUnencryptedPath(name)) {
|
||||
return this.wrapped.writeFile(name, data, selfUpdate, meta);
|
||||
}
|
||||
const newMeta = await this.wrapped.writeFile(
|
||||
await this.encryptPath(this.masterKey!, name),
|
||||
await encryptAES(this.masterKey!, data),
|
||||
selfUpdate,
|
||||
meta,
|
||||
);
|
||||
return {
|
||||
...newMeta,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteFile(name: string): Promise<void> {
|
||||
if (this.isUnencryptedPath(name)) {
|
||||
return this.wrapped.deleteFile(name);
|
||||
}
|
||||
return this.wrapped.deleteFile(
|
||||
await this.encryptPath(this.masterKey!, name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function removePadding(str: string, paddingChar: string): string {
|
||||
// let startIndex = 0;
|
||||
// while (startIndex < str.length && str[startIndex] === paddingChar) {
|
||||
// startIndex++;
|
||||
// }
|
||||
// return str.substring(startIndex);
|
||||
let endIndex = str.length - 1;
|
||||
while (endIndex >= 0 && str[endIndex] === paddingChar) {
|
||||
endIndex--;
|
||||
}
|
||||
return str.substring(0, endIndex + 1);
|
||||
}
|
||||
-136
@@ -11,142 +11,6 @@ export function simpleHash(s: string): number {
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function base32Encode(data: Uint8Array): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let result = "";
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
for (const byte of data) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
result += alphabet[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
result += alphabet[(value << (5 - bits)) & 31];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function base32Decode(data: string): Uint8Array {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const result = new Uint8Array(Math.floor(data.length * 5 / 8));
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let index = 0;
|
||||
for (const char of data) {
|
||||
value = (value << 5) | alphabet.indexOf(char);
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
result[index++] = (value >>> (bits - 8)) & 255;
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deriveKeyFromPassword(
|
||||
salt: Uint8Array,
|
||||
password: string,
|
||||
): Promise<CryptoKey> {
|
||||
const baseKey = new TextEncoder().encode(password);
|
||||
const importedKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
baseKey,
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: 10000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
importedKey,
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256,
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts using AES-GCM and prepends the IV to the ciphertext
|
||||
* @param key
|
||||
* @param message
|
||||
* @returns
|
||||
*/
|
||||
export async function encryptAES(
|
||||
key: CryptoKey,
|
||||
message: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: iv,
|
||||
},
|
||||
key,
|
||||
message,
|
||||
);
|
||||
return appendBuffer(iv, new Uint8Array(ciphertext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts using AES-GCM and expects the IV to be prepended to the ciphertext
|
||||
* @param key
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
export async function decryptAES(
|
||||
key: CryptoKey,
|
||||
data: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const iv = data.slice(0, 12);
|
||||
const ciphertext = data.slice(12);
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: iv,
|
||||
},
|
||||
key,
|
||||
ciphertext,
|
||||
);
|
||||
return new Uint8Array(decrypted);
|
||||
}
|
||||
|
||||
export function generateSalt(): Uint8Array {
|
||||
return crypto.getRandomValues(new Uint8Array(16));
|
||||
}
|
||||
|
||||
export async function exportKey(key: CryptoKey): Promise<Uint8Array> {
|
||||
const arrayBuffer = await window.crypto.subtle.exportKey("raw", key);
|
||||
return new Uint8Array(arrayBuffer);
|
||||
}
|
||||
|
||||
export function importKey(key: Uint8Array): Promise<CryptoKey> {
|
||||
return window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
key,
|
||||
{ name: "AES-GCM" },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
function appendBuffer(buffer1: Uint8Array, buffer2: Uint8Array): Uint8Array {
|
||||
const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
|
||||
tmp.set(new Uint8Array(buffer1), 0);
|
||||
tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
export function base64Decode(s: string): Uint8Array {
|
||||
const binString = atob(s);
|
||||
const len = binString.length;
|
||||
|
||||
@@ -161,9 +161,6 @@ export class HttpServer {
|
||||
).replace(
|
||||
"{{CONTENT}}",
|
||||
html,
|
||||
).replace(
|
||||
"{{CLIENT_ENCRYPTION}}",
|
||||
spaceServer.clientEncryption ? "true" : "false",
|
||||
);
|
||||
return c.html(
|
||||
html,
|
||||
@@ -262,7 +259,6 @@ export class HttpServer {
|
||||
"{{CONFIG_HASH}}",
|
||||
base64Encode(
|
||||
JSON.stringify([
|
||||
spaceServer.clientEncryption,
|
||||
spaceServer.syncOnly,
|
||||
spaceServer.readOnly,
|
||||
spaceServer.enableSpaceScript,
|
||||
|
||||
+1
-17
@@ -30,7 +30,6 @@ export type SpaceServerConfig = {
|
||||
syncOnly: boolean;
|
||||
readOnly: boolean;
|
||||
enableSpaceScript: boolean;
|
||||
clientEncryption: boolean;
|
||||
};
|
||||
|
||||
// Equivalent of Client on the server
|
||||
@@ -48,7 +47,6 @@ export class SpaceServer {
|
||||
// Only set when syncOnly == false
|
||||
private serverSystem?: ServerSystem;
|
||||
system?: System<SilverBulletHooks>;
|
||||
clientEncryption: boolean;
|
||||
syncOnly: boolean;
|
||||
readOnly: boolean;
|
||||
shellBackend: ShellBackend;
|
||||
@@ -63,16 +61,10 @@ export class SpaceServer {
|
||||
this.hostname = config.hostname;
|
||||
this.auth = config.auth;
|
||||
this.authToken = config.authToken;
|
||||
this.clientEncryption = !!config.clientEncryption;
|
||||
this.syncOnly = config.syncOnly;
|
||||
this.readOnly = config.readOnly;
|
||||
this.enableSpaceScript = config.enableSpaceScript;
|
||||
|
||||
if (this.clientEncryption) {
|
||||
// Sync only will forced on when encryption is enabled
|
||||
this.syncOnly = true;
|
||||
}
|
||||
|
||||
this.jwtIssuer = new JWTIssuer(kvPrimitives);
|
||||
|
||||
this.shellBackend = config.readOnly
|
||||
@@ -143,14 +135,6 @@ export class SpaceServer {
|
||||
}
|
||||
|
||||
async reloadSettings() {
|
||||
if (!this.clientEncryption) {
|
||||
// Only attempt this when the space is not encrypted
|
||||
this.settings = await ensureAndLoadSettingsAndIndex(this.spacePrimitives);
|
||||
} else {
|
||||
this.settings = {
|
||||
indexPage: "index",
|
||||
actionButtons: [],
|
||||
};
|
||||
}
|
||||
this.settings = await ensureAndLoadSettingsAndIndex(this.spacePrimitives);
|
||||
}
|
||||
}
|
||||
|
||||
+60
-124
@@ -47,9 +47,6 @@ import { DataStore } from "$lib/data/datastore.ts";
|
||||
import { IndexedDBKvPrimitives } from "$lib/data/indexeddb_kv_primitives.ts";
|
||||
import { DataStoreMQ } from "$lib/data/mq.datastore.ts";
|
||||
import { DataStoreSpacePrimitives } from "$common/spaces/datastore_space_primitives.ts";
|
||||
import {
|
||||
EncryptedSpacePrimitives,
|
||||
} from "$common/spaces/encrypted_space_primitives.ts";
|
||||
|
||||
import { ensureSpaceIndex } from "$common/space_index.ts";
|
||||
import { renderTheTemplate } from "$common/syscalls/template.ts";
|
||||
@@ -75,17 +72,36 @@ declare global {
|
||||
spaceFolderPath: string;
|
||||
syncOnly: boolean;
|
||||
readOnly: boolean;
|
||||
clientEncryption: boolean;
|
||||
enableSpaceScript: boolean;
|
||||
};
|
||||
client: Client;
|
||||
}
|
||||
}
|
||||
|
||||
// history.scrollRestoration = "manual";
|
||||
type WidgetCacheItem = {
|
||||
height: number;
|
||||
html: string;
|
||||
buttons?: CodeWidgetButton[];
|
||||
banner?: string;
|
||||
};
|
||||
|
||||
export class Client {
|
||||
// Event bus used to communicate between components
|
||||
eventHook = new EventHook();
|
||||
|
||||
space!: Space;
|
||||
settings!: BuiltinSettings;
|
||||
|
||||
clientSystem!: ClientSystem;
|
||||
plugSpaceRemotePrimitives!: PlugSpacePrimitives;
|
||||
httpSpacePrimitives!: HttpSpacePrimitives;
|
||||
|
||||
ui!: MainUI;
|
||||
stateDataStore!: DataStore;
|
||||
spaceKV?: KvPrimitives;
|
||||
mq!: DataStoreMQ;
|
||||
|
||||
// CodeMirror editor
|
||||
editorView!: EditorView;
|
||||
keyHandlerCompartment?: Compartment;
|
||||
|
||||
@@ -93,10 +109,6 @@ export class Client {
|
||||
|
||||
private dbPrefix: string;
|
||||
|
||||
plugSpaceRemotePrimitives!: PlugSpacePrimitives;
|
||||
httpSpacePrimitives!: HttpSpacePrimitives;
|
||||
space!: Space;
|
||||
|
||||
saveTimeout?: number;
|
||||
debouncedUpdateEvent = throttle(() => {
|
||||
this.eventHook
|
||||
@@ -104,21 +116,12 @@ export class Client {
|
||||
.catch((e) => console.error("Error dispatching editor:updated event", e));
|
||||
}, 1000);
|
||||
|
||||
// Sync related stuff
|
||||
// Track if plugs have been updated since sync cycle
|
||||
fullSyncCompleted = false;
|
||||
|
||||
syncService!: ISyncService;
|
||||
settings!: BuiltinSettings;
|
||||
|
||||
// Event bus used to communicate between components
|
||||
eventHook!: EventHook;
|
||||
|
||||
ui!: MainUI;
|
||||
stateDataStore!: DataStore;
|
||||
spaceKV?: KvPrimitives;
|
||||
mq!: DataStoreMQ;
|
||||
|
||||
onLoadPageRef: PageRef;
|
||||
private onLoadPageRef: PageRef;
|
||||
|
||||
constructor(
|
||||
private parent: Element,
|
||||
@@ -138,19 +141,16 @@ export class Client {
|
||||
* This is a separated from the constructor to allow for async initialization
|
||||
*/
|
||||
async init() {
|
||||
// Setup the state data store
|
||||
const stateKvPrimitives = new IndexedDBKvPrimitives(
|
||||
`${this.dbPrefix}_state`,
|
||||
);
|
||||
await stateKvPrimitives.init();
|
||||
|
||||
this.stateDataStore = new DataStore(stateKvPrimitives);
|
||||
|
||||
// Setup message queue
|
||||
this.mq = new DataStoreMQ(this.stateDataStore);
|
||||
|
||||
// Event hook
|
||||
this.eventHook = new EventHook();
|
||||
|
||||
// Instantiate a PlugOS system
|
||||
this.clientSystem = new ClientSystem(
|
||||
this,
|
||||
@@ -168,11 +168,9 @@ export class Client {
|
||||
this.plugSpaceRemotePrimitives,
|
||||
this.stateDataStore,
|
||||
this.eventHook,
|
||||
(path) => {
|
||||
// TODO: At some point we should remove the data.db exception here
|
||||
return path !== "data.db" &&
|
||||
// Exclude all plug space primitives paths
|
||||
!this.plugSpaceRemotePrimitives.isLikelyHandled(path) ||
|
||||
(path) => { // isSyncCandidate
|
||||
// Exclude all plug space primitives paths
|
||||
return !this.plugSpaceRemotePrimitives.isLikelyHandled(path) ||
|
||||
// Except federated ones
|
||||
path.startsWith("!");
|
||||
},
|
||||
@@ -194,7 +192,8 @@ export class Client {
|
||||
await this.loadSettings();
|
||||
|
||||
await this.loadCaches();
|
||||
// Pinging a remote space to ensure we're authenticated properly, if not will result in a redirect to auth page
|
||||
|
||||
// Let's ping the remote space to ensure we're authenticated properly, if not will result in a redirect to auth page
|
||||
try {
|
||||
await this.httpSpacePrimitives.ping();
|
||||
} catch (e: any) {
|
||||
@@ -225,10 +224,12 @@ export class Client {
|
||||
await this.initNavigator();
|
||||
await this.initSync();
|
||||
|
||||
// We can load custom styles async
|
||||
this.loadCustomStyles().catch(console.error);
|
||||
|
||||
await this.dispatchAppEvent("editor:init");
|
||||
|
||||
// Regularly sync the currently open file
|
||||
setInterval(() => {
|
||||
try {
|
||||
this.syncService.syncFile(`${this.currentPage}.md`).catch((e: any) => {
|
||||
@@ -239,6 +240,7 @@ export class Client {
|
||||
}
|
||||
}, pageSyncInterval);
|
||||
|
||||
// Let's update the local page list cache asynchronously
|
||||
this.updatePageListCache().catch(console.error);
|
||||
}
|
||||
|
||||
@@ -262,7 +264,6 @@ export class Client {
|
||||
let initialSync = !await this.syncService.hasInitialSyncCompleted();
|
||||
|
||||
this.eventHook.addLocalListener("sync:success", async (operations) => {
|
||||
// console.log("Operations", operations);
|
||||
if (operations > 0) {
|
||||
// Update the page list
|
||||
await this.space.updatePageList();
|
||||
@@ -280,7 +281,7 @@ export class Client {
|
||||
console.error,
|
||||
);
|
||||
} else { // initialSync
|
||||
// Let's load space scripts, which probably weren't loaded before
|
||||
// Let's load space scripts again, which probably weren't loaded before
|
||||
await this.clientSystem.loadSpaceScripts();
|
||||
console.log(
|
||||
"Initial sync completed, now need to do a full space index to ensure all pages are indexed using any custom space script indexers",
|
||||
@@ -298,20 +299,24 @@ export class Client {
|
||||
|
||||
this.ui.viewDispatch({ type: "sync-change", syncSuccess: true });
|
||||
});
|
||||
|
||||
this.eventHook.addLocalListener("sync:error", (_name) => {
|
||||
this.ui.viewDispatch({ type: "sync-change", syncSuccess: false });
|
||||
});
|
||||
|
||||
this.eventHook.addLocalListener("sync:conflict", (name) => {
|
||||
this.flashNotification(
|
||||
`Sync: conflict detected for ${name} - conflict copy created`,
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
this.eventHook.addLocalListener("sync:progress", (status: SyncStatus) => {
|
||||
this.showProgress(
|
||||
Math.round(status.filesProcessed / status.totalFiles * 100),
|
||||
);
|
||||
});
|
||||
|
||||
this.eventHook.addLocalListener(
|
||||
"file:synced",
|
||||
(meta: FileMeta, direction: string) => {
|
||||
@@ -399,7 +404,6 @@ export class Client {
|
||||
adjustedPosition = true;
|
||||
}
|
||||
if (pos !== undefined) {
|
||||
// setTimeout(() => {
|
||||
this.editorView.dispatch({
|
||||
selection: { anchor: pos! },
|
||||
effects: EditorView.scrollIntoView(pos!, {
|
||||
@@ -408,7 +412,6 @@ export class Client {
|
||||
}),
|
||||
});
|
||||
adjustedPosition = true;
|
||||
// });
|
||||
}
|
||||
|
||||
// If not: just put the cursor at the top of the page, right after the frontmatter
|
||||
@@ -446,6 +449,7 @@ export class Client {
|
||||
// Setup scroll position, cursor position, etc
|
||||
this.navigateWithinPage(pageState);
|
||||
|
||||
// Persist this page as the last opened page, we'll use this for cold start PWA loads
|
||||
await this.stateDataStore.set(
|
||||
["client", "lastOpenedPage"],
|
||||
pageState.page,
|
||||
@@ -453,17 +457,15 @@ export class Client {
|
||||
});
|
||||
|
||||
if (location.hash === "#boot" && this.settings.pwaOpenLastPage !== false) {
|
||||
(async () => {
|
||||
// Cold start PWA load
|
||||
const lastPage = await this.stateDataStore.get([
|
||||
"client",
|
||||
"lastOpenedPage",
|
||||
]);
|
||||
if (lastPage) {
|
||||
console.log("Navigating to last opened page", lastPage);
|
||||
await this.navigate({ page: lastPage });
|
||||
}
|
||||
})().catch(console.error);
|
||||
// Cold start PWA load
|
||||
const lastPage = await this.stateDataStore.get([
|
||||
"client",
|
||||
"lastOpenedPage",
|
||||
]);
|
||||
if (lastPage) {
|
||||
console.log("Navigating to last opened page", lastPage);
|
||||
await this.navigate({ page: lastPage });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,74 +477,6 @@ export class Client {
|
||||
|
||||
let remoteSpacePrimitives: SpacePrimitives = this.httpSpacePrimitives;
|
||||
|
||||
if (window.silverBulletConfig.clientEncryption) {
|
||||
console.log("Enabling encryption");
|
||||
|
||||
const encryptedSpacePrimitives = new EncryptedSpacePrimitives(
|
||||
this.httpSpacePrimitives,
|
||||
);
|
||||
remoteSpacePrimitives = encryptedSpacePrimitives;
|
||||
let loggedIn = false;
|
||||
// First figure out if we're online & if the key file exists, if not we need to initialize the space
|
||||
try {
|
||||
if (!await encryptedSpacePrimitives.init()) {
|
||||
console.log(
|
||||
"Space not initialized, will ask for password to initialize",
|
||||
);
|
||||
alert(
|
||||
"You appear to be accessing a new space with encryption enabled, you will now be asked to create a password",
|
||||
);
|
||||
const password = prompt("Choose a password");
|
||||
if (!password) {
|
||||
alert("Cannot do anything without a password, reloading");
|
||||
location.reload();
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
const password2 = prompt("Confirm password");
|
||||
if (password !== password2) {
|
||||
alert("Passwords don't match, reloading");
|
||||
location.reload();
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
await encryptedSpacePrimitives.setup(password);
|
||||
// this.stateDataStore.set(["encryptionKey"], password);
|
||||
await this.stateDataStore.set(
|
||||
["spaceSalt"],
|
||||
encryptedSpacePrimitives.spaceSalt,
|
||||
);
|
||||
loggedIn = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.message === "Offline") {
|
||||
console.log(
|
||||
"Offline, will assume encryption space is initialized, fetching salt from data store",
|
||||
);
|
||||
await encryptedSpacePrimitives.init(
|
||||
await this.stateDataStore.get(["spaceSalt"]),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!loggedIn) {
|
||||
// Let's ask for the password
|
||||
try {
|
||||
await encryptedSpacePrimitives.login(
|
||||
prompt("Password")!,
|
||||
);
|
||||
await this.stateDataStore.set(
|
||||
["spaceSalt"],
|
||||
encryptedSpacePrimitives.spaceSalt,
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.log("Got this error", e);
|
||||
if (e.message === "Incorrect password") {
|
||||
alert("Incorrect password");
|
||||
location.reload();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.readOnlyMode) {
|
||||
remoteSpacePrimitives = new ReadOnlySpacePrimitives(
|
||||
remoteSpacePrimitives,
|
||||
@@ -647,15 +581,14 @@ export class Client {
|
||||
);
|
||||
|
||||
// Caching a list of known files for the wiki_link highlighter (that checks if a file exists)
|
||||
// And keeping it up to date as we go
|
||||
this.eventHook.addLocalListener("file:changed", (fileName: string) => {
|
||||
// Make sure this file is in the list of known pages
|
||||
this.clientSystem.allKnownFiles.add(fileName);
|
||||
});
|
||||
|
||||
this.eventHook.addLocalListener("file:deleted", (fileName: string) => {
|
||||
this.clientSystem.allKnownFiles.delete(fileName);
|
||||
});
|
||||
|
||||
this.eventHook.addLocalListener(
|
||||
"file:listed",
|
||||
(allFiles: FileMeta[]) => {
|
||||
@@ -684,6 +617,7 @@ export class Client {
|
||||
return this.eventHook.dispatchEvent(name, ...args);
|
||||
}
|
||||
|
||||
// Save the current page
|
||||
save(immediate = false): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.saveTimeout) {
|
||||
@@ -778,6 +712,7 @@ export class Client {
|
||||
startPageNavigate(mode: "page" | "meta" | "all") {
|
||||
// Then show the page navigator
|
||||
this.ui.viewDispatch({ type: "start-navigate", mode });
|
||||
// And update the page list cache asynchronously
|
||||
this.updatePageListCache().catch(console.error);
|
||||
}
|
||||
|
||||
@@ -812,7 +747,9 @@ export class Client {
|
||||
});
|
||||
}
|
||||
|
||||
// Progress circle handling
|
||||
private progressTimeout?: number;
|
||||
|
||||
showProgress(progressPerc: number) {
|
||||
this.ui.viewDispatch({
|
||||
type: "set-progress",
|
||||
@@ -831,6 +768,7 @@ export class Client {
|
||||
);
|
||||
}
|
||||
|
||||
// Various UI elements
|
||||
filterBox(
|
||||
label: string,
|
||||
options: FilterOption[],
|
||||
@@ -924,6 +862,7 @@ export class Client {
|
||||
const line = editorState.doc.lineAt(selection.from);
|
||||
const linePrefix = line.text.slice(0, selection.from - line.from);
|
||||
|
||||
// Build up list of parent nodes, some completions need this
|
||||
const parentNodes: string[] = [];
|
||||
const sTree = syntaxTree(editorState);
|
||||
const currentNode = sTree.resolveInner(selection.from);
|
||||
@@ -940,12 +879,15 @@ export class Client {
|
||||
} while (node);
|
||||
}
|
||||
|
||||
// Dispatch the event
|
||||
const results = await this.dispatchAppEvent(eventName, {
|
||||
pageName: this.currentPage,
|
||||
linePrefix,
|
||||
pos: selection.from,
|
||||
parentNodes,
|
||||
} as CompleteEvent);
|
||||
|
||||
// Merge results
|
||||
let currentResult: CompletionResult | null = null;
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
@@ -975,7 +917,6 @@ export class Client {
|
||||
currentResult = result;
|
||||
}
|
||||
}
|
||||
// console.log("Compeltion result", actualResult);
|
||||
return currentResult;
|
||||
}
|
||||
|
||||
@@ -1001,6 +942,7 @@ export class Client {
|
||||
await this.loadPage(this.currentPage);
|
||||
}
|
||||
|
||||
// Focus the editor
|
||||
focus() {
|
||||
const viewState = this.ui.viewState;
|
||||
if (
|
||||
@@ -1102,6 +1044,7 @@ export class Client {
|
||||
perm: "rw",
|
||||
} as PageMeta,
|
||||
};
|
||||
// Create new page based on a template
|
||||
this.clientSystem.system.invokeFunction("template.newPage", [pageName])
|
||||
.then(
|
||||
() => {
|
||||
@@ -1132,7 +1075,7 @@ export class Client {
|
||||
});
|
||||
|
||||
// Fetch (possibly) enriched meta data asynchronously
|
||||
await this.clientSystem.getObjectByRef<
|
||||
this.clientSystem.getObjectByRef<
|
||||
PageMeta
|
||||
>(
|
||||
this.currentPage,
|
||||
@@ -1298,10 +1241,3 @@ export class Client {
|
||||
return this.widgetCache.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
type WidgetCacheItem = {
|
||||
height: number;
|
||||
html: string;
|
||||
buttons?: CodeWidgetButton[];
|
||||
banner?: string;
|
||||
};
|
||||
|
||||
+2
-3
@@ -45,7 +45,6 @@
|
||||
syncOnly: "{{SYNC_ONLY}}" === "true",
|
||||
enableSpaceScript: "{{ENABLE_SPACE_SCRIPT}}" === "true",
|
||||
readOnly: "{{READ_ONLY}}" === "true",
|
||||
clientEncryption: "{{CLIENT_ENCRYPTION}}" === "true",
|
||||
};
|
||||
// But in case these variables aren't replaced by the server, fall back sync only mode
|
||||
if (window.silverBulletConfig.spaceFolderPath.includes("{{")) {
|
||||
@@ -54,7 +53,7 @@
|
||||
syncOnly: true,
|
||||
readOnly: false,
|
||||
enableSpaceScripts: false,
|
||||
clientEncryption: false,
|
||||
s
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -80,4 +79,4 @@
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
Reference in New Issue
Block a user