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:
+109
-14
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user