Compare commits
5 Commits
c87c288d51
...
99d49dbc10
| Author | SHA1 | Date | |
|---|---|---|---|
| 99d49dbc10 | |||
| f038ca8e79 | |||
| eabc75c8de | |||
| 941d93401c | |||
| 91f19e16fe |
@@ -1,4 +1,4 @@
|
|||||||
FROM node:lts
|
FROM node:22.19-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -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
9
example.env
Normal 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
109
index.js
@@ -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
13
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
25
templates/index.html
Normal 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
22
templates/login-fail.html
Normal 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
51
templates/login.html
Normal 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>
|
||||||
22
templates/wakeup-fail.html
Normal file
22
templates/wakeup-fail.html
Normal 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>
|
||||||
21
templates/wakeup-success.html
Normal file
21
templates/wakeup-success.html
Normal 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
28
utils/templates.jsm
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user