Compare commits

...

8 commits

Author SHA1 Message Date
dd0eaf49ea fix: use nobbe/ignis:latest image; fix BASIC_AUTH_USERS $ escaping
- Switch from build:. to image: nobbe/ignis:latest — the Dockerfile
  lives at apps/ignis-server/, not the repo root, so the build was
  failing with "no such file or directory". Using the official image
  is simpler and avoids a lengthy source build on every deploy.

- Document that BASIC_AUTH_USERS must use $$ for every $ in the bcrypt
  hash, so Docker Compose doesn't expand $2y$05$... as variable refs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:02:14 +05:30
32086b7122 Add Dokploy compose: Ignis build + Traefik labels + env-injected basicAuth 2026-06-06 22:04:45 +00:00
Nystik
b88f9fdc0e
Merge pull request #20 from Nystik-gh/v0.8.4
0.8.4 Minor fixes
2026-06-03 13:39:49 +02:00
Nystik
f0b7f65a36 update changelog, bump version 2026-06-03 13:32:58 +02:00
Nystik
05a3908a7a refactor utility functions 2026-06-03 01:15:27 +02:00
Nystik
b90752e0ad headless-sync minor security improvements 2026-06-02 17:42:47 +02:00
Nystik
caaf6b3144 improve url origin checking in shim 2026-06-02 17:09:54 +02:00
Nystik
3833ef2668 clipboard reccursion fix 2026-06-02 16:58:01 +02:00
19 changed files with 259 additions and 138 deletions

View file

@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file. 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) ## [0.8.3] - Karm (2026-06-01)
### Added ### Added

View file

@ -83,15 +83,23 @@ function isAuthenticated(dataDir) {
return false; 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) { function saveInternal(dataDir, tokenData) {
const internalFile = getInternalTokenFile(dataDir); const internalFile = getInternalTokenFile(dataDir);
const dir = path.dirname(internalFile); const dir = path.dirname(internalFile);
if (!fs.existsSync(dir)) { 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) { function syncToObCli(dataDir, token) {
@ -101,10 +109,10 @@ function syncToObCli(dataDir, token) {
const dir = path.dirname(obAuthFile); const dir = path.dirname(obAuthFile);
if (!fs.existsSync(dir)) { 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 {} } catch {}
} }

View file

@ -4,6 +4,7 @@ const { spawn } = require("child_process");
const { spawnOb, runCommand } = require("./ob-cli"); const { spawnOb, runCommand } = require("./ob-cli");
const MAX_LOG_ENTRIES = 200; const MAX_LOG_ENTRIES = 200;
const MAX_LOG_LINE = 4096;
function killProcess(proc) { function killProcess(proc) {
if (!proc) { if (!proc) {
@ -151,10 +152,13 @@ class SyncManager {
const lines = data.toString().split("\n"); const lines = data.toString().split("\n");
for (const line of lines) { for (const line of lines) {
if (line.trim()) { const trimmed = line.trim();
this.addLog(state, line.trim());
if (trimmed) {
const capped = trimmed.slice(0, MAX_LOG_LINE);
this.addLog(state, capped);
state.lastActivity = new Date().toISOString(); 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) { addLog(state, line) {
state.logs.push({ state.logs.push({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
line, line: line.slice(0, MAX_LOG_LINE),
}); });
if (state.logs.length > MAX_LOG_ENTRIES) { if (state.logs.length > MAX_LOG_ENTRIES) {

View file

@ -0,0 +1,71 @@
# =============================================================================
# obsidian-notes : Ignis (real Obsidian, in the browser)
# Lives at the ROOT of your manohar/ignis Forgejo mirror as a SEPARATE compose
# file, so upstream's own docker-compose.yml stays untouched and pull-able.
# In Dokploy: Compose app -> repo manohar/ignis -> Compose Path: docker-compose.dokploy.yml
# Public endpoint: https://notes.manohargupta.com (Traefik basicAuth gate)
# =============================================================================
services:
ignis:
# Official image from Docker Hub. Dockerfile lives at apps/ignis-server/ in
# the source repo but there's no reason to build — nobbe/ignis:latest is the
# canonical published image and avoids a long source build on every deploy.
image: nobbe/ignis:latest
container_name: obsidian-ignis
restart: unless-stopped
environment:
- PORT=8080
# Pin the Obsidian version Ignis downloads. Bump deliberately, since a new
# Obsidian can outrun the shim. Keep in sync with what you run on the Mac.
- OBSIDIAN_VERSION=1.12.4
- PUID=1000
- PGID=1000
volumes:
# BIND mount (not a named volume) so the host backup cron can git-commit
# the plain-markdown vault directly. Each subdir here = one Obsidian vault.
- /opt/obsidian/vaults:/vaults
# Ignis internal state (plugin mgmt, sync state, auth tokens).
- /opt/obsidian/data:/app/data
# Cached Obsidian assets — persisted so redeploys don't re-download.
- obsidian-app:/app/obsidian-app
# Rendering happens in YOUR browser, so the server side is light. Cap anyway.
mem_limit: 512m
networks:
- dokploy-network
labels:
- traefik.enable=true
# --- HTTP router on :80. Gated by the SAME basicAuth as belt-and-suspenders
# so port 80 never serves the vault unauthenticated (in case there's no
# global 80->443 redirect). Also serves the ACME challenge. ---
- traefik.http.routers.obsidian-notes-http.rule=Host(`notes.manohargupta.com`)
- traefik.http.routers.obsidian-notes-http.entrypoints=web
- traefik.http.routers.obsidian-notes-http.middlewares=obsidian-auth
# --- HTTPS router on :443, WITH basicAuth ---
- traefik.http.routers.obsidian-notes.rule=Host(`notes.manohargupta.com`)
- traefik.http.routers.obsidian-notes.entrypoints=websecure
- traefik.http.routers.obsidian-notes.tls=true
- traefik.http.routers.obsidian-notes.tls.certresolver=letsencrypt
- traefik.http.routers.obsidian-notes.middlewares=obsidian-auth
# --- basicAuth middleware. The user:bcrypt hash is injected from Dokploy's
# Environment tab (key BASIC_AUTH_USERS) so it never lands in git.
# IMPORTANT: bcrypt hashes contain $ signs. In the Dokploy env tab you
# MUST double every $ so compose doesn't try to expand them as variables:
# htpasswd -nbB manohar 'YOUR_PASSWORD'
# Take the output e.g. manohar:$2y$05$abc... and replace every $ with $$:
# BASIC_AUTH_USERS=manohar:$$2y$$05$$abc...
- traefik.http.middlewares.obsidian-auth.basicauth.users=${BASIC_AUTH_USERS}
# --- Service: Ignis listens on 8080 ---
- traefik.http.services.obsidian-notes.loadbalancer.server.port=8080
- traefik.docker.network=dokploy-network
networks:
dokploy-network:
external: true
volumes:
obsidian-app:

View file

@ -1,6 +1,6 @@
{ {
"name": "ignis-monorepo", "name": "ignis-monorepo",
"version": "0.8.3", "version": "0.8.4",
"private": true, "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/.", "description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
"workspaces": [ "workspaces": [

View file

@ -1,5 +1,6 @@
import { showVaultManager } from "../ui-registry.js"; import { showVaultManager } from "../ui-registry.js";
import { vaultService } from "@ignis/services"; import { vaultService } from "@ignis/services";
import { arrayBufferToBase64, base64ToArrayBuffer } from "../util/base64.js";
const listeners = new Map(); const listeners = new Map();
@ -85,29 +86,6 @@ const syncHandlers = {
resources: () => "", 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) { async function handleRequestUrl(requestId, request) {
try { try {
let body = request.body; let body = request.body;

View file

@ -1,10 +1,18 @@
import { getClipboard } from "./native-clipboard.js";
export const clipboardShim = { export const clipboardShim = {
readText() { readText() {
return ""; return "";
}, },
writeText(text) { 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); console.warn("[shim:clipboard] writeText failed:", e);
}); });
}, },
@ -14,7 +22,13 @@ export const clipboardShim = {
}, },
writeHTML(html) { writeHTML(html) {
navigator.clipboard const clip = getClipboard();
if (!clip) {
return;
}
clip
.write([ .write([
new ClipboardItem({ new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }), "text/html": new Blob([html], { type: "text/html" }),
@ -35,6 +49,12 @@ export const clipboardShim = {
return; return;
} }
const clip = getClipboard();
if (!clip) {
return;
}
const pngData = image.toPNG(); const pngData = image.toPNG();
if (!pngData || pngData.length === 0) { if (!pngData || pngData.length === 0) {
@ -43,11 +63,9 @@ export const clipboardShim = {
const blob = new Blob([pngData], { type: "image/png" }); const blob = new Blob([pngData], { type: "image/png" });
navigator.clipboard clip.write([new ClipboardItem({ "image/png": blob })]).catch((e) => {
.write([new ClipboardItem({ "image/png": blob })]) console.warn("[shim:clipboard] writeImage failed:", e);
.catch((e) => { });
console.warn("[shim:clipboard] writeImage failed:", e);
});
}, },
has(format) { has(format) {
@ -59,6 +77,12 @@ export const clipboardShim = {
}, },
clear() { 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 = { const currentWindowState = {
title: "Obsidian", title: "Obsidian",
isMaximized: false, isMaximized: false,
@ -196,7 +198,13 @@ const currentWebContents = {
document.execCommand("copy"); document.execCommand("copy");
}, },
paste() { paste() {
navigator.clipboard const clip = getClipboard();
if (!clip) {
return;
}
clip
.read() .read()
.then(async (items) => { .then(async (items) => {
const dt = new DataTransfer(); const dt = new DataTransfer();
@ -233,7 +241,13 @@ const currentWebContents = {
}); });
}, },
pasteAndMatchStyle() { pasteAndMatchStyle() {
navigator.clipboard const clip = getClipboard();
if (!clip) {
return;
}
clip
.read() .read()
.then(async (items) => { .then(async (items) => {
for (const item of items) { for (const item of items) {

View file

@ -1,17 +1,10 @@
// Shared echo suppression for file watcher. // Shared echo suppression for file watcher.
// fs operations mark paths as "locally modified" so the watcher client // fs operations mark paths as "locally modified" so the watcher client can skip events that originated from this client.
// can skip events that originated from this client. import { normalize } from "../util/path.js";
const ECHO_SUPPRESS_MS = 1500; const ECHO_SUPPRESS_MS = 1500;
const recentOps = new Map(); // normalized path -> timestamp const recentOps = new Map(); // normalized path -> timestamp
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
export function markLocalOp(path) { export function markLocalOp(path) {
recentOps.set(normalize(path), Date.now()); recentOps.set(normalize(path), Date.now());
} }

View file

@ -5,19 +5,14 @@
// - 5-minute TTL per entry // - 5-minute TTL per entry
// - Entries kept until TTL expires (plugins may read the same file multiple times) // - 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 MAX_SIZE = 200 * 1024 * 1024;
const TTL_MS = 5 * 60 * 1000; const TTL_MS = 5 * 60 * 1000;
const cache = new Map(); // path -> { data, size, createdAt } const cache = new Map(); // path -> { data, size, createdAt }
let currentSize = 0; let currentSize = 0;
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
function evictExpired() { function evictExpired() {
const now = Date.now(); 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. // 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. // All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes.
function normalize(p) { import { normalize } from "../util/path.js";
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
}
// --- Path resolvers --- // --- Path resolvers ---

View file

@ -1,13 +1,17 @@
// Virtual plugin source served from memory; the fs shim's read path checks here before disk. // Virtual plugin source served from memory; the fs shim's read path checks here before disk.
function normalize(p) { import { normalize } from "../util/path.js";
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
}
const virtualFiles = new Map(); const virtualFiles = new Map();
export function setVirtualFile(path, content) { 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) { export function removeVirtualFile(path) {

View file

@ -4,6 +4,8 @@ import {
unregisterPopupWindow, unregisterPopupWindow,
} from "./electron/remote/window.js"; } from "./electron/remote/window.js";
import { showVaultManager } from "./ui-registry.js"; import { showVaultManager } from "./ui-registry.js";
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
import { isSameOrigin } from "./util/url.js";
function installProcess() { function installProcess() {
window.process = processShim; 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() { function installFetchShim() {
const originalFetch = window.fetch.bind(window); const originalFetch = window.fetch.bind(window);
window.__originalFetch = originalFetch; window.__originalFetch = originalFetch;

View file

@ -1,40 +1,16 @@
// Override window.requestUrl to proxy external requests through our server, bypassing CORS. // 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. // Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
function base64ToArrayBuffer(base64) { import { isSameOrigin } from "./util/url.js";
const binary = atob(base64); import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
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);
}
async function proxyRequestUrl(request) { async function proxyRequestUrl(request) {
if (typeof request === "string") { if (typeof request === "string") {
request = { url: request }; request = { url: request };
} }
const isSameOrigin = // Same-origin requests don't need the proxy.
request.url.startsWith(window.location.origin) || if (isSameOrigin(request.url)) {
request.url.startsWith("/");
// Same-origin requests don't need the proxy
if (isSameOrigin) {
const res = await fetch(request.url, { const res = await fetch(request.url, {
method: request.method || "GET", method: request.method || "GET",
headers: request.headers || {}, 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; 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. // Serialize per-id load/unload so rapid toggles can't race.
const inFlight = new Map(); const inFlight = new Map();
@ -128,7 +134,11 @@ export function loadVirtualPlugin(entry) {
return; return;
} }
assertSameOrigin(entry.scriptUrl);
if (entry.cssUrl) { if (entry.cssUrl) {
assertSameOrigin(entry.cssUrl);
const link = document.createElement("link"); const link = document.createElement("link");
link.rel = "stylesheet"; link.rel = "stylesheet";
link.href = entry.cssUrl; link.href = entry.cssUrl;