Skip to content

Commit 56dc2e6

Browse files
committed
Merge branch 'feature-protected-recordings'
2 parents 0e9538f + 05f26ba commit 56dc2e6

17 files changed

Lines changed: 827 additions & 65 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Protected recordings
2+
3+
Revision ID: faacfea3b608
4+
Revises: 988e3ce2a20e
5+
Create Date: 2026-04-10 14:55:08.788193
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "faacfea3b608"
17+
down_revision: Union[str, Sequence[str], None] = "988e3ce2a20e"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
"""Upgrade schema."""
24+
op.create_table(
25+
"view_tickets",
26+
sa.Column("uuid", sa.Uuid(), nullable=False),
27+
sa.Column("recording_fk", sa.Integer(), nullable=False),
28+
sa.Column("expire", sa.DateTime(), nullable=False),
29+
sa.Column("consumed", sa.Boolean(), nullable=False),
30+
sa.ForeignKeyConstraint(
31+
["recording_fk"],
32+
["recordings.id"],
33+
name=op.f("fk_view_tickets_recording_fk_recordings"),
34+
ondelete="CASCADE",
35+
),
36+
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_view_tickets")),
37+
)
38+
op.add_column("recordings", sa.Column("protected", sa.Boolean(), server_default=sa.sql.expression.false()))
39+
with op.batch_alter_table("recordings") as batch_op:
40+
batch_op.alter_column("protected", server_default=None)
41+
42+
43+
def downgrade() -> None:
44+
"""Downgrade schema."""
45+
with op.batch_alter_table("recordings") as batch_op:
46+
batch_op.drop_column("protected")
47+
48+
op.drop_table("view_tickets")

bbblb/model.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import enum
55
import logging
66
import typing
7+
import uuid
78
from uuid import UUID
89

910
import datetime
@@ -536,6 +537,7 @@ class Recording(Base):
536537
started: Mapped[datetime.datetime] = mapped_column(TZDateTime(), nullable=False)
537538
ended: Mapped[datetime.datetime] = mapped_column(TZDateTime(), nullable=False)
538539
participants: Mapped[int] = mapped_column(nullable=False, default=0)
540+
protected: Mapped[bool] = mapped_column(nullable=False, default=False)
539541

540542
@validates("meta")
541543
def validate_meta(self, key, meta):
@@ -569,6 +571,57 @@ class PlaybackFormat(Base):
569571
xml: Mapped[str] = mapped_column(nullable=False)
570572

571573

574+
class ViewTicket(Base):
575+
__tablename__ = "view_tickets"
576+
577+
uuid: Mapped[UUID] = mapped_column(primary_key=True)
578+
recording_fk: Mapped[int] = mapped_column(
579+
ForeignKey("recordings.id", ondelete="CASCADE"), nullable=False
580+
)
581+
recording: Mapped[Recording] = relationship(lazy=False)
582+
expire: Mapped[datetime.datetime] = mapped_column(TZDateTime(), nullable=False)
583+
consumed: Mapped[bool] = mapped_column(nullable=False, default=False)
584+
585+
def is_expired(self):
586+
return utcnow() > self.expire
587+
588+
@classmethod
589+
def create(cls, recording: Recording, lifetime: datetime.timedelta) -> "ViewTicket":
590+
return cls(
591+
uuid=uuid.uuid4(),
592+
recording=recording,
593+
expire=utcnow() + lifetime,
594+
)
595+
596+
@classmethod
597+
def delete_expired(cls):
598+
return cls.delete(cls.expire < utcnow())
599+
600+
async def consume(self, session: AsyncSession, commit=False) -> bool:
601+
"""Atomically mark a valid ticket as consumed.
602+
603+
Returns True if the ticket existed, was not expired and not already consumed.
604+
"""
605+
result = await session.execute(
606+
update(ViewTicket)
607+
.where(ViewTicket.uuid == self.uuid)
608+
.where(ViewTicket.expire > utcnow())
609+
.where(ViewTicket.consumed.is_(False))
610+
.values(consumed=True)
611+
.returning(ViewTicket.uuid)
612+
)
613+
row = result.fetchone()
614+
if row is None:
615+
return False
616+
if commit:
617+
await session.commit()
618+
self.consumed = True
619+
return True
620+
621+
def __str__(self):
622+
return f"ViewTicket(rec={self.recording.record_id} ticket={self.uuid})"
623+
624+
572625
# class Task(Base):
573626
# __tablename__ = "tasks"
574627
# id: Mapped[int] = mapped_column(primary_key=True)

bbblb/services/recording.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,16 @@
3434
P = typing.ParamSpec("P")
3535
R = typing.TypeVar("R")
3636

