Add BNB_TOKEN_CLEANUP_INTERVAL variable and re-design login page

This commit is contained in:
Wolfgang Kulhanek
2025-10-22 17:24:56 +02:00
parent f08004a4f1
commit f8ff9f30fb
10 changed files with 265 additions and 89 deletions

View File

@@ -107,7 +107,8 @@ const app = server(
version,
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
externalImageResolver: artistImageFetcher,
smapiTokenStore
smapiTokenStore,
tokenCleanupIntervalMinutes: config.tokenStore.cleanupIntervalMinutes
}
);

View File

@@ -107,6 +107,7 @@ export default function () {
bnbEnvVar<boolean>("REPORT_NOW_PLAYING", { default: true, parser: asBoolean }),
tokenStore: {
dbPath: bnbEnvVar<string>("TOKEN_DB_PATH", { default: "/config/tokens.db" })!,
cleanupIntervalMinutes: bnbEnvVar<number>("TOKEN_CLEANUP_INTERVAL", { default: 60, parser: asInt })!,
},
};
}

View File

@@ -113,6 +113,7 @@ export type ServerOpts = {
smapiAuthTokens: SmapiAuthTokens;
externalImageResolver: ImageFetcher;
smapiTokenStore: SmapiTokenStore;
tokenCleanupIntervalMinutes: number;
};
const DEFAULT_SERVER_OPTS: ServerOpts = {
@@ -130,6 +131,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
),
externalImageResolver: axiosImageFetcher,
smapiTokenStore: new InMemorySmapiTokenStore(),
tokenCleanupIntervalMinutes: 60,
};
function server(
@@ -747,7 +749,8 @@ function server(
i8n,
serverOpts.smapiAuthTokens,
serverOpts.smapiTokenStore,
serverOpts.logRequests
serverOpts.logRequests,
serverOpts.tokenCleanupIntervalMinutes
);
if (serverOpts.applyContextPath) {

View File

@@ -406,7 +406,8 @@ function bindSmapiSoapServiceToExpress(
i8n: I8N,
smapiAuthTokens: SmapiAuthTokens,
tokenStore: SmapiTokenStore,
_logRequests: boolean
_logRequests: boolean,
tokenCleanupIntervalMinutes: number = 60
) {
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore);
@@ -420,14 +421,16 @@ function bindSmapiSoapServiceToExpress(
logger.error("Failed to cleanup expired tokens on startup", { error });
}
// Clean up expired tokens every hour
// Clean up expired tokens periodically
const cleanupIntervalMs = tokenCleanupIntervalMinutes * 60 * 1000;
logger.info(`Token cleanup will run every ${tokenCleanupIntervalMinutes} minute(s)`);
setInterval(() => {
try {
tokenStore.cleanupExpired(smapiAuthTokens);
} catch (error) {
logger.error("Failed to cleanup expired tokens", { error });
}
}, 60 * 60 * 1000).unref(); // Run every hour, but don't prevent process exit
}, cleanupIntervalMs).unref(); // Don't prevent process exit
const urlWithToken = (accessToken: string) =>
bonobUrl.append({

View File

@@ -240,7 +240,7 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(0\)</h2>`);
expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(0\\)`));
expect(res.text).not.toMatch(/class=device/);
expect(res.text).toContain(lang("noSonosDevices"));
});
@@ -276,7 +276,7 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(0\)</h2>`);
expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(0\\)`));
expect(res.text).not.toMatch(/class=device/);
expect(res.text).toContain(lang("noSonosDevices"));
});
@@ -290,7 +290,7 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("services")} \(0\)</h2>`);
expect(res.text).toMatch(new RegExp(`${lang("services")}.*\\(0\\)`));
});
});
});
@@ -352,9 +352,9 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(2\)</h2>`);
expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/);
expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/);
expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(2\\)`));
expect(res.text).toMatch(/device1.*172\.0\.0\.1:4301/);
expect(res.text).toMatch(/device2.*172\.0\.0\.2:4302/);
});
});
@@ -366,11 +366,11 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("services")} \(4\)</h2>`);
expect(res.text).toMatch(/s1\s+\(1\)/);
expect(res.text).toMatch(/s2\s+\(2\)/);
expect(res.text).toMatch(/s3\s+\(3\)/);
expect(res.text).toMatch(/s4\s+\(4\)/);
expect(res.text).toMatch(new RegExp(`${lang("services")}.*\\(4\\)`));
expect(res.text).toMatch(/s1.*SID:\s*1/);
expect(res.text).toMatch(/s2.*SID:\s*2/);
expect(res.text).toMatch(/s3.*SID:\s*3/);
expect(res.text).toMatch(/s4.*SID:\s*4/);
});
});
@@ -382,14 +382,11 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(
`<input type="submit" value="${lang("register")}">`
);
expect(res.text).toMatch(`<h3>${lang("expectedConfig")}</h3>`);
expect(res.text).toMatch(
`<h3>${lang("noExistingServiceRegistration")}</h3>`
`<input type="submit" value="${lang("register")}" id="submit">`
);
expect(res.text).toContain(lang("noExistingServiceRegistration"));
expect(res.text).not.toMatch(
`<input type="submit" value="${lang("removeRegistration")}">`
`value="${lang("removeRegistration")}"`
);
});
});
@@ -440,14 +437,11 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(
`<input type="submit" value="${lang("register")}">`
`<input type="submit" value="${lang("register")}" id="submit">`
);
expect(res.text).toMatch(`<h3>${lang("expectedConfig")}</h3>`);
expect(res.text).toContain(lang("existingServiceConfig"));
expect(res.text).toMatch(
`<h3>${lang("existingServiceConfig")}</h3>`
);
expect(res.text).toMatch(
`<input type="submit" value="${lang("removeRegistration")}">`
`<input type="submit" value="${lang("removeRegistration")}" id="submit"`
);
});
});
@@ -632,13 +626,13 @@ describe("server", () => {
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<title>${lang("login")}</title>`);
expect(res.text).toMatch(
`<h1 class="login one-word-per-line">${lang("logInToBonob")}</h1>`
`<h1>${lang("logInToBonob")}</h1>`
);
expect(res.text).toMatch(
`<label for="username">${lang("username")}:</label>`
`<label for="username">${lang("username")}</label>`
);
expect(res.text).toMatch(
`<label for="password">${lang("password")}:</label>`
`<label for="password">${lang("password")}</label>`
);
expect(res.text).toMatch(
`<input type="submit" value="${lang("login")}" id="submit">`

View File

@@ -1,6 +1,12 @@
<% layout('./layout', { title: it.lang("failure") }) %>
<div id="content">
<h1 class="failure"><%= it.message %></h1>
<h1 class="cause"><%= it.cause || "" %></h1>
<div class="message-container">
<div class="logo">✗</div>
<h1><%= it.message %></h1>
<% if (it.cause) { %>
<p style="color: #dc3545; margin-top: 15px;"><%= it.cause %></p>
<% } %>
<p style="color: #dc3545; font-weight: 600; margin-top: 10px;"><%= it.lang("failure") %></p>
</div>
</div>

View File

@@ -1,45 +1,61 @@
<% layout('./layout') %>
<div id="content">
<div width="100%" style="text-align:right;color:grey"><%= it.version %></div>
<h1><%= it.bonobService.name %> (<%= it.bonobService.sid %>)</h1>
<h3><%= it.lang("expectedConfig") %></h3>
<div><%= JSON.stringify(it.bonobService) %></div>
<br/>
<div id="content" class="index-content">
<div style="text-align:right;color:#999;font-size:0.85rem;margin-bottom:20px"><%= it.version %></div>
<div class="logo">🎵</div>
<h1><%= it.bonobService.name %></h1>
<p style="color:#999;margin-bottom:30px">Service ID: <%= it.bonobService.sid %></p>
<% if(it.devices.length > 0) { %>
<form action="<%= it.createRegistrationRoute %>" method="POST">
<input type="submit" value="<%= it.lang("register") %>">
<form action="<%= it.createRegistrationRoute %>" method="POST" style="margin-bottom:30px">
<input type="submit" value="<%= it.lang("register") %>" id="submit">
</form>
<br/>
<% } else { %>
<h3><%= it.lang("noSonosDevices") %></h3>
<br/>
<p style="color:#dc3545;font-weight:600;margin:30px 0"><%= it.lang("noSonosDevices") %></p>
<% } %>
<% if(it.registeredBonobService) { %>
<h3><%= it.lang("existingServiceConfig") %></h3>
<div><%= JSON.stringify(it.registeredBonobService) %></div>
<div style="margin:20px 0;padding:15px;background:#f8f9fa;border-radius:8px">
<h3 style="font-size:1.1rem;margin-bottom:10px;color:#667eea"><%= it.lang("existingServiceConfig") %></h3>
<pre style="font-size:0.85rem;text-align:left;overflow-x:auto"><%= JSON.stringify(it.registeredBonobService, null, 2) %></pre>
</div>
<% } else { %>
<h3><%= it.lang("noExistingServiceRegistration") %></h3>
<p style="color:#999;margin:20px 0"><%= it.lang("noExistingServiceRegistration") %></p>
<% } %>
<% if(it.registeredBonobService) { %>
<br/>
<form action="<%= it.removeRegistrationRoute %>" method="POST">
<input type="submit" value="<%= it.lang("removeRegistration") %>">
<form action="<%= it.removeRegistrationRoute %>" method="POST" style="margin:20px 0">
<input type="submit" value="<%= it.lang("removeRegistration") %>" id="submit" style="background:#dc3545">
</form>
<% } %>
<br/>
<h2><%= it.lang("devices") %> (<%= it.devices.length %>)</h2>
<ul>
<% it.devices.forEach(function(d){ %>
<li><%= d.name %> (<%= d.ip %>:<%= d.port %>)</li>
<% }) %>
</ul>
<h2><%= it.lang("services") %> (<%= it.services.length %>)</h2>
<ul>
<% it.services.forEach(function(s){ %>
<li><%= s.name %> (<%= s.sid %>)</li>
<% }) %>
</ul>
<div style="margin-top:40px;padding-top:30px;border-top:2px solid #e0e0e0">
<h2 style="font-size:1.5rem;margin-bottom:15px"><%= it.lang("devices") %> (<%= it.devices.length %>)</h2>
<% if(it.devices.length > 0) { %>
<ul style="list-style:none;text-align:left;padding:0">
<% it.devices.forEach(function(d){ %>
<li style="padding:10px;margin:5px 0;background:#f8f9fa;border-radius:6px">
<strong><%= d.name %></strong> <span style="color:#999">(<%= d.ip %>:<%= d.port %>)</span>
</li>
<% }) %>
</ul>
<% } else { %>
<p style="color:#999">No devices found</p>
<% } %>
</div>
<div style="margin-top:30px">
<h2 style="font-size:1.5rem;margin-bottom:15px"><%= it.lang("services") %> (<%= it.services.length %>)</h2>
<% if(it.services.length > 0) { %>
<ul style="list-style:none;text-align:left;padding:0">
<% it.services.forEach(function(s){ %>
<li style="padding:10px;margin:5px 0;background:#f8f9fa;border-radius:6px">
<strong><%= s.name %></strong> <span style="color:#999">(SID: <%= s.sid %>)</span>
</li>
<% }) %>
</ul>
<% } else { %>
<p style="color:#999">No services registered</p>
<% } %>
</div>
</div>

View File

@@ -1,46 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= it.title || "bonob" %></title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
div#content {
margin: auto;
width: 60%;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 50px 40px;
max-width: 450px;
width: 100%;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
h1 {
font-size: 700%;
font-size: 2.5rem;
font-weight: 700;
color: #333;
text-align: center;
margin-bottom: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
form {
display: flex;
flex-direction: column;
gap: 25px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 300%;
font-size: 0.95rem;
font-weight: 600;
color: #555;
margin-left: 5px;
}
input {
font-size: 300%;
width: 80%;
input[type="text"],
input[type="password"] {
width: 100%;
padding: 15px 20px;
font-size: 1rem;
border: 2px solid #e0e0e0;
border-radius: 12px;
transition: all 0.3s ease;
background: #f8f9fa;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
}
input#submit {
margin-top: 100px
width: 100%;
padding: 16px;
font-size: 1.1rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.one-word-per-line {
word-spacing: 100000px;
input#submit:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.login{
width: min-intrinsic;
width: -webkit-min-content;
width: -moz-min-content;
width: min-content;
display: table-caption;
display: -ms-grid;
-ms-grid-columns: min-content;
input#submit:active {
transform: translateY(0);
}
.logo {
text-align: center;
margin-bottom: 20px;
font-size: 3rem;
opacity: 0.9;
}
/* Success and failure page styles */
.message-container {
text-align: center;
padding: 20px 0;
}
.message-container h1 {
font-size: 2rem;
margin-bottom: 20px;
}
.message-container p {
font-size: 1.1rem;
color: #666;
line-height: 1.6;
}
/* Index page styles */
.index-content {
text-align: center;
}
.index-content h1 {
font-size: 2.5rem;
margin-bottom: 20px;
}
.index-content p {
font-size: 1.1rem;
color: #666;
line-height: 1.8;
margin-bottom: 15px;
}
.index-content a {
color: #667eea;
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
}
.index-content a:hover {
color: #764ba2;
text-decoration: underline;
}
/* Responsive design */
@media (max-width: 500px) {
div#content {
padding: 40px 30px;
}
h1 {
font-size: 2rem;
}
input[type="text"],
input[type="password"] {
padding: 12px 16px;
}
}
</style>
</head>

View File

@@ -1,12 +1,17 @@
<% layout('./layout', { title: it.lang("login") }) %>
<div id="content">
<h1 class="login one-word-per-line"><%= it.lang("logInToBonob") %></h1>
<div class="logo">🎵</div>
<h1><%= it.lang("logInToBonob") %></h1>
<form action="<%= it.loginRoute %>" method="POST">
<label for="username"><%= it.lang("username") %>:</label><br>
<input type="text" id="username" name="username"><br><br>
<label for="password"><%= it.lang("password") %>:</label><br>
<input type="password" id="password" name="password"><br>
<div class="form-group">
<label for="username"><%= it.lang("username") %></label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password"><%= it.lang("password") %></label>
<input type="password" id="password" name="password" required>
</div>
<input type="hidden" name="linkCode" value="<%= it.linkCode %>">
<input type="submit" value="<%= it.lang("login") %>" id="submit">
</form>

View File

@@ -1,5 +1,9 @@
<% layout('./layout', { title: it.lang("success") }) %>
<div id="content">
<h1 class="success"><%= it.message %></h1>
<div class="message-container">
<div class="logo">✓</div>
<h1><%= it.message %></h1>
<p style="color: #28a745; font-weight: 600; margin-top: 10px;"><%= it.lang("success") %></p>
</div>
</div>