101 lines
4.1 KiB
Python
101 lines
4.1 KiB
Python
#!/usr/bin/python3
|
||
"""Publie l'avatar du compte courant via AccountsService, sans privilège local."""
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import pwd
|
||
import stat
|
||
import sys
|
||
from urllib.parse import unquote, urlsplit
|
||
|
||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||
from avatar_icon import system_icon
|
||
import gi
|
||
from gi.repository import Gio, GLib
|
||
|
||
MAX_SOURCE_BYTES = 12 * 1024 * 1024
|
||
MAX_SOURCE_PIXELS = 16_000_000
|
||
Image.MAX_IMAGE_PIXELS = MAX_SOURCE_PIXELS
|
||
|
||
|
||
class AvatarError(Exception):
|
||
pass
|
||
|
||
|
||
def source_path(value):
|
||
"""CONTRACT: seule une image locale régulière du compte appelant est acceptée."""
|
||
url = urlsplit(value)
|
||
if url.scheme not in ("", "file") or url.netloc not in ("", "localhost"):
|
||
raise AvatarError("Choisissez un fichier image local.")
|
||
path = Path(unquote(url.path) if url.scheme else value).resolve(strict=True)
|
||
info = path.stat()
|
||
if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= MAX_SOURCE_BYTES:
|
||
raise AvatarError("L’image doit être un fichier de 12 Mio au maximum.")
|
||
return path
|
||
|
||
|
||
def render_png(source, target):
|
||
"""WHY: conversion bornée en PNG carré pour le contrat SetIconFile."""
|
||
try:
|
||
with Image.open(source) as opened:
|
||
if opened.width * opened.height > MAX_SOURCE_PIXELS:
|
||
raise AvatarError("Image trop grande.")
|
||
opened.load()
|
||
normalized = ImageOps.exif_transpose(opened)
|
||
square = ImageOps.fit(normalized.convert("RGBA"), (256, 256), method=Image.Resampling.LANCZOS)
|
||
square.save(target, format="PNG", optimize=True)
|
||
except (OSError, UnidentifiedImageError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
|
||
raise AvatarError("Image illisible ou trop grande.") from exc
|
||
|
||
|
||
def set_avatar(value):
|
||
source = source_path(value)
|
||
username = pwd.getpwuid(os.getuid()).pw_name
|
||
data_home = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")
|
||
directory = data_home / "labfy-greeter"
|
||
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||
destination = directory / "avatar.png"
|
||
temporary = directory / f".avatar-{os.getpid()}.png"
|
||
try:
|
||
with temporary.open("xb") as output:
|
||
os.fchmod(output.fileno(), 0o600)
|
||
render_png(source, output)
|
||
output.flush()
|
||
os.fsync(output.fileno())
|
||
os.replace(temporary, destination)
|
||
# AccountsService ingère le fichier comme appelant puis publie sa copie root-owned.
|
||
bus = Gio.bus_get_sync(Gio.BusType.SYSTEM)
|
||
user_path = bus.call_sync("org.freedesktop.Accounts", "/org/freedesktop/Accounts",
|
||
"org.freedesktop.Accounts", "FindUserById",
|
||
GLib.Variant("(x)", (os.getuid(),)), GLib.VariantType.new("(o)"),
|
||
Gio.DBusCallFlags.NONE, 10000).unpack()[0]
|
||
bus.call_sync("org.freedesktop.Accounts", user_path, "org.freedesktop.Accounts.User",
|
||
"SetIconFile", GLib.Variant("(s)", (str(destination),)), None,
|
||
Gio.DBusCallFlags.NONE, 15000)
|
||
finally:
|
||
temporary.unlink(missing_ok=True)
|
||
published = system_icon(username)
|
||
if published is None:
|
||
raise AvatarError("La copie système de l’avatar est indisponible.")
|
||
return str(published)
|
||
|
||
|
||
def main(argv):
|
||
try:
|
||
if argv == ["current"]:
|
||
icon = system_icon(pwd.getpwuid(os.getuid()).pw_name)
|
||
print(json.dumps({"path": str(icon) if icon else ""}))
|
||
elif len(argv) == 2 and argv[0] == "set":
|
||
print(json.dumps({"path": set_avatar(argv[1])}))
|
||
else:
|
||
raise AvatarError("Commande d’avatar invalide.")
|
||
except (AvatarError, OSError, GLib.Error, ValueError) as exc:
|
||
# Les erreurs D-Bus ne sont pas reproduites dans l'interface utilisateur.
|
||
message = str(exc) if isinstance(exc, AvatarError) else "Impossible de modifier l’avatar."
|
||
print(json.dumps({"error": message}))
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv[1:]))
|