signal-bot/signal_bot/manage/envfile.py
Krzysztof kuhy Rudnicki bdd20654bf feat: the .env layer the management page will write through
envfile.py edits the file in place, line by line: only the KEY= line of a
key that actually changed is replaced, so the paragraph explaining why
GROUP_IDS is empty survives an edit made in a browser. A regenerated file
would be correct and would still lose most of what that file is for.
Temp-then-replace with the mode reapplied before the swap, because .env
holds the account number and is edited from a tab that can be closed
mid-save.

envkeys.py is the policy, as data. Nothing here re-implements validation:
load_config(candidate, use_dotenv=False) is what startup runs, so an edit
this bot would refuse at boot is refused at the form instead, with the
journal's own message.

Read-only is wider than "the phone number". API_BASE_URL repoints every
outgoing message and attachment fetch at the next restart, which is worse
than changing the number; DATABASE_PATH orphans the history; PHOTO_PACK_DIR
cannot be validated at all, because _photo_pack_dirs() reads os.environ
directly rather than the mapping it is handed -- deliberately, since that is
what keeps the suite away from a real album.

GROUP_IDS and SELFTEST_GROUPS are editable but refused while no group
listing has been read. parse_group_ids only checks syntax; startup also
requires the group to be one the bot actually joined, so writing one blind
with the container down turns a scoping mistake into a unit that will not
start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 14:31:29 +02:00

124 lines
4.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Rewriting ``.env`` without losing what it says about itself.
The file is edited in place, line by line: only the ``KEY=`` line of a key
that actually changed is replaced, so every comment in ``.env.sample``'s
descendant survives an edit made in a browser. A regenerated file would be
correct and would still lose the paragraph explaining why ``GROUP_IDS`` is
empty, which is most of what that file is.
Nothing here validates. ``admin_config`` builds the candidate mapping and
hands it to :func:`signal_bot.config_env.load_config` -- the same function
startup uses, so an edit this repository would refuse at boot is refused at
the form instead of eight hours later in ``systemctl --user --failed``.
Which keys may be edited at all is policy, and lives in ``envkeys``. This
module only knows how to read the file and put it back.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping
from pathlib import Path
# .env is a secret: it holds the account's number and the identities allowed
# to add the bot to a group. Rewriting must not widen it.
FILE_MODE: Final = 0o600
_MARKER: Final = "# Added by the management page."
def parse(text: str) -> dict[str, str]:
"""Every ``KEY=value`` in a ``.env``, comments and blanks ignored.
Args:
text: The file's contents.
Returns:
The keys in file order, values stripped of surrounding whitespace.
"""
return {key: value for key, value, _ in _assignments(text)}
def _assignments(text: str) -> Iterator[tuple[str, str, int]]:
"""Yield ``(key, value, line index)`` for every assignment line."""
for index, line in enumerate(text.splitlines()):
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
key = key.strip()
if key:
yield key, value.strip(), index
def candidate(text: str, changes: Mapping[str, str]) -> dict[str, str]:
"""The environment the bot would load if ``changes`` were written.
The process environment is included underneath because that is the order
``load_config`` sees in production: ``load_dotenv`` does not overwrite a
value systemd already exported, so a check that ignored it would validate
a file the running unit does not actually use.
Args:
text: The current ``.env`` contents.
changes: The edited keys and their new values.
Returns:
A mapping suitable for ``load_config(env, use_dotenv=False)``.
"""
merged = dict(parse(text))
merged.update(changes)
for key, value in os.environ.items():
if key not in changes:
merged.setdefault(key, value)
return merged
def render(text: str, changes: Mapping[str, str]) -> str:
"""The rewritten file: same comments, same order, new values.
A key already present is replaced where it stands. A key absent from the
file is appended under a marker rather than dropped into the middle,
because the surrounding comments explain their own key and not this one.
Args:
text: The current ``.env`` contents.
changes: The edited keys and their new values.
Returns:
The full new contents, newline-terminated.
"""
lines = text.splitlines()
seen: set[str] = set()
for key, _, index in _assignments(text):
if key in changes:
lines[index] = f"{key}={changes[key]}"
seen.add(key)
appended = [key for key in changes if key not in seen]
if appended:
lines.extend(["", _MARKER, *(f"{key}={changes[key]}" for key in appended)])
return "\n".join(lines) + "\n"
def write(path: Path, changes: Mapping[str, str]) -> None:
"""Replace ``.env`` atomically, keeping its mode.
Temp-then-replace for the same reason the album's manifests use it: a
half-written ``.env`` is not a bad value, it is a bot that will not start,
and this file is edited from a browser tab that can be closed mid-save.
Args:
path: The ``.env`` to rewrite.
changes: The edited keys and their new values.
"""
text = path.read_text(encoding="utf-8")
temporary = path.with_name(path.name + ".tmp")
temporary.write_text(render(text, changes), encoding="utf-8")
temporary.chmod(FILE_MODE)
temporary.replace(path)