Steemit Clone Coding

허정·2022년 5월 2일
0

블록체인

목록 보기
37/38

인센티브 기반의 커뮤니티를 구현했습니다. 프로젝트명은 Reward-Psychology 입니다. 해당 게시물은 백엔드를 중심으로 작성되었으며, mongoose ^6.3.0을 기준으로 설명되어 있습니다.

1. Tech Stack

(1) Frontend

React.js

(2) Backend

node.js, express, mongoDB

(3) Smart Contract

solidity

2. Features

인센티브 기반의 Web2.0 커뮤니티를 구현해보겠습니다. 팀에서 백엔드 구현을 맡았으므로, 백엔드를 기준으로 필요한 기능들은 다음에 제시된 3가지입니다.

(1) Join

  • Client로부터 < Username, Email, Password, Password2, Profile Image Url > data를 받기
  • Server는 password confirmation을 하고, userName이나 Email이 존재하는지 확인
  • Client로부터 받은 data와 Wallet Address를 DB에 저장
  • Mneomonic wallet을 생성하여 User에게 wallet address, mnemonic, private key를 전달

(2) Login

  • Client로부터 < userName, password >를 받기
  • DB에서 userName과 일치하는 document를 찾기
  • bcrypt에 의해 encrypted 된 password가 일치하는 지 확인
  • 일치한다면 user의 address를 리턴

(3) Upload

  • Client로부터 < title, contents, hashtags >를 받기
  • DB에 저장
  • user의 address로 RP 토큰을 mint

3. Routing

(1) root ( "/" )

-> /join
-> /login
-> /search
-> /upload

(2) users ( "/users" )

-> /logout
-> /myposts

(3) posts ( "/posts" )

-> /:postingId/watch
-> /:postingId/edit
-> /:postingId/delete

4. Code Review

(1) Server 구현

express를 사용하여 서버를 구현했습니다.

< server.js >

import "./db.js";
import "./models/Posting.js";
import "./models/User.js";
import express from "express";
import morgan from "morgan";
import cors from "cors";
import rootRouter from "./routers/rootRouter.js";
import userRouter from "./routers/userRouter.js";
import postingRouter from "./routers/postingRouter.js";
import dotenv from "dotenv";
dotenv.config();

const app = express();
const logger = morgan("dev");
const PORT = 4000;
const corsOptions = { origin: "http://localhost:3000" };
app.use(cors(corsOptions));

const handleListening = () => {
  console.log(`✅ Server listening on port http://localhost:${PORT}`);
};

app.use(logger);
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

app.use("/", rootRouter);
app.use("/users", userRouter);
app.use("/posts", postingRouter);

app.listen(PORT, handleListening);

export default app;

const app = express();

  • REST 서버를 편하게 구현하기 위해서 프레임워크로 express를 사용했습니다.
  • app이라는 변수에 express 함수의 값을 저장했습니다.

app.use(cors(corsOptions));

  • cors 미들웨어를 사용합니다. cors에 옵션을 사용하여 우리의 client를 origin으로 설정해줍니다.

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

  • client에서 보낸 body 데이터를 해석하기 위해서 설정을 해줘야합니다.
  • express에는 body-parse 모듈이 내장되어 있습니다.
  • json으로 보낸 데이터를 해석하기 위해서, json 파싱을 위한 설정을 해줍니다.
  • form으로 보낸 x-www-form-urlencoded형태의 데이터를 파싱하기 위한 설정을 해줍니다.

const PORT = 4000;
const handleListening = () => {console.log(✅ Server listening on port http://localhost:${PORT})};
app.listen(PORT, handleListening);

  • 4000 포트로 서버를 오픈합니다.

< db.js >

import mongoose from "mongoose";
import dotenv from "dotenv";
dotenv.config();

mongoose.connect(process.env.DB_URL);

const db = mongoose.connection;

const handleOpen = () => {
  console.log("✅ Connected to DB");
};

const handleError = (error) => {
  console.log("❌ DB Error", error);
};

db.on("error", handleError);
db.once("open", handleOpen);
  • mongoose docs를 참고하면 connection과 관련된 내용을 확인할 수 있습니다.
  • We need to define a connection. If your app uses only one database, you should use mongoose.connect.
  • on 메소드는 여러 번 발생 가능하고, once 메소드는 한 번만 발생 가능합니다.

(2) DB 구현

< Install mongoose >

mongoDB를 사용하기 위해서, mongoose를 install 해줍니다. mongoose docs의 guide를 따라 설치 가능합니다. 공식 문서에서 다음과 같은 내용을 확인할 수 있습니다.

With Mongoose, everything is derived from a Schema.
Everything in Mongoose starts with a Schema. Each schema maps to a MongoDB collection and defines the shape of the documents within that collection.

< Schema >

Schema를 통해 shape of the documents를 정의합니다.

- User Schema -

const userSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true },
  userName: { type: String, required: true, unique: true },
  password: { type: String, required: true },
  address: { type: String, required: true, unique: true },
  profileImage: {
    type: String,
    required: true,
    default: "../images/rpProfileImage.png",
  },
});
  • required 옵션은 필수 property를 의미합니다.
    required : email, userName, password, address, profileImage

  • unique 옵션은 유일해야 하는 property를 의미합니다.
    unique : email, userName, address

