summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--__init__.py6
-rw-r--r--assets/first-blood.pngbin0 -> 141954 bytes
-rw-r--r--assets/presenter.css221
-rw-r--r--assets/presenter.js70
-rw-r--r--presenter.py67
-rw-r--r--templates/presenter.html67
6 files changed, 431 insertions, 0 deletions
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..5e649cb
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,6 @@
+from CTFd.plugins import register_plugin_assets_directory
+from .presenter import presenter_blueprint
+
+def load(app):
+ register_plugin_assets_directory(app, base_path='/plugins/ctfd-presenter/assets/')
+ app.register_blueprint(presenter_blueprint)
diff --git a/assets/first-blood.png b/assets/first-blood.png
new file mode 100644
index 0000000..d36262d
--- /dev/null
+++ b/assets/first-blood.png
Binary files differ
diff --git a/assets/presenter.css b/assets/presenter.css
new file mode 100644
index 0000000..ee5bdec
--- /dev/null
+++ b/assets/presenter.css
@@ -0,0 +1,221 @@
+@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap');
+
+html, body {
+ margin: 0;
+ height: 100%;
+ background: #000;
+ overflow: hidden;
+}
+
+#scoreboard-frame {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
+#solve-overlay {
+ position: fixed;
+ inset: 0;
+ background: #050505;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ z-index: 10;
+ pointer-events: none;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+#solve-overlay.visible {
+ opacity: 1;
+}
+
+#solve-text {
+ text-align: center;
+ font-family: "Source Code Pro";
+ letter-spacing: 0.05em;
+ color: white;
+}
+
+#solve-first {
+ display: none;
+}
+
+#solve-first.visible {
+ display: flex;
+ height: 35vh;
+ margin: -5vh 0 -2vh 0;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+}
+
+#solve-first img {
+ z-index: 99999;
+ position: relative;
+ height: 100%
+}
+
+#solve-team {
+ white-space: nowrap;
+ font-size: 15vh;
+ font-weight: 900;
+ letter-spacing: 4px;
+}
+
+#solve-value {
+ margin-top: 2vh;
+ font-weight: 700;
+ font-size: 10vh;
+}
+
+#solve-challenge {
+ margin-top: 2vh;
+ font-weight: 400;
+ font-size: 10vh;
+ letter-spacing: -0.1em;
+}
+
+
+
+.glitch {
+ position: relative;
+}
+
+.glitch::before,
+.glitch::after {
+ content: attr(data-text);
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: #050505;
+}
+
+.glitch::before {
+ left: 3px;
+ text-shadow: -2px 0 #ff00c1;
+ clip-path: inset(44% 0 61% 0);
+ animation: glitch-anim 2s infinite linear alternate-reverse;
+}
+
+.glitch::after {
+ left: -3px;
+ text-shadow: -2px 0 #00fff9;
+ clip-path: inset(54% 0 10% 0);
+ animation: glitch-anim 2.5s infinite linear alternate-reverse;
+}
+
+@keyframes glitch-anim {
+ 0% { clip-path: inset(80% 0 10% 0); }
+ 20% { clip-path: inset(10% 0 70% 0); }
+ 40% { clip-path: inset(50% 0 20% 0); }
+ 60% { clip-path: inset(10% 0 60% 0); }
+ 80% { clip-path: inset(30% 0 40% 0); }
+ 100% { clip-path: inset(60% 0 5% 0); }
+}
+
+
+
+.scanlines {
+ overflow: hidden;
+ mix-blend-mode: difference;
+}
+
+.scanlines::before {
+ content: "";
+ position: absolute;
+ width: 100%;
+ height: 150%;
+ top: -25%;
+ left: 0;
+ background: repeating-linear-gradient(to bottom, transparent 0%, rgba(255, 255, 255, 0.02) 0.25%, transparent 0.5%);
+ animation: fudge 5s linear infinite;
+}
+
+@keyframes fudge {
+ from {
+ transform: translate(0px, 0);
+ }
+ to {
+ transform: translate(0px, 5%);
+ }
+}
+
+
+
+.sparkler-light {
+ width: 9rem;
+ height: 100%;
+ position: relative;
+ top: -10vh;
+}
+
+.spark {
+ position: absolute;
+ width: 0.1rem;
+ height: 0.1rem;
+ bottom: 4.3rem;
+ left: 4.35rem;
+ transform: rotate(var(--spark-rotate));
+}
+
+.spark::after {
+ content: '';
+ display: block;
+ position: absolute;
+ bottom: 0;
+ width: 0.1rem;
+ height: 2rem;
+ border-radius: 0.1rem;
+ opacity: 0;
+ background-color: white;
+ transform-origin: bottom center;
+ animation: sparkler-sparkle var(--spark-duration) infinite linear;
+ animation-delay: var(--spark-delay);
+}
+
+@keyframes sparkler-light-pulsating {
+ 0% {
+ transform: scale(1);
+ }
+ 25% {
+ transform: scale(1.1);
+ }
+ 75% {
+ transform: scale(0.9);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes sparkler-sparkle {
+ 0% {
+ transform: translateY(-0.5vh) scaleY(0.25);
+ opacity: 0;
+ }
+ 10% {
+ transform: translateY(-3vh) scaleY(0.5);
+ opacity: 0.35;
+ }
+ 30% {
+ transform: translateY(-6vh) scaleY(0.5);
+ opacity: 0.7;
+ }
+ 50% {
+ transform: translateY(-10vh) scaleY(1.5);
+ opacity: 0.7;
+ }
+ 51% {
+ opacity: 0;
+ transform: translateY(-4vh) scaleY(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(0) scaleY(0.25);
+ }
+}
diff --git a/assets/presenter.js b/assets/presenter.js
new file mode 100644
index 0000000..bfe41b8
--- /dev/null
+++ b/assets/presenter.js
@@ -0,0 +1,70 @@
+const overlayEl = document.getElementById("solve-overlay");
+const firstBloodEl = document.getElementById("solve-first");
+const teamEl = document.getElementById("solve-team");
+const challengeEl = document.getElementById("solve-challenge");
+const valueEl = document.getElementById("solve-value");
+const scoreboardFrameEl = document.getElementById("scoreboard-frame");
+
+const solveQueue = [];
+let isShowingOverlay = false;
+
+function pollNextSolve() {
+ if (isShowingOverlay) {
+ return;
+ }
+
+ if (solveQueue.length > 0) {
+ const nextSolve = solveQueue.shift();
+ showSolve(nextSolve.team, nextSolve.challenge, nextSolve.value, nextSolve.first);
+ }
+}
+
+function showSolve(team, challenge, value, first) {
+ teamEl.textContent = team;
+ teamEl.setAttribute("data-text", team);
+ challengeEl.textContent = challenge;
+ challengeEl.setAttribute("data-text", challenge);
+ valueEl.textContent = `+${value}`;
+ valueEl.setAttribute("data-text", `+${value}`);
+
+ if (first) {
+ firstBloodEl.classList.add("visible");
+ }
+
+ overlayEl.classList.add("visible");
+ isShowingOverlay = true;
+
+ setTimeout(() => {
+ overlayEl.classList.remove("visible");
+ firstBloodEl.classList.remove("visible");
+
+ setTimeout(() => {
+ isShowingOverlay = false;
+ pollNextSolve();
+ }, 1000);
+ }, first ? 8500 : 4500);
+}
+
+let reloadTimeout = null;
+
+function reloadScoreboard(delay = 1000) {
+ if (reloadTimeout) {
+ clearTimeout(reloadTimeout);
+ }
+
+ reloadTimeout = setTimeout(() => {
+ scoreboardFrameEl.src = "/scoreboard?ts=" + Date.now();
+ reloadTimeout = null;
+ }, delay);
+}
+
+const es = new EventSource("/presenter/events");
+
+es.onmessage = (e) => {
+ const data = JSON.parse(e.data);
+ if (data.type === "solve") {
+ solveQueue.push(data);
+ reloadScoreboard();
+ pollNextSolve();
+ }
+};
diff --git a/presenter.py b/presenter.py
new file mode 100644
index 0000000..613bd34
--- /dev/null
+++ b/presenter.py
@@ -0,0 +1,67 @@
+import json
+import queue
+from flask import Blueprint, Response, render_template
+from sqlalchemy import event
+
+from CTFd.models import Solves, Challenges, Teams
+from CTFd.utils.scores import get_standings
+
+presenter_blueprint = Blueprint(
+ "presenter",
+ __name__,
+ template_folder="templates"
+)
+
+listeners = []
+
+
+def sse_format(data):
+ return f"data: {json.dumps(data)}\n\n"
+
+
+@presenter_blueprint.route("/presenter")
+def presenter_view():
+ return render_template("presenter.html")
+
+
+@presenter_blueprint.route("/presenter/events")
+def presenter_events():
+ def stream():
+ q = queue.Queue()
+ listeners.append(q)
+ try:
+ while True:
+ data = q.get()
+ yield sse_format(data)
+ except GeneratorExit:
+ listeners.remove(q)
+
+ return Response(stream(), mimetype="text/event-stream")
+
+
+def broadcast(event):
+ for q in listeners:
+ q.put(event)
+
+
+@event.listens_for(Solves, "after_insert")
+def on_solve(mapper, connection, solve):
+ challenge = Challenges.query.get(solve.challenge_id)
+ team = Teams.query.get(solve.team_id)
+
+ if not challenge or not team:
+ return
+
+ first_blood = False
+ if Solves.query.filter_by(challenge_id=solve.challenge_id).count() == 1:
+ first_blood = True
+
+ broadcast(
+ {
+ "type": "solve",
+ "team": team.name,
+ "challenge": challenge.name,
+ "value": challenge.value,
+ "first": first_blood
+ }
+ )
diff --git a/templates/presenter.html b/templates/presenter.html
new file mode 100644
index 0000000..010393c
--- /dev/null
+++ b/templates/presenter.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Presenter mode</title>
+ <link href="/plugins/ctfd-presenter/assets/presenter.css" rel="stylesheet" />
+</head>
+
+<body>
+ <iframe
+ id="scoreboard-frame"
+ src="/scoreboard"
+ loading="eager">
+ </iframe>
+
+ <div id="solve-overlay">
+ <div id="solve-text">
+ <div id="solve-first">
+ <img src="/plugins/ctfd-presenter/assets/first-blood.png" />
+ <div class="sparkler-light">
+ <div class="spark" style="--spark-rotate: 10deg; --spark-delay: 223ms; --spark-duration: 750ms"></div>
+ <div class="spark" style="--spark-rotate: 20deg; --spark-delay: 844ms; --spark-duration: 775ms"></div>
+ <div class="spark" style="--spark-rotate: 30deg; --spark-delay: 130ms; --spark-duration: 780ms"></div>
+ <div class="spark" style="--spark-rotate: 40deg; --spark-delay: 747ms; --spark-duration: 725ms"></div>
+ <div class="spark" style="--spark-rotate: 50deg; --spark-delay: 928ms; --spark-duration: 740ms"></div>
+ <div class="spark" style="--spark-rotate: 60deg; --spark-delay: 392ms; --spark-duration: 770ms"></div>
+ <div class="spark" style="--spark-rotate: 70deg; --spark-delay: 483ms; --spark-duration: 735ms"></div>
+ <div class="spark" style="--spark-rotate: 80deg; --spark-delay: 621ms; --spark-duration: 755ms"></div>
+ <div class="spark" style="--spark-rotate: 90deg; --spark-delay: 814ms; --spark-duration: 715ms"></div>
+ <div class="spark" style="--spark-rotate: 100deg; --spark-delay: 802ms; --spark-duration: 730ms"></div>
+ <div class="spark" style="--spark-rotate: 110deg; --spark-delay: 837ms; --spark-duration: 785ms"></div>
+ <div class="spark" style="--spark-rotate: 120deg; --spark-delay: 238ms; --spark-duration: 790ms"></div>
+ <div class="spark" style="--spark-rotate: 130deg; --spark-delay: 642ms; --spark-duration: 740ms"></div>
+ <div class="spark" style="--spark-rotate: 140deg; --spark-delay: 58ms; --spark-duration: 715ms"></div>
+ <div class="spark" style="--spark-rotate: 150deg; --spark-delay: 404ms; --spark-duration: 775ms"></div>
+ <div class="spark" style="--spark-rotate: 160deg; --spark-delay: 576ms; --spark-duration: 725ms"></div>
+ <div class="spark" style="--spark-rotate: 170deg; --spark-delay: 944ms; --spark-duration: 755ms"></div>
+ <div class="spark" style="--spark-rotate: 180deg; --spark-delay: 635ms; --spark-duration: 785ms"></div>
+ <div class="spark" style="--spark-rotate: 190deg; --spark-delay: 205ms; --spark-duration: 770ms"></div>
+ <div class="spark" style="--spark-rotate: 200deg; --spark-delay: 91ms; --spark-duration: 795ms"></div>
+ <div class="spark" style="--spark-rotate: 210deg; --spark-delay: 829ms; --spark-duration: 745ms"></div>
+ <div class="spark" style="--spark-rotate: 220deg; --spark-delay: 369ms; --spark-duration: 730ms"></div>
+ <div class="spark" style="--spark-rotate: 230deg; --spark-delay: 861ms; --spark-duration: 780ms"></div>
+ <div class="spark" style="--spark-rotate: 240deg; --spark-delay: 201ms; --spark-duration: 735ms"></div>
+ <div class="spark" style="--spark-rotate: 250deg; --spark-delay: 173ms; --spark-duration: 755ms"></div>
+ <div class="spark" style="--spark-rotate: 260deg; --spark-delay: 967ms; --spark-duration: 765ms"></div>
+ <div class="spark" style="--spark-rotate: 270deg; --spark-delay: 548ms; --spark-duration: 715ms"></div>
+ <div class="spark" style="--spark-rotate: 280deg; --spark-delay: 392ms; --spark-duration: 775ms"></div>
+ <div class="spark" style="--spark-rotate: 290deg; --spark-delay: 973ms; --spark-duration: 740ms"></div>
+ <div class="spark" style="--spark-rotate: 300deg; --spark-delay: 6ms; --spark-duration: 725ms"></div>
+ <div class="spark" style="--spark-rotate: 310deg; --spark-delay: 1ms; --spark-duration: 760ms"></div>
+ <div class="spark" style="--spark-rotate: 320deg; --spark-delay: 854ms; --spark-duration: 735ms"></div>
+ <div class="spark" style="--spark-rotate: 330deg; --spark-delay: 159ms; --spark-duration: 750ms"></div>
+ <div class="spark" style="--spark-rotate: 340deg; --spark-delay: 60ms; --spark-duration: 780ms"></div>
+ <div class="spark" style="--spark-rotate: 350deg; --spark-delay: 986ms; --spark-duration: 765ms"></div>
+ <div class="spark" style="--spark-rotate: 360deg; --spark-delay: 559ms; --spark-duration: 740ms"></div>
+ </div>
+ </div>
+ <div id="solve-team" class="glitch"></div>
+ <div id="solve-value" class="glitch"></div>
+ <div id="solve-challenge" class="glitch"></div>
+ </div>
+ <div class="scanlines"></div>
+ </div>
+
+ <script src="/plugins/ctfd-presenter/assets/presenter.js"></script>
+</body>
+</html>