diff --git a/bbblb/cli/__init__.py b/bbblb/cli/__init__.py index d195395..288e535 100644 --- a/bbblb/cli/__init__.py +++ b/bbblb/cli/__init__.py @@ -163,7 +163,7 @@ async def main(ctx, obj, config_file, config, verbose): config_.populate() config_.set(WORKER=False) - ctx.obj = await bootstrap(config_, autostart=False, logging=False) + ctx.obj = await bootstrap(config_, logging=False) # Auto-load all modules in the bbblb.cli package to load all commands. diff --git a/bbblb/migrations/versions/cbee8afa1ca2_meeting_stats_ts_index.py b/bbblb/migrations/versions/cbee8afa1ca2_meeting_stats_ts_index.py new file mode 100644 index 0000000..25a9244 --- /dev/null +++ b/bbblb/migrations/versions/cbee8afa1ca2_meeting_stats_ts_index.py @@ -0,0 +1,35 @@ +"""MeetingStats ts index + +Revision ID: cbee8afa1ca2 +Revises: 988e3ce2a20e +Create Date: 2026-04-10 11:28:44.741959 + +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "cbee8afa1ca2" +down_revision: Union[str, Sequence[str], None] = "988e3ce2a20e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table("meeting_stats", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_meeting_stats_ts"), + ["ts"], + unique=False, + postgresql_using="brin", + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table("meeting_stats", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_meeting_stats_ts"), postgresql_using="brin") diff --git a/bbblb/migrations/versions/faacfea3b608_protected_recordings.py b/bbblb/migrations/versions/faacfea3b608_protected_recordings.py new file mode 100644 index 0000000..4aab110 --- /dev/null +++ b/bbblb/migrations/versions/faacfea3b608_protected_recordings.py @@ -0,0 +1,47 @@ +"""Protected recordings + +Revision ID: faacfea3b608 +Revises: cbee8afa1ca2 +Create Date: 2026-04-10 14:55:08.788193 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "faacfea3b608" +down_revision: Union[str, Sequence[str], None] = "cbee8afa1ca2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "view_tickets", + sa.Column("uuid", sa.Uuid(), nullable=False), + sa.Column("recording_fk", sa.Integer(), nullable=False), + sa.Column("expire", sa.DateTime(), nullable=False), + sa.Column("consumed", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["recording_fk"], + ["recordings.id"], + name=op.f("fk_view_tickets_recording_fk_recordings"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_view_tickets")), + ) + with op.batch_alter_table("recordings", schema=None) as batch_op: + batch_op.add_column(sa.Column("protected", sa.Boolean(), nullable=False)) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table("recordings", schema=None) as batch_op: + batch_op.drop_column("protected") + + op.drop_table("view_tickets") diff --git a/bbblb/model.py b/bbblb/model.py index 0edeba4..a9a5306 100644 --- a/bbblb/model.py +++ b/bbblb/model.py @@ -4,6 +4,7 @@ import enum import logging import typing +import uuid from uuid import UUID import datetime @@ -148,6 +149,29 @@ def process_result_value(self, value: int | None, dialect): return None +class TZDateTime(TypeDecorator): + """A DateTime column type that stores timestamps as UTC without + timezone, but returns `datetime` with a timezone. + + This is a workaround for sqlalchemy+sqlite3 which ignores DateTime(timezone) + setting and returns `datetime` objects without a timezone.""" + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is not None: + if not value.tzinfo or value.tzinfo.utcoffset(value) is None: + raise TypeError("tzinfo is required") + value = value.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return value + + def process_result_value(self, value, dialect): + if value is not None and value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + return value + + class ORMMixin: @classmethod def select(cls, *a, **filter): @@ -205,7 +229,7 @@ class Lock(Base): name: Mapped[str] = mapped_column(primary_key=True) owner: Mapped[str] = mapped_column(nullable=False) ts: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), insert_default=utcnow, onupdate=utcnow, nullable=False + TZDateTime(), insert_default=utcnow, onupdate=utcnow, nullable=False ) def __str__(self): @@ -426,10 +450,10 @@ class Meeting(Base): server: Mapped["Server"] = relationship(lazy=False) created: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), insert_default=utcnow, nullable=False + TZDateTime(), insert_default=utcnow, nullable=False ) modified: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), insert_default=utcnow, onupdate=utcnow, nullable=False + TZDateTime(), insert_default=utcnow, onupdate=utcnow, nullable=False ) def __str__(self): @@ -449,7 +473,7 @@ class MeetingStats(Base): #: entries created during the same poll interval, so we can group #: over the timestamp later. ts: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), insert_default=utcnow, nullable=False + TZDateTime(), insert_default=utcnow, nullable=False ) uuid: Mapped[UUID] = mapped_column(nullable=False) meeting_id: Mapped[str] = mapped_column(nullable=False) @@ -482,7 +506,7 @@ class Callback(Base): #: Original callback URL (optional) forward: Mapped[str] = mapped_column(nullable=True) created: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), insert_default=utcnow, nullable=False + TZDateTime(), insert_default=utcnow, nullable=False ) @@ -510,13 +534,10 @@ class Recording(Base): ) # Non-essential but nice to have attributes - started: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) - ended: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) + started: Mapped[datetime.datetime] = mapped_column(TZDateTime(), nullable=False) + ended: Mapped[datetime.datetime] = mapped_column(TZDateTime(), nullable=False) participants: Mapped[int] = mapped_column(nullable=False, default=0) + protected: Mapped[bool] = mapped_column(nullable=False, default=False) @validates("meta") def validate_meta(self, key, meta): @@ -550,6 +571,57 @@ class PlaybackFormat(Base): xml: Mapped[str] = mapped_column(nullable=False) +class ViewTicket(Base): + __tablename__ = "view_tickets" + + uuid: Mapped[UUID] = mapped_column(primary_key=True) + recording_fk: Mapped[int] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE"), nullable=False + ) + recording: Mapped[Recording] = relationship(lazy=False) + expire: Mapped[datetime.datetime] = mapped_column(TZDateTime(), nullable=False) + consumed: Mapped[bool] = mapped_column(nullable=False, default=False) + + def is_expired(self): + return utcnow() > self.expire + + @classmethod + def create(cls, recording: Recording, lifetime: datetime.timedelta) -> "ViewTicket": + return cls( + uuid=uuid.uuid4(), + recording=recording, + expire=utcnow() + lifetime, + ) + + @classmethod + def delete_expired(cls): + return cls.delete(cls.expire < utcnow()) + + async def consume(self, session: AsyncSession, commit=False) -> bool: + """Atomically mark a valid ticket as consumed. + + Returns True if the ticket existed, was not expired and not already consumed. + """ + result = await session.execute( + update(ViewTicket) + .where(ViewTicket.uuid == self.uuid) + .where(ViewTicket.expire > utcnow()) + .where(ViewTicket.consumed.is_(False)) + .values(consumed=True) + .returning(ViewTicket.uuid) + ) + row = result.fetchone() + if row is None: + return False + if commit: + await session.commit() + self.consumed = True + return True + + def __str__(self): + return f"ViewTicket(rec={self.recording.record_id} ticket={self.uuid})" + + # class Task(Base): # __tablename__ = "tasks" # id: Mapped[int] = mapped_column(primary_key=True) diff --git a/bbblb/services/__init__.py b/bbblb/services/__init__.py index a8af3a4..a4e164f 100644 --- a/bbblb/services/__init__.py +++ b/bbblb/services/__init__.py @@ -285,9 +285,7 @@ def configure_logging(config: BBBLBConfig): ) -async def bootstrap( - config: BBBLBConfig, autostart=True, logging=True -) -> ServiceRegistry: +async def bootstrap(config: BBBLBConfig, logging=True) -> ServiceRegistry: import bbblb.services.poller import bbblb.services.recording import bbblb.services.analytics @@ -317,9 +315,6 @@ def watch_debug_level(name, old, new): ctx.register(bbblb.services.analytics.AnalyticsHandler) ctx.register(bbblb.services.tenants.TenantCache) - if autostart: - await ctx.start_all() - LOG.debug("Bootstrapping completed!") return ctx diff --git a/bbblb/services/db.py b/bbblb/services/db.py index 0f3acb5..6f04d5e 100644 --- a/bbblb/services/db.py +++ b/bbblb/services/db.py @@ -48,6 +48,9 @@ async def on_start(self): current, target = await check_migration_state(self._db_url) if current != target and self._migrate: + LOG.info( + f"Migrating database from schema revision {current!r} to {target!r} ..." + ) await migrate_db(self._db_url) elif current != target: LOG.error(f"Expected schema revision {target!r} but found {current!r}.") diff --git a/bbblb/services/recording.py b/bbblb/services/recording.py index 6571b12..5a7d579 100644 --- a/bbblb/services/recording.py +++ b/bbblb/services/recording.py @@ -45,7 +45,11 @@ class RecordingImportError(RuntimeError): pass -def playback_to_xml(config: BBBLBConfig, playback: model.PlaybackFormat) -> Element: +def playback_to_xml( + config: BBBLBConfig, + playback: model.PlaybackFormat, + ticket_prefix: str | None = None, +) -> Element: orig = lxml.etree.fromstring(playback.xml) playback_domain = config.PLAYBACK_DOMAIN.format( DOMAIN=config.DOMAIN, REALM=playback.recording.tenant.realm @@ -80,6 +84,8 @@ def playback_to_xml(config: BBBLBConfig, playback: model.PlaybackFormat) -> Elem url = url._replace(scheme="https", netloc=playback_domain) if url.path.startswith(f"/{playback.format}"): url = url._replace(path=f"/playback{url.path}") + if ticket_prefix and url.path.startswith("/playback/"): + url = url._replace(path=ticket_prefix + url.path) node.text = url.geturl() return result diff --git a/bbblb/settings.py b/bbblb/settings.py index 16d9e8b..0c2b051 100644 --- a/bbblb/settings.py +++ b/bbblb/settings.py @@ -210,11 +210,37 @@ class BBBLBConfig(BaseConfig): then BBBLB will import new recordings as 'unpublished' regardless of their original state. """ + PROTECTED_RECORDINGS: bool = False + """ If enabled, BBBLB will support *protected recordings* similar to + the experimental and unofficial API extention implemented by + Scalelite and Greenlight. + + For protected recordings, the getRecordings API will replace + recording links with a one-time ticket that allow a single user to + watch the protected recording for a limited amount of time. + + Warning: This feature needs additional configuration if recordings + are not served through BBBLB, and does not prevent downloads. Read + the documentation to understand requirements and limitations of this + feature.""" + + PROTECTED_RECORDINGS_TIMEOUT: int = 360 + """ Number of minutes a protected recording can be watched wth a + ticket after it has been issued. """ + PLAYBACK_DOMAIN: str = "{DOMAIN}" """ Domain where recordings are hostet. The wildcards {DOMAIN} or {REALM} can be used to refer to the global DOMAIN config, or the realm of the current tenant. """ + PLAYBACK_PLAYER_ROOT: Path | None = None + """ Absolute path to a copy of the bbb-playback presentation player. + You can leave this blank if you serve the bbb-playback assets via + a front-end webserver or CDN. + + Defaults to `{PATH_DATA}/htdocs/playback/presentation/2.3/` + """ + POLL_INTERVAL: int = 30 """ Poll interval in seconds for the background server health and meeting checker. This also defines the timeout for each individual poll, and diff --git a/bbblb/utils.py b/bbblb/utils.py index aa81b2d..4bbf05f 100644 --- a/bbblb/utils.py +++ b/bbblb/utils.py @@ -1,6 +1,8 @@ # Copyright (C) 2025, 2026 Marcel Hellkamp # SPDX-License-Identifier: AGPL-3.0-or-later +import hashlib +import hmac import typing import re @@ -64,3 +66,18 @@ def checked_cast(type_: type[T], value: typing.Any) -> T: if isinstance(value, type_): return value raise TypeError(f"Expected {type_} but got {type(value)}") + + +def hmac_sign(payload: str, secret: str) -> str: + sig = hmac.digest(secret.encode("UTF8"), payload.encode("UTF8"), hashlib.sha256) + return f"{sig.hex()}:{payload}" + + +def hmac_verify(untrtusted: str, secret: str) -> str | None: + sig, sep, payload = untrtusted.partition(":") + if sig and sep: + check = hmac.digest( + secret.encode("UTF8"), payload.encode("UTF8"), hashlib.sha256 + ) + if hmac.compare_digest(check, bytes.fromhex(sig)): + return payload diff --git a/bbblb/web/__init__.py b/bbblb/web/__init__.py index cc155d6..d5d8a70 100644 --- a/bbblb/web/__init__.py +++ b/bbblb/web/__init__.py @@ -39,7 +39,7 @@ def services(self) -> ServiceRegistry: @cached_property def config(self) -> BBBLBConfig: - return cast(BBBLBConfig, self.request.app.state.config) + return self.services.get(BBBLBConfig) @cached_property def bbb(self) -> BBBHelper: @@ -61,18 +61,6 @@ def session(self): return self.db.session() -# Playback formats for which we know that they sometimes expect their files -# in /{format}/* instead of the default /playback/{format}/* path. -PLAYBACK_FROM_ROOT_FORMATS = ("presentation", "video") - - -async def format_redirect_app(format, scope, receive, send): - assert scope["type"] == "http" - path = scope["path"].lstrip("/") - response = RedirectResponse(url=f"/playback/{format}/{path}") - await response(scope, receive, send) - - def redirect(src, dst): async def handler(request): return RedirectResponse(url=dst) @@ -80,34 +68,30 @@ async def handler(request): return Route(src, endpoint=handler) -def make_routes(config: BBBLBConfig): - from bbblb.web import bbbapi, bbblbapi +async def collect_routes(sr: ServiceRegistry): + from bbblb.web import bbbapi, bbblbapi, playback - playback_dir = config.PATH_DATA / "recordings" / "public" - playback_dir.mkdir(parents=True, exist_ok=True) + config = await sr.use(BBBLBConfig) static_dir = config.PATH_DATA / "htdocs" static_dir.mkdir(parents=True, exist_ok=True) return [ Mount("/bigbluebutton/api", routes=bbbapi.api_routes), Mount("/bbblb/api", routes=bbblbapi.api_routes), - # Serve /playback/* files in case the reverse proxy in front if BBBLB does not. + Mount( + "/playback/presentation/2.3/{record_id}", + app=playback.PlaybackPlayerApp(config), + name="bbb:playback:player", + ), Mount( "/playback", - app=StaticFiles( - directory=playback_dir, - check_dir=False, - follow_symlink=True, - ), - name="bbb:playback", + app=playback.PlaybackMediaApp(config, await sr.use(DBContext)), + name="bbb:playback:media", ), # Redirect misguided playback file requests to the real path. We send # redirects instead of real files in case a reverse proxy in front if BBBLB - # serves /playback/* for us more efficiently. - *[ - Mount(f"/{format}", app=partial(format_redirect_app, format)) - for format in PLAYBACK_FROM_ROOT_FORMATS - ], + # serves /playback/* for us. + *playback.PLAYBACK_FORMAT_REDIRECTS, # Redirect non-slash requests to prefix mounts, because automatic slash handling # breaks if there are other routes matching the non-slash request :/ redirect("/bigbluebutton/api", "/bigbluebutton/api/"), @@ -135,10 +119,13 @@ def make_app(config: BBBLBConfig | None = None, autostart=True): @asynccontextmanager async def lifespan(app: Starlette): - services = await bbblb.services.bootstrap(config, autostart=autostart) + services = await bbblb.services.bootstrap(config) async with services: - app.state.config = config + if autostart: + await services.start_all() + app.router.routes.extend(await collect_routes(services)) app.state.services = services + yield - return Starlette(debug=config.DEBUG, routes=make_routes(config), lifespan=lifespan) + return Starlette(debug=config.DEBUG, lifespan=lifespan) diff --git a/bbblb/web/bbbapi.py b/bbblb/web/bbbapi.py index 85a8993..592b1f0 100644 --- a/bbblb/web/bbbapi.py +++ b/bbblb/web/bbbapi.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio +from datetime import timedelta import functools import hashlib import hmac @@ -668,7 +669,7 @@ async def handle_get_recordings(ctx: BBBApiRequest): XML.recordID(rec.record_id), XML.meetingID(rec.external_id), XML.internalMeetingID(rec.record_id), # TODO: Really always the case? - XML.name(rec.meta["meetingName"]), + XML.name(rec.meta.get("meetingName", "")), XML.isBreakout(rec.meta.get("isBreakout", "false")), XML.published( "true" if rec.state == model.RecordingState.PUBLISHED else "false" @@ -687,13 +688,32 @@ async def handle_get_recordings(ctx: BBBApiRequest): rec_xml, utils.add_scope(rec.external_id, tenant.name), rec.external_id ) + ticket = None + if ctx.config.PROTECTED_RECORDINGS: + rec_xml.append(XML.protected("true" if rec.protected else "false")) + if rec.protected: + ticket = model.ViewTicket.create( + rec, timedelta(minutes=ctx.config.PROTECTED_RECORDINGS_TIMEOUT) + ) + ctx.session.add(ticket) + playback_xml = SubElement(rec_xml, "playback") for playback in rec.formats: - format_xml = playback_to_xml(ctx.config, playback) + if ticket and rec.protected: + format_xml = playback_to_xml( + ctx.config, + playback, + ticket_prefix=f"/bbblb/api/v1/recording/ticket/{ticket.uuid}", + ) + else: + format_xml = playback_to_xml(ctx.config, playback) playback_xml.append(format_xml) all_recordings.append(rec_xml) + if ctx.config.PROTECTED_RECORDINGS: + await ctx.session.commit() + return result_xml @@ -768,6 +788,10 @@ async def handle_update_recordings(ctx: BBBApiRequest): if key.startswith("meta_") and not key.startswith("meta_bbblb-") } + protect = None + if ctx.config.PROTECTED_RECORDINGS and "protect" in params: + protect = params["protect"].lower() == "true" + stmt = model.Recording.select( model.Recording.tenant == tenant, model.Recording.record_id.in_(record_ids) ) @@ -781,6 +805,9 @@ async def handle_update_recordings(ctx: BBBApiRequest): rec.meta[key] = value else: rec.meta.pop(key, None) + if protect is not None and rec.protected != protect: + rec.protected = protect + updated = True await ctx.session.commit() diff --git a/bbblb/web/bbblbapi.py b/bbblb/web/bbblbapi.py index a32e5ce..0848786 100644 --- a/bbblb/web/bbblbapi.py +++ b/bbblb/web/bbblbapi.py @@ -8,6 +8,7 @@ import json from urllib.parse import parse_qs import logging +import uuid import jwt from bbblb.services.analytics import AnalyticsHandler @@ -17,10 +18,11 @@ from starlette.requests import Request from starlette.routing import Route -from starlette.responses import Response, JSONResponse +from starlette.responses import RedirectResponse, Response, JSONResponse from bbblb.web import ApiRequestContext from bbblb.services.recording import RecordingManager +from bbblb.web import playback LOG = logging.getLogger(__name__) @@ -377,6 +379,100 @@ async def handle_recording_upload(ctx: BBBLBApiRequest): ) +## +### Protected Recordings +## + + +@api( + "v1/recording/ticket/{ticket_uuid}/{original_path:path}", + methods=["GET"], + name="bbblb:ticket", +) +async def handle_protected_recording_link(ctx: BBBLBApiRequest): + """A recording link that can only be used by a single user. + + When visited for the first time (ticket not consumed) the ticket is + consumed, the user gets a signed cookie and is then redirected. If + visited a second time (ticket already consumed) the user either + needs a valid cookie or is rejected. + """ + if not ctx.config.PROTECTED_RECORDINGS: + return Response("Invalid recording link", 404) + + ticket_uuid = ctx.request.path_params["ticket_uuid"] + ticket = await ctx.session.get(model.ViewTicket, uuid.UUID(ticket_uuid)) + if not ticket or ticket.is_expired(): + return Response("This recording link is expired", 403) + + original_path = ctx.request.path_params["original_path"] + target = ctx.request.url.replace(scheme="https", path=original_path) + + if not ticket.recording.protected: + return RedirectResponse(target) + + record_id = ticket.recording.record_id + cookie_key = playback.PRT_COOKIE_PREFIX + record_id + cookie = ctx.request.cookies.get(cookie_key) + + if cookie and playback.verify_prt_cookie(cookie, record_id, ctx.config): + return RedirectResponse(target) + + if not ticket.consumed and await ticket.consume(ctx.session, commit=True): + rs = RedirectResponse(target) + rs.set_cookie( + cookie_key, + playback.sign_prt_cookie(record_id, ticket.expire, ctx.config), + path="/", + max_age=ctx.config.PROTECTED_RECORDINGS_TIMEOUT * 60, + ) + return rs + + return Response("This recording link is expired", 403) + + +@api("v1/recording/auth/{original_path:path}", methods=["GET"]) +async def handle_protected_recording_auth(ctx: BBBLBApiRequest): + """Auth API used by front-end webservers or CDNs to validate user + requests for recording data.""" + if not ctx.config.PROTECTED_RECORDINGS: + return Response(status_code=201) + + # Get record_id ouf of the requested path + path = ctx.request.path_params["original_path"].rstrip("/") + if path.startswith("playback/") and (match := playback.split_media_path(path[9:])): + format_name, record_id, resource = match + else: + LOG.warning(f"Unexpected auth check for path: {path!r}") + return Response(status_code=500) + + # Allow access to unprotected assets (js, css, fonts, ...) + if playback.is_unprotected_asset(format_name, resource): + return Response(status_code=201) + + # Fetch cookie and load recording + cookie_key = f"bbblb_prt_{record_id}" + cookie = ctx.request.cookies.get(cookie_key, "") + recording = await ctx.session.scalar(model.Recording.select(record_id=record_id)) + + # Reject requests for missing or unpublished recordings + if not recording or recording.state is not model.RecordingState.PUBLISHED: + return Response(status_code=404) + + # Require a valid cookie for protected recordings + if not recording.protected or playback.verify_prt_cookie( + cookie, record_id, ctx.config + ): + return Response(status_code=201) + + return Response(status_code=403) + + +## +### REST API +## + + @api("v1/tenant", methods=["GET"]) async def handle_tenants_list(ctx: BBBLBApiRequest): auth = await ctx.auth() diff --git a/bbblb/web/playback.py b/bbblb/web/playback.py new file mode 100644 index 0000000..eecf20b --- /dev/null +++ b/bbblb/web/playback.py @@ -0,0 +1,164 @@ +import datetime +import logging +import re +import time +from typing import Any, MutableMapping + +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response +from starlette.routing import Mount +from starlette.staticfiles import StaticFiles + +from bbblb import model, utils +from bbblb.services.db import DBContext +from bbblb.settings import BBBLBConfig + +LOG = logging.getLogger(__name__) + +## +### Shared helper functions for protected recordings +### used by PlaybackApp and bbblbapi.py +## + +PRT_COOKIE_PREFIX = "bbblb_prt_" + +# Pattern for a media file request path +_RE_MEDIA_PATH = re.compile(r"^/?([a-z]+)/([0-9a-f]{40}-[0-9]{12,})/(.*)") + + +def split_media_path(path: str) -> tuple[str, str, str] | None: + """Split a {format}/{record_id}/{resource...} path into its three + components, or return None for invalid paths (bad format, + bad record_id, no resource). The resource part can be empty. + """ + if match := _RE_MEDIA_PATH.match(path): + return match.groups() # type: ignore + + +# Pattern for assets that do not need protection, one per format. +_UNPROTECTED = { + "presentation": re.compile(r".*\.(js|css|html|woff)$"), + "video": re.compile(r".*\.(js|css|html)$"), +} + + +def is_unprotected_asset(format_name: str, resource: str): + """Return true if we can skip expensive checks for assets (css, js, fonts) + that do not need protection.""" + if format_name not in _UNPROTECTED: + return False + + return _UNPROTECTED[format_name].match(resource) is not None + + +def sign_prt_cookie(record_id: str, expire: datetime.datetime, conf: BBBLBConfig): + return utils.hmac_sign( + f"{int(expire.timestamp())}:{record_id}", "prc" + conf.SECRET + ) + + +def verify_prt_cookie(value: str, record_id: str, conf: BBBLBConfig): + payload = utils.hmac_verify(value, "prc" + conf.SECRET) + if not payload or ":" not in payload: + return False + ts, _, rid = payload.partition(":") + if rid != record_id or not ts.isdecimal(): + return False + if int(ts) < time.time() - conf.PROTECTED_RECORDINGS_TIMEOUT * 60: + return False + return True + + +def _make_redirect(format): + async def redirect_app(scope, receive, send): + assert scope["type"] == "http" + path = scope["path"].lstrip("/") + response = RedirectResponse(url=f"/playback/{format}/{path}") + await response(scope, receive, send) + + return redirect_app + + +# Some playback formats think they are served from /{format}/* instead of +# their proper /playback/{format}/* path, so we have to add a couple of +# redirects. +PLAYBACK_FORMAT_REDIRECTS = [ + Mount(f"/{format}", app=_make_redirect(format)) + for format in ("presentation", "video") +] + + +class PlaybackPlayerApp(StaticFiles): + """Serve the bbb-playback player from a local path. + + Should be munted to /playback/presentation/2.3/{record_id} so + the record id is no longer part of the path. + """ + + def __init__(self, config: BBBLBConfig, version="2.3") -> None: + self._config = config + root = config.PLAYBACK_PLAYER_ROOT + if not root: + root = config.PATH_DATA / "htdocs" / "playback" / "presentation" / version + + super().__init__(directory=root, follow_symlink=True, check_dir=False) + + +class PlaybackMediaApp(StaticFiles): + """Serve static files from the public recording directory, + optionally enforcing 'protected recording' restrictions. + + Should be munted to /playback (after PlaybackPlayerApp). + """ + + def __init__(self, config: BBBLBConfig, db: DBContext) -> None: + self._config = config + self._db = db + + super().__init__( + directory=self._config.PATH_DATA / "recordings" / "public", + follow_symlink=True, + check_dir=False, + ) + + async def get_response( + self, path: str, scope: MutableMapping[str, Any] + ) -> Response: + # TODO: presentation/2.3/{record_id} player requests + + if not self._config.PROTECTED_RECORDINGS: + return await super().get_response(path, scope) + + # Get record_id ouf of the requested path + if match := split_media_path(path): + format_name, record_id, resource = match + else: + raise HTTPException(status_code=404) + + # Allow access to unprotected assets (js, css, fonts, ...) + if is_unprotected_asset(format_name, resource): + return await super().get_response(path, scope) + + # Load recording + async with self._db.session() as session: + recording = await session.scalar( + model.Recording.select(record_id=record_id) + ) + + if not recording or recording.state is not model.RecordingState.PUBLISHED: + raise HTTPException(status_code=404) + + # Serve unprotected recordings + if not recording.protected: + return await super().get_response(path, scope) + + # Serve protected recordings to clients with a valid prt cookie + prt_cookie_name = PRT_COOKIE_PREFIX + record_id + prt_cookie = Request(scope).cookies.get(prt_cookie_name, "NO-COOKIE") + if recording.protected and verify_prt_cookie( + prt_cookie, record_id, self._config + ): + return await super().get_response(path, scope) + + return Response(status_code=403) diff --git a/docs/_generated/_config.rst b/docs/_generated/_config.rst index 60a9a7a..37fb50a 100644 --- a/docs/_generated/_config.rst +++ b/docs/_generated/_config.rst @@ -68,6 +68,26 @@ BBB publishes new recordings by default. If this setting is True, then BBBLB will import new recordings as 'unpublished' regardless of their original state. +``PROTECTED_RECORDINGS`` (type: ``bool``, default: ``False``) + +If enabled, BBBLB will support *protected recordings* similar to +the experimental and unofficial API extention implemented by +Scalelite and Greenlight. + +For protected recordings, the getRecordings API will replace +recording links with a one-time ticket that allow a single user to +watch the protected recording for a limited amount of time. + +Warning: This feature needs additional configuration if recordings +are not served through BBBLB, and does not prevent downloads. Read +the documentation to understand requirements and limitations of this +feature. + +``PROTECTED_RECORDINGS_TIMEOUT`` (type: ``int``, default: ``360``) + +Number of minutes a protected recording can be watched after the +ticket has been issued. + ``PLAYBACK_DOMAIN`` (type: ``str``, default: ``'{DOMAIN}'``) Domain where recordings are hostet. The wildcards {DOMAIN} or {REALM} diff --git a/docs/install.rst b/docs/install.rst index 0dcd7f0..cdd8207 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -146,7 +146,7 @@ There are multiple ways to tackle this: .. rubric:: Option 1: Piggyback on BBB -BBB already ships and serves the presentation player, so why not use that? Forward all requests for `/playback/presentation/2.3/*` to one of your BBB back-end servers. +BBB already ships and serves the presentation player, so why not use that? Proxy all requests for `/playback/presentation/2.3/*` to one of your BBB back-end servers. In nginx, this would look like this:: @@ -162,9 +162,66 @@ The player that comes with BBB expects media files in ``/presentation/{record_id .. rubric:: Option 2: Build and serve your own -You can of course also build and serve your own copy of `bbb-playback `__. The docker-compose API does exactly that. This has the added benefit that you can set ``REACT_APP_MEDIA_ROOT_URL=/playback/presentation/`` during build and skip the redirect from `/presentation/` explained earlier. +You can of course also build and serve your own copy of `bbb-playback `__. The docker-compose example does exactly that. This has the added benefit that you can set ``REACT_APP_MEDIA_ROOT_URL=/playback/presentation/`` during build and skip the `/presentation/` redirect explained earlier. + +If you want BBBLB to serve the player files, put them in ``{PATH_DATA}/htdocs/playback/presentation/2.3/``. But if you have a front-end webserver, it's usually best to serve those files directly from disk. + +In any case, remember to regularly check for updates. The player evolves alongside BBB and old versions may not be able to play new recordings. + + +Protected Recordings +==================== + +A *published* recording is accessable by anyone who knows the link, and those links are very hard to control or restrict. Scalelite introduced a non-standard API extention called *Protected recordings* to prevent users from sharing recording links with others, or at least make link-sharing more difficult. BBBLB also implements this feature, but slightly different. Read the following instructions very carefully or you risk not protecting your recordings at all. + +First, a disclaimer: *Protected recordings* only make link-sharing harder, they do not prevent users from downloading those files and share them offline, or upload them to other services. There is no real protection, skilled users will always be able to share recordings one way or the other. Keep that in mind. + +Now that we got this out of the way, let's talk about how *Protected recordings* work and what they actually do. + +Recordings are unprotected by default. When `PROTECTED_RECORDINGS` is enabled, then BBBLB will add the non-standard `true|false` XML tag to all recordings returned by the `getRecordings` API. Supporting front-end applications can now call `updateRecordings` with `protect=true` or `protect=false` to enable or disable protection for specific recordings. + +If a recording is marked as protected, then BBBLB will replace all playback links in `getRecordings` responses with a single-use ticket link. Only the first visitor will be able to exchange the ticket for a cookie, that will then allow them to watch the protected recording for the next `PROTECTED_RECORDINGS_TIMEOUT` minutes. The ticket-link continues to work for the user with the cookie, but all other visitors will get an error. + +**Warning!** If you configured your webserver to serve `/playback/*` media files directly from disk or use a CDN, proxy or cache, then you MUST ensure it is configure in a specific way for the protection to actually work: + +* If you defined `PLAYBACK_DOMAIN`, than this server MUST proxy requests for `/bbblb/api/v1/recording/ticket/*` to BBBLB. A redirect is not enough, the response must come from the same domain, or the cookie won't stick. You can ignore this if you serve recordings from the same domain as BBBLB itself. +* All requests to `/playback/*` MUST be authorized by BBBLB. For this, the webserver/proxy/CDN should send a copy of the original request (including cookies) to `/bbblb/api/v1/recording/auth/{original_uri}` and reject the original request if this auth check fails. Both nginx and caddy have built-in support for this type of auth check. +* Requests to `/playback/presentation/2.3/*` should NOT be checked against the auth API mentioned earlier. Yes, this is annoying, but that specific path should serve the static `bbb-playback`` presentation player assets and does not need protection. + + +For nginx, replace the `location /playback` block with these rules:: + + location /playback { + auth_request /bbblb_auth; + alias /path/to/recordings/public/; + } + + location = /bbblb_auth { + internal; + proxy_pass http://bbblb:8000/bbblb/api/v1/recording/auth/$request_uri; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + } + + # Only necessary if you have a separate PLAYBACK_DOMAIN configured. + location /bbblb/api/v1/recording/ticket/ { + proxy_pass http://bbblb:8000; + } + +For caddy, you only have to add a `forward_auth` line to the `handle_path /playback/*` block:: + + handle_path /playback/* { + forward_auth http://bbblb:8000/bbblb/api/v1/recording/auth/{uri} + root * /path/to/recordings/public + file_server + } + + # Only necessary if you have a separate PLAYBACK_DOMAIN configured. + reverse_proxy /bbblb/api/v1/recording/ticket/* bbblb:8000 + + +Note that this feature obviously adds a lot of overhead to every single media file request and can cause issues for popular recordings, even if those are not protected. The auth check will always be triggered and BBBLB always needs to check the protection status of the recording. Only enable this feature if you really need it. If you do, then test it! There are many things that can go wrong here. -Remember to regularly check for updates, because the player evolves alongside BBB and old versions may not be able to playback new recordings. Serving Static Files ==================== diff --git a/examples/bbblb-compose/bbblb.env.example b/examples/bbblb-compose/bbblb.env.example index 5270d62..aa55a29 100644 --- a/examples/bbblb-compose/bbblb.env.example +++ b/examples/bbblb-compose/bbblb.env.example @@ -68,6 +68,26 @@ # (default: False; type: bool) #BBBLB_RECORDING_IMPORT_UNPUBLISHED= +# If enabled, BBBLB will support *protected recordings* similar to +# the experimental and unofficial API extention implemented by +# Scalelite and Greenlight. +# +# For protected recordings, the getRecordings API will replace +# recording links with a one-time ticket that allow a single user to +# watch the protected recording for a limited amount of time. +# +# Warning: This feature needs additional configuration if recordings +# are not served through BBBLB, and does not prevent downloads. Read +# the documentation to understand requirements and limitations of this +# feature. +# (default: False; type: bool) +#BBBLB_PROTECTED_RECORDINGS= + +# Number of minutes a protected recording can be watched after the +# ticket has been issued. +# (default: 360; type: int) +#BBBLB_PROTECTED_RECORDINGS_TIMEOUT= + # Domain where recordings are hostet. The wildcards {DOMAIN} or {REALM} # can be used to refer to the global DOMAIN config, or the realm of the # current tenant. diff --git a/examples/bbblb-compose/caddy/Caddyfile b/examples/bbblb-compose/caddy/Caddyfile index 9bfb502..6e7eca5 100644 --- a/examples/bbblb-compose/caddy/Caddyfile +++ b/examples/bbblb-compose/caddy/Caddyfile @@ -24,20 +24,22 @@ example.com *.example.com { ## Serve recording files from a volume mounted into the container handle_path /playback/* { root * /usr/share/bbblb/recordings/public + ## Uncomment if you enabled PROTECTED_RECORDINGS + # forward_auth bbblb:8000/bbblb/api/v1/recording/auth/{uri} file_server } ## Serve 'presentation' files from a different path because the player ## expects them to be there by default. Not needed for the 'fixed' bundled ## player. - handle_path /presentation/* { - root * /usr/share/bbblb/recordings/public/presentation - file_server - } + # handle_path /presentation/* { + # root * /usr/share/bbblb/recordings/public/presentation + # file_server + # } ## For BBB Cluster Proxy setups you need to forward certain requests to the - ## back-end servers based on a path prefix. You need one of these for each - ## back-end server. Tell me if you know a better way! + ## back-end servers based on a path prefix. You need to repeat those rules for + # each back-end server. Tell me if you know a better way! #redir /bbb-01/html5client /bbb-01/html5client/?{query} #handle /bbb-01/html5client/* { # reverse_proxy https://bbb-01.example.com diff --git a/tests/conftest.py b/tests/conftest.py index 529e598..fa09fed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import logging import os from pathlib import Path +import lxml.etree import pytest import pytest_asyncio import bbblb.model @@ -51,7 +52,7 @@ def config(db_url, tmp_path): @pytest_asyncio.fixture(scope="function") async def services(config: bbblb.settings.BBBLBConfig): - services = await bbblb.services.bootstrap(config, autostart=False) + services = await bbblb.services.bootstrap(config) async with services: yield services @@ -75,10 +76,35 @@ async def orm(db: bbblb.services.db.DBContext): yield session +@pytest_asyncio.fixture(scope="function") +async def test_tenant(orm: bbblb.model.AsyncSession): + tenant = bbblb.model.Tenant(name="test", realm="bbb.example.com", secret="test") + orm.add(tenant) + await orm.commit() + return tenant + + +class BBBTestClient(TestClient): + def bbb_api_request(self, tenant: bbblb.model.Tenant, action: str, **query): + import bbblb.lib.bbb + + url = f"/bigbluebutton/api/{action}" + url += "?" + bbblb.lib.bbb.sign_query(action, query, tenant.secret) + rs = self.get(url=url, headers={"Host": tenant.realm}) + return rs + + def bbb_api_request_xml(self, tenant: bbblb.model.Tenant, action: str, **query): + rs = self.bbb_api_request(tenant, action, **query) + assert "xml" in rs.headers["content-type"] + xml = lxml.etree.fromstring(rs.content) + assert xml.findtext("returncode") == "SUCCESS" + return rs, xml + + @pytest.fixture(scope="function") def client(config: bbblb.settings.BBBLBConfig): app = bbblb.web.make_app(config, autostart=False) - with TestClient(app) as client: + with BBBTestClient(app, base_url=f"http://{config.DOMAIN}") as client: yield client diff --git a/tests/test_model.py b/tests/test_model.py index 8d84065..1439881 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -14,8 +14,8 @@ async def insert_testdata(orm: AsyncSession): record_id="123", external_id="123", state=model.RecordingState.PUBLISHED, - started=datetime.now(), - ended=datetime.now(), + started=model.utcnow(), + ended=model.utcnow(), tenant=tenant, ) playback = model.PlaybackFormat(recording=record, format="presentation", xml="") diff --git a/tests/test_protected_recordings.py b/tests/test_protected_recordings.py new file mode 100644 index 0000000..cb92b9a --- /dev/null +++ b/tests/test_protected_recordings.py @@ -0,0 +1,148 @@ +# Copyright (C) 2025, 2026 Marcel Hellkamp +# SPDX-License-Identifier: AGPL-3.0-or-later + +from datetime import timedelta +import uuid + +import pytest +import pytest_asyncio +from bbblb.lib.bbb import sign_query +from bbblb.services.tenants import TenantCache +from bbblb.settings import BBBLBConfig +from conftest import BBBTestClient, TestClient +import lxml.etree +from unittest.mock import MagicMock +import bbblb.web.bbbapi +from bbblb import model +from bbblb.services import ServiceRegistry +import bbblb.web.playback as bwp + + +@pytest.fixture(scope="function") +def config(config: BBBLBConfig): + config.PROTECTED_RECORDINGS = True + yield config + + +async def test_ticket(client: TestClient, orm: model.AsyncSession): + tenant = model.Tenant(name="test", realm="localhost", secret="1234") + recording = model.Recording( + tenant=tenant, + record_id="1234567890123456789012345678901234567890-1775488952", + external_id="foo", + state=model.RecordingState.PUBLISHED, + started=model.utcnow(), + ended=model.utcnow() + timedelta(minutes=5), + ) + ticket = model.ViewTicket.create(recording=recording, lifetime=timedelta(minutes=5)) + orm.add_all([tenant, recording, ticket]) + await orm.commit() + + # Fresh ticket + assert not ticket.consumed + assert not ticket.is_expired() + + # Consume ticket + await ticket.consume(orm, commit=True) + await orm.refresh(ticket) + assert ticket.consumed + assert not ticket.is_expired() + + # Expire ticket + ticket.expire -= timedelta(minutes=5) + await orm.commit() + await orm.refresh(ticket) + assert ticket.is_expired() + + # Ticket cleanup + await orm.execute(model.ViewTicket.delete_expired()) + assert (await orm.get(model.ViewTicket, ticket.uuid)) is None + + +async def test_get_recordings_protected( + client: BBBTestClient, orm: model.AsyncSession, config: BBBLBConfig +): + + tenant = model.Tenant(name="test", realm="localhost", secret="1234") + recording = model.Recording( + tenant=tenant, + record_id="1234567890123456789012345678901234567890-1775488952000", + external_id="foo", + state=model.RecordingState.PUBLISHED, + started=model.utcnow(), + ended=model.utcnow() + timedelta(minutes=5), + ) + playback_link = f"https://localhost/playback/presentation/2.3/{recording.record_id}" + playback = model.PlaybackFormat( + recording=recording, + format="presentation", + xml=f""" + presentation + {playback_link} + 56734 + 196310 + + + + Test Preview{playback_link}/presentation/0be1d53b8cc27d94d621b06d9171f0b35e6c0dad-1645733919254/thumbnails/thumb-1.png + + + + 2743009 + """, + ) + orm.add_all([tenant, recording, playback]) + await orm.commit() + + # Fetch recordings without protection + rs, xml = client.bbb_api_request_xml(tenant, "getRecordings") + assert xml.findtext("recordings/recording/protected") == "false" + link = xml.findtext("recordings/recording/playback/format/url") + assert link == playback_link + + # Protect recording + rs = client.bbb_api_request( + tenant, "updateRecordings", recordID=recording.record_id, protect="true" + ) + assert rs.is_success + await orm.refresh(recording) + assert recording.protected + + # Fetch recording again, this time it should be protected and return a ticket link + rs, xml = client.bbb_api_request_xml(tenant, "getRecordings") + assert xml.findtext("recordings/recording/protected") == "true" + ticket_link = xml.findtext("recordings/recording/playback/format/url") + assert ticket_link + assert ticket_link.startswith("https://localhost/bbblb/api/v1/recording/ticket/") + ticket_id, ticket_suffix = ticket_link.partition("/ticket/")[2].split("/", 1) + assert ticket_suffix == f"playback/presentation/2.3/{recording.record_id}" + + # Check ticket, it should be not expired or consumed + ticket = await orm.get(model.ViewTicket, uuid.UUID(ticket_id)) + assert ticket + assert not ticket.consumed + assert not ticket.is_expired() + + # Consume ticket + rs = client.get(ticket_link[17:], follow_redirects=False) + assert rs.has_redirect_location + assert rs.headers["Location"] == playback_link + + # Ticket should now be consumed + await orm.refresh(ticket) + assert ticket.consumed + + # Cookie should be present and valid + cookie_name = bwp.PRT_COOKIE_PREFIX + recording.record_id + cookie = rs.cookies[cookie_name] + assert cookie + assert bwp.verify_prt_cookie(cookie, recording.record_id, config) + + # Accessing the ticket link again (with cookie) should succeed + rs = client.get(ticket_link[17:], follow_redirects=False) + assert rs.is_redirect + + # Accessing the ticket link without cookie should fail + client.cookies.clear() + rs = client.get(ticket_link[17:], follow_redirects=False) + assert rs.is_client_error diff --git a/tests/test_recordings.py b/tests/test_recordings.py index aa11c3c..50ee2ed 100644 --- a/tests/test_recordings.py +++ b/tests/test_recordings.py @@ -55,14 +55,6 @@ async def run_import(rm: RecordingManager, name, force_tenant=None): return task, rec_meta -@pytest_asyncio.fixture(scope="function") -async def test_tenant(orm: AsyncSession): - tenant = model.Tenant(name="test", realm="bbb.example.com", secret="test") - orm.add(tenant) - await orm.commit() - return tenant - - @pytest.mark.parametrize("rec_name", ["video", "presentation"]) async def test_import( rec_name: str,