- User pre -

userSchema.pre("save", async function () {
  this.password = await bcrypt.hash(this.password, 5);
});
  • pre를 사용해서 User model의 pre hook을 정의해줍니다.
  • DB에 User data를 저장할 때, bcrypt를 통해 encrypted (hash 처리된) password를 저장할 수 있도록 하는 pre hook을 작성했습니다.
  • Number형인 " 5 "는 saltRounds에 해당하는 값입니다.

- Posting Schema -

const postingSchema = new mongoose.Schema({
  title: { type: String, required: true, trim: true, maxlength: 50 },
  createdAt: { type: Date, default: Date.now },
  contents: { type: String, required: true, trim: true, minlength: 20 },
  hashtags: [{ type: String, trim: true }],
  meta: {
    voting: { type: Number, default: 0 },
    views: { type: Number, default: 0 },
    comments: { type: Number, default: 0 },
  },
  owner: { type: String, required: true, ref: "User" },
});
  • required : title, contents, owner

  • trim 옵션은 자동으로 앞뒤 공백을 없애줍니다.
    trim : title, contents, hashtags

  • minlength/maxlength 옵션은 최소, 최대 길이를 정해줍니다.

- Posting static -

postingSchema.static("formatHashtags", function (hashtags) {
  console.log(this.hashtags);
  return hashtags
    .split(",")
    .map((hashtag) => (hashtag.startsWith("#") ? hashtag : `#${hashtag}`));
});
  • static을 이용해서 모델의 method를 만들어줍니다.
  • this가 어떻게 사용되는지 보여주기 위해서 console.log(this.hashtags)를 작성했습니다. static 내에서 사용되는 this는 모델 그 자체를 나타냅니다.

< Model >

Schema를 이용해서 Model을 정의합니다. 공식 문서를 통해서 다음 내용을 확인할 수 있습니다.

A Model is a class that's your primary tool for interacting with MongoDB. An instance of a Model is called a Document.

- User model 전체 코드 -

const userSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true },
  userName: { type: String, required: true, unique: true },
  password: { type: String, required: true },
  address: { type: String, required: true, unique: true },
  profileImage: {
    type: String,
    required: true,
    default: "../images/rpProfileImage.png",
  },
});

userSchema.pre("save", async function () {
  this.password = await bcrypt.hash(this.password, 5);
});

const User = mongoose.model("User", userSchema);

- Posting model 전체 코드 -

const postingSchema = new mongoose.Schema({
  title: { type: String, required: true, trim: true, maxlength: 50 },
  createdAt: { type: Date, default: Date.now },
  contents: { type: String, required: true, trim: true, minlength: 20 },
  hashtags: [{ type: String, trim: true }],
  meta: {
    voting: { type: Number, default: 0 },
    views: { type: Number, default: 0 },
    comments: { type: Number, default: 0 },
  },
  owner: { type: String, required: true, ref: "User" },
});

postingSchema.static("formatHashtags", function (hashtags) {
  return hashtags
    .split(",")
    .map((hashtag) => (hashtag.startsWith("#") ? hashtag : `#${hashtag}`));
});

const Posting = mongoose.model("Posting", postingSchema);

< Controller >

- User Controller -

import User from "../models/User.js";
import bcrypt from "bcrypt";
import lightwallet from "eth-lightwallet";

export const postJoin = async (req, res) => {
  const { email, userName, password, password2 } = req.body;
  const mnemonic = lightwallet.keystore.generateRandomSeed();
  const userNameOrEmailExists = await User.find({
    $or: [{ email }, { userName }],
  });


  if (password !== password2) {
    return res
      .status(400)
      .send({ errorMessage: "Password Confirmation does not match" });
  }
 
  if (userNameOrEmailExists.length !== 0) {
    return res
      .status(400)
      .send({ errorMessage: "This user is already registered" });
  }
  • 로그인 과정에서 입력된 password 2개를 비교하여, 사용자가 의도한 password가 입력되도록 합니다.
  • users DB에 겹치는 userName이나 email이 있는지 확인합니다.
  try {
    lightwallet.keystore.createVault(
      {
        password: password,
        seedPhrase: mnemonic,
        hdPathString: "m/0'/0'/0'",
      },
      function (err, ks) {
        if (err) {
          console.log("❌ Keystore Error:", err);
          return res.status(400).send({
            errorMessage: `❌ KeyStore Error: ${err}`,
          });
        }

        ks.keyFromPassword(password, function (err, pwDerivedKey) {
          if (err) {
            console.log("❌ Key from password Error:", err);
            return res.status(400).send({
              errorMessage: `❌ Key from password Error: ${err}`,
            });
          }

          ks.generateNewAddress(pwDerivedKey, 1);

          let address = ks.getAddresses().toString();
          let privateKey = ks.exportPrivateKey(address, pwDerivedKey);

          // user를 users DB에 저장합니다.
          User.create({
            email,
            userName,
            password,
            address,
          });

          return res.send({
            successMessage: "Join Success, Go to Login Page",
            address,
            privateKey,
            mnemonic,
          });
        });
      }
    );
  } catch (error) {
    console.log("❌ Join Not Available!");
    return res.status(400).send({ errorMessage: "Join Not Available" });
  }
};
  • lightwallet을 사용해서 유저에게 wallet address, private key, mnemonic code를 생성해줍니다.
  • 받은 user 데이터를 users DB에 저장합니다.
