管理画面のヘッダーを更新し、タイトルを「神椿ライブスケジュール」に変更。ログアウト機能を追加し、Docker設定を修正。新しいログインページを作成し、環境変数を更新。

This commit is contained in:
ntki72 2025-01-03 21:46:33 +09:00
parent 62d1e72ae9
commit 72031bba37
17 changed files with 166 additions and 81 deletions

17
Dockerfile.production Normal file
View File

@ -0,0 +1,17 @@
FROM node:22.12.0-alpine
WORKDIR /app
# パッケージファイルをコピーしてインストール
COPY package*.json ./
RUN npm install --production
# ソースコードをコピーしてビルド
COPY . .
RUN npm run build
COPY wait-for-it.sh /wait-for-it.sh
RUN chmod +x /wait-for-it.sh
# シェルスクリプトを使って、MySQL サーバーの起動を待機
CMD ["/bin/sh", "/wait-for-it.sh", "db", "3306", "--", "npm", "run", "start"]

View File

@ -1,5 +1,17 @@
services:
app:
build:
dockerfile: Dockerfile
volumes:
- ./public/css:/app/public/css
- ./views:/app/views
ports:
- "3000:3000"
environment:
NODE_ENV: development
db:
volumes:
- db-store:/var/lib/mysql
volumes:
db-store:

View File

@ -2,13 +2,13 @@ services:
app:
build:
context: .
dockerfile: Dockerfile
dockerfile: Dockerfile.prodcution
ports:
- "3000:3000"
- "80:3000"
depends_on:
- db
environment:
NODE_ENV: development
NODE_ENV: production
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
@ -16,11 +16,12 @@ services:
TZ: Asia/Tokyo
volumes:
- ./public/thumbnails:/app/public/thumbnails
- ./logs/app:/app/logs
db:
image: mysql:8.0
volumes:
- db-store:/var/lib/mysql
- ./logs:/var/log/mysql
- db-store-prodcution:/var/lib/mysql
- ./logs/db:/var/log/mysql
- ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
environment:
MYSQL_DATABASE: ${DB_NAME}
@ -32,5 +33,4 @@ services:
- ${DB_PORT}:3306
volumes:
db-store:
db-store-prodcution:

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View File

