Простой блог на Preact, Node.js и MongoDB: Часть 2

Простой блог на Preact, Node.js и MongoDB: Часть 2


Первая часть тут

Авторизация админа

Чтобы не каждый мог писать статьи, а только владелец блога, нужно добавить простую авторизацию.

БД

Нужно создать базу данных "admin", и в ней добавить юзера "guest", который будет только иметь право на чтение записей бд, а не создание / изменение.

Теперь вместо service mongodb start мы будем запускать базу через mongod --port 27017 --dbpath /var/lib/mongodb, потому что нам нужна именно бд по такому пути. Далее мы заходим в шелл mongo и создаём юзера "guest", а также "root", если его у вас нет.

Весь процесс:

$ mongod --port 27017 --dbpath /var/lib/mongodb
$ mongo --shell --port 27017
> use db
> db.createUser({
  user: "guest",
  pwd: "guest",
  roles: [{ role: "read", db: "test_db" }]
  })
> db.createUser({
  user: "root",
  pwd: "secret",
  roles: [ 
        { role: "userAdminAnyDatabase", db: "admin" },
        "readWriteAnyDatabase"
        ]
  })
> exit

Теперь нам нужно добавить URI для админа и гостя в файл api/.env:

ADMIN_URI=mongodb://root:secret@localhost:27017/test_db?authSource=admin
URI=mongodb://guest:guest@localhost:27017/test_db?authSource=admin

?authSource=admin означает, что мы используем admin как бд для авторизации.

Бекенд (API)

Для авторизации мы добавим новый HTTP заголовок, x-secret. Он будет содержать пароль к админской базе.

Данный подход авторизации небезопасен. В реальных кейсах нужно использовать шифрование, например bcrypt, но так как это туториал, мы выберем самый простой путь.

Чтобы принимать заголовки, нужно прописать их в Access-Control-Allow-Headers .

res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-secret, *')

Теперь нужно немного изменить способ подключения к бд:

const admin = req.headers['x-secret'] === process.env.SECRET
const db = await connectToDb(admin ? process.env.ADMIN_URI : process.env.URI)

Здесь мы сравниваем пароль в заголовке и пароль в .env и подключаемся к бд в зависимости от юзера (админ или гость). В .env нужно прописать SECRET, это переменная будет хранить пароль от админа.

SECRET=secret

теперь давайте проверим работает ли авторизация. Сделаем простой if, который будет выводить "logon"

