Add multi-site docker configuration

This commit is contained in:
Tocho Tochev 2024-10-27 14:47:03 +02:00
parent bc8b805b2f
commit e5a6270d9c
13 changed files with 258 additions and 123 deletions

25
Dockerfile Normal file
View File

@ -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

View File

@ -1 +0,0 @@
web: node app.js

View File

@ -2,17 +2,49 @@
## Deployment ## Deployment
Clone to `/opt/timer`. ### Configuration
Put `openfest-timer@.service` in `/etc/systemd/system`.
Requires NodeJS and npm.
```bash Configuration is done by environmental variables:
systemctl daemon-reload
npm install ```yaml
systemctl enable --now openfest-timer@hall-a.service HALLS: "/hall-a:Hall A;/hall-b:Hall B"
systemctl enable --now openfest-timer@hall-b.service 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 <<EOF
[Container]
ContainerName=timer
Image=timer:latest
Environment=PORT=5050
Environment=HALLS="/hall-a:Hall A;/hall-b:Hall B;/test-hall:TEST"
Environment=BASIC_AUTH="user:password"
PublishPort=127.0.0.1:5050:5050
Network=INSERT_THE_NETWORK
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user restart timer
```
### Local Development
`docker-compose up --build`
## Based on Defcon ## Based on Defcon
### Defcon ### Defcon

131
app.js
View File

@ -4,17 +4,40 @@ const
server = require('http').createServer(app), server = require('http').createServer(app),
Stopwatch = require('./models/stopwatch'), Stopwatch = require('./models/stopwatch'),
errorHandler = require('errorhandler'), errorHandler = require('errorhandler'),
expressLayouts = require('express-ejs-layouts'), expressLayouts = require('express-ejs-layouts');
io = require('socket.io')(server, {});
const auth = (req, res, next) => {
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 // Configuration
if (process.env.BASIC_AUTH) {
app.use(auth);
}
app.set('views', __dirname + '/views'); app.set('views', __dirname + '/views');
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.use(expressLayouts);
app.use(express.static(__dirname + '/public'));
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
app.use(errorHandler({ dumpExceptions: true, showStack: true })); app.use(errorHandler({ dumpExceptions: true, showStack: true }));
} }
@ -24,27 +47,42 @@ 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 port = process.env.PORT || 5000;
var host = process.env.HOST || '0.0.0.0'; var host = process.env.HOST || '0.0.0.0';
server.listen(port, host, function() { var prefix = process.env.PREFIX || '';
console.log("Express server listening on %j in %s mode", server.address(), app.settings.env);
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()};
}); });
var stopwatch = new Stopwatch(); console.log("Configuring halls:", halls)
stopwatch.on('tick:stopwatch', function(time) { 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 }); io.sockets.emit('time', { time: time });
}); });
stopwatch.on('reset:stopwatch', function(time) { stopwatch.on('reset:stopwatch', function(time) {
io.sockets.emit('time', { time: time }); io.sockets.emit('time', { time: time });
}); });
stopwatch.start(); // stopwatch.start(); // we probably don't want autostart on server start
io.sockets.on('connection', function (socket) { io.sockets.on('connection', function (socket) {
io.sockets.emit('time', { time: stopwatch.getTime() }); io.sockets.emit('time', { time: stopwatch.getTime() });
socket.on('click:start', function () { socket.on('click:start', function () {
@ -66,53 +104,62 @@ io.sockets.on('connection', function (socket) {
socket.on('click:resetShort', function () { socket.on('click:resetShort', function () {
stopwatch.resetShort(); stopwatch.resetShort();
}); });
}); });
// configure title router.get('/', function(req, res) {
//app.use(function (req, res, next) { res.render('index', { title: title, prefix: prefix });
// res.locals.title = process.env.TITLE || 'Timer' });
//});
app.get('/', function(req, res) {
res.render('index', { title: process.env.TITLE });
});
const control = express.Router(); const control = express.Router();
control.get('/', function(req, res) { control.get('/', function(req, res) {
res.render('control', { title: process.env.TITLE }); res.render('control', { title: title, prefix: prefix });
}); });
control.post('/reset/', function (req, res) { control.post('/reset/', function (req, res) {
stopwatch.reset(); stopwatch.reset();
res.send("OK"); res.send("OK");
}); });
control.post('/reset-short/', function (req, res) { control.post('/reset-short/', function (req, res) {
stopwatch.resetShort(); stopwatch.resetShort();
res.send("OK"); res.send("OK");
}); });
control.post('/start-from-reset/', function (req, res) { control.post('/start-from-reset/', function (req, res) {
stopwatch.reset(); stopwatch.reset();
stopwatch.start(); stopwatch.start();
res.send("OK"); res.send("OK");
}); });
control.post('/start-from-reset-short/', function (req, res) { control.post('/start-from-reset-short/', function (req, res) {
stopwatch.resetShort(); stopwatch.resetShort();
stopwatch.start(); stopwatch.start();
res.send("OK"); res.send("OK");
}); });
control.post('/start/', function (req, res) { control.post('/start/', function (req, res) {
stopwatch.start(); stopwatch.start();
res.send("OK"); res.send("OK");
}); });
control.post('/stop/', function (req, res) { control.post('/stop/', function (req, res) {
stopwatch.stop(); stopwatch.stop();
res.send("OK"); res.send("OK");
}); });
control.post('/zero/', function (req, res) { control.post('/zero/', function (req, res) {
stopwatch.zero(); stopwatch.zero();
res.send("OK"); res.send("OK");
});
router.use('/control', control);
app.use(prefix, router);
}); });
app.use('/control', control); server.listen(port, host, function() {
console.log("Express server listening on %j in %s mode", server.address(), app.settings.env);
});
if (halls.length != 1 && halls[0]["prefix"] != "") {
app.get('/', function(req, res) {
res.render("list", { layout: false, halls: halls });
});
}

13
docker-compose.yml Normal file
View File

@ -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

View File

@ -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

View File

@ -11,6 +11,6 @@
"underscore": "^1.13.6" "underscore": "^1.13.6"
}, },
"engines": { "engines": {
"node": "0.6.x" "node": "23.0"
} }
} }

View File

@ -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; width: 475px;
height: 171px; height: 171px;
margin: 100px auto; margin: 100px auto;
} }
#countdown { .control-wrapper #countdown {
font-family: 'Black Ops One', cursive; font-family: 'Black Ops One', cursive;
font-size: 90px; font-size: 90px;
} }

View File

@ -1,5 +1,5 @@
//var socket = io.connect(window.location.hostname); // a bit hackish way to find the path but it will get the job done
var socket = io(); var socket = io({"path": window.location.pathname.replace(/(\/control\/?|\/)?$/, "") + "/socket.io"});
socket.on('time', function (data) { socket.on('time', function (data) {
$('#countdown').html(data.time); $('#countdown').html(data.time);

View File

@ -1,4 +1,4 @@
<div id="wrapper"> <div class="control-wrapper">
<h1><%= title %></h1> <h1><%= title %></h1>
<div id="countdown"></div> <div id="countdown"></div>
<button id="start" class="thoughtbot">Start</button> <button id="start" class="thoughtbot">Start</button>

View File

@ -1,3 +1,3 @@
<div id="wrapper"> <div class="wrapper">
<div id="countdown"></div> <div id="countdown"></div>
</div> </div>

View File

@ -8,20 +8,20 @@
<!-- HTML5 shim, for IE6-8 support of HTML elements --> <!-- HTML5 shim, for IE6-8 support of HTML elements -->
<!--[if lt IE 9]> <!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script> <script src="//html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]--> <![endif]-->
<!-- styles --> <!-- styles -->
<link href="/css/main.css" rel="stylesheet"> <link href="<%= prefix %>/css/main.css" rel="stylesheet">
<!-- fonts --> <!-- fonts -->
<link href='http://fonts.googleapis.com/css?family=Black+Ops+One' rel='stylesheet' type='text/css'> <link href='//fonts.googleapis.com/css?family=Black+Ops+One' rel='stylesheet' type='text/css'>
</head> </head>
<body> <body>
<%- body %> <%- body %>
<script src="/socket.io/socket.io.js"></script> <script src="<%= prefix %>/socket.io/socket.io.js"></script>
<script src="/js/libs/jquery.js"></script> <script src="<%= prefix %>/js/libs/jquery.js"></script>
<script src="/js/main.js"></script> <script src="<%= prefix %>/js/main.js"></script>
</body> </body>
</html> </html>

21
views/list.ejs Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Timer</title>
<meta name="description" content="">
<meta name="author" content="">
</head>
<body>
<ul>
<% for (const hall of halls) { %>
<li>
<a href="<%= hall["prefix"] %>"><%= hall["title"] %></a>
<ul>
<a href="<%= hall["prefix"] %>/control">Control <%= hall["title"] %></a>
</ul>
</li>
<% } %>
</ul>
</body>
</html>