export const postLogin = async (req, res) => {
  const { userName, password } = req.body;
  const user = await User.findOne({ userName });

  if (!user) {
    console.log("❌ An account with this username dose not exist");
    return res.status(404).send({
      errorMessage: "An account with this username dose not exist",
    });
  }
  const passwordComparision = await bcrypt.compare(password, user.password);

  if (!passwordComparision) {
    console.log("❌ Password Comparision does not match");
    return res
      .status(404)
      .send({ errorMessage: "Password Comparision does not match" });
  }

  return res.send({ address: user.address });
};
  • body 데이터를 확인하여 로그인을 진행합니다.
  • DB에 user가 존재하는지 확인합니다.
  • 입력된 password가 DB에 저장된 user의 password와 일치하는지 확인합니다. bcrypt로 암호화된 값들을 비교합니다.

- Posting Controller -

export const home = async (req, res) => {
  try {
    const postings = await Posting.find({}).sort({ createdAt: "desc" });
    return res.send({ postings });
  } catch (error) {
    console.log("❌ Get Home Error:", error);
    return res.status(400).send({
      errorMessage: "This is not the web page you are looking for!",
    });
  }
};
  • DB에 저장된 posting들을 불러오는 home controller입니다.
  • createdAt을 기준으로 desc 옵션을 줘서 posting들이 최신순으로 정렬되도록 합니다.
export const search = async (req, res) => {
  const { keyword } = req.query;
  let postings = [];
  if (keyword) {
    postings = await Posting.find({
      title: { $regex: new RegExp(keyword, "i") },
    }).sort({ createdAt: "desc" });
  }
  return res.send(postings);
};
  • query를 통해 받은 keyword로 posting DB에서 일치하는 title을 find합니다.
  • Regular_Expressions을 통해서 정규식에 관한 내용을 확일 수 있습니다.
  • "i"는 ignore의 약자로, 대소문자를 구분하지 않는다는 것을 의미합니다.
  • createdAt을 기준으로 desc 옵션을 줘서, 검색된 posting들이 최신순으로 정렬되도록 합니다.
export const postUpload = (req, res) => {
  const { title, contents, hashtags, userName, address } = req.body;

  try {
    if (!title || !contents || !userName || !address) {
      return res.status(400).send({ errorMessage: "❌ Insufficient data!" });
    }

    Posting.create(
      {
        title,
        contents,
        hashtags: Posting.formatHashtags(hashtags),
        owner: userName,
      },
      () => mint(address, 1)
    );

    return res.send("Upload Success, Go to Home Page");
  } catch (error) {
    console.log("❌ Post Upload Error:", error);

    return res.status(400).send({ errorMessage: "Fail Post Upload" });
  }
};
export const watch = async (req, res) => {
  const { postingId } = req.params;
  const posting = await Posting.findById(postingId);

  if (!posting) {
    return res.status(404).send({ errorMessage: "404 Not Found!" });
  }

  return res.send(posting);
};
export const getEdit = async (req, res) => {
  const { postingId } = req.params;
  const posting = await Posting.findById(postingId);
  if (!posting) {
    return res.status(404).send({ errorMessage: "404 Not Found!" });
  }
  return res.send({ posting });
};
export const postEdit = async (req, res) => {
  const { postingId } = req.params;
  const posting = await Posting.exists({ _id: postingId });
  const { title, contents, hashtags } = req.body;

  if (!posting) {
    return res.status(404).send({ errorMessage: "404 Not Found!" });
  }

  await Posting.findByIdAndUpdate(postingId, {
    title,
    contents,
    hashtags: Posting.formatHashtags(hashtags),
  });
  return res.send("Upload Success");
};
export const deletePosting = async (req, res) => {
  const { postingId } = req.params;

  await Posting.findByIdAndDelete(postingId);

  return res.send("Success Delete the Post");
};

0개의 댓글