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:
parent
5280dc7a50
commit
482937fb30
154
backend/audit.py
Normal file
154
backend/audit.py
Normal 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 []
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
123
backend/main.py
123
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)
|
||||
|
||||
|
||||
97
backend/ratelimit.py
Normal file
97
backend/ratelimit.py
Normal 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
|
||||
),
|
||||
}
|
||||
87
backend/secret_redactor.py
Normal file
87
backend/secret_redactor.py
Normal 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
|
||||
@ -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
321
context.md
Normal 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 **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 `<h1>`–`<h6>` 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 `<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 **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`
|
||||
60
docs/IMPLEMENTATION_PLAN.md
Normal file
60
docs/IMPLEMENTATION_PLAN.md
Normal 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 (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
|
||||
@ -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+
|
||||
|
||||
|
||||
147
frontend/app.js
147
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 = `
|
||||
<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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user