Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
170 lines
5.4 KiB
Python
170 lines
5.4 KiB
Python
import base64
|
|
import getpass
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from webapp.config import ConfigFileError
|
|
|
|
|
|
PBKDF2_ALGORITHM = "sha256"
|
|
PBKDF2_ITERATIONS = 600000
|
|
SALT_BYTES = 16
|
|
SUPPORTED_TRANSLATION_LANGUAGES = ["en", "fi", "sv"]
|
|
SUPPORTED_UI_LANGUAGES = ["auto", "fi", "sv", "en"]
|
|
PERMISSION_KEYS = [
|
|
"edit_content",
|
|
"edit_targets",
|
|
"save_announcements",
|
|
"open_telegram",
|
|
"post_now",
|
|
"schedule",
|
|
"unschedule",
|
|
"unlink_telegram",
|
|
"delete_telegram",
|
|
"delete_storage",
|
|
]
|
|
|
|
|
|
def load_users(users_file: Path) -> dict[str, dict]:
|
|
if not users_file.exists():
|
|
return {}
|
|
|
|
try:
|
|
payload = json.loads(users_file.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as error:
|
|
raise ConfigFileError(users_file, f"Invalid JSON: {error.msg}") from error
|
|
users = payload.get("users", [])
|
|
result: dict[str, dict] = {}
|
|
changed = False
|
|
for user in users:
|
|
username = str(user.get("username", "")).strip()
|
|
password_hash = str(user.get("password_hash", "")).strip()
|
|
if username and password_hash:
|
|
permissions_payload, permissions_changed = normalize_permissions_payload(user.get("permissions", {}))
|
|
if permissions_changed:
|
|
user["permissions"] = permissions_payload
|
|
changed = True
|
|
|
|
language = normalize_ui_language(user.get("language", "en"))
|
|
if user.get("language") != language:
|
|
user["language"] = language
|
|
changed = True
|
|
|
|
result[username] = {
|
|
"password_hash": password_hash,
|
|
"permissions": load_permissions(permissions_payload),
|
|
"language": language,
|
|
}
|
|
if changed:
|
|
users_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
return result
|
|
|
|
|
|
def load_permissions(payload: object) -> dict[str, bool]:
|
|
source = payload if isinstance(payload, dict) else {}
|
|
permissions: dict[str, bool] = {}
|
|
for key in PERMISSION_KEYS:
|
|
permissions[key] = as_permission_bool(source.get(key, False))
|
|
return permissions
|
|
|
|
|
|
def normalize_permissions_payload(payload: object) -> tuple[dict[str, object], bool]:
|
|
source = dict(payload) if isinstance(payload, dict) else {}
|
|
changed = not isinstance(payload, dict)
|
|
normalized = {key: source[key] for key in source if key in PERMISSION_KEYS}
|
|
if len(normalized) != len(source):
|
|
changed = True
|
|
for key in PERMISSION_KEYS:
|
|
normalized_value = as_permission_bool(source.get(key, False))
|
|
if source.get(key) != normalized_value:
|
|
changed = True
|
|
normalized[key] = normalized_value
|
|
return normalized, changed
|
|
|
|
|
|
def as_permission_bool(value: object) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, int):
|
|
return value != 0
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
return lowered in {"1", "true", "yes", "on"}
|
|
return False
|
|
|
|
|
|
def normalize_ui_language(value: object) -> str:
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in SUPPORTED_UI_LANGUAGES:
|
|
return lowered
|
|
return "en"
|
|
|
|
|
|
def normalize_translation_language(value: object) -> str:
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in SUPPORTED_TRANSLATION_LANGUAGES:
|
|
return lowered
|
|
return "en"
|
|
|
|
|
|
def set_user_language(users_file: Path, username: str, language: str) -> None:
|
|
normalized = normalize_ui_language(language)
|
|
try:
|
|
payload = json.loads(users_file.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as error:
|
|
raise ConfigFileError(users_file, f"Invalid JSON: {error.msg}") from error
|
|
users = payload.get("users", [])
|
|
updated = False
|
|
for user in users:
|
|
if str(user.get("username", "")).strip() == username:
|
|
user["language"] = normalized
|
|
updated = True
|
|
break
|
|
|
|
if not updated:
|
|
raise ValueError(f"Unknown user: {username}")
|
|
|
|
users_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
salt = os.urandom(SALT_BYTES)
|
|
digest = hashlib.pbkdf2_hmac(PBKDF2_ALGORITHM, password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
|
|
return "$".join(
|
|
[
|
|
"pbkdf2_sha256",
|
|
str(PBKDF2_ITERATIONS),
|
|
base64.urlsafe_b64encode(salt).decode("ascii"),
|
|
base64.urlsafe_b64encode(digest).decode("ascii"),
|
|
]
|
|
)
|
|
|
|
|
|
def verify_password(password: str, password_hash: str) -> bool:
|
|
algorithm, iterations_text, salt_text, digest_text = password_hash.split("$", 3)
|
|
if algorithm != "pbkdf2_sha256":
|
|
return False
|
|
|
|
iterations = int(iterations_text)
|
|
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
|
|
expected_digest = base64.urlsafe_b64decode(digest_text.encode("ascii"))
|
|
actual_digest = hashlib.pbkdf2_hmac(PBKDF2_ALGORITHM, password.encode("utf-8"), salt, iterations)
|
|
return hmac.compare_digest(actual_digest, expected_digest)
|
|
|
|
|
|
def hash_password_cli() -> None:
|
|
password = getpass.getpass("Password: ")
|
|
password_repeat = getpass.getpass("Repeat password: ")
|
|
if password != password_repeat:
|
|
raise ValueError("Passwords did not match.")
|
|
print(hash_password(password))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
hash_password_cli()
|