tinyjukebox 0.47.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.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,262 @@
1
+ """Upload of new albums: mp3 files are collected in a staging folder; on 'finish' the playlist is written, the
2
+ folder is moved into the albums folder and the album is added to the targets."""
3
+
4
+ import contextlib
5
+ import logging
6
+ import os
7
+ import re
8
+ import secrets
9
+ import shutil
10
+ import threading
11
+ import time
12
+ from typing import Any, Protocol
13
+
14
+ from tinyjukebox.core.logging import get_child
15
+ from tinyjukebox.data import targetalbumtools
16
+ from tinyjukebox.data.target import TargetAlbum, check_album_title, is_mp3_filename
17
+ from tinyjukebox.data.targetalbumtools import natural_key, playlist_order
18
+ from tinyjukebox.data.targets import Targets
19
+ from tinyjukebox.errors import BadRequest, Conflict, NotFound, PayloadTooLarge
20
+ from tinyjukebox.metadata import TrackInfo, mp3_info, read_track
21
+
22
+ __all__ = ["MAX_FILES", "MIN_FREE_SPACE", "STAGING_FOLDER", "UPLOAD_EXPIRY", "Upload", "Uploads", "safe_name"]
23
+
24
+ STAGING_FOLDER = ".tinyjukebox-uploads"
25
+ UPLOAD_EXPIRY = 3600 # uploads without changes for this many seconds are discarded
26
+ MAX_FILES = 500 # files per upload
27
+ MIN_FREE_SPACE = 200 * 2**20 # bytes that must stay free on the albums file system after an upload
28
+ # no path separators, no '#' (comment in m3u files), no shell or url specials
29
+ _UNSAFE = re.compile(r"[^\w \-–—.,()&'!+]")
30
+
31
+
32
+ def safe_name(name: str | None, what: str = "name") -> str:
33
+ """File or folder name without path separators or other surprising characters."""
34
+ name = _UNSAFE.sub("_", (name or "").strip()).strip(" .")
35
+ if not name:
36
+ raise BadRequest(f"invalid {what}.", code="upload.name_invalid")
37
+ if len(name.encode("utf-8")) > 200:
38
+ raise BadRequest(f"{what} is too long.", code="upload.name_too_long")
39
+ return name
40
+
41
+
42
+ class Readable(Protocol):
43
+ def read(self, size: int = -1, /) -> bytes: ...
44
+
45
+
46
+ class Upload:
47
+ def __init__(self, upload_id: str, folder_name: str, staging_folder: str, title: str | None = None) -> None:
48
+ self.upload_id = upload_id
49
+ self.folder_name = folder_name
50
+ self.title = title or folder_name
51
+ self.staging_folder = staging_folder
52
+ self.files: dict[str, TrackInfo] = {}
53
+ self.last_change = time.monotonic()
54
+ self.lock = threading.Lock()
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ files = list(self.files.values())
58
+ return {
59
+ "id": self.upload_id,
60
+ "folder": self.folder_name,
61
+ "title": self.title,
62
+ "files": sorted(files, key=lambda f: natural_key(f["filename"])),
63
+ "playlist": playlist_order(files),
64
+ }
65
+
66
+
67
+ class Uploads:
68
+ """Uploads in progress (in memory - leftovers of a previous run are removed on start)."""
69
+
70
+ def __init__(
71
+ self,
72
+ targets: Targets,
73
+ playlist_filename: str,
74
+ max_file_size: int,
75
+ logger: logging.Logger,
76
+ min_free_space: int = MIN_FREE_SPACE,
77
+ ) -> None:
78
+ self._logger = get_child(logger, type(self).__name__)
79
+ self._targets = targets
80
+ self._albums_folder = os.path.abspath(targets.root_folder)
81
+ self._playlist_filename = playlist_filename
82
+ self._max_file_size = max_file_size
83
+ self._min_free_space = min_free_space
84
+ self._staging_root = os.path.join(self._albums_folder, STAGING_FOLDER)
85
+ self._uploads: dict[str, Upload] = {}
86
+ self._lock = threading.Lock()
87
+
88
+ def remove_stale_uploads(self) -> None:
89
+ if os.path.isdir(self._staging_root):
90
+ self._logger.info("removing unfinished uploads in '%s'.", self._staging_root)
91
+ shutil.rmtree(self._staging_root, ignore_errors=True)
92
+
93
+ def _remove_expired(self) -> None:
94
+ now = time.monotonic()
95
+ with self._lock:
96
+ expired = [u for u in self._uploads.values() if now - u.last_change > UPLOAD_EXPIRY]
97
+ for upload in expired:
98
+ self._logger.info("upload %s expired.", upload.upload_id)
99
+ with contextlib.suppress(NotFound): # aborted meanwhile
100
+ self.abort(upload.upload_id)
101
+
102
+ def _album_folder(self, folder_name: str) -> str:
103
+ return os.path.join(self._albums_folder, folder_name)
104
+
105
+ def create(self, name: object) -> dict[str, Any]:
106
+ """A new upload. ``name`` is the title of the album; its folder gets the same name, without the characters
107
+ that a folder must not have."""
108
+ try:
109
+ title = check_album_title(name)
110
+ except ValueError as e:
111
+ raise BadRequest(f"album name: {e}", code="upload.name_invalid") from None
112
+ folder_name = safe_name(title, "album name")
113
+ self._remove_expired()
114
+ with self._lock:
115
+ if os.path.exists(self._album_folder(folder_name)):
116
+ raise Conflict(
117
+ f"album folder '{folder_name}' exists already.", code="upload.folder_exists", folder=folder_name
118
+ )
119
+ if any(u.folder_name == folder_name for u in self._uploads.values()):
120
+ raise Conflict(
121
+ f"an upload for '{folder_name}' is already in progress.",
122
+ code="upload.in_progress",
123
+ folder=folder_name,
124
+ )
125
+ upload_id = secrets.token_hex(8)
126
+ staging_folder = os.path.join(self._staging_root, upload_id)
127
+ os.makedirs(staging_folder)
128
+ upload = Upload(upload_id, folder_name, staging_folder, title)
129
+ self._uploads[upload_id] = upload
130
+ self._logger.info("upload %s for album '%s' created.", upload_id, folder_name)
131
+ return upload.to_dict()
132
+
133
+ def _get(self, upload_id: str) -> Upload:
134
+ with self._lock:
135
+ upload = self._uploads.get(upload_id)
136
+ if upload is None:
137
+ raise NotFound(f"unknown upload '{upload_id}'.", code="upload.unknown")
138
+ return upload
139
+
140
+ def get(self, upload_id: str) -> dict[str, Any]:
141
+ upload = self._get(upload_id)
142
+ with upload.lock:
143
+ return upload.to_dict()
144
+
145
+ def list(self) -> list[dict[str, Any]]:
146
+ self._remove_expired()
147
+ with self._lock:
148
+ uploads = list(self._uploads.values())
149
+ return [u.to_dict() for u in uploads]
150
+
151
+ def _check_space(self, length: int) -> None:
152
+ free = shutil.disk_usage(self._albums_folder).free
153
+ if free - length < self._min_free_space:
154
+ raise PayloadTooLarge(
155
+ f"not enough free space on the box ({free // 2**20} MB free).",
156
+ code="upload.no_space",
157
+ free=free // 2**20,
158
+ )
159
+
160
+ def add_file(self, upload_id: str, filename: str, stream: Readable, length: int) -> TrackInfo:
161
+ """Copy ``length`` bytes from ``stream`` into the upload. Only valid mp3 files are accepted."""
162
+ upload = self._get(upload_id)
163
+ filename = safe_name(filename, "file name")
164
+ if not is_mp3_filename(filename):
165
+ raise BadRequest(f"only mp3 files are supported ('{filename}').", code="upload.only_mp3", filename=filename)
166
+ if filename.lower() == self._playlist_filename.lower():
167
+ raise BadRequest(f"'{filename}' is reserved for the playlist.", code="upload.reserved", filename=filename)
168
+ if length > self._max_file_size:
169
+ raise PayloadTooLarge(
170
+ f"'{filename}' is larger than {self._max_file_size // 2**20} MB.",
171
+ code="upload.file_too_large",
172
+ filename=filename,
173
+ limit=self._max_file_size // 2**20,
174
+ )
175
+ if len(upload.files) >= MAX_FILES and filename not in upload.files:
176
+ raise BadRequest(f"an album may have at most {MAX_FILES} files.", code="upload.too_many", limit=MAX_FILES)
177
+ self._check_space(length)
178
+
179
+ target = os.path.join(upload.staging_folder, filename)
180
+ partial = os.path.join(upload.staging_folder, "." + secrets.token_hex(4) + ".part")
181
+ try:
182
+ with open(partial, "wb") as f:
183
+ remaining = length
184
+ while remaining > 0:
185
+ chunk = stream.read(min(remaining, 64 * 1024))
186
+ if not chunk:
187
+ raise BadRequest(
188
+ f"upload of '{filename}' incomplete.", code="upload.incomplete", filename=filename
189
+ )
190
+ f.write(chunk)
191
+ remaining -= len(chunk)
192
+ try:
193
+ mp3_info(partial)
194
+ except Exception: # HeaderNotFoundError and everything mutagen raises for garbage
195
+ raise BadRequest(
196
+ f"'{filename}' is not a valid mp3 file.", code="upload.not_mp3", filename=filename
197
+ ) from None
198
+ info = read_track(partial)
199
+ info["filename"] = filename
200
+ info["size"] = length
201
+ with upload.lock:
202
+ if self._uploads.get(upload_id) is not upload: # aborted meanwhile
203
+ raise NotFound(f"unknown upload '{upload_id}'.", code="upload.unknown")
204
+ os.replace(partial, target)
205
+ upload.files[filename] = info
206
+ upload.last_change = time.monotonic()
207
+ finally:
208
+ if os.path.exists(partial):
209
+ os.remove(partial)
210
+ self._logger.info("upload %s: received '%s' (%s bytes).", upload_id, filename, length)
211
+ return info
212
+
213
+ def remove_file(self, upload_id: str, filename: str) -> None:
214
+ upload = self._get(upload_id)
215
+ with upload.lock:
216
+ if filename not in upload.files: # only names from the list are used as path
217
+ raise NotFound(f"unknown file '{filename}'.")
218
+ os.remove(os.path.join(upload.staging_folder, filename))
219
+ del upload.files[filename]
220
+
221
+ def abort(self, upload_id: str) -> None:
222
+ with self._lock:
223
+ upload = self._uploads.pop(upload_id, None)
224
+ if upload is None:
225
+ raise NotFound(f"unknown upload '{upload_id}'.", code="upload.unknown")
226
+ with upload.lock:
227
+ shutil.rmtree(upload.staging_folder, ignore_errors=True)
228
+ self._logger.info("upload %s aborted.", upload_id)
229
+
230
+ def finish(self, upload_id: str, order: object = None) -> TargetAlbum:
231
+ """Write the playlist (given order or :func:`playlist_order`), move the folder to the albums folder and add
232
+ the album to the targets."""
233
+ upload = self._get(upload_id)
234
+ with upload.lock:
235
+ if not upload.files:
236
+ raise BadRequest("no files uploaded.", code="upload.empty")
237
+ if order is None:
238
+ order = playlist_order(list(upload.files.values()))
239
+ elif not isinstance(order, list) or sorted(map(str, order)) != sorted(upload.files):
240
+ raise BadRequest("order must contain every uploaded file exactly once.")
241
+ album_folder = self._album_folder(upload.folder_name)
242
+ if os.path.exists(album_folder):
243
+ raise Conflict(
244
+ f"album folder '{upload.folder_name}' exists already.",
245
+ code="upload.folder_exists",
246
+ folder=upload.folder_name,
247
+ )
248
+ os.rename(upload.staging_folder, album_folder)
249
+ # the playlist is written after moving - a scan of the albums folder never sees a half finished album
250
+ targetalbumtools.write_playlist(album_folder, self._playlist_filename, [str(name) for name in order])
251
+ try:
252
+ album = targetalbumtools.add_album_to_targets(
253
+ self._targets, self._logger, upload.folder_name, self._playlist_filename, upload.title
254
+ )
255
+ except ValueError as e:
256
+ os.remove(os.path.join(album_folder, self._playlist_filename))
257
+ os.rename(album_folder, upload.staging_folder)
258
+ raise BadRequest(f"album cannot be added: {e}", code="upload.add_failed", reason=str(e)) from None
259
+ with self._lock:
260
+ del self._uploads[upload_id]
261
+ self._logger.info("upload %s finished - album [%s] '%s'.", upload_id, album.target_id, upload.folder_name)
262
+ return album
@@ -0,0 +1,106 @@
1
+ """tinyjukebox-users - manage the users of the rest api.
2
+
3
+ tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml add mama --role admin
4
+ tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml add kind # role 'user': player, volume, buttons
5
+ tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml passwd mama
6
+ tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml role kind admin
7
+ tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml remove kind
8
+ tinyjukebox-users -f /srv/tinyjukebox/ssl/users.yaml list
9
+
10
+ The running service picks up changes immediately: a new password ends the sessions of that user, a new role applies
11
+ at the next request. The last admin cannot be removed or made a user. Admins can do the same on the web page (tab
12
+ "System"); every user can change the own password there.
13
+ """
14
+
15
+ import argparse
16
+ import getpass
17
+ import sys
18
+ from collections.abc import Sequence
19
+ from typing import Any
20
+
21
+ from tinyjukebox.web.auth import (
22
+ MAX_USERNAME_LENGTH,
23
+ MIN_PASSWORD_LENGTH,
24
+ ROLES,
25
+ UsersFile,
26
+ hash_password,
27
+ valid_username,
28
+ )
29
+
30
+ __all__ = ["MIN_PASSWORD_LENGTH", "main"]
31
+
32
+
33
+ def _read_password(args: argparse.Namespace) -> str:
34
+ if args.password_stdin:
35
+ password = sys.stdin.readline().rstrip("\n")
36
+ else:
37
+ password = getpass.getpass("password: ")
38
+ if getpass.getpass("repeat password: ") != password:
39
+ raise SystemExit("passwords do not match.")
40
+ if len(password) < MIN_PASSWORD_LENGTH:
41
+ raise SystemExit(f"password must have at least {MIN_PASSWORD_LENGTH} characters.")
42
+ return password
43
+
44
+
45
+ def _parser() -> argparse.ArgumentParser:
46
+ parser = argparse.ArgumentParser(
47
+ prog="tinyjukebox-users", description="manage the users of the Tiny Jukebox rest api."
48
+ )
49
+ parser.add_argument("-f", "--file", required=True, help="users file (rest-api/users-file in the config)")
50
+ parser.add_argument("--password-stdin", action="store_true", help="read the password from stdin")
51
+ commands = parser.add_subparsers(dest="command", required=True)
52
+ add = commands.add_parser("add", help="add a user")
53
+ add.add_argument("name")
54
+ add.add_argument("--role", choices=ROLES, default="user")
55
+ commands.add_parser("passwd", help="change the password of a user").add_argument("name")
56
+ role = commands.add_parser("role", help="change the role of a user")
57
+ role.add_argument("name")
58
+ role.add_argument("role", choices=ROLES)
59
+ commands.add_parser("remove", help="remove a user").add_argument("name")
60
+ commands.add_parser("list", help="list all users")
61
+ return parser
62
+
63
+
64
+ def _keep_an_admin(users: dict[str, dict[str, Any]], name: str) -> None:
65
+ """The last admin stays one: without an admin nobody can manage the page."""
66
+ if users[name]["role"] == "admin" and sum(user["role"] == "admin" for user in users.values()) == 1:
67
+ raise SystemExit(f"'{name}' is the last admin - add another admin first.")
68
+
69
+
70
+ def main(argv: Sequence[str] | None = None) -> None:
71
+ args = _parser().parse_args(argv)
72
+ try:
73
+ users = UsersFile.read(args.file)
74
+ except ValueError as e:
75
+ raise SystemExit(str(e)) from None
76
+
77
+ if args.command == "list":
78
+ for name, user in sorted(users.items()):
79
+ print(f"{name} ({user['role']})")
80
+ return
81
+ if args.command == "add":
82
+ if args.name in users:
83
+ raise SystemExit(f"user '{args.name}' exists already.")
84
+ if not valid_username(args.name):
85
+ raise SystemExit(
86
+ f"invalid user name: no blanks around it, no ':', at most {MAX_USERNAME_LENGTH} characters."
87
+ )
88
+ users[args.name] = {"password-hash": hash_password(_read_password(args)), "role": args.role}
89
+ else:
90
+ if args.name not in users:
91
+ raise SystemExit(f"unknown user '{args.name}'.")
92
+ if args.command == "passwd":
93
+ users[args.name]["password-hash"] = hash_password(_read_password(args))
94
+ elif args.command == "role":
95
+ if args.role != "admin":
96
+ _keep_an_admin(users, args.name)
97
+ users[args.name]["role"] = args.role
98
+ elif args.command == "remove":
99
+ _keep_an_admin(users, args.name)
100
+ del users[args.name]
101
+ UsersFile.write(args.file, users)
102
+ print(f"{args.command}: {args.name} - done.")
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()