37-
URLPATTERNS = {
38-
"presentation",
39-
"{BASEURL}/playback/presentation/player/{RECORD_ID}/*",
40-
"{BASEURL}/playback/{FORMAT}/{RECORD_ID}/",
41-
}
42-
4337

4438
class RecordingImportError(RuntimeError):
4539
pass
4640

4741

48-
def playback_to_xml(config: BBBLBConfig, playback: model.PlaybackFormat) -> Element:
42+
def playback_to_xml(
43+
config: BBBLBConfig,
44+
playback: model.PlaybackFormat,
45+
ticket_prefix: str | None = None,
46+
) -> Element:
4947
orig = lxml.etree.fromstring(playback.xml)
5048
playback_domain = config.PLAYBACK_DOMAIN.format(
5149
DOMAIN=config.DOMAIN, REALM=playback.recording.tenant.realm
@@ -80,6 +78,8 @@ def playback_to_xml(config: BBBLBConfig, playback: model.PlaybackFormat) -> Elem
8078
url = url._replace(scheme="https", netloc=playback_domain)
8179
if url.path.startswith(f"/{playback.format}"):
8280
url = url._replace(path=f"/playback{url.path}")
81+
if ticket_prefix and url.path.startswith("/playback/"):
82+
url = url._replace(path=ticket_prefix + url.path)
8383
node.text = url.geturl()
8484

8585
return result
@@ -259,7 +259,12 @@ async def import_waiting(self):
259259

260260
async def cleanup(self):
261261
# TODO: Cleanup *.failed and *.canceled work directories.
262-
pass
262+
263+
# Cleanup expored ViewTicket entries for protected recordings.
264+
async with self.db.connect() as conn:
265+
result = await conn.execute(model.ViewTicket.delete_expired())
266+
if result.rowcount:
267+
LOG.debug(f"Removed {result.rowcount} expired ViewTickets")
263268

264269
async def close(self):
265270
for task in list(self.tasks.values()):

bbblb/settings.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,37 @@ class BBBLBConfig(BaseConfig):
210210
then BBBLB will import new recordings as 'unpublished' regardless of
211211
their original state. """
212212

213+
PROTECTED_RECORDINGS: bool = False
214+
""" If enabled, BBBLB will support *protected recordings* similar to
215+
the experimental and unofficial API extention implemented by
216+
Scalelite and Greenlight.
217+
218+
For protected recordings, the getRecordings API will replace
219+
recording links with a one-time ticket that allow a single user to
220+
watch the protected recording for a limited amount of time.
221+
222+
Warning: This feature needs additional configuration if recordings
223+
are not served through BBBLB, and does not prevent downloads. Read
224+
the documentation to understand requirements and limitations of this
225+
feature."""
226+
227+
PROTECTED_RECORDINGS_TIMEOUT: int = 360
228+
""" Number of minutes a protected recording can be watched wth a
229+
ticket after it has been issued. """
230+
213231
PLAYBACK_DOMAIN: str = "{DOMAIN}"
214232
""" Domain where recordings are hostet. The wildcards {DOMAIN} or {REALM}
215233
can be used to refer to the global DOMAIN config, or the realm of the
216234
current tenant. """
217235

236+
PLAYBACK_PLAYER_ROOT: Path | None = None
237+
""" Absolute path to a copy of the bbb-playback presentation player.
238+
You can leave this blank if you serve the bbb-playback assets via
239+
a front-end webserver or CDN.
240+
241+
Defaults to `{PATH_DATA}/htdocs/playback/presentation/2.3/`
242+
"""
243+
218244
POLL_INTERVAL: int = 30
219245
""" Poll interval in seconds for the background server health and meeting
220246
checker. This also defines the timeout for each individual poll, and

bbblb/utils.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright (C) 2025, 2026 Marcel Hellkamp
22
# SPDX-License-Identifier: AGPL-3.0-or-later
33

4+
import hashlib
5+
import hmac
46
import typing
57
import re
68

@@ -12,7 +14,7 @@
1214
# Common regular expressions
1315
RE_MEETING_ID = re.compile("^[a-zA-Z0-9-_]{2,%d}$" % MAX_MEETING_ID_LEN)
1416
RE_FORMAT_NAME = re.compile("^[a-zA-Z0-9]{1,64}$")
15-
RE_RECORD_ID = re.compile("^[0-9a-fA-F]+-\\d+$")
17+
RE_RECORD_ID = re.compile("^[0-9a-f]{40}-\\d{12,}$")
1618
RE_TENANT_NAME = re.compile("^[a-zA-Z0-9]{1,%d}$" % MAX_TENANT_NAME_LEN)
1719

1820

@@ -64,3 +66,21 @@ def checked_cast(type_: type[T], value: typing.Any) -> T:
6466
if isinstance(value, type_):
6567
return value
6668
raise TypeError(f"Expected {type_} but got {type(value)}")
69+
70+
71+
def hmac_sign(payload: str, secret: str) -> str:
72+
sig = hmac.digest(secret.encode("UTF8"), payload.encode("UTF8"), hashlib.sha256)
73+
return f"{sig.hex()}:{payload}"
74+
75+
76+
def hmac_verify(untrtusted: str, secret: str) -> str | None:
77+
sig, sep, payload = untrtusted.partition(":")
78+
if sig and sep:
79+
check = hmac.digest(
80+
secret.encode("UTF8"), payload.encode("UTF8"), hashlib.sha256
81+
)
82+
try:
83+
if hmac.compare_digest(check, bytes.fromhex(sig)):
84+
return payload
85+
except ValueError:
86+
pass

bbblb/web/__init__.py

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# SPDX-License-Identifier: AGPL-3.0-or-later
33

44
from contextlib import asynccontextmanager
5-
from functools import partial
65
from starlette.applications import Starlette
76
from starlette.routing import Mount, Route
87
from starlette.staticfiles import StaticFiles
@@ -61,53 +60,37 @@ def session(self):
6160
return self.db.session()
6261

6362

64-
# Playback formats for which we know that they sometimes expect their files
65-
# in /{format}/* instead of the default /playback/{format}/* path.
66-
PLAYBACK_FROM_ROOT_FORMATS = ("presentation", "video")
67-
68-
69-
async def format_redirect_app(format, scope, receive, send):
70-
assert scope["type"] == "http"
71-
path = scope["path"].lstrip("/")
72-
response = RedirectResponse(url=f"/playback/{format}/{path}")
73-
await response(scope, receive, send)
74-
75-
7663
def redirect(src, dst):
7764
async def handler(request):
7865
return RedirectResponse(url=dst)
7966

8067
return Route(src, endpoint=handler)
8168

8269

83-
def make_routes(config: BBBLBConfig):
84-
from bbblb.web import bbbapi, bbblbapi
70+
async def collect_routes(sr: ServiceRegistry):
71+
from bbblb.web import bbbapi, bbblbapi, playback
8572

86-
playback_dir = config.PATH_DATA / "recordings" / "public"
87-
playback_dir.mkdir(parents=True, exist_ok=True)
73+
config = await sr.use(BBBLBConfig)
8874
static_dir = config.PATH_DATA / "htdocs"
8975
static_dir.mkdir(parents=True, exist_ok=True)
9076

9177
return [
9278
Mount("/bigbluebutton/api", routes=bbbapi.api_routes),
9379
Mount("/bbblb/api", routes=bbblbapi.api_routes),
94-
# Serve /playback/* files in case the reverse proxy in front if BBBLB does not.
80+
Mount(
81+
"/playback/presentation/2.3/{record_id}",
82+
app=playback.PlaybackPlayerApp(config),
83+
name="bbb:playback:player",
84+
),
9585
Mount(
9686
"/playback",
97-
app=StaticFiles(
98-
directory=playback_dir,
99-
check_dir=False,
100-
follow_symlink=True,
101-
),
102-
name="bbb:playback",
87+
app=playback.PlaybackMediaApp(config, await sr.use(DBContext)),
88+
name="bbb:playback:media",
10389
),
10490
# Redirect misguided playback file requests to the real path. We send
10591
# redirects instead of real files in case a reverse proxy in front if BBBLB
106-
# serves /playback/* for us more efficiently.
107-
*[
108-
Mount(f"/{format}", app=partial(format_redirect_app, format))
109-
for format in PLAYBACK_FROM_ROOT_FORMATS
110-
],
92+
# serves /playback/* for us.
93+
*playback.PLAYBACK_FORMAT_REDIRECTS,
11194
# Redirect non-slash requests to prefix mounts, because automatic slash handling
11295
# breaks if there are other routes matching the non-slash request :/
11396
redirect("/bigbluebutton/api", "/bigbluebutton/api/"),
@@ -139,8 +122,9 @@ async def lifespan(app: Starlette):
139122
async with services:
140123
if autostart:
141124
await services.start_all()
142-
app.state.config = config
125+
app.router.routes.extend(await collect_routes(services))
143126
app.state.services = services
127+
144128
yield
145129

146-
return Starlette(debug=config.DEBUG, routes=make_routes(config), lifespan=lifespan)
130+
return Starlette(debug=config.DEBUG, lifespan=lifespan)

0 commit comments

Comments
 (0)