0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-05-24

はじめに

今回は簡易的なブログサイトを作ってみました

利用したモジュール

今回は下記のモジュールを使用しました。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?