@ -1,4 +1,4 @@
import { WEB_PORT, BASIC_AUTH_USER, BASIC_AUTH_PASS, SESSION_SECRET } from "./config/env";
import { WEB_PORT, BASIC_AUTH_ID, BASIC_AUTH_PASS, SESSION_SECRET, CRON_SCHEDULE, ADMIN_ID, ADMIN_PASS, SESSION_EXPIRATION } from "./config/env";
import cron from "node-cron";
import express from "express";
import session from "express-session";
@ -9,9 +9,6 @@ import { runMigrations } from "./utils/migrate";
import methodOverride from "method-override";
import { updateAllLiveSchedules } from "./services/updateLiveSchedules";
// セッションとベーシック認証の有効期限
const SESSION_EXPIRATION = 60 * 1 * 1000; // テスト目的で1分に設定
const app = express();
const startServer = async () => {
@ -19,66 +16,13 @@ const startServer = async () => {
// マイグレーション実行
await runMigrations();
// 開発中なので定期実行タスクをコメントアウト
// cron.schedule("0 * * * *", async () => {
// console.log("Updating live schedules...");
// await updateAllLiveSchedules();
// });
// セッション設定
app.use(
session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: false,
cookie: { maxAge: SESSION_EXPIRATION, secure: false, httpOnly: true },
})
);
// キャッシュを抑止
app.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
next();
// 定期実行タスクを設定
cron.schedule(CRON_SCHEDULE, async () => {
console.log("Updating live schedules...");
await updateAllLiveSchedules();
});
/**
*
* 1.
* 2. 401 Basic認証を要求
* 3.
*/
app.use("/hyAdmin", (req, res, next) => {
const sessionExpires = req.session.cookie.expires;
// セッションが有効期限内の場合はそのまま進む
if (req.session.isAuthenticated && sessionExpires && sessionExpires > new Date()) {
return next();
}
// セッションが切れた(または存在しない)場合、
// Basic認証を実行して認証に成功すればセッション再生成、失敗すれば401を返す
basicAuth({
users: { [BASIC_AUTH_USER]: BASIC_AUTH_PASS },
challenge: true, // 401時にWWW-Authenticateヘッダーを付加
unauthorizedResponse: "Unauthorized",
})(req, res, (authError) => {
// authError があればそのままエラーに
if (authError) {
return next(authError);
}
// 認証が通ったのでセッション再生成
req.session.isAuthenticated = true;
req.session.cookie.expires = new Date(Date.now() + SESSION_EXPIRATION);
return next();
});
});
// 管理画面ビューの設定
// ビューの設定
app.set("view engine", "ejs");
app.set("views", "./views");
@ -89,6 +33,45 @@ const startServer = async () => {
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// セッション設定
app.use(
session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: { maxAge: SESSION_EXPIRATION, secure: false, httpOnly: true },
})
);
// 管理画面ベーシック認証
app.use(
"/hyAdmin",
basicAuth({
users: { [BASIC_AUTH_ID]: BASIC_AUTH_PASS },
challenge: true,
})
);
// 管理画面認証ミドルウェア
app.use("/hyAdmin", (req, res, next) => {
if (req.session.isAuthenticated) {
return next();
}
if (req.method === "POST" && req.url === "/login") {
const { username, password } = req.body;
if (username === ADMIN_ID && password === ADMIN_PASS) {
req.session.isAuthenticated = true;
req.session.cookie.expires = new Date(Date.now() + SESSION_EXPIRATION);
return res.redirect("/hyAdmin");
}
return res.render("admin/login", { error: "Invalid credentials" });
}
res.render("admin/login");
});
// 管理画面ルート
app.use("/hyAdmin", adminRoutes);
@ -99,6 +82,7 @@ const startServer = async () => {
app.use("/thumbnails", express.static("public/thumbnails"));
app.use("/css", express.static("public/css"));
app.use("/js", express.static("public/js"));
app.use("/favicon.ico", express.static("public/favicon.ico"));
// 404エラーハンドリング
app.use((req, res, next) => {

View File

@ -8,11 +8,14 @@ const requiredEnvVars = [
"DB_USER",
"DB_PASS",
"DB_HOST",
"BASIC_AUTH_USER",
"BASIC_AUTH_ID",
"BASIC_AUTH_PASS",
"WEB_PORT",
"YOUTUBE_API_KEY",
"SESSION_SECRET",
"CRON_INTERVAL_MINUTES",
"ADMIN_ID",
"ADMIN_PASS",
];
requiredEnvVars.forEach((varName) => {
@ -21,12 +24,42 @@ requiredEnvVars.forEach((varName) => {
}
});
export const SESSION_EXPIRATION = 60 * 1 * 1000;
export const DB_NAME = process.env.DB_NAME as string;
export const DB_USER = process.env.DB_USER as string;
export const DB_PASS = process.env.DB_PASS as string;
export const DB_HOST = process.env.DB_HOST as string;
export const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER as string;
export const BASIC_AUTH_ID = process.env.BASIC_AUTH_ID as string;
export const BASIC_AUTH_PASS = process.env.BASIC_AUTH_PASS as string;
export const WEB_PORT = parseInt(process.env.WEB_PORT as string, 10);
export const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY as string;
export const SESSION_SECRET = process.env.SESSION_SECRET as string;
export const ADMIN_ID = process.env.ADMIN_ID as string;
export const ADMIN_PASS = process.env.ADMIN_PASS as string;
// cron のスケジュールを計算
const intervalMinutes = parseInt(process.env.CRON_INTERVAL_MINUTES || "60", 10);
if (isNaN(intervalMinutes) || intervalMinutes <= 0) {
throw new Error("Invalid CRON_INTERVAL_MINUTES value in .env file. Must be a positive number.");
}
let cronSchedule = "";
if (intervalMinutes <= 60) {
// 60分以下の場合、分単位で設定
cronSchedule = `*/${intervalMinutes} * * * *`;
} else {
// 60分を超える場合、時間と分に分割
const hours = Math.floor(intervalMinutes / 60);
const minutes = intervalMinutes % 60;
if (minutes === 0) {
// 分が0の場合
cronSchedule = `0 */${hours} * * *`;
} else {
// 分が0以外の場合
cronSchedule = `${minutes} */${hours} * * *`;
}
}
export const CRON_SCHEDULE = cronSchedule;

View File

@ -7,6 +7,13 @@ import { updateLiveSchedulesForChannel } from "../services/liveScheduleService";
const router = Router();
// ログアウト処理
router.get("/logout", (req, res) => {
req.session.destroy(() => {
res.redirect("/hyAdmin");
});
});
// 管理画面トップページ
router.get("/", (req, res) => {
res.render("admin/index");
@ -214,7 +221,7 @@ router.put("/schedule/:id", async (req, res) => {
if (!schedule) {
return res.status(404).send("Schedule not found");
}
}
await schedule.update({
channel_id,

View File

@ -7,7 +7,7 @@
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<%- include("../partials/admin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>チャンネルを編集</h1>

View File

@ -7,7 +7,7 @@
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<%- include("../partials/admin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>新しいチャンネルを追加</h1>

View File

@ -7,7 +7,7 @@
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<%- include("../partials/admin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>チャンネル管理</h1>

View File

@ -6,13 +6,15 @@
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<%- include("../partials/admin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>管理画面</h1>
<div class="admin-menu">
<a href="/hyAdmin/channel" class="btn">チャンネル管理</a><br>
<a href="/hyAdmin/schedule" class="btn">配信スケジュール管理</a>
<br>
<a href="/hyAdmin/logout" class="btn">ログアウト</a>
</div>
</body>
</html>

29
views/admin/login.ejs Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/admin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>管理画面ログイン</h1>
<% if (typeof error !== "undefined") { %>
<p class="error"><%= error %></p>
<% } %>
<form action="/hyAdmin/login" method="POST">
<div class="form-group">
<label for="username">ユーザー</label>
<input type="text" id="username" name="username" required />
</div>
<div class="form-group">
<label for="password">パスワード</label>
<input type="password" id="password" name="password" required />
</div>
<button type="submit" class="btn-login">ログイン</button>
</form>
</div>
</body>
</html>

View File

@ -7,7 +7,7 @@
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<%- include("../partials/admin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>スケジュール編集</h1>

View File

@ -7,7 +7,7 @@
</head>
<%- include("../partials/head") %>
<body>
<%- include("../partials/hyAdmin/header") %>
<%- include("../partials/admin/header") %>
<div class="header-spacer"></div>
<div class="main-content">
<h1>配信スケジュール管理</h1>

View File

@ -3,7 +3,7 @@
</head>
<header class="header">
<div class="header-left">
<a href="/hyAdmin">神椿配信スケジュール(管理画面)</a>
<a href="/hyAdmin">神椿ライブスケジュール(管理画面)</a>
</div>
<div class="header-right">
<a href="/" class="header-link">一般ページへ</a>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>神椿配信スケジュール</title>
<title>神椿ライブスケジュール</title>
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/errors.css">
</head>

View File

@ -1,8 +1,9 @@
<header class="header">
<div class="header-left">
<span>神椿配信スケジュール</span>
<span>神椿ライブスケジュール</span>
</div>
<div class="header-right">
<a href="/terms" class="header-link">利用規約</a>
<a href="https://forms.gle/uP7DsKR6HxzU9TKH7" class="header-link" target="_blank">お問い合わせ</a>
</div>
</header>