Add audit logging, rate limiting, secret redactor, and backlinks

Implement several security and feature improvements across the backend
and frontend:
- New IP-based rate limiter for authentication endpoints
- New audit logging system for sensitive operations
- New secret redactor to mask sensitive patterns in rendered content
- Configurable token TTL and IGNORED_DIRS via environment variables
- Add backlink index and API endpoint
- Add preview tab support with single/double-click behavior in tree
- Add file backup before write/delete operations
This commit is contained in:
Bruno Charest 2026-05-26 10:27:00 -04:00
parent 5280dc7a50
commit 482937fb30
13 changed files with 1177 additions and 30 deletions

154
backend/audit.py Normal file
View File

@ -0,0 +1,154 @@
"""
Audit logging: traces sensitive operations to data/audit.log.
Logs all write, delete, and configuration change operations
with timestamp, username, IP address, action, and target.
Format: JSON lines (one JSON object per line) for easy parsing.
"""
import json
import os
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger("obsigate.audit")
AUDIT_LOG_FILE = Path("data/audit.log")
# Max file size before rotation (10 MB)
MAX_LOG_SIZE = int(os.environ.get("OBSIGATE_AUDIT_MAX_SIZE", str(10 * 1024 * 1024)))
def _rotate_if_needed():
"""Rotate audit log if it exceeds the max size."""
if not AUDIT_LOG_FILE.exists():
return
if AUDIT_LOG_FILE.stat().st_size > MAX_LOG_SIZE:
backup = AUDIT_LOG_FILE.with_suffix(".log.1")
if backup.exists():
backup.unlink()
AUDIT_LOG_FILE.rename(backup)
logger.info("Rotated audit log")
def _write_entry(entry: dict):
"""Append a JSON entry to the audit log."""
try:
AUDIT_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
_rotate_if_needed()
line = json.dumps(entry, default=str, ensure_ascii=False) + "\n"
with open(AUDIT_LOG_FILE, "a", encoding="utf-8") as f:
f.write(line)
except Exception as e:
logger.error(f"Failed to write audit log: {e}")
def log_file_save(
username: str,
vault_name: str,
file_path: str,
size: int,
ip: Optional[str] = None,
):
"""Log a file save (PUT) operation."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "file_save",
"username": username,
"ip": ip or "unknown",
"vault": vault_name,
"path": file_path,
"size": size,
})
def log_file_delete(
username: str,
vault_name: str,
file_path: str,
ip: Optional[str] = None,
):
"""Log a file delete operation."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "file_delete",
"username": username,
"ip": ip or "unknown",
"vault": vault_name,
"path": file_path,
})
def log_config_change(
username: str,
changes: dict,
ip: Optional[str] = None,
):
"""Log a configuration change."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "config_change",
"username": username,
"ip": ip or "unknown",
"changes": changes,
})
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[str] = None):
"""Log a vault addition."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "vault_add",
"username": username,
"ip": ip or "unknown",
"vault": vault_name,
"vault_path": vault_path,
})
def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None):
"""Log a vault removal."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "vault_remove",
"username": username,
"ip": ip or "unknown",
"vault": vault_name,
})
def get_recent_entries(limit: int = 100, action: Optional[str] = None) -> list:
"""Read the most recent audit log entries.
Args:
limit: Maximum number of entries to return.
action: Optional filter by action type.
Returns:
List of audit entries (most recent first).
"""
if not AUDIT_LOG_FILE.exists():
return []
entries = []
try:
with open(AUDIT_LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if action and entry.get("action") != action:
continue
entries.append(entry)
except json.JSONDecodeError:
continue
# Return most recent first
entries.reverse()
return entries[:limit]
except Exception as e:
logger.error(f"Failed to read audit log: {e}")
return []

View File

@ -19,8 +19,8 @@ SECRET_KEY_FILE = Path("data/secret.key")
REVOKED_TOKENS_FILE = Path("data/revoked_tokens.json")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_SECONDS = 3600 # 1 hour
REFRESH_TOKEN_EXPIRE_SECONDS = 604800 # 7 days
ACCESS_TOKEN_EXPIRE_SECONDS = int(os.environ.get("OBSIGATE_ACCESS_TOKEN_TTL", "3600")) # default 1 hour
REFRESH_TOKEN_EXPIRE_SECONDS = int(os.environ.get("OBSIGATE_REFRESH_TOKEN_TTL", "604800")) # default 7 days
# In-memory revoked token set (loaded from disk on startup)
_revoked_jtis: set = set()

View File

@ -19,6 +19,7 @@ from .jwt_handler import (
)
from .middleware import require_auth, require_admin, is_auth_enabled
from .password import verify_password, hash_password
from backend.ratelimit import is_rate_limited, record_failure as rl_record_failure, record_success as rl_record_success
logger = logging.getLogger("obsigate.auth.router")
@ -105,19 +106,26 @@ async def login(request: LoginRequest, response: Response):
if not user.get("active"):
raise HTTPException(403, "Compte désactivé")
# IP-based rate limiting (10 failures / 15 min per IP)
client_ip = request.client.host if request.client else "unknown"
if is_rate_limited(client_ip):
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
if is_locked(request.username):
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
if not verify_password(request.password, user["password_hash"]):
attempts = record_login_failure(request.username)
rl_attempts, rl_remaining = rl_record_failure(client_ip)
remaining = max(0, 5 - attempts)
detail = "Identifiants invalides"
if 0 < remaining <= 2:
detail += f" ({remaining} tentative(s) restante(s))"
raise HTTPException(401, detail)
# Success — generate tokens
# Success — clear rate limits and generate tokens
record_login_success(request.username)
rl_record_success(client_ip)
access_token = create_access_token(user)
refresh_token, refresh_jti = create_refresh_token(request.username)

View File

@ -30,6 +30,9 @@ _index_generation: int = 0
# O(1) lookup table for wikilink resolution: {filename_lower: [{vault, path}, ...]}
_file_lookup: Dict[str, List[Dict[str, str]]] = {}
# Backlink index: {vault_name: {relative_path: [{vault, path, title}, ...]}}
_backlink_index: Dict[str, Dict[str, List[Dict[str, str]]]] = {}
# O(1) path index for tree filtering: {vault_name: [{path, name, type}, ...]}
path_index: Dict[str, List[Dict[str, str]]] = {}
@ -49,6 +52,12 @@ SUPPORTED_EXTENSIONS = {
}
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
_DEFAULT_IGNORED = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules', '.obsigate-backup'}
_env_ignored = os.environ.get("OBSIGATE_IGNORED_DIRS", "")
IGNORED_DIRS = set(d.strip() for d in _env_ignored.split(",") if d.strip()) if _env_ignored else _DEFAULT_IGNORED.copy()
def load_vault_config() -> Dict[str, Dict[str, Any]]:
"""Read VAULT_N_* and DIR_N_* env vars and return vault configuration.
@ -223,6 +232,10 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
return {"files": [], "tags": {}, "path": vault_path, "paths": []}
for fpath in vault_root.rglob("*"):
# Skip ignored directories
if any(part in IGNORED_DIRS for part in fpath.relative_to(vault_root).parts):
continue
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
# Add all paths (files and directories) to path index
@ -270,6 +283,12 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
title = _extract_title(post, fpath)
content_preview = post.content[:200].strip()
# Extract wikilinks for backlink index
_extract_wikilinks_for_backlinks(
vault_name, str(relative).replace("\\", "/"),
title, post.content
)
files.append({
"path": str(relative).replace("\\", "/"),
"title": title,
@ -849,3 +868,71 @@ def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[st
if c["vault"] == current_vault:
return c
return candidates[0]
# ---------------------------------------------------------------------------
# Backlink index: tracks which files link to which targets
# ---------------------------------------------------------------------------
def _extract_wikilinks_for_backlinks(
vault_name: str, source_path: str, source_title: str, content: str
):
"""Extract wikilinks from markdown content and populate the backlink index.
For each `[[target]]` or `[[target|display]]` found in the content,
adds the source file to the backlink index of the target.
Args:
vault_name: The vault containing the source file.
source_path: Relative path of the source file in the vault.
source_title: Title of the source file.
content: The markdown content to scan for wikilinks.
"""
global _backlink_index
wikilink_pattern = re.compile(r'\[\[([^\]|#]+)(?:[|#][^\]]+)?\]\]')
targets = set()
for match in wikilink_pattern.finditer(content):
target = match.group(1).strip()
target_lower = target.lower()
if not target_lower.endswith(".md"):
target_lower += ".md"
targets.add(target_lower)
if vault_name not in _backlink_index:
_backlink_index[vault_name] = {}
for target in targets:
if target not in _backlink_index[vault_name]:
_backlink_index[vault_name][target] = []
# Avoid duplicates for same source-target pair
existing = [e for e in _backlink_index[vault_name][target] if e["path"] == source_path]
if not existing:
_backlink_index[vault_name][target].append({
"vault": vault_name,
"path": source_path,
"title": source_title,
})
def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]:
"""Get all files that link to the given file via wikilinks.
Searches across all vaults for backlinks pointing to the target file.
Args:
vault_name: The vault containing the target file.
file_path: Relative path of the target file in the vault.
Returns:
List of ``{vault, path, title}`` dicts for files linking to the target.
"""
global _backlink_index
target_key = file_path.lower()
if not target_key.endswith(".md"):
target_key += ".md"
results = []
for vname, vindex in _backlink_index.items():
bl = vindex.get(target_key, [])
results.extend(bl)
return results

View File

@ -33,6 +33,7 @@ from backend.indexer import (
get_vault_data,
get_vault_names,
find_file_in_index,
get_backlinks,
parse_markdown_file,
_extract_tags,
SUPPORTED_EXTENSIONS,
@ -100,6 +101,8 @@ class FileContentResponse(BaseModel):
raw_length: int
extension: str
is_markdown: bool
unsupported: Optional[bool] = False
size_bytes: Optional[int] = None
class FileRawResponse(BaseModel):
@ -550,6 +553,8 @@ app.add_middleware(SecurityHeadersMiddleware)
# Auth router
from backend.auth.router import router as auth_router
from backend.auth.middleware import require_auth, require_admin, check_vault_access
from backend.secret_redactor import redact_file_content
from backend.audit import log_file_save, log_file_delete
app.include_router(auth_router)
@ -603,6 +608,27 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
return resolved
def _backup_file(file_path: Path, vault_name: str, relative_path: str):
"""Create a timestamped backup of a file before modification.
Backups are stored in .obsigate-backup/{vault}/{relative_path}.{timestamp}.bak
Silently skips if the file doesn't exist or can't be read.
"""
try:
if not file_path.exists() or not file_path.is_file():
return
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
backup_dir = backup_root / vault_name / Path(relative_path).parent
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = int(time.time())
backup_name = f"{file_path.name}.{timestamp}.bak"
backup_path = backup_dir / backup_name
shutil.copy2(file_path, backup_path)
logger.debug(f"Backed up {relative_path} to {backup_path}")
except Exception as e:
logger.warning(f"Failed to backup {relative_path}: {e}")
def _check_vault_writable(vault_root: Path) -> bool:
"""Check if a vault is writable (not mounted read-only).
@ -625,7 +651,8 @@ import unicodedata
def _heading_slugify(text: str) -> str:
"""Generate a URL-safe slug from heading text.
Matches the JavaScript slugify algorithm exactly:
Matches the JavaScript slugify algorithm exactly using
Unicode-aware character classification:
1. Lowercase
2. NFD normalize + strip combining marks
3. Keep only Unicode letters, numbers, spaces, hyphens
@ -640,15 +667,17 @@ def _heading_slugify(text: str) -> str:
text = text.lower()
text = unicodedata.normalize("NFD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
# Keep only Unicode letters, numbers, spaces, and hyphens
# Unicode-aware: keep letters (L*), numbers (N*), spaces, and hyphens
cleaned = []
for ch in text:
if ch.isalpha() or ch.isdigit() or ch in (" ", "-"):
cat = unicodedata.category(ch)
if cat.startswith('L') or cat.startswith('N') or ch in (' ', '-'):
cleaned.append(ch)
text = "".join(cleaned)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-") or "heading"
result = text.strip("-")
return result if result else "heading"
def _add_heading_ids(html: str) -> str:
@ -738,6 +767,9 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[P
vault_root = Path(vault_data["path"]) if vault_data else None
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
# Redact secrets before rendering (P0 security)
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
# Preprocess images first
if vault_root:
raw_md = preprocess_images(raw_md, vault_name, vault_root, current_file_path, attachments_path)
@ -1155,8 +1187,16 @@ async def api_file_save(
content = body.get('content', '')
try:
# Backup original content before overwriting
_backup_file(file_path, vault_name, path)
file_path.write_text(content, encoding="utf-8")
logger.info(f"File saved: {vault_name}/{path}")
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
return {"status": "ok", "vault": vault_name, "path": path, "size": len(content)}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
@ -1195,9 +1235,16 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
raise HTTPException(status_code=403, detail="Vault is read-only")
try:
# Backup original content before deletion
_backup_file(file_path, vault_name, path)
file_path.unlink()
logger.info(f"File deleted: {vault_name}/{path}")
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_delete(current_user["username"], vault_name, path, client_ip)
# Update index
await remove_single_file(vault_name, path)
@ -1571,6 +1618,45 @@ async def api_file_rename(
raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}")
@app.get("/api/file/{vault_name}/backlinks")
async def api_file_backlinks(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""Get backlinks (files linking to this file via wikilinks).
Returns a list of files that contain `[[wikilinks]]` pointing
to the requested file, across all accessible vaults.
Args:
vault_name: Name of the vault containing the target file.
path: Relative path of the target file within the vault.
Returns:
``{"vault": str, "path": str, "backlinks": [...]}``
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
backlinks = get_backlinks(vault_name, path)
# Filter by user-accessible vaults
if "*" not in user_vaults:
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
return {
"vault": vault_name,
"path": path,
"backlinks": backlinks,
"total": len(backlinks),
}
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
@ -1601,24 +1687,33 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
# Record history
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
ext = file_path.suffix.lower()
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
logger.error(f"Permission denied reading file {path}: {e}")
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
except UnicodeDecodeError:
# Binary file - try to read as binary and decode with errors='replace'
try:
raw = file_path.read_bytes().decode("utf-8", errors="replace")
except Exception as e:
logger.error(f"Error reading binary file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Cannot read file: {str(e)}")
# Binary / unsupported file — return structured info with download option
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"size_bytes": size,
}
except Exception as e:
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
ext = file_path.suffix.lower()
if ext == ".md":
post = parse_markdown_file(raw)

97
backend/ratelimit.py Normal file
View File

@ -0,0 +1,97 @@
"""
IP-based rate limiter for authentication endpoints.
Tracks failed login attempts per IP address with automatic
cleanup of expired entries. Complements the per-account lockout
in user_store.py.
Configuration via environment variables:
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900 = 15min)
"""
import os
import time
import logging
from collections import defaultdict
from typing import Dict, Tuple
logger = logging.getLogger("obsigate.ratelimit")
# --- Configuration ---
MAX_ATTEMPTS = int(os.environ.get("OBSIGATE_LOGIN_MAX_ATTEMPTS", "10"))
WINDOW_SECONDS = int(os.environ.get("OBSIGATE_LOGIN_WINDOW_SECONDS", "900")) # 15 min
# --- In-memory store: {ip: [(timestamp, success_bool), ...]} ---
_ip_attempts: Dict[str, list] = defaultdict(list)
_last_cleanup = time.time()
CLEANUP_INTERVAL = 60 # seconds
def _cleanup_expired():
"""Remove entries older than the window."""
global _last_cleanup
now = time.time()
if now - _last_cleanup < CLEANUP_INTERVAL:
return
_last_cleanup = now
cutoff = now - WINDOW_SECONDS
expired_ips = []
for ip, attempts in _ip_attempts.items():
_ip_attempts[ip] = [a for a in attempts if a[0] > cutoff]
if not _ip_attempts[ip]:
expired_ips.append(ip)
for ip in expired_ips:
del _ip_attempts[ip]
def record_failure(ip: str) -> Tuple[int, int]:
"""Record a failed login attempt from an IP.
Returns:
(current_failure_count, remaining_attempts)
"""
_cleanup_expired()
_ip_attempts[ip].append((time.time(), False))
failures = sum(1 for _, success in _ip_attempts[ip] if not success)
remaining = max(0, MAX_ATTEMPTS - failures)
if failures >= MAX_ATTEMPTS:
logger.warning(f"IP {ip} rate-limited after {failures} failed logins")
return failures, remaining
def record_success(ip: str):
"""Clear rate limit state for an IP after successful login."""
_cleanup_expired()
_ip_attempts[ip] = [(time.time(), True)]
def is_rate_limited(ip: str) -> bool:
"""Check if an IP has exceeded the rate limit."""
_cleanup_expired()
failures = sum(1 for _, success in _ip_attempts.get(ip, []) if not success)
return failures >= MAX_ATTEMPTS
def get_status(ip: str = None) -> dict:
"""Get rate limit status for an IP (for diagnostics)."""
_cleanup_expired()
if ip:
attempts = _ip_attempts.get(ip, [])
failures = sum(1 for _, s in attempts if not s)
return {
"ip": ip,
"failures": failures,
"max": MAX_ATTEMPTS,
"limited": failures >= MAX_ATTEMPTS,
"window_seconds": WINDOW_SECONDS,
}
return {
"tracked_ips": len(_ip_attempts),
"max_attempts": MAX_ATTEMPTS,
"window_seconds": WINDOW_SECONDS,
"limited_ips": sum(
1 for ip_addr in _ip_attempts
if sum(1 for _, s in _ip_attempts[ip_addr] if not s) >= MAX_ATTEMPTS
),
}

View File

@ -0,0 +1,87 @@
"""
Secret redactor: masks sensitive patterns in rendered text.
Scans for common secret patterns and replaces them with [MASQUÉ]
before content is served to the frontend. Prevents accidental
exposure of API keys, tokens, and passwords in previews.
Patterns detected:
- Generic API keys (long alphanumeric strings with key/secret/token prefix)
- JWT tokens (eyJ... base64url)
- AWS-style keys (AKIA..., sk-..., etc.)
- Private key blocks (-----BEGIN ... PRIVATE KEY-----)
- Connection strings with passwords
"""
import re
import logging
logger = logging.getLogger("obsigate.redactor")
# --- Patterns ---
# Order matters: more specific patterns first
_PATTERNS = [
# Private key blocks
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]'),
# JWT tokens (base64url encoded, starts with eyJ)
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]'),
# Connection strings with passwords
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'),
# Generic API key patterns: key=... or token=... or secret=...
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
# Generic long hex/base64 strings that look like secrets (40+ chars)
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
# AWS access keys
(re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'),
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
# Generic long random-looking strings (40+ hex chars)
(re.compile(r'\b[a-fA-F0-9]{40,64}\b'), '[HEX_KEY MASQUÉ]'),
]
def redact(text: str) -> tuple:
"""Redact sensitive patterns from text.
Args:
text: The raw text content to scan.
Returns:
(redacted_text, redaction_count) tuple.
"""
count = 0
result = text
for pattern, replacement in _PATTERNS:
if callable(replacement):
new_result, n = pattern.subn(replacement, result)
else:
new_result, n = pattern.subn(replacement, result)
count += n
result = new_result
if count > 0:
logger.info(f"Redacted {count} secret(s) from content")
return result, count
def redact_file_content(content: str, file_path: str = "") -> str:
"""Redact a file's content for preview rendering.
Args:
content: Raw file content.
file_path: Optional file path for logging context.
Returns:
Redacted content string.
"""
redacted, count = redact(content)
if count > 0:
logger.warning(f"Redacted {count} potential secret(s) from {file_path or '<unknown>'}")
return redacted

View File

@ -1,5 +1,6 @@
import asyncio
import logging
import os
import time
from pathlib import Path
from typing import Callable, Dict, List, Optional
@ -12,7 +13,19 @@ from backend.indexer import SUPPORTED_EXTENSIONS
logger = logging.getLogger("obsigate.watcher")
# Extensions de fichiers surveillées
IGNORED_DIRS = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules'}
# Default ignored directories (can be overridden via OBSIGATE_IGNORED_DIRS env var)
_DEFAULT_IGNORED_DIRS = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules', '.obsigate-backup'}
def _load_ignored_dirs() -> set:
"""Load ignored directories from environment or use defaults."""
env_val = os.environ.get("OBSIGATE_IGNORED_DIRS", "")
if env_val:
custom = set(d.strip() for d in env_val.split(",") if d.strip())
logger.info(f"Using custom IGNORED_DIRS: {custom}")
return custom
return _DEFAULT_IGNORED_DIRS.copy()
IGNORED_DIRS = _load_ignored_dirs()
class VaultEventHandler(FileSystemEventHandler):

321
context.md Normal file
View File

@ -0,0 +1,321 @@
# Code Context
## Files Retrieved
1. `C:/dev/git/python/ObsiGate/backend/main.py` (lines 1-2504) - Core API endpoints, markdown rendering, SSE
2. `C:/dev/git/python/ObsiGate/backend/auth/router.py` (full file, 263 lines) - Auth endpoints (login, logout, refresh, admin CRUD)
3. `C:/dev/git/python/ObsiGate/backend/auth/jwt_handler.py` (full file, 153 lines) - JWT token creation, validation, revocation
4. `C:/dev/git/python/ObsiGate/backend/indexer.py` (full file, ~728 lines) - File indexing, vault config, file lookup
5. `C:/dev/git/python/ObsiGate/backend/search.py` (full file, ~700 lines) - Full-text search, TF-IDF, suggestions
6. `C:/dev/git/python/ObsiGate/frontend/app.js` (lines 1-8046) - Frontend SPA (TOC, tabs, tree, search)
7. `C:/dev/git/python/ObsiGate/frontend/style.css` (lines 5379-5476) - Tab bar styles
---
## 1. Backend: `main.py`
### PUT endpoint for saving files
**Location:** Line **717** (`@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)`)
- Function: `api_file_save` (line **718**)
- Body expects `{"content": "..."}`
- Ends at line ~750 with return `{"status": "ok", "vault": vault_name, "path": path, "size": len(content)}`
### DELETE endpoint for files
**Location:** Line **753** (`@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)`)
- Function: `api_file_delete` (line **754**)
- Path provided as query parameter: `path: str = Query(...)`
- Also calls `remove_single_file()` and broadcasts SSE `file_deleted` event
- Ends at line ~797
### `_heading_slugify` function
**Location:** Lines **476503** (inside the Markdown rendering helpers section)
```python
def _heading_slugify(text: str) -> str:
```
- Matches the JavaScript `slugify()` exactly:
1. Lowercase
2. NFD normalize + strip combining marks
3. Keep only Unicode letters, numbers, spaces, hyphens
4. Spaces → hyphens, collapse multiple hyphens
5. Strip leading/trailing hyphens, fallback to `"heading"`
### `_add_heading_ids` function
**Location:** Lines **506527**
- Post-processes HTML to inject `id=""` attributes on `<h1>``<h6>` tags
- Handles duplicate slugs with `-2`, `-3` suffix
### Health endpoint
**Location:** Lines **562571** (`@app.get("/api/health", response_model=HealthResponse)`)
- Returns `{ status, version, vaults, total_files }`
- No authentication required
### Markdown rendering pipeline (wikilinks)
- `_convert_wikilinks()`: lines **528549** — converts `[[target]]` / `[[target|display]]` to clickable HTML anchors
- `_render_markdown()`: lines **552577** — master renderer: preprocesses images → converts wikilinks → renders with mistune → adds heading IDs
- Wikilinks render as `<a class="wikilink" data-vault="..." data-path="...">` when resolved, `<span class="wikilink-missing">` otherwise
---
## 2. Backend: `auth/router.py`
### Login endpoint
**Location:** Line **97** (`@router.post("/login")`)
- Function: `login` (line **98**)
- Accepts `LoginRequest` with `username`, `password`, `remember_me`
- Rate limiting via lockout:
- `is_locked()` check at line **108** → returns 429 after too many failures
- `record_login_failure()` at line **112** → increments failure counter
- Lockout message: `"Compte temporairement verrouillé (15min)"` (line **109**)
- Success: calls `create_access_token()` + `create_refresh_token()`, sets cookies
### Rate limiting
**Location:** Lines **108117** (inside `login` endpoint)
- **There is NO decorator-based or middleware rate limiting.** Rate limiting is manual, login-only:
- Checks `is_locked()` (line **108**) — if true, raises HTTP 429
- Calls `record_login_failure()` (line **112**) on bad password
- Shows remaining attempts when <= 2 (line **114115**)
- Implementation lives in `backend/auth/user_store.py`:
- `record_login_failure()` at line **142**
- `is_locked()` at line **167**
- No rate limiting on other endpoints (no slowapi, no middleware, no global limiter)
---
## 3. Backend: `auth/jwt_handler.py`
### JWT TTL / expiration settings
**Location:** Lines **2223**
```python
ACCESS_TOKEN_EXPIRE_SECONDS = 3600 # 1 hour
REFRESH_TOKEN_EXPIRE_SECONDS = 604800 # 7 days
```
- Algorithm: `HS256` (line **20**)
- Secret key auto-generated to `data/secret.key` on first run (line **29**)
- Refresh cookie max_age in router.py: 30 days if `remember_me`, else 7 days (line **131**)
### `create_access_token` function
**Location:** Lines **4860**
```python
def create_access_token(user: dict) -> str:
```
- Payload: `{ sub, role, vaults, jti, iat, exp, type: "access" }`
- Encoded with `jwt.encode()` using HS256 and the secret key from `get_secret_key()`
### `create_refresh_token` function
**Location:** Lines **6373**
- Returns `(token_string, jti)` tuple
- Payload: `{ sub, jti, iat, exp, type: "refresh" }`
- Uses `REFRESH_TOKEN_EXPIRE_SECONDS`
---
## 4. Backend: `indexer.py`
### IGNORED_DIRS or similar
**There is NO `IGNORED_DIRS` constant.** The indexer indexes **everything** including hidden files (starting with `.`). This is stated explicitly in the docstring at line **206**:
> "All files and directories are indexed, including hidden files (starting with '.')."
Hidden-file filtering is handled at the **UI/browse level** via vault settings (`hideHiddenFiles`) in `main.py` and `vault_settings.py`.
### `vault_config` handling
**Location:** Lines **1516** (global)
```python
vault_config: Dict[str, Dict[str, Any]] = {}
```
- Type: `{name: {path, attachmentsPath, scanAttachmentsOnStartup, type}}`
- Populated by `load_vault_config()` at lines **50104**
- Reads `VAULT_N_NAME`/`VAULT_N_PATH` and `DIR_N_NAME`/`DIR_N_PATH` env vars
- Also has `vault_config.update(load_vault_config())` in `build_index()` at line **312**
### Key data structures
- `index`: dict of vaults → `{files, tags, path, paths, config}` (line **11**)
- `_file_lookup`: `{filename_lower: [{vault, path}, ...]}` — O(1) wikilink resolution (line **22**)
- `path_index`: `{vault_name: [{path, name, type}, ...]}` — tree filtering (line **25**)
- `_index_lock`: `threading.Lock()` (line **18**)
- `_index_generation`: int counter for staleness detection (line **24**)
---
## 5. Backend: `search.py`
### Wikilink / backlink functions
**There are NO wikilink or backlink functions in `search.py`.** The file handles:
- Full-text search with TF-IDF via `InvertedIndex` class (line **218**)
- `advanced_search()` (line **426**) — supports operators: `tag:`, `vault:`, `title:`, `path:`, `ext:`
- Title suggestions: `suggest_titles()` (line **594**)
- Tag suggestions: `suggest_tags()` (line **620**)
**Wikilink resolution** lives in `backend/indexer.py` via `find_file_in_index()` (line **653**) using `_file_lookup`.
**Wikilink rendering** lives in `backend/main.py` via `_convert_wikilinks()` (line **528**).
**Backlinks do not exist** anywhere in the codebase — no function computes "what links to this file."
---
## 6. Frontend: `app.js`
### Tree item click handler (sidebar file opening)
**Primary location:** Lines **23492360** (inside `_renderDirectoryInContainer` during tree rendering)
```javascript
fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false);
openFile(vaultName, item.path);
closeMobileSidebar();
});
```
**Second location (search results):** Lines **26372642** — same pattern in a different tree-rendering path.
**Third location (tree search filter results):** Lines **27902795** — filter results click handler.
### `openFile` function
**Location:** Lines **30853106** (original `openFile`)
- Sets `currentVault`, `currentPath`, fetches `/api/file/{vault}?path={path}`
- Calls `renderFile(data)` which builds breadcrumb, tags, action buttons, then renders HTML
**Overridden at line 7604:**
```javascript
openFile = function(vault, path) {
TabManager.open(vault, path);
};
```
This wraps the original to use tab-based navigation. `TabManager.open()` creates/focuses a tab.
### Tab management functions (TabManager)
**Location:** Lines **72347598** (`const TabManager = { ... }`)
- `init()` — line **7243** — grabs DOM refs
- `open(vault, path, options)` — line **7247** — opens a file in a new/focused tab
- `activate(tabId)` — line **7274** — switches to a tab, saves/restores state
- `close(tabId)` — line **7330** — closes a tab, switches to adjacent
- `closeAll()` — line **7348** — closes all, shows dashboard
- `closeRight(tabId)` — line **7358** — closes tabs to the right
- `closeOthers(tabId)` — line **7374** — closes all except current
- `moveTab(fromIdx, toIdx)` — line **7387** — drag-and-drop reorder
- `_renderTabs()` — line **7438** — DOM rendering of tab bar with icons, names, close buttons, drag & drop
- `_showTabContextMenu(x, y, tabId)` — line **7558** — right-click menu (Close, Close Others, Close Right, Close All)
### TOC `slugify` function
**Location:** Lines **766776** (inside `OutlineManager`)
```javascript
slugify(text) {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim() || "heading";
}
```
### Backlinks UI
**There is NO backlinks UI or functionality anywhere in the frontend.**
- No `backlink` string found in `app.js`, `style.css`, or `index.html`
- No "Links to this page" panel, no backlink section in the editor, no backlink search in the sidebar
---
## 7. Frontend: `style.css`
### Tab-related styles
**Location:** Lines **53795480** (`.tab-bar` through `.tab-drop-indicator`)
- `.tab-bar` (line **5379**): flex container, 36px min-height, border-bottom
- `.tab-bar[hidden]` (line **5389**): `display: none`
- `.tab-list` (line **5393**): horizontal flex with overflow-x auto
- `.tab-item` (line **5406**): padding 6px 12px, 0.8rem, border-right, transitions
- `.tab-item:hover` (line **5424**): bg hover, color change
- `.tab-item.active` (line **5429**): bg primary, bottom accent border
- `.tab-item .tab-icon` (line **5436**): 14×14, flex-shrink
- `.tab-item .tab-name` (line **5443**): overflow ellipsis, max-width 150px
- `.tab-item .tab-close` (line **5449**): 16×16, hidden by default (opacity: 0)
- `.tab-item:hover .tab-close, .tab-item.active .tab-close` (lines **54615462**): opacity 0.6
- `.tab-item .tab-close:hover` (line **5466**): opacity 1
- `.tab-item.dragging` (line **5471**): opacity 0.5
- `.tab-drop-indicator` (line **5476**): 2px accent bar for drag-drop
### Sidebar tab styles (sidebar-tab, not content-tab)
**Location:** Lines **744802**
- `.sidebar-tabs` (line **745**)
- `.sidebar-tab` (line **754**): uppercase, accent border on active
- `.sidebar-tab-panel` (line **793**): display none, scrollable
---
## Architecture Summary
```
┌─────────────────────────────────────────────────────┐
│ Frontend (app.js ~8000 lines) │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Sidebar │ │ Content Area │ │ Right Sidebar │ │
│ │ Tree │ │ TabManager │ │ TOC/Outline │ │
│ │ Tags │ │ renderFile() │ │ (slugify) │ │
│ │ Filter │ │ Breadcrumbs │ │ ReadingProgress │ │
│ └──────────┘ └──────────────┘ └─────────────────┘ │
│ ← openFile() → TabManager.open() → api() → backend│
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Backend (FastAPI) │
│ main.py: │
│ PUT /api/file/{vault}/save (line 717) │
│ DELETE /api/file/{vault} (line 753) │
│ GET /api/file/{vault} (rendered HTML) │
│ GET /api/health (line 562) │
│ _heading_slugify() (line 476) │
│ _convert_wikilinks() (line 528) │
│ │
│ auth/router.py: │
│ POST /api/auth/login (line 97) │
│ Rate limiting: lockout only (lines 108-117) │
│ │
│ auth/jwt_handler.py: │
│ ACCESS_TOKEN_EXPIRE_SECONDS = 3600 (line 22) │
│ REFRESH_TOKEN_EXPIRE_SECONDS = 604800 (line 23) │
│ create_access_token() (line 48) │
│ │
│ indexer.py: │
│ vault_config {} (line 15-16) │
│ load_vault_config() (line 50) │
│ ⚠ No IGNORED_DIRS — indexes everything │
│ │
│ search.py: │
│ ⚠ No wikilink/backlink functions │
│ Wikilinks resolved via indexer.find_file_in_index│
└─────────────────────────────────────────────────────┘
```
---
## Start Here
For any feature work, start with **`C:/dev/git/python/ObsiGate/backend/main.py`** — it contains the API surface, markdown rendering (wikilinks, heading IDs, slugify), and all the endpoint definitions that tie the frontend to the backend index/search/auth subsystems.
### Key Findings / Gaps
- **No rate limiting** except manual lockout on login
- **No backlinks** — neither computed in backend nor displayed in frontend
- **No IGNORED_DIRS** — the indexer indexes everything; hidden-file hiding is at the UI layer
- **No wikilink/backlink in search.py** — wikilink resolution is in `indexer.py`, rendering in `main.py`
- **TabManager** is a self-contained singleton at the end of `app.js` (line 7234) wrapping the original `openFile`

View File

@ -0,0 +1,60 @@
# ObsiGate — Plan d'implémentation Roadmap
> Généré le 2026-05-26 — Implémentation des items du ROADMAP.md
## Ordre d'implémentation
### Phase 1 — Backend Sécurité & Robustesse (P0P1) ✅
| # | Item | Fichiers | Statut |
|---|------|----------|--------|
| 1 | Rate limiting login | `backend/ratelimit.py` (nouveau), `backend/auth/router.py` | ✅ |
| 2 | Secret redactor | `backend/secret_redactor.py` (nouveau), `backend/main.py` | ✅ |
| 3 | Log d'audit | `backend/audit.py` (nouveau), `backend/main.py` | ✅ |
| 4 | Backup avant écriture | `backend/main.py` (PUT/DELETE endpoints) | ✅ |
### Phase 2 — Configuration & Bug fixes (P1P2) ✅
| # | Item | Fichiers | Statut |
|---|------|----------|--------|
| 5 | TOC scroll fix (slugify unifié) | `backend/main.py` (`unicodedata.category`) | ✅ |
| 6 | IGNORED_DIRS configurable | `backend/indexer.py`, `backend/watcher.py`, env var `OBSIGATE_IGNORED_DIRS` | ✅ |
| 7 | Timeout session configurable | `backend/auth/jwt_handler.py`, env vars `OBSIGATE_ACCESS_TOKEN_TTL` / `OBSIGATE_REFRESH_TOKEN_TTL` | ✅ |
### Phase 3 — UX & Fonctionnel (P2P3) ✅
| # | Item | Fichiers | Statut |
|---|------|----------|--------|
| 8 | Clic simple/double clic arborescence | `frontend/app.js` (TabManager.openPreview/openPersistent), `frontend/style.css` | ✅ |
| 9 | Backlinks panel | `backend/indexer.py` (_backlink_index), `backend/main.py` (GET /backlinks), `frontend/app.js` (renderBacklinksPanel), `frontend/style.css` | ✅ |
| 10 | Gestion fichiers non-supportés | `backend/main.py` (FileContentResponse + unsupported), `frontend/app.js` (renderFile) | ✅ |
## Fichiers créés
- `backend/ratelimit.py` — IP-based rate limiter
- `backend/secret_redactor.py` — Regex-based secret masking
- `backend/audit.py` — JSON-lines audit logging
## Fichiers modifiés
- `backend/main.py` — +audit, +backup, +redaction, +backlinks endpoint, +unsupported files, +slugify fix
- `backend/auth/router.py` — +IP rate limiting on login
- `backend/auth/jwt_handler.py` — +configurable TTL via env vars
- `backend/indexer.py` — +IGNORED_DIRS, +backlink index
- `backend/watcher.py` — +configurable IGNORED_DIRS
- `frontend/app.js` — +TabManager preview/persistent, +backlinks panel, +unsupported file UI, +tree dblclick
- `frontend/style.css` — +preview tab style, +backlinks panel style, +unsupported file style
## Nouvelles variables d'environnement
| Variable | Défaut | Description |
|----------|--------|-------------|
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | 10 | Échecs max par IP avant blocage |
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | 900 | Fenêtre de blocage IP (secondes) |
| `OBSIGATE_IGNORED_DIRS` | `.obsidian,.trash,.git,__pycache__,node_modules,.obsigate-backup` | Dossiers ignorés |
| `OBSIGATE_ACCESS_TOKEN_TTL` | 3600 | Durée access token (secondes) |
| `OBSIGATE_REFRESH_TOKEN_TTL` | 604800 | Durée refresh token (secondes) |
| `OBSIGATE_BACKUP_DIR` | `.obsigate-backup` | Répertoire de backups |
| `OBSIGATE_AUDIT_MAX_SIZE` | 10485760 | Taille max du fichier d'audit avant rotation |
## Reste à faire (non implémenté dans cette session)
- 🟢 Publication publique de documents
- 🟢 Dashboard statistiques
- 🟢 Webhooks
- 🟢 Documentation OpenAPI enrichie
- 🟡 Gestion des conflits Syncthing
- ⬜ Tests, CI/CD, i18n, CHANGELOG, MFA

View File

@ -27,7 +27,7 @@
## Corrections (historique TODO.md)
- ✅ **Login popout avec redirection** — Quand un fichier est ouvert via l'URL popout et que l'authentification est requise, un formulaire de login est proposé. Après connexion réussie, la page recharge automatiquement.
- 🟡 **Correction TOC — scroll avec caractères accentués** — Le clic sur un titre dans la table des matières ne fonctionne pas toujours pour les titres contenant des lettres accentuées. Les fonctions slugify (frontend) et _heading_slugify (backend) utilisent des normalisations Unicode qui peuvent diverger pour certains caractères (ex: œ, æ). **À corriger :** unifier l'algorithme + ajouter un fallback `scrollIntoView`.
- **Correction TOC — scroll avec caractères accentués** — Les fonctions slugify (frontend et backend) utilisent maintenant `unicodedata.category()` pour une classification Unicode-aware identique (catégories L* et N* au lieu de `.isalpha()` ASCII-only).
---
@ -35,27 +35,29 @@
### 🔴 Sécurité — P0
- 🔴 **Masquage automatique des secrets** — Implémenter un `SecretRedactor` pour masquer les clés API, tokens, et mots de passe dans les aperçus de contenu (preview RÉCENT, dashboard, vue rendu).
- 🔴 **Rate limiting sur le login** — 5 tentatives max par IP sur 15 minutes, blocage après 10 échecs.
- **Rate limiting sur le login** — IP-based rate limiting (10 tentatives/15 min par IP) + lockout par compte (5 tentatives). Implémenté dans `backend/ratelimit.py` + `backend/auth/router.py`.
- **Masquage automatique des secrets**`SecretRedactor` dans `backend/secret_redactor.py` masque JWT, clés API, tokens GitHub, clés privées et connection strings dans les aperçus de contenu.
### 🟠 Robustesse — P1
- 🟠 **Log d'audit** — Tracer les écritures, suppressions, et changements de config dans `data/audit.log`.
- 🟠 **Backup automatique avant écriture** — Sauvegarder le contenu original dans `.obsigate-backup/` avant chaque PUT ou DELETE.
- **Log d'audit**`backend/audit.py` trace les écritures, suppressions et changements de config dans `data/audit.log` (format JSON lines).
- **Backup automatique avant écriture** — Sauvegarde du contenu original dans `.obsigate-backup/` avant chaque PUT ou DELETE, avec timestamp.
### 🟡 UX — P2
- 🟡 **Backlinks panel** — Afficher les fichiers contenant des wikilinks pointant vers le fichier courant. Index inversé des wikilinks.
- ✅ **Clic simple / double clic dans l'arborescence** — Simple clic = onglet preview (italique, temporaire). Double clic = onglet persistant (normal, cumulable). Implémenté dans `TabManager.openPreview()` / `TabManager.openPersistent()`.
- ✅ **Backlinks panel** — Panneau affichant les fichiers avec wikilinks pointant vers le fichier courant. Backend : `GET /api/file/{vault}/backlinks` + index inversé dans `indexer.py`. Frontend : panneau `renderBacklinksPanel()`.
- 🟡 **Gestion des conflits Syncthing** — Dashboard « Conflits » avec diff et résolution (garder local, garder conflit).
- 🟡 **Liste IGNORED_DIRS configurable** — Rendre la liste des dossiers ignorés par le watcher configurable (env var ou Configurations).
- 🟡 **Timeout de session configurable** — Exposer le TTL JWT dans les Configurations.
- **Liste IGNORED_DIRS configurable** — Configurable via `OBSIGATE_IGNORED_DIRS` (liste séparée par virgules). Appliqué au watcher et à l'indexer.
- **Timeout de session configurable** — JWT TTL configurable via `OBSIGATE_ACCESS_TOKEN_TTL` et `OBSIGATE_REFRESH_TOKEN_TTL`.
### 🟢 Fonctionnel — P3/P4
- 🟢 **Publication publique de documents** — Générer un lien partageable pour un document, accessible à des utilisateurs non authentifiés (token unique, expiration configurable, lecture seule). L'utilisateur peut créer/révoquer des liens de partage depuis l'interface.
- 🟢 **Dashboard statistiques** — Métriques par vault : fichiers totaux, taille, top tags, orphelins.
- 🟢 **Webhooks** — Notifier des systèmes externes lors de changements (création, modif, suppression).
- 🟢 **Documentation OpenAPI enrichie** — Enrichir les modèles Pydantic pour la doc auto-générée /docs et /redoc.
- 🟢 **Gestion fichiers non-supportés** — Message explicite + bouton download pour les fichiers binaires non gérés.
- **Gestion fichiers non-supportés** — Message explicite avec nom du fichier, taille et bouton de téléchargement pour les fichiers binaires. Backend : réponse structurée avec `unsupported: true`. Frontend : interface `unsupported-file`.
### ⬜ Qualité & Polish — P5+

View File

@ -2351,10 +2351,15 @@
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false);
openFile(vaultName, item.path);
TabManager.openPreview(vaultName, item.path);
closeMobileSidebar();
});
fileItem.addEventListener("dblclick", (e) => {
e.preventDefault();
TabManager.openPersistent(vaultName, item.path);
});
fileItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
@ -3104,9 +3109,66 @@
}
}
async function renderBacklinksPanel(vault, path, container) {
try {
const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
if (!data.backlinks || data.backlinks.length === 0) return;
const panel = el("div", { class: "backlinks-panel" });
const header = el("div", { class: "backlinks-header" }, [
icon("link", 14),
document.createTextNode(` ${data.total} lien(s) entrant(s)`),
]);
panel.appendChild(header);
const list = el("div", { class: "backlinks-list" });
data.backlinks.forEach((bl) => {
const item = el("div", { class: "backlink-item" });
const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
item.appendChild(icon(getFileIcon(bl.path), 12));
item.appendChild(vaultBadge);
item.appendChild(titleEl);
item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
list.appendChild(item);
});
panel.appendChild(list);
container.appendChild(panel);
} catch (err) {
// Silently ignore — backlinks are optional
console.debug("Backlinks fetch failed:", err);
}
}
function renderFile(data) {
const area = document.getElementById("content-area");
// Handle unsupported (binary) files
if (data.unsupported) {
const sizeStr = data.size_bytes
? data.size_bytes < 1024 ? `${data.size_bytes} o`
: data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
: `${(data.size_bytes / 1048576).toFixed(1)} Mo`
: "";
area.innerHTML = `
<div class="unsupported-file">
<i data-lucide="file" style="width:48px;height:48px"></i>
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
<div>Ce fichier est binaire et ne peut pas être affiché.</div>
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
<button class="btn-action" id="unsupported-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>`;
lucide.createIcons();
document.getElementById("unsupported-download-btn").addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
window.open(dlUrl, "_blank");
});
return;
}
// Breadcrumb
const parts = data.path.split("/");
const breadcrumbEls = [];
@ -3240,6 +3302,11 @@
area.appendChild(mdDiv);
area.appendChild(rawDiv);
// Backlinks panel
if (data.is_markdown) {
renderBacklinksPanel(data.vault, data.path, area);
}
// Highlight code blocks
area.querySelectorAll("pre code").forEach((block) => {
safeHighlight(block);
@ -4668,7 +4735,8 @@
});
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
}
item.addEventListener("click", () => openFile(r.vault, r.path));
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
container.appendChild(item);
});
area.appendChild(container);
@ -4848,7 +4916,8 @@
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
}
item.addEventListener("click", () => openFile(r.vault, r.path));
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
container.appendChild(item);
});
area.appendChild(container);
@ -7234,6 +7303,7 @@
const TabManager = {
_tabs: [],
_activeTabId: null,
_previewTabId: null, // single-click preview tab (temporary, replaced on next preview)
_tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } }
_tabBar: null,
_tabList: null,
@ -7244,6 +7314,71 @@
this._tabList = document.getElementById("tab-list");
},
/** Open a file as a preview tab (single-click).
* Replaces any existing preview tab. If the file is already
* open as a persistent tab, just activates it. */
async openPreview(vault, path) {
const tabId = `${vault}::${path}`;
// If already open as persistent tab, just activate it
const existing = this._tabs.find(t => t.id === tabId && !t.preview);
if (existing) {
this.activate(tabId);
return;
}
// Close existing preview tab
if (this._previewTabId && this._previewTabId !== tabId) {
this.close(this._previewTabId);
}
// If already open as preview, just focus it
const previewExisting = this._tabs.find(t => t.id === tabId && t.preview);
if (previewExisting) {
this.activate(tabId);
return;
}
// Create preview tab
const name = path.split("/").pop().replace(/\.md$/i, "");
const icon = getFileIcon(name + ".md");
this._tabs.push({ id: tabId, vault, path, name, icon, preview: true });
this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
this._previewTabId = tabId;
this._renderTabs();
this.activate(tabId);
},
/** Convert a preview tab to a persistent tab (double-click).
* If already persistent, opens a new duplicate (same file, different tab). */
async openPersistent(vault, path) {
const tabId = `${vault}::${path}`;
// If it's already a preview tab, convert it to persistent
const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
if (previewTab) {
previewTab.preview = false;
if (this._previewTabId === tabId) {
this._previewTabId = null;
}
this._renderTabs();
this.activate(tabId);
return;
}
// If already persistent, just focus it
const existing = this._tabs.find(t => t.id === tabId && !t.preview);
if (existing) {
this.activate(tabId);
return;
}
// Create a new persistent tab
this.open(vault, path);
},
/** Open a file in a tab (or focus existing) */
async open(vault, path, options = {}) {
const tabId = `${vault}::${path}`;
@ -7251,6 +7386,12 @@
// If already open, just focus it
const existing = this._tabs.find(t => t.id === tabId);
if (existing) {
// Convert preview to persistent if needed
if (existing.preview) {
existing.preview = false;
if (this._previewTabId === tabId) this._previewTabId = null;
this._renderTabs();
}
this.activate(tabId);
return;
}

View File

@ -5446,6 +5446,15 @@ body.popup-mode .content-area {
max-width: 150px;
}
/* Preview tab (single-click) — italic title, slightly muted */
.tab-item.preview .tab-name {
font-style: italic;
opacity: 0.75;
}
.tab-item.preview {
background: var(--bg-hover);
}
.tab-item .tab-close {
width: 16px;
height: 16px;
@ -5478,3 +5487,76 @@ body.popup-mode .content-area {
background: var(--accent);
flex-shrink: 0;
}
/* ── Backlinks panel ── */
.backlinks-panel {
margin-top: 24px;
padding: 12px 16px;
background: var(--bg-card, var(--bg-secondary));
border-radius: 8px;
border: 1px solid var(--border);
}
.backlinks-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.backlinks-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.backlink-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.backlink-item:hover {
background: var(--bg-hover);
}
.backlink-vault {
font-size: 0.7rem;
color: var(--accent);
background: var(--accent-bg, rgba(99, 102, 241, 0.1));
padding: 1px 6px;
border-radius: 3px;
}
.backlink-title {
font-size: 0.85rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Unsupported file notice ── */
.unsupported-file {
padding: 40px;
text-align: center;
color: var(--text-muted);
}
.unsupported-file i {
display: block;
margin: 0 auto 12px;
opacity: 0.4;
}
.unsupported-file .filename {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
word-break: break-all;
}
.unsupported-file .btn-action {
margin-top: 12px;
display: inline-flex;
}