// api/server.js
// ...
createServer(async (req, res) => {

  res.setHeader('Content-Type', 'application/json')
  res.setHeader('access-control-allow-origin', 'http://localhost')
  res.setHeader('Access-Control-Allow-Methods', 'POST, GET')
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-secret, *')

  const admin = req.headers['x-secret'] === process.env.SECRET
  const db = await connectToDb(admin ? process.env.ADMIN_URI : process.env.URI)
  const collection = await db.collection('test_data')
  const posts = await collection.find({}).toArray()

  req.body = ''

  const { url, method } = req

  const slug = url.slice(1, url.length)

  // Зашёл как админ

  if (admin) console.log('logon')

Теперь затестим через curl:

curl -H "x-secret: secret" http://localhost:3000

Теперь чекаем консоль и видим "logon". Авторизация работает.

Фронтенд

Для авторизации мы будем использовать куки, и класть туда наш пароль. Также создадим две страницы для логина и логаута (выход из админки).

// www/index.js
import { render, useState, useEffect } from 'react'
import { Router, Link, navigate } from '@reach/router'
import html from './html'
import Editor from './editor'
import Post from './post'
import './style.css'

const LogIn = () => {
  const [secret, setSecret] = useState('')

  return html`
    <main class="login">
      <input value=${secret} onInput=${e => setSecret(e.target.value)} />
      <button onClick=${() => {
        document.cookie = 'secret=' + secret
        navigate('/')
      }}>Log In</button>
      <${Link} to="/"><a>Home</a></Link>
    </main>
  `
}

const LogOut = () => html`
  <main>
    <button
      onClick=${() => {
        document.cookie = 'secret='
        navigate('/')
      }}
    >
      Log Out
    </button>
  </main>
`

const Home = () => {
  const [posts, setPosts] = useState([])

  useEffect(() => {
    fetch('http://localhost:3000')
      .then(res => res.json())
      .then(data => setPosts(data))
  }, [])

  return html`
    <main>
      <h1>My cool blog</h1>
      <${Link} to="/login"><a>LogIn</a></Link>
      ${' '} 
      <${Link} to="/logout"><a>Log Out</a></Link>
      <ul>
        ${posts.map(
          post =>
            html`
              <li><${Link} to="${'/post/' + post.slug}">${post.name}</Link></li>
            `
        )}
      </ul>
      <${Link} to="/new">New post</Link>
    </main>
  `
}

render(
  html`
    <${Router}>
      <${Home} path="/" />
      <${Editor} path="/new" />
      <${LogIn} path="/login" />
      <${LogOut} path="/logout" />
    </Router>
  `,
  document.getElementById('app')

Здесь вы ставим куки, затем возвращаемся на домашнюю страницу в обоих случаях. Разница только в том, что в LogIn мы ставим секрет как пароль, а в LogOut мы стираем куки.

Редактирование / создание постов

Чтобы не каждый человек мог писать статьи мы ввели авторизацию. Теперь API будем сохранять статьи только тогда, когда есть специальный HTTP заголовок.

Бекенд (API)

Продолжим писать внутри if (admin):

// api/server.js
// ...
// Зашёл как админ
  if (admin) {
    // Новый пост
    if (method === 'POST') {
      req
      .on('data', chunk => req.body += chunk.toString())
      .on('end', () => {
        if (url === '/') res.end('Please specify a URL')
        else {
          collection.insertOne({ slug, ...JSON.parse(req.body)})
          res.end('Saved to database')
        }
        // Сбрасываем данные
        req.body = ''
      })
    }
// ...

Раньше хендлер для POSTа мог принимать любые запросы, теперь только если у нас есть пароль. Процедура та же самая, что и в первой части статьи.

Теперь нам нужно сделать обработку PUT запросов. В кратце, они вытаскивают контент как если было бы у GETа, и добавляют его в тело. То есть PUT выглядит примерно так (псевдокод):

PUT('/my-new-article', data) {
  old_data = GET('/my-new-article')
  POST('/my-new-article', old_data + data)
}

Мы также обрабатываем приходящие данные, как и в POSTе. Только теперь вместо insertOne, мы используем метод updateOne, который позволяет обновлять записи в MongoDB без изменения id:

// api/server.js
// ...
    // Редачим статью
    else if (method === 'PUT') {
      console.log('Editing post...')
      req
        .on('data', chunk => req.body += chunk.toString())
        .on('end', async () => {
          const { url } = req
          if (url === '/') res.end('Please specify a URL')
          else {
            const { name, story } = JSON.parse(req.body).data

            collection.updateOne({ slug },
              {
                "$set": {
                  slug,
                  name, story
                }
              }
            )
          }
        })
    }
// ...

Но что если юзер не авторизовался? Не будем же мы просто оставлять висящий запрос без ответа. Добавим ответы в случае POSTа без авторизации.

if (admin) {
// ...
}
else if (method === 'POST') res.end('Not allowed. Login first.')

Фронтенд

Придётся частично переписать редактор. Также я положил компонент в отдельный файл, так как код редактора стал довольно большим.

У редактора обновилась функция sendStory, теперь мы достаём наш пароль из куки и отправляем как заголовок.

// www/editor.js
const sendStory = () => {
    const secret = document.cookie.replace(/(?:(?:^|.*;\s*)secret\s*\=\s*([^;]*).*$)|^.*$/, '$1')
    fetch(`http://localhost:3000/${slug}`, {
      method: 'POST',
      headers: {
        'x-secret': secret
      },
      body: JSON.stringify({
        name: title,
        story
      })
    })
      .then(res => res.text())
      .then(data => setReply(data))
  }

Остальное в редакторе остаётся прежним.

В компоненте поста тоже появятся изменения, теперь у нас появится возможность редактировать посты.

import { useState, useEffect } from 'react'
import { Link } from '@reach/router'
import html from './html'

const Post = ({ slug }) => {
  const [data, setData] = useState({})
  const [edit, setEdit] = useState(false)

  const secret = document.cookie.replace(/(?:(?:^|.*;\s*)secret\s*\=\s*([^;]*).*$)|^.*$/, '$1')

  useEffect(() => {
    fetch(`http://localhost:3000/${slug}`)
      .then(res => res.json())
      .then(data => setData(data))
  }, [])

  const send = secret => {
    fetch(`http://localhost:3000/${slug}`, {
      method: 'PUT',
      body: JSON.stringify({ data }),
      headers: {
        'x-secret': secret
      }
    })
      .then(res => res.text())
      .then(text => alert(text))
    setEdit(!edit)
  }

  return html`
    <main>
      <button onClick=${() => setEdit(!edit)}>Edit</button>
      <h1>${data.name}</h1>
      ${
        edit && secret
          ? html`
              <div>
                <textarea onInput=${e => setData({ ...data, story: e.target.value })}>
                  ${data.story}
                </textarea
                >
                <button onClick=${() => send(secret)}>Send</button>
              </div>
            `
          : html`
              <p>${data.story}</p>
            `
      }
      <${Link} to="/">Back to main page</Link>
    </main>
  `
}

export default Post

Процедура отправки запроса почти та же, что и у редактора. Также мы не показываем текстовое поле если юзер не авторизовался.

Финал

Всё, наш блог полностью готов. Теперь надо запустить его.

Для этого разделяем терминал на две части (tmux / byobu), или просто делаем два окна, в одном запускаем API, в другом фронтенд.

# бек
$ cd api
$ yarn dev
# фронт
$ cd www
$ yarn dev

Результат выглядит примерно так:

Заключение

Мы создали простенький текстовый блог. Также можно добавить поддержку markdown (react-markdown), даты постов, и их удаление. Но это зависит от фантазии. Весь код я выложил в этот гист.

Report Page