Merge pull request #20 from Nystik-gh/v0.8.4

0.8.4 Minor fixes
This commit is contained in:
Nystik
2026-06-03 13:39:49 +02:00
committed by GitHub
18 changed files with 188 additions and 138 deletions

View File

@@ -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

View File

@@ -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 {}
}

View File

@@ -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) {

View File

@@ -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": [

View File

@@ -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;

View File

@@ -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(() => {});
},
};

View 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),
};
}

View File

@@ -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) {

View File

@@ -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());
}

View File

@@ -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();

View File

@@ -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 ---

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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 || {},

View 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 };

View 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 };

View 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 };

View File

@@ -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;