mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
Harden attachment access controls and stabilize the Weixin bridge.
This persists referenced media safely, adds explicit attachment ACL/backfill flows, and fixes the Weixin sidecar so official login state can bridge reliably into oclaw without duplicate replays or dropped replies. Made-with: Cursor
This commit is contained in:
parent
6cfaff06f6
commit
31e3962c74
22 changed files with 1885 additions and 55 deletions
|
|
@ -4,6 +4,7 @@ import base64
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -11,6 +12,7 @@ from typing import Any, Optional
|
|||
|
||||
from oclaw.platform.config.paths import attachments_dir
|
||||
_META_SUFFIX: Final[str] = ".meta.json"
|
||||
_ATTACHMENT_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
def _utc_ts() -> int:
|
||||
|
|
@ -96,16 +98,23 @@ class AttachmentAssetStore:
|
|||
self.root = Path(root_dir) if root_dir is not None else attachments_dir()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_attachment_id(attachment_id: str) -> str:
|
||||
aid = str(attachment_id or "").strip().lower()
|
||||
if not _ATTACHMENT_ID_RE.fullmatch(aid):
|
||||
raise ValueError("attachment_id_invalid")
|
||||
return aid
|
||||
|
||||
def _data_path(self, attachment_id: str, *, ext: str) -> Path:
|
||||
# bucket to avoid huge single dir
|
||||
aid = (attachment_id or "").strip()
|
||||
aid = self._normalize_attachment_id(attachment_id)
|
||||
p1, p2 = (aid[:2] or "xx"), (aid[2:4] or "yy")
|
||||
d = self.root / p1 / p2
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d / f"{aid}{ext}"
|
||||
|
||||
def _meta_path(self, attachment_id: str) -> Path:
|
||||
aid = (attachment_id or "").strip()
|
||||
aid = self._normalize_attachment_id(attachment_id)
|
||||
p1, p2 = (aid[:2] or "xx"), (aid[2:4] or "yy")
|
||||
d = self.root / p1 / p2
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -158,7 +167,10 @@ class AttachmentAssetStore:
|
|||
return meta
|
||||
|
||||
def get_meta(self, attachment_id: str) -> Optional[AttachmentMeta]:
|
||||
mp = self._meta_path(attachment_id)
|
||||
try:
|
||||
mp = self._meta_path(attachment_id)
|
||||
except Exception:
|
||||
return None
|
||||
if not mp.exists():
|
||||
return None
|
||||
try:
|
||||
|
|
@ -170,7 +182,10 @@ class AttachmentAssetStore:
|
|||
return None
|
||||
|
||||
def touch(self, attachment_id: str) -> None:
|
||||
mp = self._meta_path(attachment_id)
|
||||
try:
|
||||
mp = self._meta_path(attachment_id)
|
||||
except Exception:
|
||||
return
|
||||
if not mp.exists():
|
||||
return
|
||||
try:
|
||||
|
|
@ -183,12 +198,16 @@ class AttachmentAssetStore:
|
|||
return
|
||||
|
||||
def load_bytes(self, attachment_id: str) -> tuple[bytes, Optional[AttachmentMeta]]:
|
||||
meta = self.get_meta(attachment_id)
|
||||
try:
|
||||
aid = self._normalize_attachment_id(attachment_id)
|
||||
except Exception:
|
||||
return b"", None
|
||||
meta = self.get_meta(aid)
|
||||
# try find data file by scanning common extensions
|
||||
exts = (".png", ".jpg", ".jpeg", ".webp", ".gif", "")
|
||||
data_path = None
|
||||
for ext in exts:
|
||||
p = self._data_path(attachment_id, ext=ext)
|
||||
p = self._data_path(aid, ext=ext)
|
||||
if p.exists():
|
||||
data_path = p
|
||||
break
|
||||
|
|
@ -198,15 +217,19 @@ class AttachmentAssetStore:
|
|||
blob = data_path.read_bytes()
|
||||
except Exception:
|
||||
return b"", meta
|
||||
self.touch(attachment_id)
|
||||
self.touch(aid)
|
||||
return blob, meta
|
||||
|
||||
def get_local_path(self, attachment_id: str) -> Path | None:
|
||||
try:
|
||||
aid = self._normalize_attachment_id(attachment_id)
|
||||
except Exception:
|
||||
return None
|
||||
exts = (".png", ".jpg", ".jpeg", ".webp", ".gif", "")
|
||||
for ext in exts:
|
||||
p = self._data_path(attachment_id, ext=ext)
|
||||
p = self._data_path(aid, ext=ext)
|
||||
if p.exists():
|
||||
self.touch(attachment_id)
|
||||
self.touch(aid)
|
||||
return p
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -607,6 +607,31 @@ class SqliteStore:
|
|||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_admin_audit_actor_ts ON admin_audit_log(actor_user_id, timestamp DESC)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS attachment_acl (
|
||||
attachment_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (attachment_id, tenant_id, user_id, session_id, source),
|
||||
FOREIGN KEY(session_id) REFERENCES chat_session(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_attachment_acl_tenant_attachment ON attachment_acl(tenant_id, attachment_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_attachment_acl_user_attachment ON attachment_acl(tenant_id, user_id, attachment_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_attachment_acl_session_attachment ON attachment_acl(session_id, attachment_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tool_log (
|
||||
|
|
@ -1306,6 +1331,206 @@ class SqliteStore:
|
|||
last_message_at=row["last_message_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _attachments_contain_attachment_id(raw_attachments: Any, *, attachment_id: str) -> bool:
|
||||
aid = str(attachment_id or "").strip()
|
||||
if not aid:
|
||||
return False
|
||||
obj = raw_attachments
|
||||
if isinstance(raw_attachments, str):
|
||||
s = str(raw_attachments or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
except Exception:
|
||||
return False
|
||||
if isinstance(obj, dict):
|
||||
items = [obj]
|
||||
elif isinstance(obj, list):
|
||||
items = obj
|
||||
else:
|
||||
return False
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
if str(it.get("attachment_id") or "").strip() == aid:
|
||||
return True
|
||||
return False
|
||||
|
||||
def attachment_referenced_by_user(self, *, tenant_id: str, user_id: str, attachment_id: str, scan_limit: int = 2000) -> bool:
|
||||
aid = str(attachment_id or "").strip()
|
||||
tid = str(tenant_id or "").strip()
|
||||
uid = str(user_id or "").strip()
|
||||
if not aid or not tid or not uid:
|
||||
return False
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT m.attachments
|
||||
FROM chat_message m
|
||||
INNER JOIN ui_session_owner o ON o.session_id = m.session_id
|
||||
WHERE o.tenant_id = ? AND o.user_id = ? AND m.attachments IS NOT NULL AND m.attachments <> ''
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(tid, uid, int(max(1, scan_limit))),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if self._attachments_contain_attachment_id(r["attachments"], attachment_id=aid):
|
||||
return True
|
||||
return False
|
||||
|
||||
def attachment_referenced_in_tenant(self, *, tenant_id: str, attachment_id: str, scan_limit: int = 4000) -> bool:
|
||||
aid = str(attachment_id or "").strip()
|
||||
tid = str(tenant_id or "").strip()
|
||||
if not aid or not tid:
|
||||
return False
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT m.attachments
|
||||
FROM chat_message m
|
||||
INNER JOIN ui_session_owner o ON o.session_id = m.session_id
|
||||
WHERE o.tenant_id = ? AND m.attachments IS NOT NULL AND m.attachments <> ''
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(tid, int(max(1, scan_limit))),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if self._attachments_contain_attachment_id(r["attachments"], attachment_id=aid):
|
||||
return True
|
||||
return False
|
||||
|
||||
def link_attachment_acl(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
attachment_id: str,
|
||||
source: str,
|
||||
) -> None:
|
||||
tid = str(tenant_id or "").strip()
|
||||
uid = str(user_id or "").strip()
|
||||
sid = str(session_id or "").strip()
|
||||
aid = str(attachment_id or "").strip().lower()
|
||||
src = str(source or "").strip() or "unknown"
|
||||
if not tid or not uid or not sid or not aid:
|
||||
return
|
||||
ts = utc_now_iso()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO attachment_acl
|
||||
(attachment_id, tenant_id, user_id, session_id, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(aid, tid, uid, sid, src, ts),
|
||||
)
|
||||
|
||||
def attachment_acl_allows_user(self, *, tenant_id: str, user_id: str, attachment_id: str) -> bool:
|
||||
tid = str(tenant_id or "").strip()
|
||||
uid = str(user_id or "").strip()
|
||||
aid = str(attachment_id or "").strip().lower()
|
||||
if not tid or not uid or not aid:
|
||||
return False
|
||||
with self._connect() as conn:
|
||||
r = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM attachment_acl
|
||||
WHERE tenant_id = ? AND user_id = ? AND attachment_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(tid, uid, aid),
|
||||
).fetchone()
|
||||
return bool(r)
|
||||
|
||||
def attachment_acl_allows_tenant(self, *, tenant_id: str, attachment_id: str) -> bool:
|
||||
tid = str(tenant_id or "").strip()
|
||||
aid = str(attachment_id or "").strip().lower()
|
||||
if not tid or not aid:
|
||||
return False
|
||||
with self._connect() as conn:
|
||||
r = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM attachment_acl
|
||||
WHERE tenant_id = ? AND attachment_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(tid, aid),
|
||||
).fetchone()
|
||||
return bool(r)
|
||||
|
||||
def backfill_attachment_acl_from_messages(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
limit_messages: int = 50_000,
|
||||
) -> dict[str, Any]:
|
||||
"""Best-effort backfill: scan chat_message.attachments and populate attachment_acl.
|
||||
|
||||
This is intended for one-off migration / operator maintenance.
|
||||
"""
|
||||
tid = str(tenant_id or "").strip()
|
||||
lim = max(1, int(limit_messages))
|
||||
if not tid:
|
||||
return {"ok": False, "error": "tenant_id_required"}
|
||||
inserted = 0
|
||||
scanned_msgs = 0
|
||||
scanned_atts = 0
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT m.session_id, m.attachments, o.user_id
|
||||
FROM chat_message m
|
||||
INNER JOIN ui_session_owner o ON o.session_id = m.session_id
|
||||
WHERE o.tenant_id = ? AND m.attachments IS NOT NULL AND m.attachments <> ''
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(tid, lim),
|
||||
).fetchall()
|
||||
ts = utc_now_iso()
|
||||
for r in rows:
|
||||
scanned_msgs += 1
|
||||
sid = str(r["session_id"] or "").strip()
|
||||
uid = str(r["user_id"] or "").strip()
|
||||
if not sid or not uid:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(str(r["attachments"] or ""))
|
||||
except Exception:
|
||||
continue
|
||||
items = obj if isinstance(obj, list) else ([obj] if isinstance(obj, dict) else [])
|
||||
for a in items:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
scanned_atts += 1
|
||||
aid = str(a.get("attachment_id") or "").strip().lower()
|
||||
if not aid:
|
||||
continue
|
||||
src = "backfill:chat_message"
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO attachment_acl
|
||||
(attachment_id, tenant_id, user_id, session_id, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(aid, tid, uid, sid, src, ts),
|
||||
)
|
||||
inserted += int(cur.rowcount or 0)
|
||||
return {
|
||||
"ok": True,
|
||||
"tenant_id": tid,
|
||||
"scanned_messages": int(scanned_msgs),
|
||||
"scanned_attachments": int(scanned_atts),
|
||||
"inserted": int(inserted),
|
||||
}
|
||||
|
||||
def list_admin_sessions(
|
||||
self,
|
||||
*,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue