はじめに
今回は簡易的なブログサイトを作ってみました
利用したモジュール
今回は下記のモジュールを使用しました。
- express
- cors
- path
- fs(記事のデータ保存に使用)
- ip(ipv4アドレスの取得に使用)
- uuid(記事IDの生成に使用)
ファイルの構成
今回は以下のようにファイルを配置しました。
project/
├── src/
│ ├── app.js
│ └── data.json
├── public/
│ ├── index.html
│ └── post.html
各ファイルの説明:
app.js:サーバーサイドのメインのファイル
data.json: 記事を保存するためのファイル
index.html: クライアントサイドのメインファイル
post.html: 記事を投稿するためのファイル
コードの内容
各ファイルの中身は以下のとおりです。
app.js
app.js
//モジュールのインポート
const express = require('express');
const cors = require('cors');
const ip = require('ip');
const path = require('node:path');
const fs = require('node:fs');
const { v4: uuidv4 } = require('uuid')
// サーバの設定
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, "..", 'public')));
// 保存先の指定と保存先のデータの取得
const jsonPath = path.join(__dirname, 'data.json');
// デフォルト(/)で表示されるファイル
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
})
// 記事投稿のファイル
app.get('/writearticle', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'post.html'))
})
// 記事の受け渡し
app.post('/getdata', (req, res) => {
const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
const data = parsed.find(article => article.id === req.body.id);
res.json({ data: data });
})
// 記事の受け渡し2(クライアントのonliad時に呼び出されます)
app.get('/getArticles', (req, res) => {
try {
res.json({ data: JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) })
} catch (error) {
res.json({ err: "something went wrong." })
console.log(error)
}
})
// 記事の投稿
app.post('/post', (req, res) => {
if (req.body.name && req.body.content && !req.body.id) {
try {
const article = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
if (['script', 'javascript:', 'onclick', 'onload', 'onerror', 'onmouseover', 'onmouseenter', 'onmouseleave', 'onfocus', 'onblur', 'onkeydown', 'onkeyup', 'onchange', 'onsubmit', 'onreset', 'onselect', 'onunload', 'onabort'].some(event => req.body.content.includes(event))) {
res.status(400).json({ err: "無効なHTMLタグを検出しました。" })
}
const id = uuidv4();
if (!article.find(ids => ids.id === id))
article.push({
name: req.body.name,
content: req.body.content,
created: req.body.created,
id: id
})
try {
fs.writeFileSync(jsonPath, JSON.stringify(article), 'utf8');
} catch (error) {
console.error(error)
res.status(500).json({ err: "error" })
}
res.send('success');
} catch (error) {
console.error(error)
res.status(500)
}
}
})
// サーバの起動
const port = 3000
app.listen(port, ip.address(), () => {
console.log(`server on running http://${ip.address()}:${port}`)
})
index.html
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>簡易的なブログサイト</title>
<style>
:root {
--bg-color: #f9f9f9;
--text-color: #333;
--header-color: #2c3e50;
--subtext-color: #7f8c8d;
--card-bg: white;
--card-shadow: rgba(0,0,0,0.1);
--card-shadow-hover: rgba(0,0,0,0.15);
--border-color: #3498db;
--error-color: red;
}
body.dark {
--bg-color: #121212;
--text-color: #ddd;
--header-color: #eee;
--subtext-color: #aaa;
--card-bg: #1e1e1e;
--card-shadow: rgba(255,255,255,0.05);
--card-shadow-hover: rgba(255,255,255,0.1);
--border-color: #3ba1ff;
--error-color: #ff6b6b;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-color);
margin: 0;
padding: 40px 20px;
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
header {
text-align: center;
margin-bottom: 40px;
}
header h1 {
font-size: 3rem;
margin-bottom: 8px;
color: var(--header-color);
}
header p {
font-size: 1.1rem;
color: var(--subtext-color);
}
#toggle-theme-btn, #post-article-btn {
background: var(--card-bg);
border: 2px solid var(--border-color);
color: var(--header-color);
padding: 8px 14px;
border-radius: 8px;
font-weight: bold;
transition: background-color 0.3s ease, color 0.3s ease;
user-select: none;
text-decoration: none;
display: inline-block;
}
#toggle-theme-btn {
position: fixed;
top: 20px;
right: 20px;
cursor: pointer;
}
#post-article-btn {
position: fixed;
top: 20px;
left: 20px;
cursor: pointer;
}
#toggle-theme-btn:hover, #post-article-btn:hover {
background: var(--border-color);
color: white;
}
#articles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
max-width: 1200px;
margin: 0 auto 40px auto;
}
.card {
background: var(--card-bg);
border-radius: 12px;
padding: 20px;
box-shadow: 0 5px 15px var(--card-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card:hover {
transform: translateY(-8px);
box-shadow: 0 10px 25px var(--card-shadow-hover);
}
.card h2 {
margin: 0 0 12px 0;
font-size: 1.5rem;
color: var(--header-color);
}
.card small {
color: var(--subtext-color);
margin-top: 12px;
font-style: italic;
}
#article-detail {
max-width: 800px;
margin: 0 auto;
background: var(--card-bg);
border-radius: 12px;
padding: 30px;
box-shadow: 0 8px 25px rgba(0,0,0,0.12);
color: var(--header-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
#article-detail h2 {
margin-top: 0;
font-size: 2rem;
border-bottom: 2px solid var(--border-color);
padding-bottom: 8px;
}
#article-detail .content {
margin-top: 20px;
line-height: 1.6;
font-size: 1.1rem;
}
#article-detail .error {
color: var(--error-color);
font-weight: bold;
}
</style>
</head>
<body>
<header>
<h1>簡易的なブログサイト</h1>
<p>UIはchatGPTに書いてもらいました。</p>
</header>
<a id="post-article-btn" href="/writearticle" aria-label="記事投稿">記事を投稿する</a>
<button id="toggle-theme-btn" aria-label="テーマ切替">ダークモード切替</button>
<div id="articles"></div>
<div id="article-detail">
<p>記事をクリックするとここに詳細が表示されます。</p>
</div>
<script>
const articles = document.getElementById('articles');
const articleDetail = document.getElementById('article-detail');
const toggleBtn = document.getElementById('toggle-theme-btn');
// 記事一覧表示
function updateArticles(data) {
articles.innerHTML = "";
data.forEach(article => {
const card = document.createElement('div');
card.classList.add('card');
card.dataset.id = article.id || article._id || "";
card.innerHTML = `
<h2>${article.name}</h2>
<small>記事ID: ${card.dataset.id}</small>
<small>投稿者: ${article.created}</small>
`;
card.addEventListener('click', () => {
getBlog(card.dataset.id);
});
articles.appendChild(card);
});
}
// 記事一覧取得
function getArticle() {
fetch('/getArticles', {
method: "get"
})
.then(response => response.json())
.then(data => {
if(data.err) {
articles.innerHTML = `<h2>情報の取得に失敗しました</h2>`;
return;
}
updateArticles(data.data);
})
.catch(() => {
articles.innerHTML = `<h2>通信エラーが発生しました</h2>`;
});
}
// 記事詳細取得&表示
function getBlog(id) {
fetch('/getdata', {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ id: id })
})
.then(response => response.json())
.then(data => {
if(data.err) {
articleDetail.innerHTML = `<p class="error">記事の取得に失敗しました。</p>`;
} else {
articleDetail.innerHTML = `
<h2>${data.data.title || data.data.name || "記事詳細"}</h2>
<div class="content">${data.data.content || ""}</div>
`;
}
})
.catch(() => {
articleDetail.innerHTML = `<p class="error">通信エラーが発生しました。</p>`;
});
}
// テーマ切替
toggleBtn.addEventListener('click', () => {
document.body.classList.toggle('dark');
if(document.body.classList.contains('dark')) {
toggleBtn.textContent = "ライトモード切替";
} else {
toggleBtn.textContent = "ダークモード切替";
}
});
window.onload = getArticle;
</script>
</body>
</html>
post.html
post.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>記事投稿</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
font-family: 'Segoe UI', sans-serif;
background-color: #121212;
color: #e0e0e0;
margin: 0;
padding: 20px;
}
h1, h2 {
color: #ffffff;
}
.container {
max-width: 800px;
margin: auto;
padding: 20px;
background-color: #1e1e1e;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.4);
}
input[type="text"] {
width: 100%;
padding: 10px;
font-size: 1.2em;
background-color: #2a2a2a;
color: #fff;
border: none;
border-radius: 5px;
margin-bottom: 20px;
}
#content {
min-height: 200px;
width: 90%;
padding: 10px;
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 5px;
color: #fff;
outline: none;
}
button {
background-color: #4caf50;
color: white;
padding: 10px 20px;
font-size: 1em;
border: none;
border-radius: 5px;
cursor: pointer;
margin-top: 20px;
}
button:hover {
background-color: #45a049;
}
.preview {
background-color: #1f1f1f;
margin-top: 30px;
padding: 15px;
border-radius: 5px;
border: 1px solid #333;
}
.preview h2 {
border-bottom: 1px solid #333;
padding-bottom: 10px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>記事投稿</h1>
<h2>タイトル</h2>
<input type="text" id="title" placeholder="ここにタイトルを入力" />
<h2>本文</h2>
<textarea id="content" placeholder="ここに本文を入力..."></textarea>
<button id="send">送信</button>
<div class="preview">
<h2>プレビュー</h2>
<div id="preview"></div>
</div>
</div>
<script>
const titleInput = document.getElementById("title");
const contentInput = document.getElementById("content");
const preview = document.getElementById("preview");
// プレビュー更新関数
function updatePreview() {
const title = titleInput.value;
const content = contentInput.value;
preview.innerHTML = `
<h3>${title}</h3>
<div>${content}</div>
`;
}
// 入力ごとにプレビュー更新(リアルタイム)
titleInput.addEventListener("input", updatePreview);
contentInput.addEventListener("input", updatePreview);
// 初期表示用に一回呼ぶ(空の場合でも)
updatePreview();
// 送信ボタンの処理
document.getElementById("send").addEventListener("click", () => {
const title = titleInput.value;
const content = contentInput.value;
const username = prompt('enter your username', 'Noname');
if(!title || !content) {
alert('すべての項目を埋めてください。')
return;
}
if(username === "Admin") {
alert('この名前では投稿できません');
return;
}
if (!['script' , 'javascript:', 'onclick', 'onload', 'onerror', 'onmouseover', 'onmouseenter', 'onmouseleave', 'onfocus', 'onblur', 'onkeydown', 'onkeyup', 'onchange', 'onsubmit', 'onreset', 'onselect', 'onunload', 'onabort'].some(event => content.includes(event))) {
fetch("/post", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({name: title, content: content, created: username})
})
.then(res => res.text())
.then(data => {
console.log("投稿成功:", data);
})
.catch(err => {
console.error("エラー:", err);
});
} else {
alert('エラー:onclickなどのイベント名が検出されました。')
}
});
</script>
</body>
</html>
data.json(テストデータです)
json.data.json
[{"name":"testData","content":"<h1>Hi!</h1>","created":"test","id":"74e0ffbf-a135-415b-a09c-469f9698cd1e"}]
⚠注意点
このapp.jsにはipv4アドレスを取得、使用(app.listenのところです)するコードが含まれています。環境によってはセキュリティ上のリスクとなる可能性があるので、このコードを使用する場合は、必要に応じて、IPv4を指定せずにapp.listen(3000)を使用して実行することをおすすめします。
また、ipv4のログも環境によっては注意したほうがいいらしいです(ChatGPTの出力を参考)
最後に
今回はNode.jsで簡易的なブログサイトを書いてみました。久々にコードを書いたのでjavscript部分が読みにくくなってるかもしれません(今回もhtmlとcssはchatGPTに書いてもらいました)
それではまたお会いしましょう!