first commit
This commit is contained in:
+105
@@ -0,0 +1,105 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
DATABASE_DIR = BASE_DIR / "data"
|
||||
DATABASE_PATH = DATABASE_DIR / "parental-control.db"
|
||||
|
||||
|
||||
def initialize_database():
|
||||
DATABASE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with sqlite3.connect(DATABASE_PATH) as db:
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_allowances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
weekday INTEGER NOT NULL,
|
||||
allowance_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE(user_id, weekday),
|
||||
|
||||
FOREIGN KEY(user_id)
|
||||
REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS access_windows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
weekday INTEGER NOT NULL,
|
||||
start_minute INTEGER NOT NULL,
|
||||
end_minute INTEGER NOT NULL,
|
||||
|
||||
FOREIGN KEY(user_id)
|
||||
REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
used_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE(user_id, date),
|
||||
|
||||
FOREIGN KEY(user_id)
|
||||
REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS temporary_grants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
seconds INTEGER NOT NULL,
|
||||
remaining_seconds INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT,
|
||||
consumed INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY(user_id)
|
||||
REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
db = sqlite3.connect(DATABASE_PATH)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,455 @@
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
|
||||
from .database import get_db
|
||||
from .users import (
|
||||
lock_user,
|
||||
unlock_user,
|
||||
terminate_user,
|
||||
is_locked,
|
||||
)
|
||||
|
||||
|
||||
def user_has_session(username: str) -> bool:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"loginctl",
|
||||
"list-users",
|
||||
"--no-legend",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
|
||||
if len(parts) >= 2 and parts[1] == username:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def current_time():
|
||||
now = datetime.now()
|
||||
weekday = now.weekday()
|
||||
minute = now.hour * 60 + now.minute
|
||||
|
||||
return now, weekday, minute
|
||||
|
||||
|
||||
def get_user_policy(user_id: int, weekday: int):
|
||||
with get_db() as db:
|
||||
allowance_row = db.execute(
|
||||
"""
|
||||
SELECT allowance_seconds
|
||||
FROM daily_allowances
|
||||
WHERE user_id = ?
|
||||
AND weekday = ?
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
weekday,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
allowance_seconds = (
|
||||
allowance_row["allowance_seconds"]
|
||||
if allowance_row
|
||||
else 0
|
||||
)
|
||||
|
||||
today = datetime.now().date().isoformat()
|
||||
|
||||
usage_row = db.execute(
|
||||
"""
|
||||
SELECT used_seconds
|
||||
FROM usage
|
||||
WHERE user_id = ?
|
||||
AND date = ?
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
today,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
usage_seconds = (
|
||||
usage_row["used_seconds"]
|
||||
if usage_row
|
||||
else 0
|
||||
)
|
||||
|
||||
windows = db.execute(
|
||||
"""
|
||||
SELECT id, start_minute, end_minute
|
||||
FROM access_windows
|
||||
WHERE user_id = ?
|
||||
AND weekday = ?
|
||||
ORDER BY start_minute
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
weekday,
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
grant_row = db.execute(
|
||||
"""
|
||||
SELECT COALESCE(
|
||||
SUM(remaining_seconds),
|
||||
0
|
||||
) AS total
|
||||
FROM temporary_grants
|
||||
WHERE user_id = ?
|
||||
AND consumed = 0
|
||||
AND (
|
||||
expires_at IS NULL
|
||||
OR expires_at > ?
|
||||
)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
datetime.now().isoformat(),
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
grant_seconds = grant_row["total"]
|
||||
|
||||
return (
|
||||
allowance_seconds,
|
||||
usage_seconds,
|
||||
windows,
|
||||
grant_seconds,
|
||||
)
|
||||
|
||||
|
||||
def is_inside_window(windows, minute: int) -> bool:
|
||||
if not windows:
|
||||
return True
|
||||
|
||||
for window in windows:
|
||||
if (
|
||||
window["start_minute"]
|
||||
<= minute
|
||||
< window["end_minute"]
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_remaining_grant_seconds(user_id: int) -> int:
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT COALESCE(
|
||||
SUM(remaining_seconds),
|
||||
0
|
||||
) AS total
|
||||
FROM temporary_grants
|
||||
WHERE user_id = ?
|
||||
AND consumed = 0
|
||||
AND (
|
||||
expires_at IS NULL
|
||||
OR expires_at > ?
|
||||
)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
now,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
return row["total"]
|
||||
|
||||
|
||||
def consume_grant_seconds(
|
||||
user_id: int,
|
||||
seconds: int,
|
||||
):
|
||||
if seconds <= 0:
|
||||
return
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
with get_db() as db:
|
||||
grants = db.execute(
|
||||
"""
|
||||
SELECT id, remaining_seconds
|
||||
FROM temporary_grants
|
||||
WHERE user_id = ?
|
||||
AND consumed = 0
|
||||
AND remaining_seconds > 0
|
||||
AND (
|
||||
expires_at IS NULL
|
||||
OR expires_at > ?
|
||||
)
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
now,
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
remaining = seconds
|
||||
|
||||
for grant in grants:
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
available = grant["remaining_seconds"]
|
||||
|
||||
consumed = min(
|
||||
available,
|
||||
remaining,
|
||||
)
|
||||
|
||||
new_remaining = (
|
||||
available - consumed
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE temporary_grants
|
||||
SET remaining_seconds = ?,
|
||||
consumed = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
new_remaining,
|
||||
1 if new_remaining <= 0 else 0,
|
||||
grant["id"],
|
||||
),
|
||||
)
|
||||
|
||||
remaining -= consumed
|
||||
|
||||
|
||||
def record_usage(
|
||||
user_id: int,
|
||||
seconds: int,
|
||||
):
|
||||
if seconds <= 0:
|
||||
return
|
||||
|
||||
today = datetime.now().date().isoformat()
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT used_seconds
|
||||
FROM usage
|
||||
WHERE user_id = ?
|
||||
AND date = ?
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
today,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO usage (
|
||||
user_id,
|
||||
date,
|
||||
used_seconds
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
today,
|
||||
seconds,
|
||||
),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE usage
|
||||
SET used_seconds =
|
||||
used_seconds + ?
|
||||
WHERE user_id = ?
|
||||
AND date = ?
|
||||
""",
|
||||
(
|
||||
seconds,
|
||||
user_id,
|
||||
today,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def record_event(
|
||||
user_id: int,
|
||||
event_type: str,
|
||||
details: str = "",
|
||||
):
|
||||
with get_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO events (
|
||||
user_id,
|
||||
event_type,
|
||||
details
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
event_type,
|
||||
details,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def evaluate_user(
|
||||
user_id: int,
|
||||
username: str,
|
||||
):
|
||||
now, weekday, minute = current_time()
|
||||
|
||||
(
|
||||
allowance_seconds,
|
||||
usage_seconds,
|
||||
windows,
|
||||
grant_seconds,
|
||||
) = get_user_policy(
|
||||
user_id,
|
||||
weekday,
|
||||
)
|
||||
|
||||
inside_window = is_inside_window(
|
||||
windows,
|
||||
minute,
|
||||
)
|
||||
|
||||
allowance_remaining = max(
|
||||
0,
|
||||
allowance_seconds - usage_seconds,
|
||||
)
|
||||
|
||||
total_remaining = (
|
||||
allowance_remaining
|
||||
+ grant_seconds
|
||||
)
|
||||
|
||||
logged_in = user_has_session(
|
||||
username
|
||||
)
|
||||
|
||||
allowed_by_schedule = (
|
||||
inside_window
|
||||
and allowance_remaining > 0
|
||||
)
|
||||
|
||||
allowed_by_grant = (
|
||||
grant_seconds > 0
|
||||
)
|
||||
|
||||
should_allow = (
|
||||
allowed_by_schedule
|
||||
or allowed_by_grant
|
||||
)
|
||||
|
||||
locked = is_locked(username)
|
||||
|
||||
if should_allow:
|
||||
if locked:
|
||||
try:
|
||||
unlock_user(username)
|
||||
|
||||
record_event(
|
||||
user_id,
|
||||
"auto_unlock",
|
||||
"Access became available",
|
||||
)
|
||||
except Exception as exc:
|
||||
record_event(
|
||||
user_id,
|
||||
"unlock_error",
|
||||
str(exc),
|
||||
)
|
||||
|
||||
else:
|
||||
if logged_in:
|
||||
terminate_user(username)
|
||||
|
||||
record_event(
|
||||
user_id,
|
||||
"session_terminated",
|
||||
"Access is not currently permitted",
|
||||
)
|
||||
|
||||
if not locked:
|
||||
try:
|
||||
lock_user(username)
|
||||
|
||||
record_event(
|
||||
user_id,
|
||||
"auto_lock",
|
||||
"Access is not currently permitted",
|
||||
)
|
||||
except Exception as exc:
|
||||
record_event(
|
||||
user_id,
|
||||
"lock_error",
|
||||
str(exc),
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"timestamp": now.isoformat(),
|
||||
"weekday": weekday,
|
||||
"minute": minute,
|
||||
"inside_window": inside_window,
|
||||
"logged_in": logged_in,
|
||||
"allowance_seconds": allowance_seconds,
|
||||
"usage_seconds": usage_seconds,
|
||||
"allowance_remaining": allowance_remaining,
|
||||
"grant_seconds": grant_seconds,
|
||||
"total_remaining": total_remaining,
|
||||
"allowed": should_allow,
|
||||
}
|
||||
|
||||
|
||||
def enforce_all_users():
|
||||
with get_db() as db:
|
||||
users = db.execute(
|
||||
"""
|
||||
SELECT id, username, enabled
|
||||
FROM users
|
||||
WHERE enabled = 1
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
results = []
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
result = evaluate_user(
|
||||
user["id"],
|
||||
user["username"],
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
|
||||
except Exception as exc:
|
||||
record_event(
|
||||
user["id"],
|
||||
"enforcement_error",
|
||||
str(exc),
|
||||
)
|
||||
|
||||
return results
|
||||
+817
@@ -0,0 +1,817 @@
|
||||
from fastapi import (
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Request,
|
||||
Form,
|
||||
)
|
||||
|
||||
from fastapi.responses import (
|
||||
RedirectResponse,
|
||||
)
|
||||
|
||||
from .scheduler import (
|
||||
start_scheduler,
|
||||
stop_scheduler,
|
||||
)
|
||||
|
||||
from fastapi.templating import (
|
||||
Jinja2Templates,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
from .database import (
|
||||
initialize_database,
|
||||
get_db,
|
||||
)
|
||||
|
||||
from .users import (
|
||||
linux_user_exists,
|
||||
lock_user,
|
||||
unlock_user,
|
||||
terminate_user,
|
||||
is_locked,
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Parental Control",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory="templates"
|
||||
)
|
||||
|
||||
|
||||
WEEKDAYS = [
|
||||
(0, "Monday"),
|
||||
(1, "Tuesday"),
|
||||
(2, "Wednesday"),
|
||||
(3, "Thursday"),
|
||||
(4, "Friday"),
|
||||
(5, "Saturday"),
|
||||
(6, "Sunday"),
|
||||
]
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
initialize_database()
|
||||
start_scheduler()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown():
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
class AllowanceRequest(BaseModel):
|
||||
weekday: int
|
||||
seconds: int
|
||||
|
||||
|
||||
class WindowRequest(BaseModel):
|
||||
weekday: int
|
||||
start_minute: int
|
||||
end_minute: int
|
||||
|
||||
|
||||
class GrantRequest(BaseModel):
|
||||
seconds: int
|
||||
|
||||
|
||||
def get_user(user_id: int):
|
||||
with get_db() as db:
|
||||
return db.execute(
|
||||
"""
|
||||
SELECT id, username, enabled
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"application": "Parental Control",
|
||||
"version": "0.1.0",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/users")
|
||||
def list_users():
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, username, enabled
|
||||
FROM users
|
||||
ORDER BY username
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"locked": is_locked(row["username"])
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/lock")
|
||||
def manually_lock(user_id: int):
|
||||
row = get_user(user_id)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
lock_user(row["username"])
|
||||
|
||||
return {
|
||||
"username": row["username"],
|
||||
"locked": True
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/unlock")
|
||||
def manually_unlock(user_id: int):
|
||||
row = get_user(user_id)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
unlock_user(row["username"])
|
||||
|
||||
return {
|
||||
"username": row["username"],
|
||||
"locked": False
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/terminate")
|
||||
def manually_terminate(user_id: int):
|
||||
row = get_user(user_id)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
terminate_user(row["username"])
|
||||
|
||||
return {
|
||||
"username": row["username"],
|
||||
"terminated": True
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/allowance")
|
||||
def set_allowance(
|
||||
user_id: int,
|
||||
request: AllowanceRequest
|
||||
):
|
||||
if get_user(user_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if request.weekday < 0 or request.weekday > 6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid weekday"
|
||||
)
|
||||
|
||||
if request.seconds < 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Allowance cannot be negative"
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
db.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,
|
||||
request.weekday,
|
||||
request.seconds
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"weekday": request.weekday,
|
||||
"seconds": request.seconds
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/windows")
|
||||
def add_window(
|
||||
user_id: int,
|
||||
request: WindowRequest
|
||||
):
|
||||
if get_user(user_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if request.weekday < 0 or request.weekday > 6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid weekday"
|
||||
)
|
||||
|
||||
if request.start_minute < 0 or request.start_minute >= 1440:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid start time"
|
||||
)
|
||||
|
||||
if request.end_minute < 0 or request.end_minute > 1440:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid end time"
|
||||
)
|
||||
|
||||
if request.end_minute <= request.start_minute:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="End time must be after start time"
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO access_windows
|
||||
(
|
||||
user_id,
|
||||
weekday,
|
||||
start_minute,
|
||||
end_minute
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
request.weekday,
|
||||
request.start_minute,
|
||||
request.end_minute
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "created"
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/windows/{window_id}")
|
||||
def delete_window(window_id: int):
|
||||
with get_db() as db:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
DELETE FROM access_windows
|
||||
WHERE id = ?
|
||||
""",
|
||||
(window_id,)
|
||||
)
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Window not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "deleted"
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/grant")
|
||||
def grant_time(
|
||||
user_id: int,
|
||||
request: GrantRequest
|
||||
):
|
||||
if get_user(user_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if request.seconds <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Grant must be greater than zero"
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO temporary_grants
|
||||
(
|
||||
user_id,
|
||||
seconds,
|
||||
remaining_seconds
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
request.seconds,
|
||||
request.seconds
|
||||
)
|
||||
)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"seconds": request.seconds
|
||||
}
|
||||
|
||||
|
||||
@app.get("/admin")
|
||||
def admin_page(
|
||||
request: Request
|
||||
):
|
||||
with get_db() as db:
|
||||
users = db.execute(
|
||||
"""
|
||||
SELECT id, username, enabled
|
||||
FROM users
|
||||
ORDER BY username
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"users": users
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin/users/{user_id}")
|
||||
def admin_user_page(
|
||||
request: Request,
|
||||
user_id: int
|
||||
):
|
||||
user = get_user(user_id)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
|
||||
allowances = db.execute(
|
||||
"""
|
||||
SELECT weekday, allowance_seconds
|
||||
FROM daily_allowances
|
||||
WHERE user_id = ?
|
||||
ORDER BY weekday
|
||||
""",
|
||||
(user_id,)
|
||||
).fetchall()
|
||||
|
||||
windows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
weekday,
|
||||
start_minute,
|
||||
end_minute
|
||||
FROM access_windows
|
||||
WHERE user_id = ?
|
||||
ORDER BY weekday, start_minute
|
||||
""",
|
||||
(user_id,)
|
||||
).fetchall()
|
||||
|
||||
grants = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
seconds,
|
||||
created_at,
|
||||
expires_at,
|
||||
consumed
|
||||
FROM temporary_grants
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
(user_id,)
|
||||
).fetchall()
|
||||
|
||||
allowance_map = {
|
||||
row["weekday"]: row["allowance_seconds"]
|
||||
for row in allowances
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="user.html",
|
||||
context={
|
||||
"user": user,
|
||||
"locked": is_locked(user["username"]),
|
||||
"weekdays": WEEKDAYS,
|
||||
"allowances": allowance_map,
|
||||
"windows": windows,
|
||||
"grants": grants,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/allowance")
|
||||
def admin_set_allowance(
|
||||
user_id: int,
|
||||
weekday: int = Form(...),
|
||||
hours: int = Form(...),
|
||||
minutes: int = Form(...),
|
||||
):
|
||||
if get_user(user_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if weekday < 0 or weekday > 6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid weekday"
|
||||
)
|
||||
|
||||
if hours < 0 or minutes < 0 or minutes > 59:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid time"
|
||||
)
|
||||
|
||||
seconds = (hours * 3600) + (minutes * 60)
|
||||
|
||||
with get_db() as db:
|
||||
db.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
|
||||
)
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{user_id}",
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/window")
|
||||
def admin_add_window(
|
||||
user_id: int,
|
||||
weekday: int = Form(...),
|
||||
start_time: str = Form(...),
|
||||
end_time: str = Form(...),
|
||||
):
|
||||
if get_user(user_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
try:
|
||||
start_hour, start_minute = map(
|
||||
int,
|
||||
start_time.split(":")
|
||||
)
|
||||
|
||||
end_hour, end_minute = map(
|
||||
int,
|
||||
end_time.split(":")
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid time format"
|
||||
)
|
||||
|
||||
start_total = (
|
||||
start_hour * 60
|
||||
+ start_minute
|
||||
)
|
||||
|
||||
end_total = (
|
||||
end_hour * 60
|
||||
+ end_minute
|
||||
)
|
||||
|
||||
if weekday < 0 or weekday > 6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid weekday"
|
||||
)
|
||||
|
||||
if start_total < 0 or start_total >= 1440:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid start time"
|
||||
)
|
||||
|
||||
if end_total <= start_total:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="End time must be after start time"
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO access_windows
|
||||
(
|
||||
user_id,
|
||||
weekday,
|
||||
start_minute,
|
||||
end_minute
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
weekday,
|
||||
start_total,
|
||||
end_total
|
||||
)
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{user_id}",
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/windows/{window_id}/delete")
|
||||
def admin_delete_window(
|
||||
window_id: int
|
||||
):
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT user_id
|
||||
FROM access_windows
|
||||
WHERE id = ?
|
||||
""",
|
||||
(window_id,)
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Window not found"
|
||||
)
|
||||
|
||||
user_id = row["user_id"]
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
DELETE FROM access_windows
|
||||
WHERE id = ?
|
||||
""",
|
||||
(window_id,)
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{user_id}",
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/grant")
|
||||
def admin_grant_time(
|
||||
user_id: int,
|
||||
hours: int = Form(...),
|
||||
minutes: int = Form(...),
|
||||
):
|
||||
if get_user(user_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if hours < 0 or minutes < 0 or minutes > 59:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid time"
|
||||
)
|
||||
|
||||
seconds = (
|
||||
hours * 3600
|
||||
+ minutes * 60
|
||||
)
|
||||
|
||||
if seconds <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Grant must be greater than zero"
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO temporary_grants
|
||||
(
|
||||
user_id,
|
||||
seconds,
|
||||
remaining_seconds
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
seconds,
|
||||
seconds
|
||||
)
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{user_id}",
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/lock")
|
||||
def admin_lock_user(
|
||||
user_id: int
|
||||
):
|
||||
row = get_user(user_id)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
lock_user(row["username"])
|
||||
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{user_id}",
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/unlock")
|
||||
def admin_unlock_user(
|
||||
user_id: int
|
||||
):
|
||||
row = get_user(user_id)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
unlock_user(row["username"])
|
||||
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{user_id}",
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/terminate")
|
||||
def admin_terminate_user(
|
||||
user_id: int
|
||||
):
|
||||
row = get_user(user_id)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
terminate_user(row["username"])
|
||||
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{user_id}",
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/delete")
|
||||
def delete_user(
|
||||
user_id: int
|
||||
):
|
||||
with get_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
DELETE FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
"/admin",
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users")
|
||||
def admin_add_user(
|
||||
username: str = Form(...)
|
||||
):
|
||||
username = username.strip()
|
||||
|
||||
if not linux_user_exists(username):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux user does not exist"
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
|
||||
existing = db.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
""",
|
||||
(username,)
|
||||
).fetchone()
|
||||
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="User is already configured"
|
||||
)
|
||||
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO users(username)
|
||||
VALUES(?)
|
||||
""",
|
||||
(username,)
|
||||
)
|
||||
|
||||
user_id = cursor.lastrowid
|
||||
|
||||
for weekday in range(7):
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO daily_allowances
|
||||
(
|
||||
user_id,
|
||||
weekday,
|
||||
allowance_seconds
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
weekday,
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
"/admin",
|
||||
status_code=303
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
id: int
|
||||
username: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyAllowance:
|
||||
user_id: int
|
||||
weekday: int
|
||||
allowance_seconds: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessWindow:
|
||||
id: int
|
||||
user_id: int
|
||||
weekday: int
|
||||
start_minute: int
|
||||
end_minute: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemporaryGrant:
|
||||
id: int
|
||||
user_id: int
|
||||
seconds: int
|
||||
expires_at: Optional[str]
|
||||
consumed: bool
|
||||
@@ -0,0 +1,183 @@
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from .enforcement import (
|
||||
enforce_all_users,
|
||||
record_usage,
|
||||
get_user_policy,
|
||||
consume_grant_seconds,
|
||||
)
|
||||
|
||||
|
||||
CHECK_INTERVAL = 5
|
||||
|
||||
|
||||
class Scheduler:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
interval: int = CHECK_INTERVAL,
|
||||
):
|
||||
self.interval = interval
|
||||
|
||||
self._thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self._last_usage_update = {}
|
||||
|
||||
def start(self):
|
||||
|
||||
if (
|
||||
self._thread is not None
|
||||
and self._thread.is_alive()
|
||||
):
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="parental-control-scheduler",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
|
||||
self._stop_event.set()
|
||||
|
||||
if self._thread is not None:
|
||||
self._thread.join(
|
||||
timeout=self.interval + 2
|
||||
)
|
||||
|
||||
def _run(self):
|
||||
|
||||
# Evaluate immediately when the
|
||||
# application starts.
|
||||
self._tick()
|
||||
|
||||
while not self._stop_event.wait(
|
||||
self.interval
|
||||
):
|
||||
self._tick()
|
||||
|
||||
def _tick(self):
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
results = enforce_all_users()
|
||||
|
||||
for result in results:
|
||||
|
||||
user_id = result["user_id"]
|
||||
|
||||
if not result["logged_in"]:
|
||||
self._last_usage_update.pop(
|
||||
user_id,
|
||||
None,
|
||||
)
|
||||
continue
|
||||
|
||||
if not result["allowed"]:
|
||||
self._last_usage_update.pop(
|
||||
user_id,
|
||||
None,
|
||||
)
|
||||
continue
|
||||
|
||||
previous = (
|
||||
self._last_usage_update.get(
|
||||
user_id
|
||||
)
|
||||
)
|
||||
|
||||
self._last_usage_update[user_id] = now
|
||||
|
||||
if previous is None:
|
||||
continue
|
||||
|
||||
elapsed = int(
|
||||
now - previous
|
||||
)
|
||||
|
||||
if elapsed <= 0:
|
||||
continue
|
||||
|
||||
self._record_allowed_usage(
|
||||
user_id,
|
||||
elapsed,
|
||||
)
|
||||
|
||||
def _record_allowed_usage(
|
||||
self,
|
||||
user_id: int,
|
||||
seconds: int,
|
||||
):
|
||||
|
||||
if seconds <= 0:
|
||||
return
|
||||
|
||||
weekday = datetime.now().weekday()
|
||||
|
||||
(
|
||||
allowance_seconds,
|
||||
usage_seconds,
|
||||
windows,
|
||||
grant_seconds,
|
||||
) = get_user_policy(
|
||||
user_id,
|
||||
weekday,
|
||||
)
|
||||
|
||||
allowance_remaining = max(
|
||||
0,
|
||||
allowance_seconds
|
||||
- usage_seconds,
|
||||
)
|
||||
|
||||
normal_usage = min(
|
||||
seconds,
|
||||
allowance_remaining,
|
||||
)
|
||||
|
||||
grant_usage = (
|
||||
seconds
|
||||
- normal_usage
|
||||
)
|
||||
|
||||
if grant_usage > grant_seconds:
|
||||
grant_usage = grant_seconds
|
||||
|
||||
total_usage = (
|
||||
normal_usage
|
||||
+ grant_usage
|
||||
)
|
||||
|
||||
if total_usage <= 0:
|
||||
return
|
||||
|
||||
record_usage(
|
||||
user_id,
|
||||
total_usage,
|
||||
)
|
||||
|
||||
if grant_usage > 0:
|
||||
|
||||
consume_grant_seconds(
|
||||
user_id,
|
||||
grant_usage,
|
||||
)
|
||||
|
||||
|
||||
scheduler = Scheduler()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
scheduler.start()
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
scheduler.stop()
|
||||
@@ -0,0 +1,59 @@
|
||||
import pwd
|
||||
import subprocess
|
||||
|
||||
|
||||
def linux_user_exists(username: str) -> bool:
|
||||
try:
|
||||
pwd.getpwnam(username)
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
|
||||
def get_uid(username: str) -> int:
|
||||
return pwd.getpwnam(username).pw_uid
|
||||
|
||||
|
||||
def is_locked(username: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["passwd", "-S", username],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
parts = result.stdout.split()
|
||||
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
|
||||
return parts[1] == "L"
|
||||
|
||||
|
||||
def lock_user(username: str):
|
||||
subprocess.run(
|
||||
["loginctl", "terminate-user", username],
|
||||
check=False,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["passwd", "-l", username],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def unlock_user(username: str):
|
||||
subprocess.run(
|
||||
["passwd", "-u", username],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def terminate_user(username: str):
|
||||
subprocess.run(
|
||||
["loginctl", "terminate-user", username],
|
||||
check=False,
|
||||
)
|
||||
Reference in New Issue
Block a user