Node.jsで簡易的なブログサイトを書いてみた

# はじめに
今回は簡易的なブログサイトを作ってみました
# 利用したモジュール
今回は下記のモジュールを使用しました。
- 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
```javascript:app.js
        //モジュールのインポート
    const express = require('express');
    const cors = require('cors');
    const ip = require('ip');
    const path = require('path');
    const fs = require('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');
    let articles = fs.readFileSync(jsonPath, 'utf8')

    // デフォルト(/)で表示されるファイル
    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(articles)
        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(articles)})     
            
    } 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(articles);
            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');
                articles = JSON.parse(article)
            } catch (error) {
                console.error(error)
                res.status(500).json({err: "error"})
            }
    res.send('success');
        } catch(error) {
            console.error(error)
            res.status(500)
        }
    }
    })
    // サーバーの起動
    app.listen(3000,  ip.address(), () => {
        console.log(ip.address())
    })
```
    
#### index.html
```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 {
    position: fixed;
    top: 20px;
    right: 20px;
    background: var(--card-bg);
    border: 2px solid var(--border-color);
    color: var(--header-color);
    padding: 8px 14px;
    border-radius: 8px;
    cursor: pointer;
    font-weight: bold;
    transition: background-color 0.3s ease, color 0.3s ease;
    user-select: none;
  }
  #toggle-theme-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>

<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
```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に書いてもらいました）
それではまたお会いしましょう！