Compare commits

...

9 Commits

Author SHA1 Message Date
41351442f3 Update docker-compose.yml 2025-10-24 22:42:57 +08:00
c06b212787 Update docker-compose.yml 2025-10-24 22:25:48 +08:00
57a7421e46 Convert templates.jsm to CommonJS 2025-10-21 00:38:52 +08:00
2096aeb06a Audit fix 2025-10-20 05:20:18 +08:00
99d49dbc10 Use dynamic redirectUri 2025-10-19 02:33:44 +08:00
f038ca8e79 Pass COMPUTER_NAME to templates 2025-10-19 02:06:40 +08:00
eabc75c8de Create simple template renderer 2025-10-19 01:59:17 +08:00
941d93401c Use dotenv 2025-10-19 01:13:22 +08:00
91f19e16fe Set base image to node:22.19-alpine 2025-10-19 01:13:03 +08:00
12 changed files with 270 additions and 67 deletions

View File

@@ -1,4 +1,4 @@
FROM node:lts
FROM node:22.19-alpine
WORKDIR /app
COPY . .

View File

@@ -1,14 +1,15 @@
services:
wakeup3770:
web:
build: .
image: pinlin/wakeup3770:latest
environment:
- PORT=8701
- MAC_ADDRESS=12:34:56:78:90:AB
- OIDC_WELL_KNOWN_URL=https://shubana.synology.me:15001/webman/sso/.well-known/openid-configuration
- CLIENT_ID=XXXX
- CLIENT_SECRET=XXXX
- REDIRECT_URI=http://127.0.0.1:3000/callback
- COOKIE_SECRET=XXXX
NODE_ENV: production
PORT: 3000
COMPUTER_NAME: MyComputer
MAC_ADDRESS: 12:34:56:78:90:AB
OIDC_WELL_KNOWN_URL:
CLIENT_ID:
CLIENT_SECRET:
COOKIE_SECRET:
network_mode: host
restart: always

9
example.env Normal file
View File

@@ -0,0 +1,9 @@
NODE_ENV=production
PORT=3000
COMPUTER_NAME=MyComputer
MAC_ADDRESS=12:34:56:78:90:AB
OIDC_WELL_KNOWN_URL=
CLIENT_ID=
CLIENT_SECRET=
REDIRECT_URI=http://127.0.0.1:3000/callback
COOKIE_SECRET=

108
index.js
View File

@@ -1,37 +1,27 @@
const express = require("express");
const session = require("express-session");
const { Issuer } = require("openid-client");
const { createTemplateRenderer } = require("./utils/templates");
const wol = require("wake_on_lan");
const app = express();
require("dotenv").config();
const PORT = process.env.PORT || 3000;
const COMPUTER_NAME = process.env.COMPUTER_NAME || "MyComputer";
const REDIRECT_URI = process.env.REDIRECT_URI || "http://127.0.0.1:3000/callback";
const app = express();
app.use(express.json());
app.use(
session({
secret: process.env.COOKIE_SECRET,
saveUninitialized: false,
cookie: { maxAge: 10 * 60 * 1000 },
})
);
app.use(session({
secret: process.env.COOKIE_SECRET,
saveUninitialized: false,
cookie: { maxAge: 10 * 60 * 1000 }
}));
function expandHtml(body) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; padding: 1.5rem; font-size: 1.2rem; }
a, button { font-size: 1.1rem; }
</style>
</head>
<body>
${body}
</body>
</html>
`
}
const renderHtml = createTemplateRenderer({
useCache: process.env.NODE_ENV === "production",
});
let client;
@@ -40,58 +30,74 @@ let client;
client = new issuer.Client({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
redirect_uris: [REDIRECT_URI],
response_types: ["code"]
response_types: ["code"],
});
app.get("/", (req, res) => {
if (req.session.user) {
res.send(expandHtml(`
<h1>喚醒 PinLin3770</h1>
<p>已登入為身分 ${req.session.user.sub}</p>
<form method="POST" action="/logout">
<button type="submit">登出</button>
</form>
<br>
<a href="/wakeup3770">喚醒 PinLin3770</a>
`));
res.send(
renderHtml("index.html", {
COMPUTER_NAME,
USERNAME: req.session.user.sub,
})
);
} else {
res.send(expandHtml(`
<h1>喚醒 PinLin3770</h1>
<a href="/login">點此登入</a>
`));
res.send(renderHtml("login.html", { COMPUTER_NAME }));
}
});
app.get("/login", (req, res) => {
const stateMap = {};
app.post("/authorize", (req, res) => {
const { redirectUri } = req.body;
if (!redirectUri) {
return res.send(
renderHtml("login-fail.html", {
COMPUTER_NAME,
ERROR: "redirectUri missing or invalid",
})
);
}
const state = Math.random().toString(36).substring(2);
stateMap[state] = { redirectUri };
const url = client.authorizationUrl({
scope: "openid profile",
redirect_uri: REDIRECT_URI
redirect_uri: redirectUri,
state,
});
res.redirect(url);
res.json({ redirect: url });
});
app.get("/callback", async (req, res) => {
try {
const state = req.query.state;
const { redirectUri } = stateMap[state];
const params = client.callbackParams(req);
const tokenSet = await client.callback(REDIRECT_URI, params);
const tokenSet = await client.callback(redirectUri, params, { state });
const userinfo = await client.userinfo(tokenSet.access_token);
req.session.user = userinfo;
res.redirect("/");
} catch (err) {
res.send(expandHtml(`<p>⚠️ 登入失敗</p><p>${err}</p><a href="/">回首頁</a>`));
} catch (error) {
res.send(renderHtml("login-fail.html", { COMPUTER_NAME, ERROR: error }));
}
});
app.get("/wakeup3770", (req, res) => {
app.get("/wakeup", (req, res) => {
if (!req.session.user) return res.redirect("/");
wol.wake(process.env.MAC_ADDRESS, (error) => {
if (error) {
return res.send(expandHtml(`<p>⚠️ 發送魔法封包失敗</p><p>${error}</p><a href="/">回首頁</a>`));
res.send(
renderHtml("wakeup-fail.html", { COMPUTER_NAME, ERROR: error })
);
} else {
res.send(renderHtml("wakeup-success.html", { COMPUTER_NAME }));
}
res.send(expandHtml(`<p>✅ 已經發送魔法封包</p><a href="/">回首頁</a>`));
});
});
@@ -102,6 +108,6 @@ let client;
});
app.listen(PORT, () => {
console.log(`wakeup3770 listening at http://localhost:${PORT}`);
console.log(`listening at http://localhost:${PORT}`);
});
})();

