user management fix

This commit is contained in:
2026-09-17 16:14:19 +05:00
parent 298124c30f
commit 77c00c4ed5
3 changed files with 327 additions and 391 deletions
+60 -5
View File
@@ -1,6 +1,8 @@
import json
import os
import socket
import sqlite3
import struct
import subprocess
import threading
@@ -16,6 +18,10 @@ BUFFER_SIZE = 65536
POLKIT_ACTION = "org.sarlink.parentalcontrol.modify"
# ============================================================
# Peer credentials and authorization
# ============================================================
def get_peer_credentials(connection):
credentials = connection.getsockopt(
socket.SOL_SOCKET,
@@ -26,7 +32,19 @@ def get_peer_credentials(connection):
return struct.unpack("3i", credentials)
def authorize_write(pid: int) -> bool:
def authorize_write(pid: int, uid: int) -> bool:
"""
Authorize administrative operations.
Root is automatically authorized.
Non-root users must pass Polkit authorization.
"""
# Root is already an administrator.
if uid == 0:
return True
try:
os.kill(pid, 0)
except OSError:
@@ -53,6 +71,10 @@ def authorize_write(pid: int) -> bool:
return False
# ============================================================
# Database operations
# ============================================================
class IPCOperations:
@staticmethod
@@ -359,6 +381,10 @@ class IPCOperations:
)
# ============================================================
# Allowed IPC methods
# ============================================================
READ_METHODS = {
"get_users",
"get_user",
@@ -377,7 +403,15 @@ WRITE_METHODS = {
}
def handle_request(request, pid):
# ============================================================
# Request handling
# ============================================================
def handle_request(
request,
pid,
uid,
):
method = request.get("method")
arguments = request.get("args", {})
@@ -396,7 +430,7 @@ def handle_request(request, pid):
return operation(**arguments)
if method in WRITE_METHODS:
if not authorize_write(pid):
if not authorize_write(pid, uid):
raise PermissionError(
"Administrative authorization required"
)
@@ -419,6 +453,10 @@ def handle_request(request, pid):
)
# ============================================================
# Client handling
# ============================================================
def handle_client(connection):
try:
pid, uid, gid = get_peer_credentials(
@@ -456,6 +494,7 @@ def handle_client(connection):
result = handle_request(
request,
pid,
uid,
)
response = {
@@ -470,6 +509,13 @@ def handle_client(connection):
"error_type": "authorization",
}
except sqlite3.IntegrityError as exc:
response = {
"ok": False,
"error": str(exc),
"error_type": "database",
}
except Exception as exc:
response = {
"ok": False,
@@ -500,6 +546,10 @@ def handle_client(connection):
connection.close()
# ============================================================
# IPC server
# ============================================================
class IPCServer:
def __init__(
@@ -515,7 +565,10 @@ class IPCServer:
self._stop_event = threading.Event()
def start(self):
if self._thread is not None:
if (
self._thread is not None
and self._thread.is_alive()
):
return
self.socket_path.parent.mkdir(
@@ -564,7 +617,9 @@ class IPCServer:
pass
if self._thread is not None:
self._thread.join(timeout=2)
self._thread.join(
timeout=2
)
self._thread = None
self.server_socket = None
+265
View File
@@ -0,0 +1,265 @@
import json
import socket
from pathlib import Path
SOCKET_PATH = Path("/run/parental-control.sock")
SOCKET_TIMEOUT = 10
class IPCError(Exception):
"""Base exception for IPC errors."""
class DatabaseError(IPCError):
"""Raised when a database operation fails."""
class AuthorizationError(IPCError):
"""Raised when an administrative operation is denied."""
class IPCClient:
@staticmethod
def call(method, **arguments):
request = {
"method": method,
"args": arguments,
}
payload = (
json.dumps(
request,
separators=(",", ":"),
).encode("utf-8")
+ b"\n"
)
data = bytearray()
try:
with socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM,
) as connection:
connection.settimeout(
SOCKET_TIMEOUT
)
connection.connect(
str(SOCKET_PATH)
)
connection.sendall(
payload
)
while True:
chunk = connection.recv(
65536
)
if not chunk:
break
data.extend(chunk)
if b"\n" in chunk:
break
except FileNotFoundError:
raise IPCError(
"Parental control service is not running."
)
except ConnectionRefusedError:
raise IPCError(
"Could not connect to the parental control service."
)
except TimeoutError:
raise IPCError(
"The parental control service did not respond."
)
except OSError as exc:
raise IPCError(
f"Could not connect to the parental control service: {exc}"
)
if not data:
raise IPCError(
"The parental control service returned no response."
)
line = bytes(data).split(
b"\n",
1,
)[0]
try:
response = json.loads(
line.decode("utf-8")
)
except (
UnicodeDecodeError,
json.JSONDecodeError,
):
raise IPCError(
"The parental control service returned an invalid response."
)
if response.get("ok"):
return response.get("result")
error = response.get(
"error",
"Unknown error",
)
if response.get("error_type") == "authorization":
raise AuthorizationError(error)
if response.get("error_type") == "database":
raise DatabaseError(error)
raise IPCError(error)
class Database:
"""
Compatibility interface for the GUI.
The GUI keeps using Database.* methods, but all operations
now go through the root parental-control daemon instead of
accessing SQLite directly.
"""
@staticmethod
def get_users():
return IPCClient.call(
"get_users"
)
@staticmethod
def get_user(user_id):
return IPCClient.call(
"get_user",
user_id=user_id,
)
@staticmethod
def get_remaining_time(user_id):
result = IPCClient.call(
"get_remaining_time",
user_id=user_id,
)
return (
result[0],
result[1],
)
@staticmethod
def get_allowances(user_id):
result = IPCClient.call(
"get_allowances",
user_id=user_id,
)
return {
int(key): value
for key, value in result.items()
}
@staticmethod
def get_access_windows(user_id):
result = IPCClient.call(
"get_access_windows",
user_id=user_id,
)
return {
int(key): [
tuple(window)
for window in windows
]
for key, windows in result.items()
}
@staticmethod
def create_user(
username,
enabled,
allowances,
access_windows,
):
return IPCClient.call(
"create_user",
username=username,
enabled=enabled,
allowances={
str(key): value
for key, value in allowances.items()
},
access_windows={
str(key): [
list(window)
for window in windows
]
for key, windows in access_windows.items()
},
)
@staticmethod
def update_user(
user_id,
enabled,
allowances,
access_windows,
):
return IPCClient.call(
"update_user",
user_id=user_id,
enabled=enabled,
allowances={
str(key): value
for key, value in allowances.items()
},
access_windows={
str(key): [
list(window)
for window in windows
]
for key, windows in access_windows.items()
},
)
@staticmethod
def delete_user(user_id):
return IPCClient.call(
"delete_user",
user_id=user_id,
)
@staticmethod
def set_enabled(
user_id,
enabled,
):
return IPCClient.call(
"set_enabled",
user_id=user_id,
enabled=enabled,
)
@staticmethod
def add_temporary_time(
user_id,
seconds,
):
return IPCClient.call(
"add_temporary_time",
user_id=user_id,
seconds=seconds,
)
+2 -386
View File
@@ -1,6 +1,5 @@
import sys
import pwd
import sqlite3
from PySide6.QtCore import Qt, QTime
from PySide6.QtWidgets import (
@@ -26,389 +25,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from app.database import DATABASE_PATH
# ============================================================
# Database
# ============================================================
class Database:
@staticmethod
def get_connection():
connection = sqlite3.connect(DATABASE_PATH)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
@staticmethod
def get_users():
if not DATABASE_PATH.exists():
return []
connection = Database.get_connection()
try:
return connection.execute(
"""
SELECT *
FROM users
ORDER BY username COLLATE NOCASE
"""
).fetchall()
finally:
connection.close()
@staticmethod
def get_user(user_id):
connection = Database.get_connection()
try:
return connection.execute(
"""
SELECT *
FROM users
WHERE id = ?
""",
(user_id,),
).fetchone()
finally:
connection.close()
@staticmethod
def get_remaining_time(user_id):
if not DATABASE_PATH.exists():
return 0, 0
connection = Database.get_connection()
try:
allowance_row = connection.execute(
"""
SELECT COALESCE(
allowance_seconds,
0
) AS total
FROM daily_allowances
WHERE user_id = ?
AND weekday = CAST(
strftime('%w', 'now', 'localtime')
AS INTEGER
)
""",
(user_id,),
).fetchone()
allowance = (
allowance_row["total"]
if allowance_row
else 0
)
usage_row = connection.execute(
"""
SELECT COALESCE(
used_seconds,
0
) AS total
FROM usage
WHERE user_id = ?
AND date = date('now', 'localtime')
""",
(user_id,),
).fetchone()
used = (
usage_row["total"]
if usage_row
else 0
)
remaining = max(
allowance - used,
0,
)
return remaining, allowance
finally:
connection.close()
@staticmethod
def get_allowances(user_id):
connection = Database.get_connection()
try:
rows = connection.execute(
"""
SELECT weekday, allowance_seconds
FROM daily_allowances
WHERE user_id = ?
""",
(user_id,),
).fetchall()
return {
row["weekday"]: row["allowance_seconds"]
for row in rows
}
finally:
connection.close()
@staticmethod
def get_access_windows(user_id):
connection = Database.get_connection()
try:
rows = connection.execute(
"""
SELECT
weekday,
start_minute,
end_minute
FROM access_windows
WHERE user_id = ?
ORDER BY weekday, start_minute
""",
(user_id,),
).fetchall()
result = {}
for row in rows:
result.setdefault(
row["weekday"],
[],
).append(
(
row["start_minute"],
row["end_minute"],
)
)
return result
finally:
connection.close()
@staticmethod
def create_user(
username,
enabled,
allowances,
access_windows,
):
connection = Database.get_connection()
try:
cursor = connection.execute(
"""
INSERT INTO users (
username,
enabled
)
VALUES (?, ?)
""",
(
username,
1 if enabled else 0,
),
)
user_id = cursor.lastrowid
for weekday, seconds in allowances.items():
connection.execute(
"""
INSERT INTO daily_allowances (
user_id,
weekday,
allowance_seconds
)
VALUES (?, ?, ?)
""",
(
user_id,
weekday,
seconds,
),
)
for weekday, windows in access_windows.items():
for start_minute, end_minute in windows:
connection.execute(
"""
INSERT INTO access_windows (
user_id,
weekday,
start_minute,
end_minute
)
VALUES (?, ?, ?, ?)
""",
(
user_id,
weekday,
start_minute,
end_minute,
),
)
connection.commit()
return user_id
finally:
connection.close()
@staticmethod
def update_user(
user_id,
enabled,
allowances,
access_windows,
):
connection = Database.get_connection()
try:
connection.execute(
"""
UPDATE users
SET enabled = ?
WHERE id = ?
""",
(
1 if enabled else 0,
user_id,
),
)
for weekday, seconds in allowances.items():
connection.execute(
"""
INSERT INTO daily_allowances (
user_id,
weekday,
allowance_seconds
)
VALUES (?, ?, ?)
ON CONFLICT(user_id, weekday)
DO UPDATE SET
allowance_seconds =
excluded.allowance_seconds
""",
(
user_id,
weekday,
seconds,
),
)
# Replace access windows completely.
connection.execute(
"""
DELETE FROM access_windows
WHERE user_id = ?
""",
(user_id,),
)
for weekday, windows in access_windows.items():
for start_minute, end_minute in windows:
connection.execute(
"""
INSERT INTO access_windows (
user_id,
weekday,
start_minute,
end_minute
)
VALUES (?, ?, ?, ?)
""",
(
user_id,
weekday,
start_minute,
end_minute,
),
)
connection.commit()
finally:
connection.close()
@staticmethod
def delete_user(user_id):
connection = Database.get_connection()
try:
connection.execute(
"""
DELETE FROM users
WHERE id = ?
""",
(user_id,),
)
connection.commit()
finally:
connection.close()
@staticmethod
def set_enabled(user_id, enabled):
connection = Database.get_connection()
try:
connection.execute(
"""
UPDATE users
SET enabled = ?
WHERE id = ?
""",
(
1 if enabled else 0,
user_id,
),
)
connection.commit()
finally:
connection.close()
@staticmethod
def add_temporary_time(
user_id,
seconds,
):
connection = Database.get_connection()
try:
connection.execute(
"""
INSERT INTO temporary_grants (
user_id,
seconds,
remaining_seconds
)
VALUES (?, ?, ?)
""",
(
user_id,
seconds,
seconds,
),
)
connection.commit()
finally:
connection.close()
from .client import Database, DatabaseError
# ============================================================
# Linux users
@@ -421,7 +38,6 @@ def linux_user_exists(username):
except KeyError:
return False
# ============================================================
# Helpers
# ============================================================
@@ -1464,7 +1080,7 @@ class UserDialog(QDialog):
access_windows,
)
except sqlite3.IntegrityError:
except DatabaseError:
QMessageBox.warning(
self,