"""An optional narrowing of what a signed-in account may touch. Joomla is the authority on permissions and stays that way — `joomla.permits` walks the real asset line and nothing here can widen its answer. This only subtracts, and exists because of a practical gap: most Joomla sites leave every article inheriting the root rules, so "this person may edit exactly one page" is not expressible in Joomla without hand-configuring per-article permissions. An account with no rows here is unrestricted, which keeps the overlay opt-in — adding the table changes nothing until someone deliberately narrows an account. Owner and Super User scope bypasses this by design: it is a delegation tool, not a way to fence in the person who owns the site. Rows live in the site's own database (see `store`), so a fence survives a restart. A fence that quietly disappeared would silently re-widen an account, which is the worst direction for this particular mistake to fail in. """ import time import store COLUMNS = """ id INT AUTO_INCREMENT PRIMARY KEY, ts DOUBLE NOT NULL, user_id INT NOT NULL, kind VARCHAR(16) NOT NULL, ref INT NOT NULL, UNIQUE KEY kyklos_scope_once (user_id, kind, ref) """ INDEXES = ("kyklos_scope_by_user (user_id)",) KINDS = ("article", "category") def _t(site=None): return store.ensure("scope", COLUMNS, INDEXES, site) def limits(site, user_id): """What this account is fenced to, or None when it is not fenced at all. None and an empty fence are deliberately different: None means nobody has narrowed this account, empty would mean somebody narrowed it to nothing. """ rows = store.query(f"SELECT kind, ref FROM {_t(site)} WHERE user_id = %s", (int(user_id),), site) if not rows: return None return {"articles": {r["ref"] for r in rows if r["kind"] == "article"}, "categories": {r["ref"] for r in rows if r["kind"] == "category"}} def allows(site, user_id, article_id, category_id=None): """Is this article inside the fence? Unfenced accounts are always allowed.""" fence = limits(site, user_id) if fence is None: return True if int(article_id) in fence["articles"]: return True return category_id is not None and int(category_id) in fence["categories"] def grant(site, user_id, kind, ref): """Add one page or one category to an account's fence. The first grant is what creates the fence, so this both narrows and restricts: an unfenced account becomes fenced to exactly this. """ if kind not in KINDS: raise ValueError(f"scope kind must be article or category, not {kind!r}") store.write( f"INSERT IGNORE INTO {_t(site)} (ts,user_id,kind,ref) VALUES (%s,%s,%s,%s)", (time.time(), int(user_id), kind, int(ref)), site) def revoke(site, user_id, kind=None, ref=None): """Drop one entry, or the whole fence when called with neither. Dropping the last entry returns the account to unfenced — Joomla's answer alone — rather than to a fence containing nothing, which would silently lock the person out. """ q, args = f"DELETE FROM {_t(site)} WHERE user_id = %s", [int(user_id)] if kind is not None: q += " AND kind = %s"; args.append(kind) if ref is not None: q += " AND ref = %s"; args.append(int(ref)) _, dropped = store.write(q, tuple(args), site) return dropped def fenced(site): """Every narrowed account on one site, for the owner's view of who is.""" rows = store.query( f"SELECT user_id, kind, ref FROM {_t(site)} ORDER BY user_id", (), site) out = {} for r in rows: out.setdefault(r["user_id"], []).append({"kind": r["kind"], "ref": r["ref"]}) return out