prashna 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prashna/__init__.py +0 -0
- prashna/__main__.py +6 -0
- prashna/auth.py +198 -0
- prashna/backup.py +194 -0
- prashna/cli.py +134 -0
- prashna/db.py +155 -0
- prashna/diagnostics.py +58 -0
- prashna/installers/install.ps1 +22 -0
- prashna/installers/install.sh +24 -0
- prashna/main.py +2800 -0
- prashna/models.py +320 -0
- prashna/oauth.py +115 -0
- prashna/ratelimit.py +90 -0
- prashna/static/assets/common-B1uJCwh4.css +1 -0
- prashna/static/assets/common-C-iimYTB.js +1 -0
- prashna/static/assets/index-DZnWpx3x.js +1 -0
- prashna/static/assets/moderator-dnk9gqKk.js +1 -0
- prashna/static/assets/participant-C9Hyu7Br.js +1 -0
- prashna/static/assets/projector-lCmSbmv0.js +1 -0
- prashna/static/assets/quizzes-BCrX8Dds.js +1 -0
- prashna/static/index.html +133 -0
- prashna/static/moderator.html +133 -0
- prashna/static/participant.html +182 -0
- prashna/static/projector.html +37 -0
- prashna/static/quizzes.html +89 -0
- prashna/static/vendor/qrcode.js +2297 -0
- prashna/ws.py +71 -0
- prashna-0.2.0.dist-info/METADATA +117 -0
- prashna-0.2.0.dist-info/RECORD +32 -0
- prashna-0.2.0.dist-info/WHEEL +4 -0
- prashna-0.2.0.dist-info/entry_points.txt +2 -0
- prashna-0.2.0.dist-info/licenses/LICENSE +21 -0
prashna/__init__.py
ADDED
|
File without changes
|
prashna/__main__.py
ADDED
prashna/auth.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Admin auth: password hashing + signed session cookie.
|
|
2
|
+
|
|
3
|
+
Single-admin model for now (local self-host). Multi-user/multi-org is a
|
|
4
|
+
future expansion; keep this module the seam where that change lands.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import secrets as pysecrets
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import bcrypt
|
|
14
|
+
from fastapi import Cookie, Depends, HTTPException, Response
|
|
15
|
+
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
|
|
16
|
+
from sqlalchemy import select
|
|
17
|
+
from sqlalchemy.orm import Session as DbSession
|
|
18
|
+
|
|
19
|
+
from . import models as m
|
|
20
|
+
from .db import DB_PATH, get_db
|
|
21
|
+
|
|
22
|
+
COOKIE_NAME = "prashna_admin"
|
|
23
|
+
SESSION_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
|
|
24
|
+
# bcrypt silently truncates inputs beyond 72 bytes; reject longer to avoid
|
|
25
|
+
# users believing a long passphrase is fully protecting them.
|
|
26
|
+
MAX_PASSWORD_BYTES = 72
|
|
27
|
+
|
|
28
|
+
# Operator-free state lives beside the database, so a backup of the data
|
|
29
|
+
# folder carries it: the signing key (UC-X-07) and, until an admin exists,
|
|
30
|
+
# the one-time setup token (UC-A-01).
|
|
31
|
+
DATA_DIR = DB_PATH.parent
|
|
32
|
+
SECRET_KEY_FILE = DATA_DIR / "secret-key"
|
|
33
|
+
SETUP_TOKEN_FILE = DATA_DIR / "setup-token"
|
|
34
|
+
# Generated values carry a fixed prefix so a scanner (scripts/check_pii.py)
|
|
35
|
+
# recognises a leaked key or token in any file, whatever it is called.
|
|
36
|
+
SECRET_KEY_PREFIX = "prashna_sk_" # pragma: allowlist secret
|
|
37
|
+
SETUP_TOKEN_PREFIX = "prashna_st_"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_private(path: Path, value: str) -> None:
|
|
41
|
+
"""Create `path` readable only by this user; fail if it already exists."""
|
|
42
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
43
|
+
with os.fdopen(fd, "w") as fh:
|
|
44
|
+
fh.write(value + "\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_or_create(path: Path, make) -> str:
|
|
48
|
+
try:
|
|
49
|
+
value = path.read_text().strip()
|
|
50
|
+
if value:
|
|
51
|
+
return value
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
pass
|
|
54
|
+
value = make()
|
|
55
|
+
try:
|
|
56
|
+
_write_private(path, value)
|
|
57
|
+
except FileExistsError: # another start won the race; use its value
|
|
58
|
+
return path.read_text().strip()
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_secret(env_value: str | None, key_file: Path) -> tuple[str, bool]:
|
|
63
|
+
"""Return (signing_key, is_ephemeral) (UC-X-07).
|
|
64
|
+
|
|
65
|
+
PRASHNA_SECRET_KEY wins. Otherwise the key is read from, or created in,
|
|
66
|
+
`key_file`, so sessions survive restarts with no operator step. Only if
|
|
67
|
+
that file can't be used do we fall back to a process-lifetime key
|
|
68
|
+
(ephemeral), which startup warns about.
|
|
69
|
+
"""
|
|
70
|
+
if env_value:
|
|
71
|
+
return env_value, False
|
|
72
|
+
try:
|
|
73
|
+
return _read_or_create(key_file, lambda: SECRET_KEY_PREFIX + pysecrets.token_urlsafe(48)), False
|
|
74
|
+
except OSError:
|
|
75
|
+
return SECRET_KEY_PREFIX + pysecrets.token_urlsafe(32), True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_secret, SECRET_KEY_IS_EPHEMERAL = _resolve_secret(os.environ.get("PRASHNA_SECRET_KEY"), SECRET_KEY_FILE)
|
|
79
|
+
_signer = TimestampSigner(_secret)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def new_signer() -> TimestampSigner:
|
|
83
|
+
"""A signer over the same app secret — for one-off signed tokens like
|
|
84
|
+
the GitHub OAuth `state` (CSRF) that aren't session cookies."""
|
|
85
|
+
return TimestampSigner(_secret)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def hash_password(pw: str) -> str:
|
|
89
|
+
pw_bytes = pw.encode("utf-8")
|
|
90
|
+
if len(pw_bytes) > MAX_PASSWORD_BYTES:
|
|
91
|
+
raise HTTPException(400, f"password must be at most {MAX_PASSWORD_BYTES} bytes")
|
|
92
|
+
return bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def verify_password(pw: str, hashed: str) -> bool:
|
|
96
|
+
try:
|
|
97
|
+
return bcrypt.checkpw(pw.encode("utf-8"), hashed.encode("utf-8"))
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def issue_session(response: Response, user_id: int, session_version: int, *, secure: bool) -> None:
|
|
103
|
+
# Cookie value is "user_id:session_version". The session_version comes
|
|
104
|
+
# from the User row; bumping it on password change invalidates older
|
|
105
|
+
# cookies even though they remain validly signed.
|
|
106
|
+
token = _signer.sign(f"{user_id}:{session_version}").decode()
|
|
107
|
+
response.set_cookie(
|
|
108
|
+
COOKIE_NAME,
|
|
109
|
+
token,
|
|
110
|
+
max_age=SESSION_MAX_AGE,
|
|
111
|
+
httponly=True,
|
|
112
|
+
samesite="lax",
|
|
113
|
+
secure=secure,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def clear_session(response: Response) -> None:
|
|
118
|
+
response.delete_cookie(COOKIE_NAME)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_cookie(cookie: str | None) -> tuple[int, int] | None:
|
|
122
|
+
"""Return (user_id, session_version) from a signed cookie, or None."""
|
|
123
|
+
if not cookie:
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
raw = _signer.unsign(cookie, max_age=SESSION_MAX_AGE).decode()
|
|
127
|
+
except (BadSignature, SignatureExpired):
|
|
128
|
+
return None
|
|
129
|
+
# Backwards-compat for older cookies (no session_version field).
|
|
130
|
+
if ":" in raw:
|
|
131
|
+
try:
|
|
132
|
+
uid_str, ver_str = raw.split(":", 1)
|
|
133
|
+
return int(uid_str), int(ver_str)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
return int(raw), 1
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def current_admin(
|
|
143
|
+
prashna_admin: Annotated[str | None, Cookie()] = None,
|
|
144
|
+
db: DbSession = Depends(get_db),
|
|
145
|
+
) -> m.User:
|
|
146
|
+
parsed = _parse_cookie(prashna_admin)
|
|
147
|
+
if parsed is None:
|
|
148
|
+
raise HTTPException(401, "Not authenticated")
|
|
149
|
+
uid, ver = parsed
|
|
150
|
+
u = db.get(m.User, uid)
|
|
151
|
+
if u is None or u.session_version != ver:
|
|
152
|
+
raise HTTPException(401, "Not authenticated")
|
|
153
|
+
return u
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def optional_admin(
|
|
157
|
+
prashna_admin: Annotated[str | None, Cookie()] = None,
|
|
158
|
+
db: DbSession = Depends(get_db),
|
|
159
|
+
) -> m.User | None:
|
|
160
|
+
parsed = _parse_cookie(prashna_admin)
|
|
161
|
+
if parsed is None:
|
|
162
|
+
return None
|
|
163
|
+
uid, ver = parsed
|
|
164
|
+
u = db.get(m.User, uid)
|
|
165
|
+
if u is None or u.session_version != ver:
|
|
166
|
+
return None
|
|
167
|
+
return u
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def admin_exists(db: DbSession) -> bool:
|
|
171
|
+
return db.scalar(select(m.User.id).limit(1)) is not None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------- one-time first-admin setup token (UC-A-01) ----------
|
|
175
|
+
|
|
176
|
+
def setup_token(db: DbSession) -> str | None:
|
|
177
|
+
"""The token that authorizes creating the first admin, or None once an
|
|
178
|
+
admin exists. PRASHNA_SETUP_TOKEN wins; otherwise a random token is kept
|
|
179
|
+
in SETUP_TOKEN_FILE and reused across restarts until setup completes."""
|
|
180
|
+
if admin_exists(db):
|
|
181
|
+
retire_setup_token()
|
|
182
|
+
return None
|
|
183
|
+
env_token = os.environ.get("PRASHNA_SETUP_TOKEN", "").strip()
|
|
184
|
+
if env_token:
|
|
185
|
+
return env_token
|
|
186
|
+
return _read_or_create(SETUP_TOKEN_FILE, lambda: SETUP_TOKEN_PREFIX + pysecrets.token_urlsafe(24))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def setup_token_matches(db: DbSession, supplied: object) -> bool:
|
|
190
|
+
expected = setup_token(db)
|
|
191
|
+
return bool(expected) and isinstance(supplied, str) and pysecrets.compare_digest(supplied, expected)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def retire_setup_token() -> None:
|
|
195
|
+
try:
|
|
196
|
+
SETUP_TOKEN_FILE.unlink()
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
pass
|
prashna/backup.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Create and restore portable Prashna data archives.
|
|
2
|
+
|
|
3
|
+
This module is intentionally runnable inside the published container so a
|
|
4
|
+
Docker-only installation does not need Python or sqlite tooling on the host.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sqlite3
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
|
|
20
|
+
ARCHIVE_FORMAT = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _db_path() -> Path:
|
|
24
|
+
return Path(os.environ.get("PRASHNA_DB_PATH", "/data/prashna.db"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _uploads_path() -> Path:
|
|
28
|
+
return Path(os.environ.get("PRASHNA_UPLOADS_DIR", "/app/uploads"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _add_bytes(archive: tarfile.TarFile, name: str, payload: bytes) -> None:
|
|
32
|
+
info = tarfile.TarInfo(name)
|
|
33
|
+
info.size = len(payload)
|
|
34
|
+
info.mode = 0o600
|
|
35
|
+
info.mtime = 0
|
|
36
|
+
archive.addfile(info, io.BytesIO(payload))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_archive(destination: Path) -> Path:
|
|
40
|
+
"""Create a consistent SQLite + uploads archive at *destination*."""
|
|
41
|
+
source_db = _db_path()
|
|
42
|
+
uploads = _uploads_path()
|
|
43
|
+
if not source_db.is_file():
|
|
44
|
+
raise RuntimeError(f"database not found: {source_db}")
|
|
45
|
+
destination = destination.resolve()
|
|
46
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
with tempfile.TemporaryDirectory(prefix="prashna-backup-") as temp:
|
|
49
|
+
snapshot = Path(temp) / "prashna.db"
|
|
50
|
+
with sqlite3.connect(source_db) as source, sqlite3.connect(snapshot) as target:
|
|
51
|
+
source.backup(target)
|
|
52
|
+
|
|
53
|
+
manifest = {
|
|
54
|
+
"format": ARCHIVE_FORMAT,
|
|
55
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
56
|
+
"database": "prashna.db",
|
|
57
|
+
"uploads": "uploads/",
|
|
58
|
+
}
|
|
59
|
+
with tarfile.open(destination, "w:gz", format=tarfile.PAX_FORMAT) as archive:
|
|
60
|
+
_add_bytes(
|
|
61
|
+
archive,
|
|
62
|
+
"manifest.json",
|
|
63
|
+
(json.dumps(manifest, sort_keys=True) + "\n").encode(),
|
|
64
|
+
)
|
|
65
|
+
archive.add(snapshot, arcname="prashna.db", recursive=False)
|
|
66
|
+
if uploads.is_dir():
|
|
67
|
+
for path in sorted(uploads.rglob("*")):
|
|
68
|
+
if path.is_symlink() or not path.is_file():
|
|
69
|
+
continue
|
|
70
|
+
relative = path.relative_to(uploads).as_posix()
|
|
71
|
+
archive.add(path, arcname=f"uploads/{relative}", recursive=False)
|
|
72
|
+
return destination
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_checked(archive_path: Path, destination: Path) -> None:
|
|
76
|
+
seen: set[str] = set()
|
|
77
|
+
with tarfile.open(archive_path, "r:gz") as archive:
|
|
78
|
+
for member in archive.getmembers():
|
|
79
|
+
name = PurePosixPath(member.name)
|
|
80
|
+
if (
|
|
81
|
+
member.name in seen
|
|
82
|
+
or name.is_absolute()
|
|
83
|
+
or ".." in name.parts
|
|
84
|
+
or member.issym()
|
|
85
|
+
or member.islnk()
|
|
86
|
+
or not (
|
|
87
|
+
member.name in {"manifest.json", "prashna.db"}
|
|
88
|
+
or (name.parts and name.parts[0] == "uploads")
|
|
89
|
+
)
|
|
90
|
+
):
|
|
91
|
+
raise RuntimeError(f"unsafe backup member: {member.name}")
|
|
92
|
+
seen.add(member.name)
|
|
93
|
+
if member.isdir():
|
|
94
|
+
(destination / Path(*name.parts)).mkdir(parents=True, exist_ok=True)
|
|
95
|
+
continue
|
|
96
|
+
if not member.isfile():
|
|
97
|
+
raise RuntimeError(f"unsupported backup member: {member.name}")
|
|
98
|
+
source = archive.extractfile(member)
|
|
99
|
+
if source is None:
|
|
100
|
+
raise RuntimeError(f"cannot read backup member: {member.name}")
|
|
101
|
+
target = destination / Path(*name.parts)
|
|
102
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
with target.open("wb") as output:
|
|
104
|
+
shutil.copyfileobj(source, output)
|
|
105
|
+
|
|
106
|
+
required = {"manifest.json", "prashna.db"}
|
|
107
|
+
if not required.issubset(seen):
|
|
108
|
+
raise RuntimeError("backup is missing manifest.json or prashna.db")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_snapshot(snapshot: Path, manifest_path: Path) -> None:
|
|
112
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
113
|
+
if manifest.get("format") != ARCHIVE_FORMAT:
|
|
114
|
+
raise RuntimeError(f"unsupported backup format: {manifest.get('format')!r}")
|
|
115
|
+
with sqlite3.connect(f"file:{snapshot}?mode=ro", uri=True) as connection:
|
|
116
|
+
result = connection.execute("PRAGMA quick_check").fetchone()
|
|
117
|
+
if not result or result[0] != "ok":
|
|
118
|
+
raise RuntimeError(f"backup database failed quick_check: {result!r}")
|
|
119
|
+
tables = {
|
|
120
|
+
row[0]
|
|
121
|
+
for row in connection.execute(
|
|
122
|
+
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
|
123
|
+
).fetchall()
|
|
124
|
+
}
|
|
125
|
+
if "users" not in tables or "sessions" not in tables:
|
|
126
|
+
raise RuntimeError("backup database is not a Prashna database")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def restore_archive(archive_path: Path, *, confirmed: bool = False) -> Path:
|
|
130
|
+
"""Restore an archive after creating a sibling pre-restore backup."""
|
|
131
|
+
if not confirmed:
|
|
132
|
+
raise RuntimeError("restore requires --confirm and a stopped Prashna service")
|
|
133
|
+
archive_path = archive_path.resolve()
|
|
134
|
+
if not archive_path.is_file():
|
|
135
|
+
raise RuntimeError(f"backup not found: {archive_path}")
|
|
136
|
+
|
|
137
|
+
with tempfile.TemporaryDirectory(prefix="prashna-restore-") as temp:
|
|
138
|
+
extracted = Path(temp)
|
|
139
|
+
_extract_checked(archive_path, extracted)
|
|
140
|
+
snapshot = extracted / "prashna.db"
|
|
141
|
+
_validate_snapshot(snapshot, extracted / "manifest.json")
|
|
142
|
+
|
|
143
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
144
|
+
pre_restore = archive_path.with_name(f"prashna-pre-restore-{stamp}.tar.gz")
|
|
145
|
+
create_archive(pre_restore)
|
|
146
|
+
|
|
147
|
+
target_db = _db_path()
|
|
148
|
+
target_db.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
staged_db = target_db.with_name(f".{target_db.name}.restore")
|
|
150
|
+
shutil.copy2(snapshot, staged_db)
|
|
151
|
+
os.replace(staged_db, target_db)
|
|
152
|
+
for suffix in ("-wal", "-shm", "-journal"):
|
|
153
|
+
target_db.with_name(target_db.name + suffix).unlink(missing_ok=True)
|
|
154
|
+
|
|
155
|
+
uploads = _uploads_path()
|
|
156
|
+
uploads.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
for current in uploads.iterdir():
|
|
158
|
+
if current.is_dir() and not current.is_symlink():
|
|
159
|
+
shutil.rmtree(current)
|
|
160
|
+
else:
|
|
161
|
+
current.unlink()
|
|
162
|
+
restored_uploads = extracted / "uploads"
|
|
163
|
+
if restored_uploads.is_dir():
|
|
164
|
+
for source in restored_uploads.rglob("*"):
|
|
165
|
+
relative = source.relative_to(restored_uploads)
|
|
166
|
+
target = uploads / relative
|
|
167
|
+
if source.is_dir():
|
|
168
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
elif source.is_file():
|
|
170
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
shutil.copy2(source, target)
|
|
172
|
+
return pre_restore
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv: list[str] | None = None) -> int:
|
|
176
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
177
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
178
|
+
create = commands.add_parser("create", help="create a SQLite + uploads archive")
|
|
179
|
+
create.add_argument("archive", type=Path)
|
|
180
|
+
restore = commands.add_parser("restore", help="restore an archive while the app is stopped")
|
|
181
|
+
restore.add_argument("archive", type=Path)
|
|
182
|
+
restore.add_argument("--confirm", action="store_true")
|
|
183
|
+
args = parser.parse_args(argv)
|
|
184
|
+
|
|
185
|
+
if args.command == "create":
|
|
186
|
+
print(create_archive(args.archive))
|
|
187
|
+
return 0
|
|
188
|
+
pre_restore = restore_archive(args.archive, confirmed=args.confirm)
|
|
189
|
+
print(f"restored {args.archive}; previous state: {pre_restore}")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
raise SystemExit(main())
|
prashna/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The `prashna` command: run Prashna on this computer (UC-X-15).
|
|
2
|
+
|
|
3
|
+
prashna start on http://127.0.0.1:8000 and open the browser
|
|
4
|
+
prashna --port 8080 use another port
|
|
5
|
+
prashna --data-dir DIR keep the database and uploads in DIR
|
|
6
|
+
prashna backup create F archive the data folder (see `prashna backup --help`)
|
|
7
|
+
|
|
8
|
+
Data lives in the per-user app-data folder unless --data-dir or
|
|
9
|
+
PRASHNA_DATA_DIR says otherwise. On the first run the browser opens the
|
|
10
|
+
one-time setup page, so creating the admin needs no copy-and-paste.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import webbrowser
|
|
23
|
+
from importlib import metadata
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version() -> str:
|
|
28
|
+
try:
|
|
29
|
+
return metadata.version("prashna")
|
|
30
|
+
except metadata.PackageNotFoundError:
|
|
31
|
+
return "0+unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="prashna", description="Live Q&A and quizzes for any session with an audience.")
|
|
37
|
+
parser.add_argument("--host", default="127.0.0.1",
|
|
38
|
+
help="address to listen on (default 127.0.0.1; 0.0.0.0 to allow other devices)")
|
|
39
|
+
parser.add_argument("--port", type=int, default=8000, help="port to listen on (default 8000)")
|
|
40
|
+
parser.add_argument("--data-dir", help="folder for the database and uploads "
|
|
41
|
+
"(default: PRASHNA_DATA_DIR or your app-data folder)")
|
|
42
|
+
parser.add_argument("--no-browser", action="store_true", help="don't open the browser")
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"prashna {_version()}")
|
|
44
|
+
commands = parser.add_subparsers(dest="command")
|
|
45
|
+
backup = commands.add_parser("backup", add_help=False, help="create or restore a backup of the data folder")
|
|
46
|
+
backup.add_argument("args", nargs=argparse.REMAINDER)
|
|
47
|
+
return parser
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _data_dir(arg: str | None) -> Path:
|
|
51
|
+
chosen = arg or os.environ.get("PRASHNA_DATA_DIR")
|
|
52
|
+
if chosen:
|
|
53
|
+
return Path(chosen).expanduser().resolve()
|
|
54
|
+
from .db import default_data_dir
|
|
55
|
+
return default_data_dir()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _port_free(host: str, port: int) -> bool:
|
|
59
|
+
with socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET) as sock:
|
|
60
|
+
try:
|
|
61
|
+
sock.bind((host, port))
|
|
62
|
+
except OSError:
|
|
63
|
+
return False
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _launch_browser(url: str) -> None:
|
|
68
|
+
"""Open `url`. A BROWSER setting (the usual way to choose a browser) is
|
|
69
|
+
launched directly: webbrowser's discovery of every installed browser can
|
|
70
|
+
take several seconds on Linux desktops before it tries BROWSER."""
|
|
71
|
+
for entry in filter(None, os.environ.get("BROWSER", "").split(os.pathsep)):
|
|
72
|
+
command = entry.replace("%s", url) if "%s" in entry else f"{entry} {shlex.quote(url)}"
|
|
73
|
+
try:
|
|
74
|
+
subprocess.Popen(shlex.split(command))
|
|
75
|
+
return
|
|
76
|
+
except OSError:
|
|
77
|
+
continue
|
|
78
|
+
webbrowser.open(url)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _open_when_ready(host: str, port: int, url: str) -> None:
|
|
82
|
+
deadline = time.monotonic() + 30
|
|
83
|
+
while time.monotonic() < deadline:
|
|
84
|
+
try:
|
|
85
|
+
with socket.create_connection((host, port), timeout=0.5):
|
|
86
|
+
break
|
|
87
|
+
except OSError:
|
|
88
|
+
time.sleep(0.2)
|
|
89
|
+
_launch_browser(url)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv: list[str] | None = None) -> int:
|
|
93
|
+
args = _parser().parse_args(argv)
|
|
94
|
+
data_dir = _data_dir(args.data_dir)
|
|
95
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
# Set before the server modules are imported; they read these at import.
|
|
97
|
+
os.environ.setdefault("PRASHNA_DB_PATH", str(data_dir / "prashna.db"))
|
|
98
|
+
os.environ.setdefault("PRASHNA_UPLOADS_DIR", str(data_dir / "uploads"))
|
|
99
|
+
|
|
100
|
+
if args.command == "backup":
|
|
101
|
+
from .backup import main as backup_main
|
|
102
|
+
return backup_main(args.args)
|
|
103
|
+
|
|
104
|
+
if not _port_free(args.host, args.port):
|
|
105
|
+
print(f"Port {args.port} is already in use. Start on another port, "
|
|
106
|
+
f"for example: prashna --port {args.port + 1}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
from . import auth, db
|
|
110
|
+
db.init_db()
|
|
111
|
+
with db.SessionLocal() as session:
|
|
112
|
+
token = auth.setup_token(session)
|
|
113
|
+
shown_host = "127.0.0.1" if args.host in ("0.0.0.0", "::") else args.host
|
|
114
|
+
address = f"http://{shown_host}:{args.port}"
|
|
115
|
+
url = f"{address}/#setup={token}" if token else f"{address}/"
|
|
116
|
+
|
|
117
|
+
print(f"\nPrashna is running at {address}")
|
|
118
|
+
print(f"Data folder: {data_dir}")
|
|
119
|
+
if token:
|
|
120
|
+
if args.no_browser:
|
|
121
|
+
print(f"Create your admin account here (one-time link): {url}")
|
|
122
|
+
else:
|
|
123
|
+
print("Opening the one-time setup page in your browser to create your admin account.")
|
|
124
|
+
print("Press Ctrl+C to stop.\n", flush=True)
|
|
125
|
+
if not args.no_browser:
|
|
126
|
+
threading.Thread(target=_open_when_ready, args=(shown_host, args.port, url), daemon=True).start()
|
|
127
|
+
|
|
128
|
+
import uvicorn
|
|
129
|
+
uvicorn.run("prashna.main:app", host=args.host, port=args.port, log_level="warning")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
sys.exit(main())
|
prashna/db.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import create_engine, event
|
|
6
|
+
from sqlalchemy.engine import Engine
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
|
8
|
+
|
|
9
|
+
from .diagnostics import log_diagnostic as _log_diagnostic
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_data_dir() -> Path:
|
|
13
|
+
"""Where data lives when PRASHNA_DB_PATH / PRASHNA_UPLOADS_DIR are unset:
|
|
14
|
+
the platform's per-user app-data folder, for a source checkout too, so
|
|
15
|
+
the database and the generated secrets never land in a repository."""
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "prashna"
|
|
18
|
+
if sys.platform == "darwin":
|
|
19
|
+
return Path.home() / "Library" / "Application Support" / "prashna"
|
|
20
|
+
return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "prashna"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Allow the DB location to be overridden via env (Docker volume in prod).
|
|
24
|
+
DB_PATH = Path(os.environ.get("PRASHNA_DB_PATH") or default_data_dir() / "prashna.db")
|
|
25
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
engine = create_engine(
|
|
27
|
+
f"sqlite:///{DB_PATH}",
|
|
28
|
+
connect_args={"check_same_thread": False},
|
|
29
|
+
future=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Per-connection SQLite pragmas:
|
|
33
|
+
# - foreign_keys=ON: SQLite ignores ON DELETE CASCADE / FK constraints unless
|
|
34
|
+
# set per connection; without it orphaned rows are possible.
|
|
35
|
+
# - journal_mode=WAL: readers don't block the single writer (and vice versa),
|
|
36
|
+
# which matters under a live quiz where many participants submit at once.
|
|
37
|
+
# - busy_timeout=5000: a writer waits up to 5s for the lock instead of
|
|
38
|
+
# erroring immediately with "database is locked".
|
|
39
|
+
# - synchronous=NORMAL: safe with WAL, much less fsync stalling under load.
|
|
40
|
+
@event.listens_for(Engine, "connect")
|
|
41
|
+
def _set_sqlite_pragmas(dbapi_conn, _conn_record):
|
|
42
|
+
try:
|
|
43
|
+
cursor = dbapi_conn.cursor()
|
|
44
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
45
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
46
|
+
cursor.execute("PRAGMA busy_timeout=5000")
|
|
47
|
+
cursor.execute("PRAGMA synchronous=NORMAL")
|
|
48
|
+
cursor.close()
|
|
49
|
+
except Exception:
|
|
50
|
+
# Other dialects (Postgres etc.) don't need this; ignore quietly.
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Base(DeclarativeBase):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_db():
|
|
62
|
+
db = SessionLocal()
|
|
63
|
+
try:
|
|
64
|
+
yield db
|
|
65
|
+
finally:
|
|
66
|
+
db.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def init_db():
|
|
70
|
+
from . import models # noqa: F401
|
|
71
|
+
Base.metadata.create_all(bind=engine)
|
|
72
|
+
_apply_additive_migrations()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _apply_additive_migrations(*, bind: Engine | None = None):
|
|
76
|
+
"""Bridge gap between `create_all()` and additive column changes.
|
|
77
|
+
|
|
78
|
+
Prashna intentionally has no Alembic. `create_all` only creates
|
|
79
|
+
missing tables, not missing columns or indexes on existing tables. For
|
|
80
|
+
each supported additive change we introduce post-launch, add it here so
|
|
81
|
+
operators don't have to delete `prashna.db` on every model addition.
|
|
82
|
+
"""
|
|
83
|
+
target = bind or engine
|
|
84
|
+
additive = [
|
|
85
|
+
# (table, column, ddl-snippet)
|
|
86
|
+
("users", "session_version", "INTEGER NOT NULL DEFAULT 1"),
|
|
87
|
+
("users", "github_id", "INTEGER"),
|
|
88
|
+
("sessions", "participant_base_url", "VARCHAR(500)"),
|
|
89
|
+
("sessions", "feedback_enabled", "BOOLEAN NOT NULL DEFAULT 0"),
|
|
90
|
+
("participants", "email", "VARCHAR(254)"),
|
|
91
|
+
("participants", "contact_number", "VARCHAR(40)"),
|
|
92
|
+
("participants", "organization", "VARCHAR(120)"),
|
|
93
|
+
("participants", "role", "VARCHAR(120)"),
|
|
94
|
+
("participants", "linkedin_url", "VARCHAR(500)"),
|
|
95
|
+
("participants", "photo_url", "VARCHAR(500)"),
|
|
96
|
+
("participants", "current_work", "TEXT"),
|
|
97
|
+
("participants", "learning_goal", "TEXT"),
|
|
98
|
+
("quiz_questions", "graded", "BOOLEAN NOT NULL DEFAULT 1"),
|
|
99
|
+
("quiz_version_questions", "graded", "BOOLEAN NOT NULL DEFAULT 1"),
|
|
100
|
+
("quiz_runs", "show_live_counts", "BOOLEAN NOT NULL DEFAULT 0"),
|
|
101
|
+
]
|
|
102
|
+
# Indexes to (re)create on existing DBs. `ALTER TABLE ADD COLUMN` can't
|
|
103
|
+
# carry UNIQUE/partial-index constraints; create those separately.
|
|
104
|
+
indexes = [
|
|
105
|
+
("ix_users_github_id", "users", ["github_id"], True, None),
|
|
106
|
+
(
|
|
107
|
+
"uq_session_live_run",
|
|
108
|
+
"quiz_runs",
|
|
109
|
+
["session_id"],
|
|
110
|
+
True,
|
|
111
|
+
"status = 'live'",
|
|
112
|
+
),
|
|
113
|
+
("ix_qsub_run_question", "quiz_submissions", ["quiz_run_id", "question_id"], False, None),
|
|
114
|
+
]
|
|
115
|
+
with target.begin() as conn:
|
|
116
|
+
tables = {
|
|
117
|
+
row[0]
|
|
118
|
+
for row in conn.exec_driver_sql(
|
|
119
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
120
|
+
).fetchall()
|
|
121
|
+
}
|
|
122
|
+
for table, column, ddl in additive:
|
|
123
|
+
if table not in tables:
|
|
124
|
+
continue
|
|
125
|
+
cols = {row[1] for row in conn.exec_driver_sql(
|
|
126
|
+
f"PRAGMA table_info({table})").fetchall()}
|
|
127
|
+
if column not in cols:
|
|
128
|
+
conn.exec_driver_sql(
|
|
129
|
+
f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
|
|
130
|
+
_log_diagnostic(
|
|
131
|
+
"db.migration",
|
|
132
|
+
action="add_column",
|
|
133
|
+
outcome="success",
|
|
134
|
+
table=table,
|
|
135
|
+
column=column,
|
|
136
|
+
)
|
|
137
|
+
for name, table, columns, unique, where in indexes:
|
|
138
|
+
if table not in tables:
|
|
139
|
+
continue
|
|
140
|
+
existing_cols = {
|
|
141
|
+
row[1] for row in conn.exec_driver_sql(f"PRAGMA table_info({table})")
|
|
142
|
+
}
|
|
143
|
+
if not set(columns).issubset(existing_cols):
|
|
144
|
+
continue
|
|
145
|
+
uniq = "UNIQUE " if unique else ""
|
|
146
|
+
where_sql = f" WHERE {where}" if where else ""
|
|
147
|
+
conn.exec_driver_sql(
|
|
148
|
+
f"CREATE {uniq}INDEX IF NOT EXISTS {name} ON {table} ({', '.join(columns)}){where_sql}")
|
|
149
|
+
_log_diagnostic(
|
|
150
|
+
"db.migration",
|
|
151
|
+
action="create_index",
|
|
152
|
+
outcome="success",
|
|
153
|
+
table=table,
|
|
154
|
+
index=name,
|
|
155
|
+
)
|