diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..025a2a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:23-alpine + +RUN mkdir /app + +WORKDIR /app + +COPY package.json /app +RUN npm install + +COPY models/ /app/models/ +COPY views/ /app/views/ +COPY public/ /app/public/ + +COPY app.js /app/ + +ENV NODE_ENV=production + +ENV HOST=0.0.0.0 +ENV PORT=5050 + +ENV TITLE="Hall X" +ENV PREFIX= +#ENV HALLS="/hall-a:Hall A;/hall-b:Hall B" + +CMD node app.js diff --git a/Procfile b/Procfile deleted file mode 100644 index e1d4131..0000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: node app.js diff --git a/README.md b/README.md index 6c2e411..b6a89e3 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,49 @@ ## Deployment -Clone to `/opt/timer`. -Put `openfest-timer@.service` in `/etc/systemd/system`. -Requires NodeJS and npm. +### Configuration -```bash -systemctl daemon-reload -npm install -systemctl enable --now openfest-timer@hall-a.service -systemctl enable --now openfest-timer@hall-b.service +Configuration is done by environmental variables: + +```yaml +HALLS: "/hall-a:Hall A;/hall-b:Hall B" +TITLE: "Hall A" # not in use if HALLS is set to non-empty +PREFIX: /hall-a # not in use if HALLS is set to non-empty +BASIC_AUTH: "admin:password" # user:plaintext-password ``` +### Prod Deployment + +```sh +cd timer-repo/ +git pull +podman build -t timer . + +cat > .config/containers/systemd/timer.container < { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return res.set("WWW-Authenticate", "Basic realm='timer'").status(401).send('Unauthorized'); + } + else { + const authTokens = authHeader.split(' '); + if (authTokens[0] === 'Basic') { + const credentials = Buffer.from(authTokens[1], 'base64').toString('utf8'); + + if (credentials !== process.env.BASIC_AUTH) { // this is obviously not the most secure way, but we don't care + return res.status(401).send('Unauthorized'); + } + else { + next(); + } + } else { + return res.status(401).send('Unauthorized'); + } + } +}; // Configuration +if (process.env.BASIC_AUTH) { + app.use(auth); +} + app.set('views', __dirname + '/views'); app.set('view engine', 'ejs'); -app.use(expressLayouts); -app.use(express.static(__dirname + '/public')); - if (process.env.NODE_ENV === 'development') { app.use(errorHandler({ dumpExceptions: true, showStack: true })); } @@ -24,95 +47,119 @@ if (process.env.NODE_ENV === 'production') { } -// Use the port that Heroku provides or default to 5000 +// Use the port and host var port = process.env.PORT || 5000; var host = process.env.HOST || '0.0.0.0'; +var prefix = process.env.PREFIX || ''; + +const halls = (process.env.HALLS || ((process.env.PREFIX || "") + ":" + (process.env.TITLE || "TIMER"))).replace("; ?$", "").split(';').map(tuple => { + const parts = tuple.split(':'); + return {"prefix": parts[0].trim(), "title": parts[1].trim()}; +}); + +console.log("Configuring halls:", halls) + +halls.map(hall => { + const title = hall["title"] + const prefix = hall["prefix"] + const router = express.Router(); + + const io = require('socket.io')(server, {path: prefix + '/socket.io'}) + + router.use(expressLayouts); + router.use(express.static(__dirname + '/public')); + + var stopwatch = new Stopwatch(); + + stopwatch.on('tick:stopwatch', function(time) { + io.sockets.emit('time', { time: time }); + }); + + stopwatch.on('reset:stopwatch', function(time) { + io.sockets.emit('time', { time: time }); + }); + + // stopwatch.start(); // we probably don't want autostart on server start + + io.sockets.on('connection', function (socket) { + io.sockets.emit('time', { time: stopwatch.getTime() }); + + socket.on('click:start', function () { + stopwatch.start(); + }); + + socket.on('click:stop', function () { + stopwatch.stop(); + }); + + socket.on('click:zero', function () { + stopwatch.zero(); + }); + + socket.on('click:reset', function () { + stopwatch.reset(); + }); + + socket.on('click:resetShort', function () { + stopwatch.resetShort(); + }); + }); + + + router.get('/', function(req, res) { + res.render('index', { title: title, prefix: prefix }); + }); + + + const control = express.Router(); + + control.get('/', function(req, res) { + res.render('control', { title: title, prefix: prefix }); + }); + control.post('/reset/', function (req, res) { + stopwatch.reset(); + res.send("OK"); + }); + control.post('/reset-short/', function (req, res) { + stopwatch.resetShort(); + res.send("OK"); + }); + control.post('/start-from-reset/', function (req, res) { + stopwatch.reset(); + stopwatch.start(); + res.send("OK"); + }); + control.post('/start-from-reset-short/', function (req, res) { + stopwatch.resetShort(); + stopwatch.start(); + res.send("OK"); + }); + control.post('/start/', function (req, res) { + stopwatch.start(); + res.send("OK"); + }); + control.post('/stop/', function (req, res) { + stopwatch.stop(); + res.send("OK"); + }); + control.post('/zero/', function (req, res) { + stopwatch.zero(); + res.send("OK"); + }); + + router.use('/control', control); + + app.use(prefix, router); +}); + server.listen(port, host, function() { console.log("Express server listening on %j in %s mode", server.address(), app.settings.env); }); -var stopwatch = new Stopwatch(); - -stopwatch.on('tick:stopwatch', function(time) { - io.sockets.emit('time', { time: time }); -}); - -stopwatch.on('reset:stopwatch', function(time) { - io.sockets.emit('time', { time: time }); -}); - -stopwatch.start(); - -io.sockets.on('connection', function (socket) { - io.sockets.emit('time', { time: stopwatch.getTime() }); - - socket.on('click:start', function () { - stopwatch.start(); +if (halls.length != 1 && halls[0]["prefix"] != "") { + app.get('/', function(req, res) { + res.render("list", { layout: false, halls: halls }); }); +} - socket.on('click:stop', function () { - stopwatch.stop(); - }); - - socket.on('click:zero', function () { - stopwatch.zero(); - }); - - socket.on('click:reset', function () { - stopwatch.reset(); - }); - - socket.on('click:resetShort', function () { - stopwatch.resetShort(); - }); -}); - - -// configure title -//app.use(function (req, res, next) { -// res.locals.title = process.env.TITLE || 'Timer' -//}); - -app.get('/', function(req, res) { - res.render('index', { title: process.env.TITLE }); -}); - - -const control = express.Router(); - -control.get('/', function(req, res) { - res.render('control', { title: process.env.TITLE }); -}); -control.post('/reset/', function (req, res) { - stopwatch.reset(); - res.send("OK"); -}); -control.post('/reset-short/', function (req, res) { - stopwatch.resetShort(); - res.send("OK"); -}); -control.post('/start-from-reset/', function (req, res) { - stopwatch.reset(); - stopwatch.start(); - res.send("OK"); -}); -control.post('/start-from-reset-short/', function (req, res) { - stopwatch.resetShort(); - stopwatch.start(); - res.send("OK"); -}); -control.post('/start/', function (req, res) { - stopwatch.start(); - res.send("OK"); -}); -control.post('/stop/', function (req, res) { - stopwatch.stop(); - res.send("OK"); -}); -control.post('/zero/', function (req, res) { - stopwatch.zero(); - res.send("OK"); -}); - -app.use('/control', control); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01765cd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + timer: + build: + dockerfile: Dockerfile + context: . + environment: + HOST: 0.0.0.0 + #TITLE: "Hall A" + #PREFIX: /hall-a + #HALLS: "/hall-a:Hall A;/hall-b:Hall B" + #BASIC_AUTH: "admin:password" + ports: + - 127.0.0.1:5050:5050 diff --git a/openfest-timer@.service b/openfest-timer@.service deleted file mode 100644 index df054c9..0000000 --- a/openfest-timer@.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=OpenFest Timer (%i) -After=network.target - -[Service] -EnvironmentFile=/opt/timer/%i.env -Type=simple -Restart=on-failure -WorkingDirectory=/opt/timer -ExecStart=/usr/bin/env node app.js - -[Install] -WantedBy=multi-user.target diff --git a/package.json b/package.json index 63a1f33..92c1f38 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,6 @@ "underscore": "^1.13.6" }, "engines": { - "node": "0.6.x" + "node": "23.0" } } diff --git a/public/css/main.css b/public/css/main.css index e865142..3be6e77 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1,10 +1,21 @@ -#wrapper { +.wrapper { + width: 95%; + height: 100%; + margin: 30vh 14vw; +} + +.wrapper #countdown { + font-family: 'Black Ops One', cursive; + font-size: 15vw; +} + +.control-wrapper { width: 475px; height: 171px; margin: 100px auto; } -#countdown { +.control-wrapper #countdown { font-family: 'Black Ops One', cursive; font-size: 90px; } diff --git a/public/js/main.js b/public/js/main.js index a18b2f6..053451d 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,5 +1,5 @@ -//var socket = io.connect(window.location.hostname); -var socket = io(); +// a bit hackish way to find the path but it will get the job done +var socket = io({"path": window.location.pathname.replace(/(\/control\/?|\/)?$/, "") + "/socket.io"}); socket.on('time', function (data) { $('#countdown').html(data.time); diff --git a/views/control.ejs b/views/control.ejs index 6f3e1c2..fe5af6e 100644 --- a/views/control.ejs +++ b/views/control.ejs @@ -1,4 +1,4 @@ -
+

<%= title %>

diff --git a/views/index.ejs b/views/index.ejs index 61f7de0..a2344f6 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -1,3 +1,3 @@ -
+
diff --git a/views/layout.ejs b/views/layout.ejs index c5a1fb4..6fb9c5e 100644 --- a/views/layout.ejs +++ b/views/layout.ejs @@ -8,20 +8,20 @@ - + - + <%- body %> - - - + + + diff --git a/views/list.ejs b/views/list.ejs new file mode 100644 index 0000000..400af52 --- /dev/null +++ b/views/list.ejs @@ -0,0 +1,21 @@ + + + + + Timer + + + + + + +