29
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "wakeup3770",
"version": "1.0.0",
"dependencies": {
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-session": "^1.18.1",
"openid-client": "^5.6.0",
@@ -146,6 +147,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -258,15 +271,16 @@
}
},
"node_modules/express-session": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
"integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.0.2",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
@@ -577,9 +591,10 @@
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}

View File

@@ -6,6 +6,7 @@
"start": "node index.js"
},
"dependencies": {
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-session": "^1.18.1",
"openid-client": "^5.6.0",

25
templates/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>喚醒 {{COMPUTER_NAME}}</title>
<style>
body {
font-family: sans-serif; padding: 1.5rem; font-size: 1.2rem;
}
a, button {
font-size: 1.1rem;
}
</style>
</head>
<body>
<h1>喚醒 {{COMPUTER_NAME}}</h1>
<p>已登入為身分 {{USERNAME}}</p>
<form method="POST" action="/logout">
<button type="submit">登出</button>
</form>
<br>
<a href="/wakeup">喚醒 {{COMPUTER_NAME}}</a>
</body>
</html>

22
templates/login-fail.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入失敗 | 喚醒 {{COMPUTER_NAME}}</title>
<style>
body {
font-family: sans-serif; padding: 1.5rem; font-size: 1.2rem;
}
a, button {
font-size: 1.1rem;
}
</style>
</head>
<body>
<h1>喚醒 {{COMPUTER_NAME}}</h1>
<p>⚠️ 登入失敗</p>
<p>{{ERROR}}</p>
<a href="/">回首頁</a>
</body>
</html>

51
templates/login.html Normal file
View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 | 喚醒 {{COMPUTER_NAME}}</title>
<style>
body {
font-family: sans-serif;
padding: 1.5rem;
font-size: 1.2rem;
}
a,
button {
font-size: 1.1rem;
}
</style>
</head>
<body>
<h1>喚醒 {{COMPUTER_NAME}}</h1>
<button id="loginBtn">登入</button>
<script>
document.getElementById('loginBtn').addEventListener('click', async function () {
const redirectUri = window.location.origin + '/callback';
try {
const res = await fetch('/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirectUri })
});
if (!res.ok) {
alert('伺服器錯誤');
return;
}
const data = await res.json();
if (data && data.redirect) {
window.location.href = data.redirect;
}
} catch (err) {
alert('請求失敗: ' + err);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>喚醒失敗 | 喚醒 {{COMPUTER_NAME}}</title>
<style>
body {
font-family: sans-serif; padding: 1.5rem; font-size: 1.2rem;
}
a, button {
font-size: 1.1rem;
}
</style>
</head>
<body>
<h1>喚醒 {{COMPUTER_NAME}}</h1>
<p>⚠️ 發送魔法封包失敗</p>
<p>{{ERROR}}</p>
<a href="/">回首頁</a>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>喚醒成功 | 喚醒 {{COMPUTER_NAME}}</title>
<style>
body {
font-family: sans-serif; padding: 1.5rem; font-size: 1.2rem;
}
a, button {
font-size: 1.1rem;
}
</style>
</head>
<body>
<h1>喚醒 {{COMPUTER_NAME}}</h1>
<p>✅ 已經發送魔法封包</p>
<a href="/">回首頁</a>
</body>
</html>

30
utils/templates.js Normal file
View File

@@ -0,0 +1,30 @@
const fs = require("fs");
const path = require("path");
function createTemplateRenderer({
baseDir = path.resolve("templates"),
useCache = true,
} = {}) {
const cache = {};
return function render(fileName, vars = {}) {
const filePath = path.join(baseDir, fileName);
let template;
if (useCache && cache[filePath]) {
template = cache[filePath];
} else {
template = fs.readFileSync(filePath, "utf8");
if (useCache) cache[filePath] = template;
}
let html = template;
for (const [key, value] of Object.entries(vars)) {
html = html.replace(new RegExp(`{{${key}}}`, "g"), value);
}
return html;
};
}
module.exports = { createTemplateRenderer };