Ctrl K
ComunidadGuías…

simple_nas/app/accesslog.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Access log of the public share site: one JSON object per line.

Bounded by RotatingFileHandler (stdlib, thread-safe). Never the token, never
an absolute filesystem path - only the link id and the path relative to the
link root.
"""

import json
import logging
import logging.handlers
import os
import threading
import time

_logger = None
_path = None
_handlers = {}          # slot -> (path, handler); "main" is the configured one,
                        # "crowdsec" the copy the CrowdSec add-on can read
_lock = threading.Lock()

EVENTS = ("view", "auth_ok", "auth_fail", "download", "zip", "upload",
          "upload_reject", "rate_limited", "link_404", "edit_open", "edit_save", "wopi_denied", "delete")


def init(path, max_mb=5, export_path=None):
    """export_path: an optional copy somewhere another add-on can read.
    Failure to open it must never break the site."""
    global _logger, _path
    _path = path
    lg = logging.getLogger("nas.share.access")
    lg.setLevel(logging.INFO)
    lg.propagate = False
    for h in list(lg.handlers):
        lg.removeHandler(h)
        h.close()
    _handlers.clear()
    os.makedirs(os.path.dirname(path), exist_ok=True)
    h = logging.handlers.RotatingFileHandler(
        path, maxBytes=int(max_mb) * 1024 * 1024, backupCount=1, encoding="utf-8")
    h.setFormatter(logging.Formatter("%(message)s"))
    lg.addHandler(h)
    _logger = lg
    if export_path:
        set_export(export_path, max_mb)


def set_export(export_path, max_mb=5, slot="main"):
    """Point one export slot at a file, at runtime. Empty path closes the slot.

    Slots exist because the configured path and the path CrowdSec can read are
    not always the same file. Returns True when the slot is served - including
    the case where another slot already writes that exact file: two rotating
    handlers on one path would interleave and shred each other's rotation.
    """
    global _logger
    lg = _logger or logging.getLogger("nas.share.access")
    with _lock:
        current = _handlers.get(slot)
        if current and current[0] == export_path:
            return True
        if current:
            lg.removeHandler(current[1])
            current[1].close()
            _handlers.pop(slot, None)
        if not export_path:
            return True
        if any(p == export_path for p, _h in _handlers.values()):
            return True
        try:
            os.makedirs(os.path.dirname(export_path), exist_ok=True)
            eh = logging.handlers.RotatingFileHandler(
                export_path, maxBytes=int(max_mb) * 1024 * 1024, backupCount=1, encoding="utf-8")
            eh.setFormatter(logging.Formatter("%(message)s"))
            lg.addHandler(eh)
            _handlers[slot] = (export_path, eh)
        except OSError as e:
            print(f"[SHARE] Protokoll-Export nach {export_path} nicht moeglich: {e}", flush=True)
            return False
    if _logger is None:
        lg.setLevel(logging.INFO)
        lg.propagate = False
        _logger = lg
    return True


def attach_read(path):
    """Point tail()/clear() at a log file this process does NOT write.

    In the sandboxed layout the worker process is the only writer; the admin
    process only needs to read the file for the log view. Opening a
    RotatingFileHandler here as well would give the one file two writers and
    shred its rotation, so this sets the read path and nothing else."""
    global _path
    _path = path


def export_path(slot="main"):
    """The file this slot writes, or the file another slot writes for it."""
    entry = _handlers.get(slot)
    return entry[0] if entry else None


def writes_to(path):
    """Is this file being written by any slot?"""
    return bool(path) and any(p == path for p, _h in _handlers.values())


def log(event, **fields):
    if _logger is None:
        return
    now = time.time()
    rec = {"ts": round(now, 3),
           "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),   # RFC3339 for CrowdSec
           "event": event}
    for k, v in fields.items():
        if v is None or v == "":
            continue
        if k == "ua":
            v = str(v)[:120]
        rec[k] = v
    try:
        _logger.info(json.dumps(rec, ensure_ascii=False))
    except Exception:
        pass
    if event in ("auth_fail", "upload_reject", "rate_limited", "upload", "download", "zip", "delete"):
        print(f"[SHARE] {event} " + " ".join(f"{k}={v}" for k, v in rec.items()
                                            if k not in ("ts", "event", "ua")), flush=True)


def tail(limit=200, event=None, link_id=None, max_bytes=256 * 1024):
    """Last entries, newest first. Reads only the end of the file."""
    if not _path or not os.path.exists(_path):
        return []
    out = []
    for p in (_path, _path + ".1"):
        if not os.path.exists(p):
            continue
        try:
            with open(p, "rb") as f:
                f.seek(0, os.SEEK_END)
                size = f.tell()
                f.seek(max(0, size - max_bytes))
                chunk = f.read().decode("utf-8", "replace")
        except OSError:
            continue
        lines = chunk.split("\n")
        if size > max_bytes:
            lines = lines[1:]          # first line is probably cut
        for ln in lines:
            ln = ln.strip()
            if not ln:
                continue
            try:
                rec = json.loads(ln)
            except ValueError:
                continue
            if event and rec.get("event") != event:
                continue
            if link_id and rec.get("link_id") != link_id:
                continue
            out.append(rec)
    out.sort(key=lambda r: r.get("ts", 0), reverse=True)
    return out[:limit]


def clear():
    with _lock:
        for p in (_path, (_path or "") + ".1"):
            if p and os.path.exists(p):
                try:
                    open(p, "w").close()
                except OSError:
                    pass