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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
"""HTTPS server for the rest api and the web page (python standard library only - runs on every Raspberry Pi)."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import http.server
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import socketserver
|
|
11
|
+
import ssl
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.parse
|
|
15
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
from tinyjukebox.backup import Incomplete
|
|
19
|
+
from tinyjukebox.backups import Backups
|
|
20
|
+
from tinyjukebox.control import BoxControl, CatalogControl
|
|
21
|
+
from tinyjukebox.core import unexpected
|
|
22
|
+
from tinyjukebox.core.logging import get_child
|
|
23
|
+
from tinyjukebox.data.targets import Targets
|
|
24
|
+
from tinyjukebox.errors import (
|
|
25
|
+
ApiError,
|
|
26
|
+
BadRequest,
|
|
27
|
+
Conflict,
|
|
28
|
+
Forbidden,
|
|
29
|
+
LengthRequired,
|
|
30
|
+
MethodNotAllowed,
|
|
31
|
+
NotFound,
|
|
32
|
+
PayloadTooLarge,
|
|
33
|
+
TooManyRequests,
|
|
34
|
+
Unauthorized,
|
|
35
|
+
)
|
|
36
|
+
from tinyjukebox.restore import Restore
|
|
37
|
+
from tinyjukebox.sync import acting_as
|
|
38
|
+
from tinyjukebox.web.auth import (
|
|
39
|
+
MIN_PASSWORD_LENGTH,
|
|
40
|
+
ROLES,
|
|
41
|
+
LoginThrottle,
|
|
42
|
+
Session,
|
|
43
|
+
Sessions,
|
|
44
|
+
UsersFile,
|
|
45
|
+
has_role,
|
|
46
|
+
hash_password,
|
|
47
|
+
valid_username,
|
|
48
|
+
)
|
|
49
|
+
from tinyjukebox.web.certificates import create_ssl_context, ensure_certificate
|
|
50
|
+
from tinyjukebox.web.logbuffer import LogBuffer
|
|
51
|
+
from tinyjukebox.web.uploads import Readable, Uploads
|
|
52
|
+
|
|
53
|
+
__all__ = ["DEFAULTS", "MAX_CONNECTIONS", "NO_CONTENT", "Request", "Response", "RestApi", "Route", "Writable"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Writable(Protocol):
|
|
57
|
+
"""The part of the response stream (``wfile``) that event streams use."""
|
|
58
|
+
|
|
59
|
+
def write(self, data: bytes, /) -> int: ...
|
|
60
|
+
|
|
61
|
+
def flush(self) -> None: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
WEB_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
|
|
65
|
+
STATIC_FILES = {
|
|
66
|
+
"/": ("index.html", "text/html; charset=utf-8"),
|
|
67
|
+
"/index.html": ("index.html", "text/html; charset=utf-8"),
|
|
68
|
+
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
|
69
|
+
"/style.css": ("style.css", "text/css; charset=utf-8"),
|
|
70
|
+
"/favicon.svg": ("favicon.svg", "image/svg+xml"),
|
|
71
|
+
}
|
|
72
|
+
# the texts of the web page, one file per language (index.html and app.js have none of their own)
|
|
73
|
+
STATIC_FILES.update(
|
|
74
|
+
(f"/texts/{name}", (os.path.join("texts", name), "application/json; charset=utf-8"))
|
|
75
|
+
for name in sorted(os.listdir(os.path.join(WEB_FOLDER, "texts")))
|
|
76
|
+
if name.endswith(".json")
|
|
77
|
+
)
|
|
78
|
+
# the help: the documents of docs/user as pages, made by scripts/help/build.py - without a login, as the page itself
|
|
79
|
+
HELP_TYPES = {
|
|
80
|
+
".html": "text/html; charset=utf-8",
|
|
81
|
+
".css": "text/css; charset=utf-8",
|
|
82
|
+
".jpg": "image/jpeg",
|
|
83
|
+
".svg": "image/svg+xml",
|
|
84
|
+
}
|
|
85
|
+
STATIC_FILES.update(
|
|
86
|
+
(f"/help/{name}", (os.path.join("help", *name.split("/")), HELP_TYPES[os.path.splitext(name)[1]]))
|
|
87
|
+
for name in sorted(
|
|
88
|
+
os.path.relpath(os.path.join(folder, file), os.path.join(WEB_FOLDER, "help")).replace(os.sep, "/")
|
|
89
|
+
for folder, _, files in os.walk(os.path.join(WEB_FOLDER, "help"))
|
|
90
|
+
for file in files
|
|
91
|
+
if os.path.splitext(file)[1] in HELP_TYPES
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
QUIET_PATHS = ("/api/status", "/api/logs", "/api/events", "/api/box/hardware")
|
|
95
|
+
SESSION_COOKIE = "tinyjukebox_session"
|
|
96
|
+
IDLE_HEADER = "X-TinyJukebox-Idle" # the page: seconds since the user did something last (see Sessions.get)
|
|
97
|
+
CSRF_HEADER = "X-TinyJukebox-Request" # cross site requests cannot set custom headers without a cors preflight
|
|
98
|
+
MAX_JSON_BODY = 64 * 1024
|
|
99
|
+
MAX_IMAGE_BODY = 10 * 2**20
|
|
100
|
+
MAX_DISCARD_BODY = 16 * 2**20 # unread request bodies up to this size are read and discarded
|
|
101
|
+
MAX_CONNECTIONS = 32 # concurrent connections - each one has its own thread
|
|
102
|
+
EVENT_INTERVAL = 0.2 # seconds between two checks of an event stream for changes
|
|
103
|
+
STATUS_REFRESH = 5.0 # an event stream sends the status at least this often (playback position, keeps the line open)
|
|
104
|
+
|
|
105
|
+
SECURITY_HEADERS = {
|
|
106
|
+
"Strict-Transport-Security": "max-age=31536000",
|
|
107
|
+
"X-Content-Type-Options": "nosniff",
|
|
108
|
+
"X-Frame-Options": "DENY",
|
|
109
|
+
"Referrer-Policy": "no-referrer",
|
|
110
|
+
"Content-Security-Policy": "default-src 'self'; img-src 'self' data: blob:; object-src 'none'; "
|
|
111
|
+
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
DEFAULT_FOLDER = os.path.join("~", ".config", "tinyjukebox")
|
|
115
|
+
DEFAULTS: dict[str, Any] = {
|
|
116
|
+
"host": "0.0.0.0", # noqa: S104 - the web page is meant to be reached from the local network
|
|
117
|
+
"port": 8443,
|
|
118
|
+
"certificate": os.path.join(DEFAULT_FOLDER, "tinyjukebox.crt"),
|
|
119
|
+
"private-key": os.path.join(DEFAULT_FOLDER, "tinyjukebox.key"),
|
|
120
|
+
"users-file": os.path.join(DEFAULT_FOLDER, "users.yaml"),
|
|
121
|
+
"session-timeout": 7 * 24 * 3600,
|
|
122
|
+
"idle-timeout": 30 * 60,
|
|
123
|
+
"max-upload-size": 500,
|
|
124
|
+
"hostnames": [],
|
|
125
|
+
}
|
|
126
|
+
PATH_KEYS = ["certificate", "private-key", "users-file"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Request:
|
|
130
|
+
"""What a route handler gets: path parameters, query, session and the request body."""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self, handler: "RequestHandler", params: dict[str, str], session: Session | None, token: str | None
|
|
134
|
+
) -> None:
|
|
135
|
+
self._handler = handler
|
|
136
|
+
self.params = params
|
|
137
|
+
self.session = session
|
|
138
|
+
self.token = token
|
|
139
|
+
self.query = urllib.parse.parse_qs(urllib.parse.urlsplit(handler.path).query)
|
|
140
|
+
self.client_address = str(handler.client_address[0])
|
|
141
|
+
self.path = urllib.parse.urlsplit(handler.path).path
|
|
142
|
+
self.method = str(handler.command)
|
|
143
|
+
self.headers = handler.headers
|
|
144
|
+
self.context: dict[str, Any] = {} # what a guard found out, e.g. the box that asks
|
|
145
|
+
|
|
146
|
+
def query_flag(self, name: str) -> bool:
|
|
147
|
+
return self.query.get(name, ["0"])[-1].lower() in ("1", "true", "yes")
|
|
148
|
+
|
|
149
|
+
def query_value(self, name: str, default: str) -> str:
|
|
150
|
+
return self.query.get(name, [default])[-1]
|
|
151
|
+
|
|
152
|
+
def content_length(self) -> int:
|
|
153
|
+
if "chunked" in self._handler.headers.get("Transfer-Encoding", "").lower():
|
|
154
|
+
raise LengthRequired("chunked transfer encoding is not supported - send a Content-Length.")
|
|
155
|
+
try:
|
|
156
|
+
length = int(self._handler.headers.get("Content-Length", 0))
|
|
157
|
+
except ValueError:
|
|
158
|
+
raise BadRequest("invalid Content-Length.") from None
|
|
159
|
+
if length < 0:
|
|
160
|
+
raise BadRequest("invalid Content-Length.")
|
|
161
|
+
return length
|
|
162
|
+
|
|
163
|
+
def json(self, limit: int = MAX_JSON_BODY) -> dict[str, Any]:
|
|
164
|
+
length = self.content_length()
|
|
165
|
+
if length > limit:
|
|
166
|
+
raise PayloadTooLarge("request body too large.")
|
|
167
|
+
body = self._handler.rfile.read(length) if length else b""
|
|
168
|
+
self._handler.body_consumed = True
|
|
169
|
+
if not body:
|
|
170
|
+
return {}
|
|
171
|
+
try:
|
|
172
|
+
data = json.loads(body.decode("utf-8"))
|
|
173
|
+
except (ValueError, UnicodeDecodeError):
|
|
174
|
+
raise BadRequest("request body must be valid json.") from None
|
|
175
|
+
if not isinstance(data, dict):
|
|
176
|
+
raise BadRequest("request body must be a json object.")
|
|
177
|
+
return data
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def stream(self) -> Readable:
|
|
181
|
+
self._handler.body_consumed = True
|
|
182
|
+
return self._handler.rfile
|
|
183
|
+
|
|
184
|
+
def body(self) -> "Body":
|
|
185
|
+
"""The body as a stream of its length, for a body that is read while it arrives."""
|
|
186
|
+
return Body(self, self.content_length())
|
|
187
|
+
|
|
188
|
+
def close_connection(self) -> None:
|
|
189
|
+
"""The connection is closed after the answer: the body was not read to its end."""
|
|
190
|
+
self._handler.close_connection = True
|
|
191
|
+
|
|
192
|
+
def save(self, filename: str) -> str:
|
|
193
|
+
"""Store the body under ``filename`` and return its sha256 checksum."""
|
|
194
|
+
remaining = self.content_length()
|
|
195
|
+
stream = self.stream
|
|
196
|
+
digest = hashlib.sha256()
|
|
197
|
+
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
|
198
|
+
with open(filename, "wb") as outfile:
|
|
199
|
+
while remaining > 0:
|
|
200
|
+
chunk = stream.read(min(remaining, 1 << 16))
|
|
201
|
+
if not chunk:
|
|
202
|
+
raise BadRequest("the request ended early.")
|
|
203
|
+
outfile.write(chunk)
|
|
204
|
+
digest.update(chunk)
|
|
205
|
+
remaining -= len(chunk)
|
|
206
|
+
outfile.flush()
|
|
207
|
+
os.fsync(outfile.fileno())
|
|
208
|
+
return digest.hexdigest()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Response:
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
status: int = 200,
|
|
215
|
+
data: Any = None,
|
|
216
|
+
body: bytes | None = None,
|
|
217
|
+
content_type: str = "application/json",
|
|
218
|
+
headers: Mapping[str, str] | None = None,
|
|
219
|
+
stream: Callable[[Writable], None] | None = None,
|
|
220
|
+
file: str | None = None,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""``stream``: writes the body itself until it returns (no length - the connection is closed afterwards).
|
|
223
|
+
``file``: the body is this file; the client may ask for the rest of it (``Range: bytes=N-``)."""
|
|
224
|
+
self.status = status
|
|
225
|
+
self.stream = stream
|
|
226
|
+
self.file = file
|
|
227
|
+
if file is not None:
|
|
228
|
+
content_type = "application/octet-stream"
|
|
229
|
+
self.headers: dict[str, str] = dict(headers or {})
|
|
230
|
+
if body is None and data is not None:
|
|
231
|
+
body = json.dumps(data).encode("utf-8")
|
|
232
|
+
self.body = body if body is not None else b""
|
|
233
|
+
if self.body or stream is not None or file is not None or status != 204:
|
|
234
|
+
self.headers.setdefault("Content-Type", content_type)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
NO_CONTENT = Response(204)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Body:
|
|
241
|
+
"""The body of a request, ``size`` bytes, read while it arrives (:meth:`Request.body`)."""
|
|
242
|
+
|
|
243
|
+
def __init__(self, request: Request, size: int) -> None:
|
|
244
|
+
self._request = request
|
|
245
|
+
self._stream = request.stream
|
|
246
|
+
self.size = size
|
|
247
|
+
self.left = size
|
|
248
|
+
|
|
249
|
+
def read(self, size: int = -1) -> bytes:
|
|
250
|
+
if self.left <= 0:
|
|
251
|
+
return b""
|
|
252
|
+
data = self._stream.read(self.left if size < 0 else min(size, self.left))
|
|
253
|
+
self.left -= len(data)
|
|
254
|
+
if not data:
|
|
255
|
+
raise BadRequest("the request ended early.")
|
|
256
|
+
return data
|
|
257
|
+
|
|
258
|
+
def finish(self) -> None:
|
|
259
|
+
"""After an answer that does not need the rest: a small rest is read, otherwise the connection is closed -
|
|
260
|
+
an unread body would be taken for the next request."""
|
|
261
|
+
try:
|
|
262
|
+
if self.left > MAX_DISCARD_BODY:
|
|
263
|
+
raise BadRequest("too much is left")
|
|
264
|
+
while self.left > 0:
|
|
265
|
+
self.read(min(self.left, 1 << 16))
|
|
266
|
+
except BadRequest:
|
|
267
|
+
self._request.close_connection()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
Handler = Callable[[Request], Response]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class Route:
|
|
274
|
+
def __init__(self, method: str, pattern: str, handler: Handler, role: str | None) -> None:
|
|
275
|
+
self.method = method
|
|
276
|
+
self.regex = re.compile("^" + re.sub(r"{(\w+)}", r"(?P<\1>[^/]+)", pattern) + "$")
|
|
277
|
+
self.handler = handler
|
|
278
|
+
self.role = role # None: no login needed; GUARDED: the guard of the path decides
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
GUARDED = "guarded"
|
|
282
|
+
"""Role of a route that is not for users of the page: the guard of its path decides who may ask (``guards`` of
|
|
283
|
+
:class:`RestApi`)."""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class RequestHandler(http.server.BaseHTTPRequestHandler):
|
|
287
|
+
server_version = "TinyJukebox"
|
|
288
|
+
sys_version = ""
|
|
289
|
+
protocol_version = "HTTP/1.1"
|
|
290
|
+
api: "RestApi" # set by RestApi
|
|
291
|
+
timeout = 60 # seconds without data from the client - also for uploads
|
|
292
|
+
disable_nagle_algorithm = True # headers and body are written separately - no 40 ms delayed-ack stall
|
|
293
|
+
body_consumed = False
|
|
294
|
+
|
|
295
|
+
def log_request(self, code: int | str = "-", size: int | str = "-") -> None:
|
|
296
|
+
# the web page polls these every second (or more often) - successful polls would only flood the log
|
|
297
|
+
path = urllib.parse.urlsplit(self.path).path
|
|
298
|
+
if path in QUIET_PATHS and str(code).startswith("2"):
|
|
299
|
+
return
|
|
300
|
+
super().log_request(code, size)
|
|
301
|
+
|
|
302
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - signature of the base class
|
|
303
|
+
self.api.logger.debug("%s - %s", self.client_address[0], format % args)
|
|
304
|
+
|
|
305
|
+
def do_GET(self) -> None:
|
|
306
|
+
self._dispatch("GET")
|
|
307
|
+
|
|
308
|
+
def do_POST(self) -> None:
|
|
309
|
+
self._dispatch("POST")
|
|
310
|
+
|
|
311
|
+
def do_PUT(self) -> None:
|
|
312
|
+
self._dispatch("PUT")
|
|
313
|
+
|
|
314
|
+
def do_PATCH(self) -> None:
|
|
315
|
+
self._dispatch("PATCH")
|
|
316
|
+
|
|
317
|
+
def do_DELETE(self) -> None:
|
|
318
|
+
self._dispatch("DELETE")
|
|
319
|
+
|
|
320
|
+
def _dispatch(self, method: str) -> None:
|
|
321
|
+
self.body_consumed = False
|
|
322
|
+
path = urllib.parse.urlsplit(self.path).path
|
|
323
|
+
try:
|
|
324
|
+
if method == "GET" and path in STATIC_FILES:
|
|
325
|
+
response = self._static(*STATIC_FILES[path])
|
|
326
|
+
else:
|
|
327
|
+
response = self.api.handle(self, method, path)
|
|
328
|
+
except ApiError as e:
|
|
329
|
+
response = Response(e.status, dict({"error": e.message}, **e.details))
|
|
330
|
+
if isinstance(e, TooManyRequests):
|
|
331
|
+
response.headers["Retry-After"] = str(e.details.get("retry_after", 60))
|
|
332
|
+
except Exception as error:
|
|
333
|
+
self.api.logger.exception("%s %s failed", method, path)
|
|
334
|
+
unexpected.report(error, f"request {method} {path}")
|
|
335
|
+
response = Response(500, {"error": "internal error."})
|
|
336
|
+
self._send(response)
|
|
337
|
+
|
|
338
|
+
def _request_has_body(self) -> bool:
|
|
339
|
+
try:
|
|
340
|
+
return int(self.headers.get("Content-Length", 0) or 0) > 0 or "Transfer-Encoding" in self.headers
|
|
341
|
+
except ValueError:
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
def _discard_body(self) -> None:
|
|
345
|
+
"""An unread request body would be taken for the next request. Small bodies are read (closing the connection
|
|
346
|
+
with unread data would reset it before the client reads the response), otherwise the connection is closed."""
|
|
347
|
+
try:
|
|
348
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
349
|
+
except ValueError:
|
|
350
|
+
length = -1
|
|
351
|
+
if "Transfer-Encoding" not in self.headers and 0 <= length <= MAX_DISCARD_BODY:
|
|
352
|
+
self.rfile.read(length)
|
|
353
|
+
else:
|
|
354
|
+
self.close_connection = True
|
|
355
|
+
|
|
356
|
+
def _static(self, filename: str, content_type: str) -> Response:
|
|
357
|
+
with open(os.path.join(WEB_FOLDER, filename), "rb") as f:
|
|
358
|
+
return Response(200, body=f.read(), content_type=content_type, headers={"Cache-Control": "no-cache"})
|
|
359
|
+
|
|
360
|
+
def _send(self, response: Response) -> None:
|
|
361
|
+
if not self.body_consumed and self._request_has_body():
|
|
362
|
+
self._discard_body()
|
|
363
|
+
status = response.status
|
|
364
|
+
part = self._file_part(response.file) if response.file is not None else None
|
|
365
|
+
if part is not None and 0 < part[1] < part[0] and status == 200:
|
|
366
|
+
status = 206 # the rest of the file
|
|
367
|
+
self.send_response(status)
|
|
368
|
+
for key, value in SECURITY_HEADERS.items():
|
|
369
|
+
self.send_header(key, value)
|
|
370
|
+
response.headers.setdefault("Cache-Control", "no-store")
|
|
371
|
+
for key, value in response.headers.items():
|
|
372
|
+
self.send_header(key, value)
|
|
373
|
+
if response.stream is not None:
|
|
374
|
+
self.close_connection = True
|
|
375
|
+
self.send_header("Connection", "close")
|
|
376
|
+
self.end_headers()
|
|
377
|
+
response.stream(self.wfile)
|
|
378
|
+
return
|
|
379
|
+
if response.file is not None and part is not None:
|
|
380
|
+
self._send_file(response.file, *part)
|
|
381
|
+
return
|
|
382
|
+
self.send_header("Content-Length", str(len(response.body)))
|
|
383
|
+
if self.close_connection:
|
|
384
|
+
self.send_header("Connection", "close")
|
|
385
|
+
self.end_headers()
|
|
386
|
+
self.wfile.write(response.body)
|
|
387
|
+
|
|
388
|
+
def _file_part(self, filename: str) -> tuple[int, int]:
|
|
389
|
+
"""(size of the file, where the answer begins): at the start, or where the client asks for the rest of it
|
|
390
|
+
(``Range: bytes=N-``)."""
|
|
391
|
+
size = os.path.getsize(filename)
|
|
392
|
+
match = re.fullmatch(r"bytes=(\d+)-", self.headers.get("Range", ""))
|
|
393
|
+
return size, min(int(match.group(1)), size) if match else 0
|
|
394
|
+
|
|
395
|
+
def _send_file(self, filename: str, size: int, start: int) -> None:
|
|
396
|
+
"""The headers so far are sent; now the file, or the rest of it that the client asks for: 206 with its
|
|
397
|
+
range. A client that has the whole file already gets 200 with an empty range and no body - curl and a box
|
|
398
|
+
from 0.45 on take that for "nothing left", an older box fetches the file once more."""
|
|
399
|
+
if start:
|
|
400
|
+
self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
|
|
401
|
+
self.send_header("Content-Length", str(size - start))
|
|
402
|
+
self.end_headers()
|
|
403
|
+
with open(filename, "rb") as infile:
|
|
404
|
+
infile.seek(start)
|
|
405
|
+
while chunk := infile.read(1 << 16):
|
|
406
|
+
self.wfile.write(chunk)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class _Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
410
|
+
daemon_threads = True
|
|
411
|
+
allow_reuse_address = True
|
|
412
|
+
|
|
413
|
+
def __init__(
|
|
414
|
+
self,
|
|
415
|
+
address: tuple[str, int],
|
|
416
|
+
handler: type[RequestHandler],
|
|
417
|
+
ssl_context: ssl.SSLContext,
|
|
418
|
+
max_connections: int = MAX_CONNECTIONS,
|
|
419
|
+
) -> None:
|
|
420
|
+
self._ssl_context = ssl_context
|
|
421
|
+
self._connections = threading.BoundedSemaphore(max_connections)
|
|
422
|
+
super().__init__(address, handler)
|
|
423
|
+
|
|
424
|
+
def process_request(self, request: Any, client_address: Any) -> None:
|
|
425
|
+
# one thread per connection - but not without limit (slow or idle clients would exhaust the box)
|
|
426
|
+
if not self._connections.acquire(blocking=False):
|
|
427
|
+
self.shutdown_request(request)
|
|
428
|
+
return
|
|
429
|
+
try:
|
|
430
|
+
super().process_request(request, client_address)
|
|
431
|
+
except Exception:
|
|
432
|
+
self._connections.release()
|
|
433
|
+
raise
|
|
434
|
+
|
|
435
|
+
def process_request_thread(self, request: Any, client_address: Any) -> None:
|
|
436
|
+
try:
|
|
437
|
+
super().process_request_thread(request, client_address)
|
|
438
|
+
finally:
|
|
439
|
+
self._connections.release()
|
|
440
|
+
|
|
441
|
+
def finish_request(self, request: Any, client_address: Any) -> None:
|
|
442
|
+
# tls handshake in the worker thread - a slow client must not block the accept loop
|
|
443
|
+
try:
|
|
444
|
+
request.settimeout(RequestHandler.timeout)
|
|
445
|
+
tls_socket = self._ssl_context.wrap_socket(request, server_side=True)
|
|
446
|
+
except (ssl.SSLError, OSError):
|
|
447
|
+
return
|
|
448
|
+
try:
|
|
449
|
+
super().finish_request(tls_socket, client_address)
|
|
450
|
+
finally:
|
|
451
|
+
tls_socket.close()
|
|
452
|
+
|
|
453
|
+
def handle_error(self, request: Any, client_address: Any) -> None:
|
|
454
|
+
pass # connection errors of single clients are not interesting
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _without_position(status: dict[str, Any]) -> dict[str, Any]:
|
|
458
|
+
"""``status`` without the playback position (``album/playback/position``, ``spotify/track/position`` and the
|
|
459
|
+
stored position ``tag/activity/track_time``) - it changes all the time while playing."""
|
|
460
|
+
|
|
461
|
+
def without(entry: Any, key: str, inner: str) -> Any:
|
|
462
|
+
if not isinstance(entry, dict) or not isinstance(entry.get(key), dict):
|
|
463
|
+
return entry
|
|
464
|
+
return {**entry, key: {k: v for k, v in entry[key].items() if k != inner}}
|
|
465
|
+
|
|
466
|
+
result = {**status, "album": without(status.get("album"), "playback", "position"),
|
|
467
|
+
"tag": without(status.get("tag"), "activity", "track_time")} # fmt: skip
|
|
468
|
+
if "spotify" in status:
|
|
469
|
+
result["spotify"] = without(status["spotify"], "track", "position")
|
|
470
|
+
return result
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _write_event(out: Writable, event: str, data: Any) -> None:
|
|
474
|
+
out.write(f"event: {event}\ndata: {json.dumps(data)}\n\n".encode())
|
|
475
|
+
out.flush()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class RestApi:
|
|
479
|
+
"""The rest api and the web page (section ``rest-api`` of the config, see :data:`DEFAULTS`)."""
|
|
480
|
+
|
|
481
|
+
def __init__(
|
|
482
|
+
self,
|
|
483
|
+
api_config: Mapping[str, Any],
|
|
484
|
+
control: CatalogControl,
|
|
485
|
+
targets: Targets,
|
|
486
|
+
logger: logging.Logger,
|
|
487
|
+
log_buffer: LogBuffer | None = None,
|
|
488
|
+
routes: Sequence[Route] = (),
|
|
489
|
+
guards: Mapping[str, Callable[[Request], None]] | None = None,
|
|
490
|
+
mode: str = "box",
|
|
491
|
+
) -> None:
|
|
492
|
+
"""``log_buffer``: :class:`LogBuffer` that is already attached to the logger - otherwise the api attaches
|
|
493
|
+
its own while it is running.
|
|
494
|
+
|
|
495
|
+
``routes``: further routes, e.g. the interface for the boxes of a library. ``guards``: for the paths that
|
|
496
|
+
begin with a key, its guard decides who may ask instead of the login - it raises an error for a request that
|
|
497
|
+
may not, also for a path that does not exist. ``mode``: what the page is the page of (``box``/``library``).
|
|
498
|
+
"""
|
|
499
|
+
self.logger = get_child(logger, type(self).__name__)
|
|
500
|
+
self._root_logger = logger
|
|
501
|
+
self._config: dict[str, Any] = dict(DEFAULTS, **api_config)
|
|
502
|
+
for key in PATH_KEYS:
|
|
503
|
+
self._config[key] = os.path.expanduser(self._config[key])
|
|
504
|
+
self._own_log_buffer = log_buffer is None
|
|
505
|
+
self.log_buffer = log_buffer if log_buffer is not None else LogBuffer()
|
|
506
|
+
self.control = control
|
|
507
|
+
self.uploads = Uploads(targets, control.playlist_filename, int(self._config["max-upload-size"] * 2**20), logger)
|
|
508
|
+
self.users = UsersFile(self._config["users-file"], self.logger)
|
|
509
|
+
self.sessions = Sessions(self._config["session-timeout"], idle_timeout=self._config["idle-timeout"])
|
|
510
|
+
self.throttle = LoginThrottle()
|
|
511
|
+
self.mode = mode
|
|
512
|
+
# the page of the library has a cookie of its own: a box and the library on one host name (the PC, with two
|
|
513
|
+
# ports) would otherwise log each other out - a cookie belongs to the host, whatever the port
|
|
514
|
+
self._cookie = SESSION_COOKIE if mode == "box" else f"{SESSION_COOKIE}_{mode}"
|
|
515
|
+
self._guards = dict(guards or {})
|
|
516
|
+
self._routes = [*self._create_routes(), *routes]
|
|
517
|
+
self._server: _Server | None = None
|
|
518
|
+
self._thread: threading.Thread | None = None
|
|
519
|
+
self._stopping = threading.Event() # ends the event streams
|
|
520
|
+
self.fingerprint: str | None = None
|
|
521
|
+
|
|
522
|
+
# --- routes ------------------------------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
def _create_routes(self) -> list[Route]:
|
|
525
|
+
c = self.control
|
|
526
|
+
u = self.uploads
|
|
527
|
+
|
|
528
|
+
def done(action: Callable[[], object]) -> Response:
|
|
529
|
+
action()
|
|
530
|
+
return NO_CONTENT
|
|
531
|
+
|
|
532
|
+
routes = [
|
|
533
|
+
Route("POST", "/api/login", self._login, None),
|
|
534
|
+
Route("POST", "/api/logout", self._logout, None),
|
|
535
|
+
Route("GET", "/api/identity", self._identity, None),
|
|
536
|
+
Route("GET", "/api/session", self._session, "user"),
|
|
537
|
+
Route("PUT", "/api/session/password", self._change_own_password, "user"),
|
|
538
|
+
Route("GET", "/api/users", self._list_users, "admin"),
|
|
539
|
+
Route("POST", "/api/users", self._add_user, "admin"),
|
|
540
|
+
Route("PUT", "/api/users/{name}/password", self._set_password, "admin"),
|
|
541
|
+
Route("DELETE", "/api/users/{name}", self._remove_user, "admin"),
|
|
542
|
+
Route("GET", "/api/status", lambda r: Response(data=c.status()), "user"),
|
|
543
|
+
Route("GET", "/api/events", self._events, "user"),
|
|
544
|
+
Route("GET", "/api/tags", lambda r: Response(data=c.list_tags(r.query_flag("unmapped"))), "user"),
|
|
545
|
+
Route("POST", "/api/tags", self._create_tag, "admin"),
|
|
546
|
+
Route("GET", "/api/logs", self._logs, "admin"),
|
|
547
|
+
Route("GET", "/api/tags/{uid}", lambda r: Response(data=c.get_tag(r.params["uid"])), "user"),
|
|
548
|
+
Route(
|
|
549
|
+
"PATCH",
|
|
550
|
+
"/api/tags/{uid}",
|
|
551
|
+
lambda r: Response(data=c.set_tag_name(r.params["uid"], r.json().get("name"))),
|
|
552
|
+
"admin",
|
|
553
|
+
),
|
|
554
|
+
Route(
|
|
555
|
+
"PUT",
|
|
556
|
+
"/api/tags/{uid}/album",
|
|
557
|
+
lambda r: Response(data=c.link_tag_album(r.params["uid"], r.json().get("album_id"))),
|
|
558
|
+
"admin",
|
|
559
|
+
),
|
|
560
|
+
Route("DELETE", "/api/tags/{uid}/album", lambda r: Response(data=c.unlink_tag(r.params["uid"])), "admin"),
|
|
561
|
+
Route(
|
|
562
|
+
"PUT",
|
|
563
|
+
"/api/tags/{uid}/radio",
|
|
564
|
+
lambda r: Response(data=c.link_tag_radio(r.params["uid"], r.json().get("radio_id"))),
|
|
565
|
+
"admin",
|
|
566
|
+
),
|
|
567
|
+
Route("DELETE", "/api/tags/{uid}/radio", lambda r: Response(data=c.unlink_tag(r.params["uid"])), "admin"),
|
|
568
|
+
Route(
|
|
569
|
+
"PUT",
|
|
570
|
+
"/api/tags/{uid}/spotify",
|
|
571
|
+
lambda r: Response(data=c.link_tag_spotify(r.params["uid"], r.json().get("spotify_id"))),
|
|
572
|
+
"admin",
|
|
573
|
+
),
|
|
574
|
+
Route("DELETE", "/api/tags/{uid}/spotify", lambda r: Response(data=c.unlink_tag(r.params["uid"])), "admin"),
|
|
575
|
+
Route("DELETE", "/api/tags/{uid}", lambda r: done(lambda: c.remove_tag(r.params["uid"])), "admin"),
|
|
576
|
+
Route("GET", "/api/albums", lambda r: Response(data=c.list_albums(r.query_flag("unmapped"))), "user"),
|
|
577
|
+
Route("POST", "/api/albums", self._add_album, "admin"),
|
|
578
|
+
Route("GET", "/api/albums/new", lambda r: Response(data=c.new_albums()), "admin"),
|
|
579
|
+
Route(
|
|
580
|
+
"POST",
|
|
581
|
+
"/api/albums/new",
|
|
582
|
+
lambda r: Response(data=c.add_new_albums(r.json().get("titles"))),
|
|
583
|
+
"admin",
|
|
584
|
+
),
|
|
585
|
+
Route("GET", "/api/albums/{id}", lambda r: Response(data=c.get_album(r.params["id"])), "user"),
|
|
586
|
+
Route(
|
|
587
|
+
"PATCH",
|
|
588
|
+
"/api/albums/{id}",
|
|
589
|
+
lambda r: Response(data=c.update_album(r.params["id"], r.json().get("title"))),
|
|
590
|
+
"admin",
|
|
591
|
+
),
|
|
592
|
+
Route("GET", "/api/albums/{id}/cover", self._cover, "user"),
|
|
593
|
+
Route("PUT", "/api/albums/{id}/cover", self._set_cover, "admin"),
|
|
594
|
+
Route(
|
|
595
|
+
"POST",
|
|
596
|
+
"/api/albums/{id}/playlist",
|
|
597
|
+
lambda r: Response(data=c.rebuild_playlist(r.params["id"])),
|
|
598
|
+
"admin",
|
|
599
|
+
),
|
|
600
|
+
Route(
|
|
601
|
+
"DELETE",
|
|
602
|
+
"/api/albums/{id}",
|
|
603
|
+
lambda r: done(lambda: c.delete_album(r.params["id"], r.query_flag("delete_files"))),
|
|
604
|
+
"admin",
|
|
605
|
+
),
|
|
606
|
+
Route("GET", "/api/radios", lambda r: Response(data=c.list_radios()), "user"),
|
|
607
|
+
Route("POST", "/api/radios", self._add_radio, "admin"),
|
|
608
|
+
Route("GET", "/api/radios/{id}", lambda r: Response(data=c.get_radio(r.params["id"])), "user"),
|
|
609
|
+
Route("PATCH", "/api/radios/{id}", self._update_radio, "admin"),
|
|
610
|
+
Route("DELETE", "/api/radios/{id}", lambda r: done(lambda: c.delete_radio(r.params["id"])), "admin"),
|
|
611
|
+
Route("GET", "/api/radios/{id}/cover", self._radio_cover, "user"),
|
|
612
|
+
Route("PUT", "/api/radios/{id}/cover", self._set_radio_cover, "admin"),
|
|
613
|
+
Route("GET", "/api/spotify", lambda r: Response(data=c.list_spotify()), "user"),
|
|
614
|
+
Route("POST", "/api/spotify", self._add_spotify, "admin"),
|
|
615
|
+
Route(
|
|
616
|
+
"POST", "/api/spotify-lookup", lambda r: Response(data=c.look_up_spotify(r.json().get("link"))), "admin"
|
|
617
|
+
),
|
|
618
|
+
Route("GET", "/api/spotify/{id}", lambda r: Response(data=c.get_spotify(r.params["id"])), "user"),
|
|
619
|
+
Route("PATCH", "/api/spotify/{id}", self._update_spotify, "admin"),
|
|
620
|
+
Route("DELETE", "/api/spotify/{id}", lambda r: done(lambda: c.delete_spotify(r.params["id"])), "admin"),
|
|
621
|
+
Route("GET", "/api/spotify/{id}/cover", self._spotify_cover, "user"),
|
|
622
|
+
Route("PUT", "/api/spotify/{id}/cover", self._set_spotify_cover, "admin"),
|
|
623
|
+
Route("GET", "/api/activities", lambda r: Response(data=c.list_activities()), "user"),
|
|
624
|
+
Route("POST", "/api/activities/rebuild", lambda r: Response(data=c.rebuild_activities()), "admin"),
|
|
625
|
+
Route("POST", "/api/sanity-check", lambda r: Response(data=c.sanity_checks()), "admin"),
|
|
626
|
+
Route("GET", "/api/backup", self._backup, "admin"),
|
|
627
|
+
Route("GET", "/api/uploads", lambda r: Response(data=u.list()), "admin"),
|
|
628
|
+
Route("POST", "/api/uploads", lambda r: Response(201, u.create(r.json().get("name"))), "admin"),
|
|
629
|
+
Route("GET", "/api/uploads/{id}", lambda r: Response(data=u.get(r.params["id"])), "admin"),
|
|
630
|
+
Route("DELETE", "/api/uploads/{id}", lambda r: done(lambda: u.abort(r.params["id"])), "admin"),
|
|
631
|
+
Route("PUT", "/api/uploads/{id}/files/{filename}", self._upload_file, "admin"),
|
|
632
|
+
Route(
|
|
633
|
+
"DELETE",
|
|
634
|
+
"/api/uploads/{id}/files/{filename}",
|
|
635
|
+
lambda r: done(lambda: u.remove_file(r.params["id"], r.params["filename"])),
|
|
636
|
+
"admin",
|
|
637
|
+
),
|
|
638
|
+
Route("POST", "/api/uploads/{id}/finish", self._finish_upload, "admin"),
|
|
639
|
+
]
|
|
640
|
+
# the backups that are made here by a schedule (tinyjukebox.backups); GET /api/backup sends one at once
|
|
641
|
+
routes += self._backups_routes("/api/backups" if isinstance(c, BoxControl) else "/api/library/backups")
|
|
642
|
+
routes += self._restore_routes()
|
|
643
|
+
if isinstance(c, BoxControl):
|
|
644
|
+
routes += self._box_routes(c)
|
|
645
|
+
return routes
|
|
646
|
+
|
|
647
|
+
def _restore_routes(self) -> list[Route]:
|
|
648
|
+
"""Putting a backup back (tinyjukebox.restore): an upload, or a backup of the folders of the backups, is
|
|
649
|
+
unpacked beside the data; when it is confirmed the program starts again with it."""
|
|
650
|
+
|
|
651
|
+
def restore() -> Restore:
|
|
652
|
+
if self.control.restore is None:
|
|
653
|
+
raise NotFound("no backup can be put back here.")
|
|
654
|
+
return self.control.restore
|
|
655
|
+
|
|
656
|
+
def status() -> dict[str, Any]:
|
|
657
|
+
c = self.control
|
|
658
|
+
# a box with a library: the library wins at the next synchronization - the page says so before
|
|
659
|
+
attached = isinstance(c, BoxControl) and c.sync is not None and c.sync.attached
|
|
660
|
+
return {**restore().status(), "kind": restore().kind, "library": attached}
|
|
661
|
+
|
|
662
|
+
def upload(request: Request) -> Response:
|
|
663
|
+
body = request.body()
|
|
664
|
+
if not body.size:
|
|
665
|
+
raise BadRequest("the body must be a backup.", code="restore.incomplete", reason="nothing was sent")
|
|
666
|
+
try:
|
|
667
|
+
restore().stage(body, body.size) # type: ignore[arg-type]
|
|
668
|
+
except Incomplete as error:
|
|
669
|
+
body.finish()
|
|
670
|
+
raise BadRequest(f"no backup: {error}", code="restore.incomplete", reason=str(error)) from None
|
|
671
|
+
except Exception:
|
|
672
|
+
body.finish()
|
|
673
|
+
raise
|
|
674
|
+
return Response(data=status())
|
|
675
|
+
|
|
676
|
+
def stored(request: Request) -> Response:
|
|
677
|
+
if self.control.backups is None:
|
|
678
|
+
raise NotFound("no backups are kept here.")
|
|
679
|
+
name = request.json().get("name")
|
|
680
|
+
restore().stage_in_background(self.control.backups.path(str(name)))
|
|
681
|
+
return Response(202, status())
|
|
682
|
+
|
|
683
|
+
def confirm(request: Request) -> Response:
|
|
684
|
+
restore().confirm()
|
|
685
|
+
return Response(202, status())
|
|
686
|
+
|
|
687
|
+
def discard(request: Request) -> Response:
|
|
688
|
+
restore().discard()
|
|
689
|
+
return Response(data=status())
|
|
690
|
+
|
|
691
|
+
return [
|
|
692
|
+
Route("GET", "/api/restore", lambda r: Response(data=status()), "admin"),
|
|
693
|
+
Route("PUT", "/api/restore", upload, "admin"),
|
|
694
|
+
Route("POST", "/api/restore", stored, "admin"),
|
|
695
|
+
Route("POST", "/api/restore/confirm", confirm, "admin"),
|
|
696
|
+
Route("DELETE", "/api/restore", discard, "admin"),
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
def _backups_routes(self, prefix: str) -> list[Route]:
|
|
700
|
+
"""The schedules of the backups and the backups that are kept: the same for a box and the library."""
|
|
701
|
+
|
|
702
|
+
def backups() -> Backups:
|
|
703
|
+
if self.control.backups is None:
|
|
704
|
+
raise NotFound("no backups are kept here.")
|
|
705
|
+
return self.control.backups
|
|
706
|
+
|
|
707
|
+
def ask(request: Request) -> Response:
|
|
708
|
+
backups().ask(bool(request.json().get("albums", False)))
|
|
709
|
+
return Response(202, backups().status())
|
|
710
|
+
|
|
711
|
+
def stored(request: Request) -> Response:
|
|
712
|
+
name = request.params["name"]
|
|
713
|
+
headers = {"Content-Disposition": f'attachment; filename="{name}"'}
|
|
714
|
+
return Response(200, headers=headers, file=backups().path(name))
|
|
715
|
+
|
|
716
|
+
def remove(request: Request) -> Response:
|
|
717
|
+
backups().remove(request.params["name"])
|
|
718
|
+
return NO_CONTENT
|
|
719
|
+
|
|
720
|
+
return [
|
|
721
|
+
Route("GET", prefix, lambda r: Response(data=backups().status()), "admin"),
|
|
722
|
+
Route("PUT", prefix, lambda r: Response(data=backups().set(r.json())), "admin"),
|
|
723
|
+
Route("POST", prefix, ask, "admin"),
|
|
724
|
+
Route("GET", f"{prefix}/{{name}}", stored, "admin"),
|
|
725
|
+
Route("DELETE", f"{prefix}/{{name}}", remove, "admin"),
|
|
726
|
+
]
|
|
727
|
+
|
|
728
|
+
def _box_routes(self, c: BoxControl) -> list[Route]:
|
|
729
|
+
"""What only a box has: virtual reader, radio test, player, volume, headset, buttons."""
|
|
730
|
+
|
|
731
|
+
def done(action: Callable[[], object]) -> Response:
|
|
732
|
+
action()
|
|
733
|
+
return NO_CONTENT
|
|
734
|
+
|
|
735
|
+
return [
|
|
736
|
+
Route("GET", "/api/box", lambda r: Response(data=c.library_info()), "user"),
|
|
737
|
+
Route("PUT", "/api/box/library", self._set_library, "admin"),
|
|
738
|
+
Route("DELETE", "/api/box/library", lambda r: Response(data=c.unlink_library()), "admin"),
|
|
739
|
+
Route("PUT", "/api/box/name", lambda r: Response(data=c.set_box_name(r.json().get("name"))), "admin"),
|
|
740
|
+
# what was played on this box is for everybody with a login on its page, as in the library
|
|
741
|
+
Route("GET", "/api/box/statistics", lambda r: Response(data=c.statistics()), "user"),
|
|
742
|
+
Route("GET", "/api/box/system", lambda r: Response(data=c.system_info()), "admin"),
|
|
743
|
+
Route("GET", "/api/box/hardware", lambda r: Response(data=c.hardware_info()), "admin"),
|
|
744
|
+
Route("POST", "/api/box/system/{command}", self._system_command, "admin"),
|
|
745
|
+
Route("POST", "/api/rfid", lambda r: done(lambda: c.place_tag(r.json().get("uid"))), "user"),
|
|
746
|
+
Route("DELETE", "/api/rfid", lambda r: done(c.remove_placed_tag), "user"),
|
|
747
|
+
Route("POST", "/api/radio-test", self._test_radio, "admin"),
|
|
748
|
+
Route("POST", "/api/spotify-test", self._test_spotify, "admin"),
|
|
749
|
+
Route("DELETE", "/api/spotify-test", lambda r: done(c.stop_radio_test), "admin"),
|
|
750
|
+
Route("GET", "/api/spotify-account", lambda r: Response(data=c.spotify_account()), "admin"),
|
|
751
|
+
Route(
|
|
752
|
+
"PUT",
|
|
753
|
+
"/api/spotify-account/key",
|
|
754
|
+
lambda r: Response(data=c.set_spotify_key(r.json().get("key"))),
|
|
755
|
+
"admin",
|
|
756
|
+
),
|
|
757
|
+
Route(
|
|
758
|
+
"PUT",
|
|
759
|
+
"/api/spotify-account/speaker",
|
|
760
|
+
lambda r: Response(data=c.set_spotify_speaker(r.json().get("enabled"))),
|
|
761
|
+
"admin",
|
|
762
|
+
),
|
|
763
|
+
Route("POST", "/api/spotify-account", lambda r: Response(202, c.connect_spotify()), "admin"),
|
|
764
|
+
Route("DELETE", "/api/spotify-account", lambda r: Response(data=c.disconnect_spotify()), "admin"),
|
|
765
|
+
Route("GET", "/api/spotify-player", lambda r: Response(data=c.spotify_player()), "admin"),
|
|
766
|
+
Route("POST", "/api/spotify-player", lambda r: Response(202, c.update_spotify_player()), "admin"),
|
|
767
|
+
Route("DELETE", "/api/radio-test", lambda r: done(c.stop_radio_test), "admin"),
|
|
768
|
+
Route("POST", "/api/player/next", lambda r: done(c.track_next), "user"),
|
|
769
|
+
Route("POST", "/api/player/prev", lambda r: done(c.track_prev), "user"),
|
|
770
|
+
Route("POST", "/api/player/restart", lambda r: done(c.restart_album), "user"),
|
|
771
|
+
Route("POST", "/api/player/pause", lambda r: done(c.pause), "user"),
|
|
772
|
+
Route("POST", "/api/player/play", lambda r: done(c.play), "user"),
|
|
773
|
+
Route("GET", "/api/volume", lambda r: Response(data=c.get_volume()), "user"),
|
|
774
|
+
Route("PUT", "/api/volume", lambda r: Response(data=c.set_volume(r.json().get("volume"))), "user"),
|
|
775
|
+
# start value and maximum (hearing protection): admins only
|
|
776
|
+
Route("PUT", "/api/volume/limits/{profile}", self._set_volume_limits, "admin"),
|
|
777
|
+
Route("GET", "/api/bluetooth", lambda r: Response(data=c.bluetooth_info()), "user"),
|
|
778
|
+
Route("POST", "/api/bluetooth/pair", lambda r: Response(202, c.bluetooth_pair()), "admin"),
|
|
779
|
+
Route("DELETE", "/api/bluetooth/device", lambda r: done(c.bluetooth_unpair), "admin"),
|
|
780
|
+
Route("POST", "/api/bluetooth/virtual", self._virtual_headset, "user"),
|
|
781
|
+
Route(
|
|
782
|
+
"POST",
|
|
783
|
+
"/api/buttons/{button}",
|
|
784
|
+
lambda r: done(lambda: c.press_button(r.params["button"], r.json().get("action", "press"))),
|
|
785
|
+
"user",
|
|
786
|
+
),
|
|
787
|
+
]
|
|
788
|
+
|
|
789
|
+
@property
|
|
790
|
+
def _box(self) -> BoxControl:
|
|
791
|
+
"""The control of a box - for the handlers of the routes that only a box has."""
|
|
792
|
+
assert isinstance(self.control, BoxControl) # noqa: S101 - these routes exist for a box only
|
|
793
|
+
return self.control
|
|
794
|
+
|
|
795
|
+
def _login(self, request: Request) -> Response:
|
|
796
|
+
address = request.client_address
|
|
797
|
+
retry_after = self.throttle.retry_after(address)
|
|
798
|
+
if retry_after:
|
|
799
|
+
raise TooManyRequests("too many failed logins - try again later.", retry_after=retry_after)
|
|
800
|
+
data = request.json()
|
|
801
|
+
username, password = data.get("username"), data.get("password")
|
|
802
|
+
if not isinstance(username, str) or not isinstance(password, str):
|
|
803
|
+
raise BadRequest("username and password must be provided.")
|
|
804
|
+
role = self.users.authenticate(username, password)
|
|
805
|
+
if role is None:
|
|
806
|
+
self.throttle.failed(address)
|
|
807
|
+
self.logger.warning("failed login for '%s' from %s.", username, address)
|
|
808
|
+
raise Unauthorized("wrong username or password.")
|
|
809
|
+
self.throttle.succeeded(address)
|
|
810
|
+
token = self.sessions.create(username, role, self.users.credential(username) or "")
|
|
811
|
+
self.logger.info("'%s' (%s) logged in from %s.", username, role, address)
|
|
812
|
+
cookie = f"{self._cookie}={token}; Path=/; Secure; HttpOnly; SameSite=Strict"
|
|
813
|
+
return Response(
|
|
814
|
+
data={
|
|
815
|
+
"username": username,
|
|
816
|
+
"role": role,
|
|
817
|
+
"token": token,
|
|
818
|
+
"mode": self.mode,
|
|
819
|
+
"idle_timeout": self.sessions.idle_timeout,
|
|
820
|
+
},
|
|
821
|
+
headers={"Set-Cookie": cookie},
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
def _logout(self, request: Request) -> Response:
|
|
825
|
+
if request.token:
|
|
826
|
+
self.sessions.remove(request.token)
|
|
827
|
+
cookie = f"{self._cookie}=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0"
|
|
828
|
+
return Response(204, headers={"Set-Cookie": cookie})
|
|
829
|
+
|
|
830
|
+
def _identity(self, request: Request) -> Response:
|
|
831
|
+
"""Library or box, and which box - for the login page, so without a login."""
|
|
832
|
+
return Response(data={"mode": self.mode, **self.control.identity()})
|
|
833
|
+
|
|
834
|
+
def _session(self, request: Request) -> Response:
|
|
835
|
+
assert request.session is not None # noqa: S101 - routes with a role always have a session
|
|
836
|
+
return Response(
|
|
837
|
+
data={
|
|
838
|
+
"username": request.session.username,
|
|
839
|
+
"role": request.session.role,
|
|
840
|
+
"mode": self.mode,
|
|
841
|
+
"idle_timeout": self.sessions.idle_timeout,
|
|
842
|
+
}
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
def _list_users(self, request: Request) -> Response:
|
|
846
|
+
roles = self.users.roles()
|
|
847
|
+
return Response(data=[{"username": name, "role": roles[name]} for name in sorted(roles)])
|
|
848
|
+
|
|
849
|
+
@staticmethod
|
|
850
|
+
def _new_password_hash(data: dict[str, Any]) -> str:
|
|
851
|
+
password = data.get("password")
|
|
852
|
+
if not isinstance(password, str) or len(password) < MIN_PASSWORD_LENGTH:
|
|
853
|
+
raise BadRequest(
|
|
854
|
+
f"password must have at least {MIN_PASSWORD_LENGTH} characters.",
|
|
855
|
+
code="password.too_short",
|
|
856
|
+
min=MIN_PASSWORD_LENGTH,
|
|
857
|
+
)
|
|
858
|
+
return hash_password(password)
|
|
859
|
+
|
|
860
|
+
def _add_user(self, request: Request) -> Response:
|
|
861
|
+
assert request.session is not None # noqa: S101 - routes with a role always have a session
|
|
862
|
+
data = request.json()
|
|
863
|
+
username, role = data.get("username"), data.get("role", "user")
|
|
864
|
+
if not valid_username(username):
|
|
865
|
+
raise BadRequest("invalid user name.", code="user.invalid_name")
|
|
866
|
+
if role not in ROLES:
|
|
867
|
+
raise BadRequest(f"role must be one of {', '.join(ROLES)}.")
|
|
868
|
+
password_hash = self._new_password_hash(data)
|
|
869
|
+
|
|
870
|
+
def add(users: dict[str, dict[str, Any]]) -> None:
|
|
871
|
+
if username in users:
|
|
872
|
+
raise Conflict(f"user '{username}' exists already.", code="user.exists", name=username)
|
|
873
|
+
users[username] = {"password-hash": password_hash, "role": role}
|
|
874
|
+
|
|
875
|
+
self.users.change(add)
|
|
876
|
+
self.logger.info("'%s' added the user '%s' (%s).", request.session.username, username, role)
|
|
877
|
+
return Response(201, {"username": username, "role": role})
|
|
878
|
+
|
|
879
|
+
def _set_password(self, request: Request) -> Response:
|
|
880
|
+
assert request.session is not None # noqa: S101 - routes with a role always have a session
|
|
881
|
+
username = request.params["name"]
|
|
882
|
+
self._store_password(request, username, self._new_password_hash(request.json()))
|
|
883
|
+
self.logger.info("'%s' changed the password of '%s'.", request.session.username, username)
|
|
884
|
+
return NO_CONTENT
|
|
885
|
+
|
|
886
|
+
def _change_own_password(self, request: Request) -> Response:
|
|
887
|
+
"""Every user may change the own password - with the current one, so that an open browser or a stolen
|
|
888
|
+
session does not suffice. Wrong current passwords count as failed logins."""
|
|
889
|
+
assert request.session is not None # noqa: S101 - routes with a role always have a session
|
|
890
|
+
username = request.session.username
|
|
891
|
+
address = request.client_address
|
|
892
|
+
retry_after = self.throttle.retry_after(address)
|
|
893
|
+
if retry_after:
|
|
894
|
+
raise TooManyRequests("too many failed logins - try again later.", retry_after=retry_after)
|
|
895
|
+
data = request.json()
|
|
896
|
+
current = data.get("current_password")
|
|
897
|
+
if not isinstance(current, str):
|
|
898
|
+
raise BadRequest("current_password must be provided.")
|
|
899
|
+
password_hash = self._new_password_hash(data)
|
|
900
|
+
# not Unauthorized: the session is still valid
|
|
901
|
+
if self.users.authenticate(username, current) is None:
|
|
902
|
+
self.throttle.failed(address)
|
|
903
|
+
self.logger.warning("'%s' entered a wrong current password from %s.", username, address)
|
|
904
|
+
raise Forbidden("wrong current password.")
|
|
905
|
+
self.throttle.succeeded(address)
|
|
906
|
+
self._store_password(request, username, password_hash)
|
|
907
|
+
self.logger.info("'%s' changed the own password.", username)
|
|
908
|
+
return NO_CONTENT
|
|
909
|
+
|
|
910
|
+
def _store_password(self, request: Request, username: str, password_hash: str) -> None:
|
|
911
|
+
def set_password(users: dict[str, dict[str, Any]]) -> None:
|
|
912
|
+
if username not in users:
|
|
913
|
+
raise NotFound(f"unknown user '{username}'.", code="user.unknown", name=username)
|
|
914
|
+
users[username]["password-hash"] = password_hash
|
|
915
|
+
|
|
916
|
+
self.users.change(set_password)
|
|
917
|
+
# whoever is logged in with the old password has to log in again - except the one who just changed it
|
|
918
|
+
self.sessions.remove_user(username, keep=request.token, credential=self.users.credential(username))
|
|
919
|
+
|
|
920
|
+
def _remove_user(self, request: Request) -> Response:
|
|
921
|
+
assert request.session is not None # noqa: S101 - routes with a role always have a session
|
|
922
|
+
username = request.params["name"]
|
|
923
|
+
# only admins get here - so at least one admin always remains
|
|
924
|
+
if username == request.session.username:
|
|
925
|
+
raise Conflict("you cannot remove yourself.", code="user.self")
|
|
926
|
+
|
|
927
|
+
def remove(users: dict[str, dict[str, Any]]) -> None:
|
|
928
|
+
if username not in users:
|
|
929
|
+
raise NotFound(f"unknown user '{username}'.", code="user.unknown", name=username)
|
|
930
|
+
del users[username]
|
|
931
|
+
|
|
932
|
+
self.users.change(remove)
|
|
933
|
+
self.sessions.remove_user(username)
|
|
934
|
+
self.logger.info("'%s' removed the user '%s'.", request.session.username, username)
|
|
935
|
+
return NO_CONTENT
|
|
936
|
+
|
|
937
|
+
def _create_tag(self, request: Request) -> Response:
|
|
938
|
+
data = request.json()
|
|
939
|
+
return Response(
|
|
940
|
+
201,
|
|
941
|
+
self.control.create_tag(
|
|
942
|
+
data.get("uid"), data.get("name"), data.get("album_id"), data.get("radio_id"), data.get("spotify_id")
|
|
943
|
+
),
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
def _add_radio(self, request: Request) -> Response:
|
|
947
|
+
data = request.json()
|
|
948
|
+
return Response(201, self.control.add_radio(data.get("name"), data.get("url")))
|
|
949
|
+
|
|
950
|
+
def _test_radio(self, request: Request) -> Response:
|
|
951
|
+
data = request.json()
|
|
952
|
+
return Response(202, self._box.test_radio(data.get("url"), data.get("name")))
|
|
953
|
+
|
|
954
|
+
def _test_spotify(self, request: Request) -> Response:
|
|
955
|
+
data = request.json()
|
|
956
|
+
return Response(202, self._box.test_spotify(data.get("id"), data.get("link")))
|
|
957
|
+
|
|
958
|
+
def _update_radio(self, request: Request) -> Response:
|
|
959
|
+
data = request.json()
|
|
960
|
+
return Response(data=self.control.update_radio(request.params["id"], data.get("name"), data.get("url")))
|
|
961
|
+
|
|
962
|
+
def _radio_cover(self, request: Request) -> Response:
|
|
963
|
+
data, mime = self.control.get_radio_cover(request.params["id"])
|
|
964
|
+
# the address names the image (page: "?v=<name>"), so a new cover is never the cached one
|
|
965
|
+
return Response(body=data, content_type=mime, headers={"Cache-Control": "private, max-age=300"})
|
|
966
|
+
|
|
967
|
+
def _set_radio_cover(self, request: Request) -> Response:
|
|
968
|
+
length = request.content_length()
|
|
969
|
+
if length > MAX_IMAGE_BODY:
|
|
970
|
+
raise PayloadTooLarge("image is too large.", code="image.too_large")
|
|
971
|
+
return Response(data=self.control.set_radio_cover(request.params["id"], request.stream.read(length)))
|
|
972
|
+
|
|
973
|
+
def _add_spotify(self, request: Request) -> Response:
|
|
974
|
+
data = request.json()
|
|
975
|
+
return Response(201, self.control.add_spotify(data.get("link"), data.get("name")))
|
|
976
|
+
|
|
977
|
+
def _update_spotify(self, request: Request) -> Response:
|
|
978
|
+
data = request.json()
|
|
979
|
+
return Response(data=self.control.update_spotify(request.params["id"], data.get("name"), data.get("link")))
|
|
980
|
+
|
|
981
|
+
def _spotify_cover(self, request: Request) -> Response:
|
|
982
|
+
data, mime = self.control.get_spotify_cover(request.params["id"])
|
|
983
|
+
return Response(body=data, content_type=mime, headers={"Cache-Control": "private, max-age=300"})
|
|
984
|
+
|
|
985
|
+
def _set_spotify_cover(self, request: Request) -> Response:
|
|
986
|
+
length = request.content_length()
|
|
987
|
+
if length > MAX_IMAGE_BODY:
|
|
988
|
+
raise PayloadTooLarge("image is too large.", code="image.too_large")
|
|
989
|
+
return Response(data=self.control.set_spotify_cover(request.params["id"], request.stream.read(length)))
|
|
990
|
+
|
|
991
|
+
def _set_volume_limits(self, request: Request) -> Response:
|
|
992
|
+
data = request.json()
|
|
993
|
+
return Response(
|
|
994
|
+
data=self._box.set_volume_limits(
|
|
995
|
+
request.params["profile"], data.get("default_volume"), data.get("max_volume")
|
|
996
|
+
)
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
def _set_library(self, request: Request) -> Response:
|
|
1000
|
+
data = request.json()
|
|
1001
|
+
return Response(data=self._box.set_library(data.get("url"), data.get("bootstrap")))
|
|
1002
|
+
|
|
1003
|
+
def _system_command(self, request: Request) -> Response:
|
|
1004
|
+
"""A command of the tab "System": everything goes through the slot script."""
|
|
1005
|
+
assert request.session is not None # noqa: S101 - routes with a role always have a session
|
|
1006
|
+
arguments = request.json().get("arguments", [])
|
|
1007
|
+
by = f"web page, user {request.session.username}"
|
|
1008
|
+
return Response(data=self._box.system_command(request.params["command"], arguments, by))
|
|
1009
|
+
|
|
1010
|
+
def _virtual_headset(self, request: Request) -> Response:
|
|
1011
|
+
data = request.json()
|
|
1012
|
+
return Response(data=self._box.virtual_headset(data.get("connected"), data.get("button")))
|
|
1013
|
+
|
|
1014
|
+
def _backup(self, request: Request) -> Response:
|
|
1015
|
+
"""The data as one archive (:mod:`tinyjukebox.backup`), written while it is sent: with ``albums=1`` it may be
|
|
1016
|
+
many gigabytes. If it fails on the way the archive is cut off, which its end shows."""
|
|
1017
|
+
backup = self.control.backup(request.query_flag("albums"))
|
|
1018
|
+
|
|
1019
|
+
def send(out: Writable) -> None:
|
|
1020
|
+
try:
|
|
1021
|
+
result = backup.write(out)
|
|
1022
|
+
except OSError as error:
|
|
1023
|
+
self.logger.warning("backup %s was not sent to its end: %r", backup.filename, error)
|
|
1024
|
+
return
|
|
1025
|
+
self.logger.info("backup %s sent: %d files, %d bytes", backup.filename, result["files"], result["bytes"])
|
|
1026
|
+
|
|
1027
|
+
headers = {"Content-Disposition": f'attachment; filename="{backup.filename}"'}
|
|
1028
|
+
return Response(200, content_type=backup.content_type, headers=headers, stream=send)
|
|
1029
|
+
|
|
1030
|
+
def _events(self, request: Request) -> Response:
|
|
1031
|
+
return Response(content_type="text/event-stream", stream=lambda out: self._stream_events(request.token, out))
|
|
1032
|
+
|
|
1033
|
+
def _stream_events(self, token: str | None, out: Writable) -> None:
|
|
1034
|
+
"""Server-sent events until the client disconnects, the session ends or the api stops.
|
|
1035
|
+
|
|
1036
|
+
* ``status`` (as ``/api/status``) whenever it changes - the playback position does not count, the page
|
|
1037
|
+
interpolates it - and at least every :data:`STATUS_REFRESH` seconds,
|
|
1038
|
+
* ``log`` (admins only) with the id of the newest record whenever there are new log records.
|
|
1039
|
+
|
|
1040
|
+
Each stream checks for changes every :data:`EVENT_INTERVAL` seconds in its own (connection) thread.
|
|
1041
|
+
"""
|
|
1042
|
+
last_key: Any = None
|
|
1043
|
+
last_sent = 0.0
|
|
1044
|
+
log_id = self.log_buffer.last_id
|
|
1045
|
+
try:
|
|
1046
|
+
while not self._stopping.is_set():
|
|
1047
|
+
session = self.sessions.get(token, idle=None) # the stream says nothing about the user
|
|
1048
|
+
role = self.users.session_role(session) if session is not None else None
|
|
1049
|
+
if role is None:
|
|
1050
|
+
break # logged out, expired, removed or a new password - the page reconnects and gets a 401
|
|
1051
|
+
status = self.control.status()
|
|
1052
|
+
key = _without_position(status)
|
|
1053
|
+
now = time.monotonic()
|
|
1054
|
+
if key != last_key or now - last_sent >= STATUS_REFRESH:
|
|
1055
|
+
_write_event(out, "status", status)
|
|
1056
|
+
last_key, last_sent = key, now
|
|
1057
|
+
if has_role(role, "admin") and self.log_buffer.last_id != log_id:
|
|
1058
|
+
log_id = self.log_buffer.last_id
|
|
1059
|
+
_write_event(out, "log", {"last_id": log_id})
|
|
1060
|
+
self._stopping.wait(EVENT_INTERVAL)
|
|
1061
|
+
except OSError: # the client has gone (ssl errors are OSErrors as well)
|
|
1062
|
+
pass
|
|
1063
|
+
except Exception:
|
|
1064
|
+
self.logger.exception("event stream failed")
|
|
1065
|
+
|
|
1066
|
+
def _logs(self, request: Request) -> Response:
|
|
1067
|
+
def number(name: str, default: int) -> int:
|
|
1068
|
+
try:
|
|
1069
|
+
return int(request.query_value(name, str(default)))
|
|
1070
|
+
except ValueError:
|
|
1071
|
+
raise BadRequest(f"'{name}' must be a number.") from None
|
|
1072
|
+
|
|
1073
|
+
text = request.query.get("text", [None])[-1]
|
|
1074
|
+
return Response(
|
|
1075
|
+
data=self.log_buffer.get(
|
|
1076
|
+
after=number("after", 0),
|
|
1077
|
+
level=request.query_value("level", "DEBUG").upper(),
|
|
1078
|
+
text=text,
|
|
1079
|
+
limit=max(1, min(number("limit", 500), 5000)),
|
|
1080
|
+
)
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
def _add_album(self, request: Request) -> Response:
|
|
1084
|
+
data = request.json()
|
|
1085
|
+
return Response(201, self.control.add_album(data.get("folder"), data.get("title")))
|
|
1086
|
+
|
|
1087
|
+
def _cover(self, request: Request) -> Response:
|
|
1088
|
+
data, mime = self.control.get_cover(request.params["id"])
|
|
1089
|
+
return Response(body=data, content_type=mime, headers={"Cache-Control": "private, max-age=300"})
|
|
1090
|
+
|
|
1091
|
+
def _set_cover(self, request: Request) -> Response:
|
|
1092
|
+
length = request.content_length()
|
|
1093
|
+
if length > MAX_IMAGE_BODY:
|
|
1094
|
+
raise PayloadTooLarge("image is too large.", code="image.too_large")
|
|
1095
|
+
return Response(data=self.control.set_cover(request.params["id"], request.stream.read(length)))
|
|
1096
|
+
|
|
1097
|
+
def _upload_file(self, request: Request) -> Response:
|
|
1098
|
+
length = request.content_length()
|
|
1099
|
+
info = self.uploads.add_file(request.params["id"], request.params["filename"], request.stream, length)
|
|
1100
|
+
return Response(201, info)
|
|
1101
|
+
|
|
1102
|
+
def _finish_upload(self, request: Request) -> Response:
|
|
1103
|
+
data = request.json()
|
|
1104
|
+
album = self.uploads.finish(request.params["id"], data.get("order"))
|
|
1105
|
+
result = {"album": self.control.get_album(album.target_id), "tag": None}
|
|
1106
|
+
tag_uid = data.get("tag_uid")
|
|
1107
|
+
if tag_uid:
|
|
1108
|
+
result["tag"] = self.control.link_tag_album(str(tag_uid), album.target_id)
|
|
1109
|
+
return Response(201, result)
|
|
1110
|
+
|
|
1111
|
+
# --- request handling --------------------------------------------------------------------------------------
|
|
1112
|
+
|
|
1113
|
+
def _token(self, handler: RequestHandler) -> tuple[str | None, bool]:
|
|
1114
|
+
"""(token, from cookie)"""
|
|
1115
|
+
authorization = handler.headers.get("Authorization", "")
|
|
1116
|
+
if authorization.lower().startswith("bearer "):
|
|
1117
|
+
return authorization[7:].strip(), False
|
|
1118
|
+
for part in handler.headers.get("Cookie", "").split(";"):
|
|
1119
|
+
name, _, value = part.strip().partition("=")
|
|
1120
|
+
if name == self._cookie and value:
|
|
1121
|
+
return value, True
|
|
1122
|
+
return None, False
|
|
1123
|
+
|
|
1124
|
+
@staticmethod
|
|
1125
|
+
def _idle(handler: RequestHandler, from_cookie: bool) -> float | None:
|
|
1126
|
+
"""What a request says about when the user did something last (:meth:`Sessions.get`). The page tells with
|
|
1127
|
+
every request of its own; a request with the cookie that does not tell - an image the browser loads - says
|
|
1128
|
+
nothing. A request with a token in the header comes from a program: it is an action itself."""
|
|
1129
|
+
if not from_cookie:
|
|
1130
|
+
return 0
|
|
1131
|
+
try:
|
|
1132
|
+
idle = float(handler.headers.get(IDLE_HEADER, ""))
|
|
1133
|
+
except ValueError:
|
|
1134
|
+
return None
|
|
1135
|
+
return max(idle, 0.0) if math.isfinite(idle) else None
|
|
1136
|
+
|
|
1137
|
+
def _current_session(self, handler: RequestHandler) -> tuple[Session | None, str | None, bool]:
|
|
1138
|
+
"""(session, token, from cookie) of the request. Role changes, removed users and new passwords - also those
|
|
1139
|
+
set with tinyjukebox-users - apply at once: a session whose user is gone or has a new password ends."""
|
|
1140
|
+
token, from_cookie = self._token(handler)
|
|
1141
|
+
session = self.sessions.get(token, self._idle(handler, from_cookie))
|
|
1142
|
+
if session is not None and token is not None:
|
|
1143
|
+
role = self.users.session_role(session)
|
|
1144
|
+
if role is None:
|
|
1145
|
+
self.sessions.remove(token)
|
|
1146
|
+
return None, token, from_cookie
|
|
1147
|
+
session.role = role
|
|
1148
|
+
return session, token, from_cookie
|
|
1149
|
+
|
|
1150
|
+
def handle(self, handler: RequestHandler, method: str, path: str) -> Response:
|
|
1151
|
+
allowed_methods = []
|
|
1152
|
+
guard = next((guard for prefix, guard in self._guards.items() if path.startswith(prefix)), None)
|
|
1153
|
+
for route in self._routes:
|
|
1154
|
+
match = route.regex.match(path)
|
|
1155
|
+
if match is None:
|
|
1156
|
+
continue
|
|
1157
|
+
if route.method != method:
|
|
1158
|
+
allowed_methods.append(route.method)
|
|
1159
|
+
continue
|
|
1160
|
+
params = {k: urllib.parse.unquote(v) for k, v in match.groupdict().items()}
|
|
1161
|
+
if guard is not None:
|
|
1162
|
+
guarded = Request(handler, params, None, None)
|
|
1163
|
+
if route.role is not None:
|
|
1164
|
+
guard(guarded)
|
|
1165
|
+
return route.handler(guarded)
|
|
1166
|
+
session, token, from_cookie = self._current_session(handler)
|
|
1167
|
+
if route.role is not None:
|
|
1168
|
+
if session is None:
|
|
1169
|
+
raise Unauthorized("login required.")
|
|
1170
|
+
if not has_role(session.role, route.role):
|
|
1171
|
+
raise Forbidden(f"role '{route.role}' required.")
|
|
1172
|
+
# a changing request in the name of a user of the page - also one of a route without a role, which may
|
|
1173
|
+
# ask who is logged in (logout, a check for a release): a page of another site cannot set the header
|
|
1174
|
+
if (
|
|
1175
|
+
session is not None
|
|
1176
|
+
and from_cookie
|
|
1177
|
+
and method != "GET"
|
|
1178
|
+
and path != "/api/login"
|
|
1179
|
+
and handler.headers.get(CSRF_HEADER) is None
|
|
1180
|
+
):
|
|
1181
|
+
raise Forbidden(f"header '{CSRF_HEADER}' missing.")
|
|
1182
|
+
# what the handler changes is noted as changed by this user (tinyjukebox.sync)
|
|
1183
|
+
with acting_as(f"web:{session.username}" if session is not None else "web"):
|
|
1184
|
+
return route.handler(Request(handler, params, session, token))
|
|
1185
|
+
if guard is not None:
|
|
1186
|
+
guard(Request(handler, {}, None, None)) # who may not ask does not learn which paths exist
|
|
1187
|
+
if allowed_methods:
|
|
1188
|
+
raise MethodNotAllowed(allowed_methods)
|
|
1189
|
+
raise NotFound("not found.")
|
|
1190
|
+
|
|
1191
|
+
# --- start / stop ------------------------------------------------------------------------------------------
|
|
1192
|
+
|
|
1193
|
+
def start(self) -> None:
|
|
1194
|
+
self.fingerprint = ensure_certificate(
|
|
1195
|
+
self._config["certificate"], self._config["private-key"], self.logger, self._config["hostnames"]
|
|
1196
|
+
)
|
|
1197
|
+
context = create_ssl_context(self._config["certificate"], self._config["private-key"])
|
|
1198
|
+
self.uploads.remove_stale_uploads()
|
|
1199
|
+
if self._own_log_buffer:
|
|
1200
|
+
self._root_logger.addHandler(self.log_buffer)
|
|
1201
|
+
self._stopping.clear()
|
|
1202
|
+
handler = type("Handler", (RequestHandler,), {"api": self})
|
|
1203
|
+
self._server = _Server((self._config["host"], self._config["port"]), handler, context)
|
|
1204
|
+
self._thread = threading.Thread(target=self._server.serve_forever, name="RestApi", daemon=True)
|
|
1205
|
+
self._thread.start()
|
|
1206
|
+
self.logger.info(
|
|
1207
|
+
"rest api listening on https://%s:%s - certificate fingerprint (sha256): %s",
|
|
1208
|
+
self._config["host"],
|
|
1209
|
+
self.port,
|
|
1210
|
+
self.fingerprint,
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
@property
|
|
1214
|
+
def running(self) -> bool:
|
|
1215
|
+
return self._thread is not None and self._thread.is_alive()
|
|
1216
|
+
|
|
1217
|
+
@property
|
|
1218
|
+
def certificate_file(self) -> str:
|
|
1219
|
+
"""The certificate of the page (pem)."""
|
|
1220
|
+
return str(self._config["certificate"])
|
|
1221
|
+
|
|
1222
|
+
@property
|
|
1223
|
+
def port(self) -> int:
|
|
1224
|
+
return int(self._server.server_address[1]) if self._server is not None else int(self._config["port"])
|
|
1225
|
+
|
|
1226
|
+
def stop(self) -> None:
|
|
1227
|
+
if self._server is None:
|
|
1228
|
+
return
|
|
1229
|
+
self._stopping.set()
|
|
1230
|
+
self._server.shutdown()
|
|
1231
|
+
self._server.server_close()
|
|
1232
|
+
if self._thread is not None:
|
|
1233
|
+
self._thread.join()
|
|
1234
|
+
self._server = None
|
|
1235
|
+
self.logger.info("rest api stopped.")
|
|
1236
|
+
if self._own_log_buffer:
|
|
1237
|
+
self._root_logger.removeHandler(self.log_buffer)
|