Compare commits

...

5 Commits

Author SHA1 Message Date
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 258 additions and 58 deletions

View File

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

View File

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

109
index.js
View File

@@ -2,36 +2,26 @@ const express = require("express");
const session = require("express-session"); const session = require("express-session");
const { Issuer } = require("openid-client"); const { Issuer } = require("openid-client");
const wol = require("wake_on_lan"); const wol = require("wake_on_lan");
const { createTemplateRenderer } = require("./utils/templates.jsm");
require("dotenv").config();
const PORT = process.env.PORT || 3000;
const COMPUTER_NAME = process.env.COMPUTER_NAME || "MyComputer";
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; app.use(express.json());
app.use(
session({
secret: process.env.COOKIE_SECRET,
saveUninitialized: false,
cookie: { maxAge: 10 * 60 * 1000 },
})
);
const REDIRECT_URI = process.env.REDIRECT_URI || "http://127.0.0.1:3000/callback"; const renderHtml = createTemplateRenderer({
useCache: process.env.NODE_ENV === "production",
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>
`
}
let client; let client;
@@ -40,58 +30,75 @@ let client;
client = new issuer.Client({ client = new issuer.Client({
client_id: process.env.CLIENT_ID, client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET, client_secret: process.env.CLIENT_SECRET,
redirect_uris: [REDIRECT_URI], response_types: ["code"],
response_types: ["code"]
}); });
app.get("/", (req, res) => { app.get("/", (req, res) => {
if (req.session.user) { if (req.session.user) {
res.send(expandHtml(` res.send(
<h1>喚醒 PinLin3770</h1> renderHtml("index.html", {
<p>已登入為身分 ${req.session.user.sub}</p> COMPUTER_NAME,
<form method="POST" action="/logout"> USERNAME: req.session.user.sub,
<button type="submit">登出</button> })
</form> );
<br>
<a href="/wakeup3770">喚醒 PinLin3770</a>
`));
} else { } else {
res.send(expandHtml(` res.send(renderHtml("login.html", { COMPUTER_NAME }));
<h1>喚醒 PinLin3770</h1>
<a href="/login">點此登入</a>
`));
} }
}); });
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({ const url = client.authorizationUrl({
scope: "openid profile", scope: "openid profile",
redirect_uri: REDIRECT_URI redirect_uri: redirectUri,
state,
}); });
res.redirect(url);
// 回傳 JSON 給前端,讓前端負責導向
res.json({ redirect: url });
}); });
app.get("/callback", async (req, res) => { app.get("/callback", async (req, res) => {
try { try {
const state = req.query.state;
const { redirectUri } = stateMap[state];
const params = client.callbackParams(req); 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); const userinfo = await client.userinfo(tokenSet.access_token);
req.session.user = userinfo; req.session.user = userinfo;
res.redirect("/"); res.redirect("/");
} catch (err) { } catch (error) {
res.send(expandHtml(`<p>⚠️ 登入失敗</p><p>${err}</p><a href="/">回首頁</a>`)); 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("/"); if (!req.session.user) return res.redirect("/");
wol.wake(process.env.MAC_ADDRESS, (error) => { wol.wake(process.env.MAC_ADDRESS, (error) => {
if (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>`));
}); });
}); });

13
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "wakeup3770", "name": "wakeup3770",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"openid-client": "^5.6.0", "openid-client": "^5.6.0",
@@ -146,6 +147,18 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

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

28
utils/templates.jsm Normal file
View File

@@ -0,0 +1,28 @@
import fs from "fs";
import path from "path";
export 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;
};
}