diff --git a/apps/api/schedule.py b/apps/api/schedule.py index a85046119..a09be2a6c 100644 --- a/apps/api/schedule.py +++ b/apps/api/schedule.py @@ -7,6 +7,7 @@ from . import api from main import db +from models import event_year from models.cfp import Proposal from models.ical import CalendarEvent from models.admin_message import AdminMessage @@ -16,6 +17,9 @@ def _require_video_api_key(func): @wraps(func) def wrapper(*args, **kwargs): + if not app.config.get("VIDEO_API_KEY"): + abort(401) + auth_header = request.headers.get("authorization", None) if not auth_header or not auth_header.startswith("Bearer "): abort(401) @@ -147,9 +151,7 @@ def post(self, proposal_type): current_tickets = { t.id: t - for t in EventTicket.query.filter_by( - state="entered-lottery", user_id=current_user.id - ).all() + for t in EventTicket.query.filter_by(state="entered-lottery", user_id=current_user.id).all() if t.proposal.type == proposal_type } @@ -169,6 +171,101 @@ def post(self, proposal_type): return [t.id for t in res] +class ProposalC3VOCPublishingWebhook(Resource): + method_decorators = {"post": [_require_video_api_key]} + + def post(self): + if not request.is_json: + abort(415) + + payload = request.get_json() + + try: + conference = payload["fahrplan"]["conference"] + proposal_id = payload["fahrplan"]["id"] + + if not payload["is_master"]: + # c3voc *should* only send us information about the master + # encoding getting published. Aborting early ensures we don't + # accidentially delete video information from the database. + abort(403, message="The request referenced a non-master video edit, and has been denied.") + + if conference != f"emf{event_year()}": + abort( + 422, + message="The request did not reference the current event year, and has not been processed.", + ) + + proposal = Proposal.query.get_or_404(proposal_id) + + if payload["voctoweb"]["enabled"]: + if payload["voctoweb"]["frontend_url"]: + c3voc_url = payload["voctoweb"]["frontend_url"] + if not c3voc_url.startswith("https://media.ccc.de/"): + abort(406, message="voctoweb frontend_url must start with https://media.ccc.de/") + app.logger.info(f"C3VOC webhook set c3voc_url for {proposal.id=} to {c3voc_url}") + proposal.c3voc_url = c3voc_url + proposal.video_recording_lost = False + else: + # This allows c3voc to notify us if videos got depublished + # as well. We do not explicitely set 'video_recording_lost' + # here because the video might only need fixing audio or + # such. + app.logger.warning( + f"C3VOC webhook cleared c3voc_url for {proposal.id=}, was {proposal.c3voc_url}" + ) + proposal.c3voc_url = None + + if payload["voctoweb"]["thumb_path"]: + path = payload["voctoweb"]["thumb_path"] + if path.startswith("/static.media.ccc.de"): + path = "https://static.media.ccc.de/media" + path[len("/static.media.ccc.de"):] + if not path.startswith("https://"): + abort(406, message="voctoweb thumb_path must start with https:// or /static.media.ccc.de") + app.logger.info(f"C3VOC webhook set thumbnail_url for {proposal.id=} to {path}") + proposal.thumbnail_url = path + else: + app.logger.warning( + f"C3VOC webhook cleared thumbnail_url for {proposal.id=}, was {proposal.thumbnail_url}" + ) + proposal.thumbnail_url = None + + if payload["youtube"]["enabled"]: + if payload["youtube"]["urls"]: + # Please do not overwrite existing youtube urls + youtube_url = payload["youtube"]["urls"][0] + if not youtube_url.startswith("https://www.youtube.com/watch"): + abort(406, message="youtube url must start with https://www.youtube.com/watch") + if not proposal.youtube_url: + # c3voc will send us a list, even though we only have one + # video. + app.logger.info(f"C3VOC webhook set youtube_url for {proposal.id=} to {youtube_url}") + proposal.youtube_url = youtube_url + proposal.video_recording_lost = False + elif proposal.youtube_url not in payload["youtube"]["urls"]: + # c3voc sent us some urls, but none of them are matching + # the url we have in our database. + app.logger.warning( + "C3VOC webhook sent youtube urls update without referencing the previously stored value. Ignoring." + ) + app.logger.debug( + f"{proposal.id=} {payload['youtube']['urls']=} {proposal.youtube_url=}" + ) + else: + # see comment at c3voc_url above + app.logger.warning( + f"C3VOC webhook cleared youtube_url for {proposal.id=}, was {proposal.youtube_url}" + ) + proposal.youtube_url = None + + db.session.add(proposal) + db.session.commit() + except KeyError as e: + abort(400, message=f"Missing required field: {e}") + + return "OK", 204 + + def renderScheduleMessage(message): return {"id": message.id, "body": message.message} @@ -186,3 +283,4 @@ def get(self): api.add_resource(FavouriteExternal, "/external//favourite") api.add_resource(ScheduleMessage, "/schedule_messages") api.add_resource(UpdateLotteryPreferences, "/schedule/tickets//preferences") +api.add_resource(ProposalC3VOCPublishingWebhook, "/proposal/c3voc-publishing-webhook") diff --git a/tests/test_api_c3voc.py b/tests/test_api_c3voc.py new file mode 100644 index 000000000..3a2e99e52 --- /dev/null +++ b/tests/test_api_c3voc.py @@ -0,0 +1,557 @@ +import pytest + +from models import event_year +from models.cfp import Proposal, TalkProposal + + +@pytest.fixture(scope="module") +def proposal(db, user): + proposal = TalkProposal() + proposal.title = "Title" + proposal.description = "Description" + proposal.user = user + + db.session.add(proposal) + db.session.commit() + + return proposal + + +def clean_proposal(db, proposal, c3voc_url=None, youtube_url=None, thumbnail_url=None, video_recording_lost=True): + proposal.c3voc_url = c3voc_url + proposal.thumbnail_url = thumbnail_url + proposal.video_recording_lost = video_recording_lost + proposal.youtube_url = youtube_url + db.session.add(proposal) + db.session.commit() + + +def test_denies_request_without_api_key(client, app, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + json={ + "is_master": True, + "fahrplan": { + "conference": "emf1970", + "id": 0, + }, + "voctoweb": { + "enabled": False, + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 401 + + +def test_denies_request_no_master(client, app, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": False, + "fahrplan": { + "conference": "emf1970", + "id": 0, + }, + "voctoweb": { + "enabled": False, + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 403 + + +def test_denies_request_wrong_year(client, app, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": "emf1970", + "id": 0, + }, + "voctoweb": { + "enabled": False, + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 422 + + +def test_request_none_unchanged(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal) + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": False, + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + assert proposal.youtube_url is None + assert proposal.c3voc_url is None + + +def test_update_voctoweb_with_correct_url(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal) + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": True, + "frontend_url": "https://media.ccc.de/", + "thumb_path": "", + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + assert proposal.c3voc_url == "https://media.ccc.de/" + assert proposal.video_recording_lost == False + assert proposal.youtube_url is None + + +def test_denies_voctoweb_with_wrong_url(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal, c3voc_url="https://example.com") + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": True, + "frontend_url": "https://example.org", + "thumb_path": "", + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 406 + + proposal = Proposal.query.get(proposal.id) + # clean_proposal sets this to true, the api should not change that + assert proposal.video_recording_lost == True + assert proposal.c3voc_url == "https://example.com" + + +def test_clears_voctoweb(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal, c3voc_url="https://example.com") + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": True, + "frontend_url": "", + "thumb_path": "", + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + assert proposal.c3voc_url is None + + +def test_update_thumbnail_with_path(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal) + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": True, + "frontend_url": "", + "thumb_path": "/static.media.ccc.de/thumb.jpg", + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + assert proposal.thumbnail_url == "https://static.media.ccc.de/media/thumb.jpg" + assert proposal.c3voc_url is None + assert proposal.youtube_url is None + + +def test_update_thumbnail_with_url(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal) + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": True, + "frontend_url": "", + "thumb_path": "https://example.com/thumb.jpg", + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + assert proposal.thumbnail_url == "https://example.com/thumb.jpg" + assert proposal.c3voc_url is None + assert proposal.youtube_url is None + + +def test_denies_thumbnail_not_url(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal, thumbnail_url="https://example.com/thumb.jpg") + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": True, + "frontend_url": "", + "thumb_path": "/example.com/thumb.jpg", + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 406 + + proposal = Proposal.query.get(proposal.id) + assert proposal.thumbnail_url == "https://example.com/thumb.jpg" + + +def test_clears_thumbnail(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal, thumbnail_url="https://example.com/thumb.jpg") + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": True, + "frontend_url": "", + "thumb_path": "", + }, + "youtube": { + "enabled": False, + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + assert proposal.thumbnail_url is None + + +def test_update_youtube_with_correct_url(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal) + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": False, + }, + "youtube": { + "enabled": True, + "urls": [ + "https://www.youtube.com/watch", + ], + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + assert proposal.c3voc_url is None + assert proposal.video_recording_lost == False + assert proposal.youtube_url == "https://www.youtube.com/watch" + + +def test_denies_youtube_update_with_exisiting_url(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal, youtube_url="https://example.com") + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": False, + }, + "youtube": { + "enabled": True, + "urls": [ + "https://www.youtube.com/watch", + ], + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + # clean_proposal sets this to true, the api should not change that + assert proposal.video_recording_lost == True + assert proposal.youtube_url == "https://example.com" + + +def test_denies_youtube_update_with_wrong_url(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal, youtube_url="https://example.com") + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": False, + }, + "youtube": { + "enabled": True, + "urls": [ + "https://example.org", + ], + }, + }, + ) + assert rv.status_code == 406 + + proposal = Proposal.query.get(proposal.id) + # clean_proposal sets this to true, the api should not change that + assert proposal.video_recording_lost == True + assert proposal.youtube_url == "https://example.com" + + +def test_clears_youtube(client, app, db, proposal): + app.config.update( + { + "VIDEO_API_KEY": "api-key", + } + ) + + clean_proposal(db, proposal, youtube_url="https://example.com") + + rv = client.post( + f"/api/proposal/c3voc-publishing-webhook", + headers={ + "Authorization": "Bearer api-key", + }, + json={ + "is_master": True, + "fahrplan": { + "conference": f"emf{event_year()}", + "id": proposal.id, + }, + "voctoweb": { + "enabled": False, + }, + "youtube": { + "enabled": True, + "urls": [], + }, + }, + ) + assert rv.status_code == 204 + + proposal = Proposal.query.get(proposal.id) + assert proposal.youtube_url is None