mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
10
CHANGELOG.md
10
CHANGELOG.md
@@ -2,6 +2,16 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.8.4] - Karm (2026-06-03)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codeblocks calling clipboard APIs no longer causes reccursion error.
|
||||
|
||||
### Security
|
||||
|
||||
- Hardened same-origin checks, virtual-plugin URL validation, token file permissions, and log line bounds.
|
||||
|
||||
## [0.8.3] - Karm (2026-06-01)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -83,15 +83,23 @@ function isAuthenticated(dataDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function writeSecret(file, contents) {
|
||||
fs.writeFileSync(file, contents, { encoding: "utf-8", mode: 0o600 });
|
||||
|
||||
try {
|
||||
fs.chmodSync(file, 0o600);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function saveInternal(dataDir, tokenData) {
|
||||
const internalFile = getInternalTokenFile(dataDir);
|
||||
const dir = path.dirname(internalFile);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8");
|
||||
writeSecret(internalFile, JSON.stringify(tokenData, null, 2));
|
||||
}
|
||||
|
||||
function syncToObCli(dataDir, token) {
|
||||
@@ -101,10 +109,10 @@ function syncToObCli(dataDir, token) {
|
||||
const dir = path.dirname(obAuthFile);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
fs.writeFileSync(obAuthFile, token, "utf-8");
|
||||
writeSecret(obAuthFile, token);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const { spawn } = require("child_process");
|
||||
const { spawnOb, runCommand } = require("./ob-cli");
|
||||
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
const MAX_LOG_LINE = 4096;
|
||||
|
||||
function killProcess(proc) {
|
||||
if (!proc) {
|
||||
@@ -151,10 +152,13 @@ class SyncManager {
|
||||
const lines = data.toString().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
this.addLog(state, line.trim());
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed) {
|
||||
const capped = trimmed.slice(0, MAX_LOG_LINE);
|
||||
this.addLog(state, capped);
|
||||
state.lastActivity = new Date().toISOString();
|
||||
this.broadcaster.broadcastLog(vaultId, line.trim());
|
||||
this.broadcaster.broadcastLog(vaultId, capped);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -302,7 +306,7 @@ class SyncManager {
|
||||
addLog(state, line) {
|
||||
state.logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
line,
|
||||
line: line.slice(0, MAX_LOG_LINE),
|
||||
});
|
||||
|
||||
if (state.logs.length > MAX_LOG_ENTRIES) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ignis-monorepo",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"private": true,
|
||||
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { showVaultManager } from "../ui-registry.js";
|
||||
import { vaultService } from "@ignis/services";
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "../util/base64.js";
|
||||
|
||||
const listeners = new Map();
|
||||
|
||||
@@ -85,29 +86,6 @@ const syncHandlers = {
|
||||
resources: () => "",
|
||||
};
|
||||
|
||||
function arrayBufferToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
const chunk = 8192;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function handleRequestUrl(requestId, request) {
|
||||
try {
|
||||
let body = request.body;
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { getClipboard } from "./native-clipboard.js";
|
||||
|
||||
export const clipboardShim = {
|
||||
readText() {
|
||||
return "";
|
||||
},
|
||||
|
||||
writeText(text) {
|
||||
navigator.clipboard.writeText(text).catch((e) => {
|
||||
const clip = getClipboard();
|
||||
|
||||
if (!clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
clip.writeText(text).catch((e) => {
|
||||
console.warn("[shim:clipboard] writeText failed:", e);
|
||||
});
|
||||
},
|
||||
@@ -14,7 +22,13 @@ export const clipboardShim = {
|
||||
},
|
||||
|
||||
writeHTML(html) {
|
||||
navigator.clipboard
|
||||
const clip = getClipboard();
|
||||
|
||||
if (!clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
clip
|
||||
.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob([html], { type: "text/html" }),
|
||||
@@ -35,6 +49,12 @@ export const clipboardShim = {
|
||||
return;
|
||||
}
|
||||
|
||||
const clip = getClipboard();
|
||||
|
||||
if (!clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pngData = image.toPNG();
|
||||
|
||||
if (!pngData || pngData.length === 0) {
|
||||
@@ -43,11 +63,9 @@ export const clipboardShim = {
|
||||
|
||||
const blob = new Blob([pngData], { type: "image/png" });
|
||||
|
||||
navigator.clipboard
|
||||
.write([new ClipboardItem({ "image/png": blob })])
|
||||
.catch((e) => {
|
||||
console.warn("[shim:clipboard] writeImage failed:", e);
|
||||
});
|
||||
clip.write([new ClipboardItem({ "image/png": blob })]).catch((e) => {
|
||||
console.warn("[shim:clipboard] writeImage failed:", e);
|
||||
});
|
||||
},
|
||||
|
||||
has(format) {
|
||||
@@ -59,6 +77,12 @@ export const clipboardShim = {
|
||||
},
|
||||
|
||||
clear() {
|
||||
navigator.clipboard.writeText("").catch(() => {});
|
||||
const clip = getClipboard();
|
||||
|
||||
if (!clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
clip.writeText("").catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
22
packages/shim/src/electron/remote/native-clipboard.js
Normal file
22
packages/shim/src/electron/remote/native-clipboard.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// Obsidian points navigator.clipboard.writeText at electron.clipboard, which already points at this shim.
|
||||
// To avoid recursion, use the untouched native prototype methods.
|
||||
const proto = typeof Clipboard !== "undefined" ? Clipboard.prototype : null;
|
||||
|
||||
// Returns a native-backed clipboard facade, or null in insecure (non-localhost http) contexts.
|
||||
export function getClipboard() {
|
||||
const clip =
|
||||
typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
||||
|
||||
if (!proto || !clip) {
|
||||
console.warn(
|
||||
"[shim:clipboard] clipboard API unavailable (insecure context?)",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
writeText: (text) => proto.writeText.call(clip, text),
|
||||
write: (items) => proto.write.call(clip, items),
|
||||
read: () => proto.read.call(clip),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getClipboard } from "./native-clipboard.js";
|
||||
|
||||
const currentWindowState = {
|
||||
title: "Obsidian",
|
||||
isMaximized: false,
|
||||
@@ -196,7 +198,13 @@ const currentWebContents = {
|
||||
document.execCommand("copy");
|
||||
},
|
||||
paste() {
|
||||
navigator.clipboard
|
||||
const clip = getClipboard();
|
||||
|
||||
if (!clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
clip
|
||||
.read()
|
||||
.then(async (items) => {
|
||||
const dt = new DataTransfer();
|
||||
@@ -233,7 +241,13 @@ const currentWebContents = {
|
||||
});
|
||||
},
|
||||
pasteAndMatchStyle() {
|
||||
navigator.clipboard
|
||||
const clip = getClipboard();
|
||||
|
||||
if (!clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
clip
|
||||
.read()
|
||||
.then(async (items) => {
|
||||
for (const item of items) {
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
// Shared echo suppression for file watcher.
|
||||
// fs operations mark paths as "locally modified" so the watcher client
|
||||
// can skip events that originated from this client.
|
||||
// fs operations mark paths as "locally modified" so the watcher client can skip events that originated from this client.
|
||||
import { normalize } from "../util/path.js";
|
||||
|
||||
const ECHO_SUPPRESS_MS = 1500;
|
||||
const recentOps = new Map(); // normalized path -> timestamp
|
||||
|
||||
function normalize(p) {
|
||||
return (p || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function markLocalOp(path) {
|
||||
recentOps.set(normalize(path), Date.now());
|
||||
}
|
||||
|
||||
@@ -5,19 +5,14 @@
|
||||
// - 5-minute TTL per entry
|
||||
// - Entries kept until TTL expires (plugins may read the same file multiple times)
|
||||
|
||||
import { normalize } from "../util/path.js";
|
||||
|
||||
const MAX_SIZE = 200 * 1024 * 1024;
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const cache = new Map(); // path -> { data, size, createdAt }
|
||||
let currentSize = 0;
|
||||
|
||||
function normalize(p) {
|
||||
return (p || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function evictExpired() {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
// Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write.
|
||||
// All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes.
|
||||
|
||||
function normalize(p) {
|
||||
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
import { normalize } from "../util/path.js";
|
||||
|
||||
// --- Path resolvers ---
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// Virtual plugin source served from memory; the fs shim's read path checks here before disk.
|
||||
|
||||
function normalize(p) {
|
||||
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
import { normalize } from "../util/path.js";
|
||||
|
||||
const virtualFiles = new Map();
|
||||
|
||||
export function setVirtualFile(path, content) {
|
||||
virtualFiles.set(normalize(path), content);
|
||||
const normalized = normalize(path);
|
||||
|
||||
if (normalized.split("/").includes("..")) {
|
||||
throw new Error(`virtual file path may not contain '..': ${path}`);
|
||||
}
|
||||
|
||||
virtualFiles.set(normalized, content);
|
||||
}
|
||||
|
||||
export function removeVirtualFile(path) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
unregisterPopupWindow,
|
||||
} from "./electron/remote/window.js";
|
||||
import { showVaultManager } from "./ui-registry.js";
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
|
||||
import { isSameOrigin } from "./util/url.js";
|
||||
|
||||
function installProcess() {
|
||||
window.process = processShim;
|
||||
@@ -115,51 +117,6 @@ function installWindowOpen() {
|
||||
};
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
const chunk = 8192;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function isSameOrigin(url) {
|
||||
if (
|
||||
!url ||
|
||||
url.startsWith("/") ||
|
||||
url.startsWith("./") ||
|
||||
url.startsWith("../")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.startsWith("data:") || url.startsWith("blob:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return parsed.origin === window.location.origin;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function installFetchShim() {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.__originalFetch = originalFetch;
|
||||
|
||||
@@ -1,40 +1,16 @@
|
||||
// Override window.requestUrl to proxy external requests through our server, bypassing CORS.
|
||||
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
const chunk = 8192;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
import { isSameOrigin } from "./util/url.js";
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
|
||||
|
||||
async function proxyRequestUrl(request) {
|
||||
if (typeof request === "string") {
|
||||
request = { url: request };
|
||||
}
|
||||
|
||||
const isSameOrigin =
|
||||
request.url.startsWith(window.location.origin) ||
|
||||
request.url.startsWith("/");
|
||||
|
||||
// Same-origin requests don't need the proxy
|
||||
if (isSameOrigin) {
|
||||
// Same-origin requests don't need the proxy.
|
||||
if (isSameOrigin(request.url)) {
|
||||
const res = await fetch(request.url, {
|
||||
method: request.method || "GET",
|
||||
headers: request.headers || {},
|
||||
|
||||
26
packages/shim/src/util/base64.js
Normal file
26
packages/shim/src/util/base64.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Base64 codec for the binary bodies exchanged with the server proxy.
|
||||
|
||||
function arrayBufferToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
const chunk = 8192;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
export { arrayBufferToBase64, base64ToArrayBuffer };
|
||||
7
packages/shim/src/util/path.js
Normal file
7
packages/shim/src/util/path.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Canonical key form for fs paths: backslashes to forward slashes, no leading or trailing slash.
|
||||
// Used by caches and registries that key on path.
|
||||
function normalize(p) {
|
||||
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export { normalize };
|
||||
24
packages/shim/src/util/url.js
Normal file
24
packages/shim/src/util/url.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// True when a request URL targets the page's own origin (so it can skip the cross-origin proxy).
|
||||
function isSameOrigin(url) {
|
||||
if (
|
||||
!url ||
|
||||
url.startsWith("/") ||
|
||||
url.startsWith("./") ||
|
||||
url.startsWith("../")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.startsWith("data:") || url.startsWith("blob:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return parsed.origin === window.location.origin;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export { isSameOrigin };
|
||||
@@ -104,6 +104,12 @@ export async function extractObsidianModule() {
|
||||
return captured;
|
||||
}
|
||||
|
||||
function assertSameOrigin(url) {
|
||||
if (new URL(url, location.origin).origin !== location.origin) {
|
||||
throw new Error(`refusing cross-origin plugin URL: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize per-id load/unload so rapid toggles can't race.
|
||||
const inFlight = new Map();
|
||||
|
||||
@@ -128,7 +134,11 @@ export function loadVirtualPlugin(entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertSameOrigin(entry.scriptUrl);
|
||||
|
||||
if (entry.cssUrl) {
|
||||
assertSameOrigin(entry.cssUrl);
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = entry.cssUrl;
|
||||
|
||||
Reference in New Issue
Block a user