From 482937fb30d42828d7e08da28dcfb0b9b08a9eb6 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 26 May 2026 10:27:00 -0400 Subject: [PATCH] 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 --- backend/audit.py | 154 +++++++++++++++++ backend/auth/jwt_handler.py | 4 +- backend/auth/router.py | 10 +- backend/indexer.py | 87 ++++++++++ backend/main.py | 123 ++++++++++++-- backend/ratelimit.py | 97 +++++++++++ backend/secret_redactor.py | 87 ++++++++++ backend/watcher.py | 15 +- context.md | 321 ++++++++++++++++++++++++++++++++++++ docs/IMPLEMENTATION_PLAN.md | 60 +++++++ docs/ROADMAP.md | 20 ++- frontend/app.js | 147 ++++++++++++++++- frontend/style.css | 82 +++++++++ 13 files changed, 1177 insertions(+), 30 deletions(-) create mode 100644 backend/audit.py create mode 100644 backend/ratelimit.py create mode 100644 backend/secret_redactor.py create mode 100644 context.md create mode 100644 docs/IMPLEMENTATION_PLAN.md diff --git a/backend/audit.py b/backend/audit.py new file mode 100644 index 0000000..b3b5124 --- /dev/null +++ b/backend/audit.py @@ -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 [] diff --git a/backend/auth/jwt_handler.py b/backend/auth/jwt_handler.py index f988807..e1894ff 100644 --- a/backend/auth/jwt_handler.py +++ b/backend/auth/jwt_handler.py @@ -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() diff --git a/backend/auth/router.py b/backend/auth/router.py index e5a3fb3..7ffab75 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -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) diff --git a/backend/indexer.py b/backend/indexer.py index 89a8595..5dbbfa7 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -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 diff --git a/backend/main.py b/backend/main.py index c98a2c1..b0190c4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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: @@ -737,7 +766,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[P vault_data = get_vault_data(vault_name) 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) diff --git a/backend/ratelimit.py b/backend/ratelimit.py new file mode 100644 index 0000000..e0f66dc --- /dev/null +++ b/backend/ratelimit.py @@ -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 + ), + } diff --git a/backend/secret_redactor.py b/backend/secret_redactor.py new file mode 100644 index 0000000..42af756 --- /dev/null +++ b/backend/secret_redactor.py @@ -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 ''}") + return redacted diff --git a/backend/watcher.py b/backend/watcher.py index 6d0ffa5..9048e92 100644 --- a/backend/watcher.py +++ b/backend/watcher.py @@ -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): diff --git a/context.md b/context.md new file mode 100644 index 0000000..e283285 --- /dev/null +++ b/context.md @@ -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 **476–503** (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 **506–527** +- Post-processes HTML to inject `id=""` attributes on `

`–`

` tags +- Handles duplicate slugs with `-2`, `-3` suffix + +### Health endpoint + +**Location:** Lines **562–571** (`@app.get("/api/health", response_model=HealthResponse)`) +- Returns `{ status, version, vaults, total_files }` +- No authentication required + +### Markdown rendering pipeline (wikilinks) + +- `_convert_wikilinks()`: lines **528–549** — converts `[[target]]` / `[[target|display]]` to clickable HTML anchors +- `_render_markdown()`: lines **552–577** — master renderer: preprocesses images → converts wikilinks → renders with mistune → adds heading IDs +- Wikilinks render as `` when resolved, `` 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 **108–117** (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 **114–115**) +- 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 **22–23** +```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 **48–60** +```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 **63–73** +- 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 **15–16** (global) +```python +vault_config: Dict[str, Dict[str, Any]] = {} +``` +- Type: `{name: {path, attachmentsPath, scanAttachmentsOnStartup, type}}` +- Populated by `load_vault_config()` at lines **50–104** +- 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 **2349–2360** (inside `_renderDirectoryInContainer` during tree rendering) +```javascript +fileItem.addEventListener("click", () => { + scrollTreeItemIntoView(fileItem, false); + openFile(vaultName, item.path); + closeMobileSidebar(); +}); +``` + +**Second location (search results):** Lines **2637–2642** — same pattern in a different tree-rendering path. +**Third location (tree search filter results):** Lines **2790–2795** — filter results click handler. + +### `openFile` function + +**Location:** Lines **3085–3106** (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 **7234–7598** (`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 **766–776** (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 **5379–5480** (`.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 **5461–5462**): 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 **744–802** +- `.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` diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ac850c9 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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 (P0–P1) ✅ +| # | 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 (P1–P2) ✅ +| # | 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 (P2–P3) ✅ +| # | 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3da3344..bd5b6a2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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+ diff --git a/frontend/app.js b/frontend/app.js index 2052f12..a1a03e8 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 = ` +
+ +
${escapeHtml(data.path.split("/").pop())}
+
Ce fichier est binaire et ne peut pas être affiché.
+ ${sizeStr ? `
Taille : ${sizeStr}
` : ""} + +
`; + 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; } diff --git a/frontend/style.css b/frontend/style.css index b03f083..600732c 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; +}